You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Haskell中编写Monad高效追踪并预取i18n键?

用Haskell实现I18n键追踪以避免N+1查询问题

我想编写一个Monad来追踪代码中使用的i18n键,目的是提前批量预取这些键对应的翻译值,避免代码执行时出现“N+1”查询问题。

我已经写了一部分代码(见下文),想请教:

  • 如何为这个类型编写Monad实例?
  • 这种实现思路是否可行?
  • 还有其他实现方案吗?只要开发体验可控,我也接受类型层面追踪键的方案。

另外我想到一种“两阶段”执行的思路:

  1. 第一阶段:返回两个结果——[I18nKey](代码依赖的所有i18n键列表),以及[(I18nKey, I18nStr)] -> m Text(需要传入键值映射才能生成最终结果的未应用函数)。
  2. 第二阶段:通过单次数据库查询把[I18nKey]转换成[(I18nKey, I18nStr)],再将这个映射传入上述函数得到最终值。

请问如何用Haskell以符合人机工程学的方式实现这个逻辑?


现有代码(已实现Functor和Applicative)

import UnliftIO.IORef
import Data.Text
import Control.Monad.Reader
import qualified Data.List as DL

type I18nKey = Text
type I18nStr = Text

data I18nEnv m a = I18nEnv
  { envI18nExpectedKeys :: [I18nKey]
  , envI18nVal :: ReaderT [(I18nKey, I18nStr)] m a
  }

instance (Functor m) => Functor (I18nEnv m) where
  {-# INLINE fmap #-}
  fmap f env = 
    env{envI18nVal=fmap f (envI18nVal env)}

instance (Applicative m) => Applicative (I18nEnv m) where
  {-# INLINE pure #-}
  pure x = 
    I18nEnv { envI18nExpectedKeys = [], envI18nVal = pure x }

  {-# INLINE (<*>) #-}
  (<*>) fn a = I18nEnv
    { envI18nExpectedKeys=(envI18nExpectedKeys fn <> envI18nExpectedKeys a)
    , envI18nVal=(envI18nVal fn) <*> (envI18nVal a)
    }

尝试的实用代码(无法编译)

import Lucid
import qualified Data.HashMap.Strict as HM
import UnlifIO
import Data.Set (Set)

type I18nKey = Text -- 示例:en.user_greeting
type I18NInterpolation = Text -- 示例:"Hello {{ username }}"
type I18nReplacements = [(Text, Text)] -- 示例:[("username", "john doe"), ("email", "johndoe@gmail.com")]

data HtmlCtx = HtmlCtx 
  { ctxI18nKeysetRef :: !(IORef (Set I18nKey))
  } deriving (Eq, Show)
$(makeLensesWith abbreviatedFields ''HtmlCtx)


translate :: I18nKey -> I18nReplacements -> HM.HashMap I18nKey I18NInterpolation -> Text
translate = undefined

newtype UnappliedI18n a = UnappliedI18n { rawUnappliedI18n :: HM.HashMap I18nKey I18NInterpolation -> a } deriving (Generic, Functor, Applicative, Monad, Semigroup, Monoid)

newtype I18nHtml a 
  = I18nHtml { rawI18nHtml :: HtmlT (ReaderT HtmlCtx IO) (UnappliedI18n a) }
    deriving (Generic, Functor, Applicative, Monad, MonadReader HtmlCtx)

解决方案

1. 为I18nEnv m编写Monad实例

要实现Monad实例,核心是处理>>=操作时的键合并:当绑定一个返回I18nEnv m b的函数时,需要把当前I18nEnv m a的键,和函数返回的I18nEnv m b的键合并。

实现代码如下:

instance (Monad m) => Monad (I18nEnv m) where
  {-# INLINE (>>=) #-}
  env >>= f = I18nEnv
    { envI18nExpectedKeys = envI18nExpectedKeys env <> envI18nExpectedKeys nextEnv
    , envI18nVal = envI18nVal env >>= \x -> envI18nVal nextEnv
    }
    where
      -- 仅收集键,不需要实际映射值,用undefined占位
      nextEnv = f <$> runReaderT (envI18nVal env) undefined

  {-# INLINE return #-}
  return = pure

可行性说明:这种思路完全可行。I18nEnv本质是把“收集键”和“延迟计算”结合起来,第一阶段收集所有需要的键,第二阶段传入实际的翻译映射执行计算。

2. 优化:用Set去重键

当前代码用[I18nKey]会导致重复键被多次收集,建议换成Set I18nKey避免冗余查询,同时用HashMap替代列表提升查询效率:

import Data.Set (Set)
import qualified Data.Set as S
import qualified Data.HashMap.Strict as HM

data I18nEnv m a = I18nEnv
  { envI18nExpectedKeys :: Set I18nKey
  , envI18nVal :: ReaderT (HM.HashMap I18nKey I18nStr) m a
  }

instance (Applicative m) => Applicative (I18nEnv m) where
  pure x = I18nEnv { envI18nExpectedKeys = S.empty, envI18nVal = pure x }
  (<*>) fn a = I18nEnv
    { envI18nExpectedKeys = S.union (envI18nExpectedKeys fn) (envI18nExpectedKeys a)
    , envI18nVal = envI18nVal fn <*> envI18nVal a
    }

instance (Monad m) => Monad (I18nEnv m) where
  env >>= f = I18nEnv
    { envI18nExpectedKeys = S.union (envI18nExpectedKeys env) (envI18nExpectedKeys nextEnv)
    , envI18nVal = envI18nVal env >>= \x -> envI18nVal nextEnv
    }
    where
      nextEnv = f <$> runReaderT (envI18nVal env) HM.empty

3. 两阶段执行的人机工程学实现

可以把两阶段逻辑封装成两个函数,让使用流程更清晰:

-- 第一阶段:收集所有需要的i18n键
collectKeys :: I18nEnv m a -> Set I18nKey
collectKeys = envI18nExpectedKeys

-- 第二阶段:传入翻译映射,执行计算得到最终结果
runWithTranslations :: HM.HashMap I18nKey I18nStr -> I18nEnv m a -> m a
runWithTranslations translations env = runReaderT (envI18nVal env) translations

-- 封装helper函数,简化i18n键的收集与延迟计算
i18n :: I18nKey -> (I18nStr -> a) -> I18nEnv m a
i18n key f = I18nEnv
  { envI18nExpectedKeys = S.singleton key
  , envI18nVal = f <$> asks (HM.lookup key >>= maybe (pure key) pure)
  }

-- 使用示例
getGreeting :: I18nEnv IO Text
getGreeting = do
  username <- pure "Alice"
  greetingTemplate <- i18n "en.user.greeting" id
  pure $ replaceVars greetingTemplate [("username", username)]

-- 模拟变量替换
replaceVars :: Text -> [(Text, Text)] -> Text
replaceVars template vars = foldr (\(k,v) t -> replace ("{{ " <> k <> " }}") v t) template vars

-- 模拟数据库批量查询
fetchTranslationsFromDB :: Set I18nKey -> IO (HM.HashMap I18nKey I18nStr)
fetchTranslationsFromDB keys = pure $ HM.fromList [(k, "Hello, {{ username }}") | k <- S.toList keys]

-- 执行流程
main :: IO ()
main = do
  let keys = collectKeys getGreeting
  translations <- fetchTranslationsFromDB keys
  greeting <- runWithTranslations translations getGreeting
  print greeting

4. 修正无法编译的I18nHtml代码

原代码存在拼写错误和缺失导入的问题,修正后代码如下:

import Lucid
import qualified Data.HashMap.Strict as HM
import UnliftIO
import Data.Set (Set)
import Control.Lens.TH
import GHC.Generics (Generic)
import qualified Data.Set as S

type I18nKey = Text -- 示例:en.user_greeting
type I18NInterpolation = Text -- 示例:"Hello {{ username }}"
type I18nReplacements = [(Text, Text)] -- 示例:[("username", "john doe"), ("email", "johndoe@gmail.com")]

data HtmlCtx = HtmlCtx 
  { ctxI18nKeysetRef :: !(IORef (Set I18nKey))
  } deriving (Eq, Show)
$(makeLensesWith abbreviatedFields ''HtmlCtx)

translate :: I18nKey -> I18nReplacements -> HM.HashMap I18nKey I18NInterpolation -> Text
translate key replacements translations =
  case HM.lookup key translations of
    Just template -> foldr (\(k,v) -> replace ("{{ " <> k <> " }}") v) template replacements
    Nothing -> key

newtype UnappliedI18n a = UnappliedI18n { rawUnappliedI18n :: HM.HashMap I18nKey I18NInterpolation -> a } 
  deriving (Generic, Functor, Applicative, Monad, Semigroup, Monoid)

newtype I18nHtml a 
  = I18nHtml { rawI18nHtml :: HtmlT (ReaderT HtmlCtx IO) (UnappliedI18n a) }
    deriving (Generic, Functor, Applicative, Monad, MonadReader HtmlCtx)

-- 封装Html场景下的i18n键收集逻辑
i18nHtml :: I18nKey -> I18nReplacements -> I18nHtml (Html ())
i18nHtml key replacements = do
  HtmlCtx ref <- ask
  liftIO $ modifyIORef' ref (S.insert key)
  pure $ UnappliedI18n $ \trans -> toHtml $ translate key replacements trans

-- 使用示例
userProfileHtml :: Text -> I18nHtml (Html ())
userProfileHtml username = do
  h1_ =<< i18nHtml "en.profile.title" []
  p_ =<< i18nHtml "en.profile.greeting" [("username", username)]

-- 渲染流程
renderUserProfile :: Text -> IO (Html ())
renderUserProfile username = do
  ref <- newIORef S.empty
  unapplied <- runReaderT (runHtmlT $ rawI18nHtml $ userProfileHtml username) (HtmlCtx ref)
  keys <- readIORef ref
  trans <- fetchTranslationsFromDB keys
  pure $ rawUnappliedI18n unapplied trans

5. 类型层面追踪键的方案(进阶)

如果想在编译时就追踪所有依赖的i18n键,可以用GHC的类型级特性实现:

import GHC.TypeLits
import Data.Kind (Type)
import qualified Data.HashMap.Strict as HM

-- 用类型级列表存储i18n键
data I18n (keys :: [Symbol]) m a = I18n
  { runI18n :: ReaderT (HM.HashMap Text I18nStr) m a
  }

-- 封装函数,将键添加到类型中
i18nType :: KnownSymbol key => Proxy key -> (I18nStr -> a) -> I18n '[key] m a
i18nType proxy f = I18n $ f <$> asks (HM.lookup (symbolVal proxy) >>= maybe (pure $ symbolVal proxy) pure)

-- 合并两个I18n实例的键列表
mergeI18n :: I18n ks1 m a -> I18n ks2 m b -> I18n (ks1 ++ ks2) m (a, b)
mergeI18n (I18n a) (I18n b) = I18n $ (,) <$> a <*> b

优点:编译时就能明确所有依赖的键,避免运行时收集开销;缺点:类型会随键数量增多变得复杂,适合键数量较少的场景。


内容的提问来源于stack exchange,提问作者Saurabh Nanda

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.21 18:42:01