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

Haskell Servant开发中两结构体共享部分内容时的惯用处理方式

针对这类「输入输出数据结构大部分字段重合,仅少量字段由服务端补充」的场景,Haskell生态下有两种非常成熟的惯用写法,从地道性和易用性分别说明:

方案1:参数化阶段标记类型(最符合Haskell惯用风格)

核心是消除重复的字段定义,用类型参数标记数据所处的生命周期阶段(用户提交输入/服务端持久化输出),彻底避免给字段加撇号区分、逐字段拷贝的冗余代码。

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE RecordWildCards #-}

import Data.Aeson
import Data.Time (UTCTime)
import GHC.Generics
import Database.PostgreSQL.Simple.FromRow

-- 抽离所有阶段共有的字段,用独立的类型承载不同阶段的差异字段
data Msg inputMeta outputMeta = Msg
  { thread :: Int
  , dname :: String
  , contents :: String
  , inMeta :: inputMeta
  , outMeta :: outputMeta
  } deriving (Eq, Show, Generic)

-- 用户提交POST请求时,没有额外元数据,元数据字段用单元类型占位
type InputMsg = Msg () ()

-- 服务端存库/返回响应时,补充时间戳、IP两个字段
data ServerMeta = ServerMeta
  { stamp :: UTCTime
  , ip :: String
  } deriving (Eq, Show, Generic)
type OutputMsg = Msg () ServerMeta

-- 所有JSON、数据库实例都可以自动派生,只要简单配置aeson的字段映射规则,就能生成和你原有双类型完全一致的JSON结构,前端无感知
instance FromJSON InputMsg
instance ToJSON ServerMeta
instance ToJSON OutputMsg
instance FromRow OutputMsg

接口处理逻辑里不需要逐字段拷贝,只需要替换元数据字段即可:

-- POST接口处理逻辑
createMessage :: InputMsg -> Maybe String -> Handler APIMessage
createMessage inputMsg maybeRealIp = do
  currentTime <- liftIO getCurrentTime
  let clientIp = fromMaybe "127.0.0.1" maybeRealIp
      -- 公共字段完全不需要手动赋值,直接替换元数据部分
      persistedMsg = inputMsg { outMeta = ServerMeta currentTime clientIp }
  -- 后续存库、返回响应逻辑即可

这种写法的长期维护成本最低:后续新增公共字段时只需要修改一次Msg类型定义,不需要同时改输入、输出两个类型,也不会出现漏改字段映射的低级错误。

方案2:保留双类型,封装转换函数(改动成本最低)

如果你暂时不想调整现有类型结构,至少要把两个类型之间的转换逻辑封装到统一函数里,不要在业务代码中散落逐字段赋值的代码,配合RecordWildCards扩展可以把冗余代码降到最低:

{-# LANGUAGE RecordWildCards #-}

-- 保留你原有的Msg、Msg'定义,只需要新增一个转换函数
toOutputMsg :: Msg -> UTCTime -> String -> Msg'
toOutputMsg Msg{..} stamp' ip' = Msg'{..}
  where
    thread' = thread
    dname' = dname
    contents' = contents

业务逻辑里直接调用该函数即可,不需要每次都手写三个公共字段的赋值:

createMessage msg maybeIp = do
  now <- liftIO getCurrentTime
  let clientIp = fromMaybe "unknown" maybeIp
      output = toOutputMsg msg now clientIp
  -- 后续逻辑

这种写法几乎不需要改动现有代码,只需要加一个几行的转换函数,就能避免后续改字段时漏改映射的问题。

额外的接口规范建议

你当前GET接口用ReqBody '[JSON] Int接收线程ID不符合HTTP语义规范,GET请求不应该携带请求体,建议改成路径捕获参数,更符合REST风格也更方便Servant路由处理:

type UserAPI = "messages" :> ReqBody '[JSON] Msg :> Header "X-Real-IP" String :> Post '[JSON] APIMessage
               :<|> "messages" :> Capture "threadId" Int :> Get '[JSON] [Msg']

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.09.03 04:42:40