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

Haskell中定义新建与持久化Record类型的惯用方式

解决Servant中创建记录的类型冗余问题

针对你的场景,这里有几种实用方案,既能避免手动重复定义字段,又能确保创建接口不接受自动生成的id字段:

方案1:基于泛型自动推导无ID的类型

利用generic-lens或generic-data这类库,通过泛型自动从持久化类型中移除id字段,生成创建用的类型,无需手动维护两个重复的类型定义。

示例代码:

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE TypeApplications #-}

import GHC.Generics
import Data.Generics.Labels ()
import Data.UUID (UUID)

-- 持久化用的记录类型
data Reading = Reading
  { id :: UUID
  , value :: String
  , temperature :: Double
  , timestamp :: Int
  } deriving (Generic, Show)

-- 创建用的类型,通过泛型移除id字段
type CreateReading = GHC.Generics.Rep Reading
  `RemoveField` "id"
  `ToProductType` "CreateReading"

-- 辅助类型家族(可封装在工具模块)
type family RemoveField (rep :: * -> *) (field :: Symbol) :: * -> * where
  RemoveField (D1 m (C1 c (S1 s (Rec0 a) :*: rest))) field =
    If (s == 'MetaSel ('Just field) _ _ _)
       (D1 m (C1 c rest))
       (D1 m (C1 c (S1 s (Rec0 a) :*: RemoveField rest field)))
  RemoveField rep _ = rep

type family ToProductType (rep :: * -> *) (name :: Symbol) :: * where
  ToProductType rep name = Generic.ProductType name rep

CreateReading会自动拥有除id外的所有字段,在Servant的API类型中直接用它作为POST请求的Body类型即可。

方案2:定义基础无ID类型,扩展出持久化类型

反过来先定义不带id的核心类型,再让持久化类型基于它扩展,核心字段只需维护一次。

示例代码:

import Data.UUID (UUID)

-- 核心字段类型,用于创建请求等无需id的场景
data ReadingCore = ReadingCore
  { value :: String
  , temperature :: Double
  , timestamp :: Int
  } deriving (Show)

-- 持久化用的类型,包含自动生成的id和核心字段
data Reading = Reading
  { id :: UUID
  , core :: ReadingCore
  } deriving (Show)

-- 若不想嵌套,可启用DuplicateRecordFields和RecordDotSyntax扩展
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE RecordDotSyntax #-}

data Reading = Reading
  { id :: UUID
  , value :: String
  , temperature :: Double
  , timestamp :: Int
  } deriving (Show)

data ReadingCore = ReadingCore
  { value :: String
  , temperature :: Double
  , timestamp :: Int
  } deriving (Show)

-- 转换函数:从创建用的ReadingCore生成待持久化的Reading(填充数据库生成的id)
toReading :: UUID -> ReadingCore -> Reading
toReading rid core = Reading
  { id = rid
  , value = core.value
  , temperature = core.temperature
  , timestamp = core.timestamp
  }

这种方式核心字段只定义一次,创建接口的Body类型ReadingCore天然不含id,完全符合需求。

方案3:使用模板Haskell自动生成创建类型

如果项目中有大量这类转换需求,可以用Template Haskell写一个宏,自动从带id的类型生成无id的创建类型。

示例代码:

{-# LANGUAGE TemplateHaskell #-}

import Language.Haskell.TH
import Data.UUID (UUID)

-- 定义持久化类型
data Reading = Reading
  { id :: UUID
  , value :: String
  , temperature :: Double
  , timestamp :: Int
  } deriving (Show)

-- 自动生成CreateReading类型,移除id字段
makeCreateType "Reading" "CreateReading" ["id"]

-- 模板Haskell宏实现(可放在工具模块)
makeCreateType :: String -> String -> [String] -> Q [Dec]
makeCreateType origName newName excludedFields = do
  TyConI (DataD _ _ _ _ [RecC origConName fields] _) <- reify (mkName origName)
  let filteredFields = filter (\(name, _, _) -> name `notElem` map mkName excludedFields) fields
  let newConName = mkName newName
  return [DataD [] newConName [] Nothing [RecC newConName filteredFields] []]

运行后会自动生成CreateReading类型,包含除id外的所有字段,无需手动编写。

方案4:自定义JSON解析(备选,非类型安全)

若不想额外定义类型,可在JSON解析层拒绝包含id字段的请求,但这种方式编译期无法检查,仅作临时备选:

{-# LANGUAGE DeriveGeneric #-}

import GHC.Generics
import Data.Aeson
import Data.UUID (UUID)
import Servant

data Reading = Reading
  { id :: UUID
  , value :: String
  } deriving (Generic, Show)

-- 自定义JSON实例,禁止解析id字段
instance FromJSON Reading where
  parseJSON = withObject "Reading" $ \v -> Reading
    <$> pure undefined -- 实际不会用到,仅占位
    <*> v .: "value"
    <* (if v .:? "id" /= Nothing then fail "请求体不能包含'id'字段" else pure ())

注意:这种方式下类型本身仍包含id,持久化时需替换为数据库生成的值,类型安全性不如前几种方案。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.29 20:33:22