如何提高 Haskell 中使用 JSON 的便利性?

How can I improve the ease of working with JSON in Haskell?

Haskell 已经成为一种有用的网络语言(感谢 Servant!),但 JSON 对我来说仍然很痛苦所以我一定是做错了什么(?)

我听说 JSON 是一个痛点,我听到的回应围绕着 "use PureScript"、"wait for Sub/Row Typing"、"use esoterica, like Vinyl"、"Aeson + just deal with the explosion of boiler plate data types".

作为一个(不公平的)参考点,我真的很喜欢 Clojure 的易用性 JSON "story"(当然,它是一种动态语言,并且有它的权衡,我仍然更喜欢 Haskell).

这是我盯着看一个小时的例子。

{
    "access_token": "xxx",
    "batch": [
        {"method":"GET", "name":"oldmsg", "relative_url": "<MESSAGE-ID>?fields=from,message,id"},
        {"method":"GET", "name":"imp", "relative_url": "{result=oldmsg:$.from.id}?fields=impersonate_token"},
        {"method":"POST", "name":"newmsg", "relative_url": "<GROUP-ID>/feed?access_token={result=imp:$.impersonate_token}", "body":"message={result=oldmsg:$.message}"},
        {"method":"POST", "name":"oldcomment", "relative_url": "{result=oldmsg:$.id}/comments", "body":"message=Post moved to https://workplace.facebook.com/{result=newmsg:$.id}"},
        {"method":"POST", "name":"newcomment", "relative_url": "{result=newmsg:$.id}/comments", "body":"message=Post moved from https://workplace.facebook.com/{result=oldmsg:$.id}"},
    ]
}

我需要POST这个到FB工作场所,它会复制一条消息到一个新组,并在两者上评论link,link互相交流。

我的第一次尝试是这样的:

data BatchReq = BatchReq {
  method :: Text
  , name :: Text
  , relativeUrl :: Text
  , body :: Maybe Text
  }

data BatchReqs = BatchReqs {
  accessToken :: Text
  , batch :: [BatchReq]
  }

softMove tok msgId= BatchReqs tok [
  BatchReq "GET" "oldmsg" (msgId `append` "?fields=from,message,id") Nothing
  ...
  ]

那太僵硬了,和 Maybe 打交道很不舒服。 Nothing 是 JSON null 吗?还是该字段不存在?然后我担心派生 Aeson 实例,并且不得不弄清楚如何将例如 relativeUrl 转换为 relative_url。然后我添加了一个端点,现在我有名称冲突。 DuplicateRecordFields!但是等等,这会在其他地方引起很多问题。因此更新数据类型以使用例如 batchReqRelativeUrl,并在使用 Typeables 和 Proxys 派生实例时将其剥离。然后我需要添加端点,或者调整我添加更多数据点的刚性数据类型的形状,尽量不让 "tyranny of small differences" 使我的数据类型膨胀太多。

在这一点上,我主要是 消费 JSON,所以决定 "dynamic" 使用 lenses。因此,要深入到包含组 ID 的 JSON 字段,我做了:

filteredBy :: (Choice p, Applicative f) =>  (a -> Bool) -> Getting (Data.Monoid.First a) s a -> Optic' p f s s
filteredBy cond lens = filtered (\x -> maybe False cond (x ^? lens))

-- the group to which to move the message
groupId :: AsValue s => s -> AppM Text
groupId json  = maybe (error500 "couldn't find group id in json.")
                pure (json ^? l)
  where l = changeValue . key "message_tags" . values . filteredBy (== "group") (key "type") . key "id" . _String

访问字段相当繁重。但我还需要生成有效负载,而且我还不够熟练,无法了解镜头对此有何好处。围绕激励批处理请求,我想出了一种 "dynamic" 编写这些有效负载的方法。它可以用 helper fns 简化,但是,我什至不确定它会得到多少更好。

softMove :: Text -> Text -> Text -> Value
softMove accessToken msgId groupId = object [
  "access_token" .= accessToken
  , "batch" .= [
        object ["method" .= String "GET", "name" .= String "oldmsg", "relative_url" .= String (msgId `append` "?fields=from,message,id")]
      , object ["method" .= String "GET", "name" .= String "imp", "relative_url" .= String "{result=oldmsg:$.from.id}?fields=impersonate_token"]
      , object ["method" .= String "POST", "name" .= String "newmsg", "relative_url" .= String (groupId `append` "/feed?access_token={result=imp:$.impersonate_token}"), "body" .= String "message={result=oldmsg:$.message}"]
      , object ["method" .= String "POST", "name" .= String "oldcomment", "relative_url" .= String "{result=oldmsg:$.id}/comments", "body" .= String "message=Post moved to https://workplace.facebook.com/{result=newmsg:$.id}"]
      , object ["method" .= String "POST", "name" .= String "newcomment", "relative_url" .= String "{result=newmsg:$.id}/comments", "body" .= String "message=Post moved from https://workplace.facebook.com/{result=oldmsg:$.id}"]
      ]
  ]

我正在考虑在代码中使用 JSON blob 或将它们作为文件读取并使用 Text.Printf 拼接变量...

我的意思是,我可以像这样完成这一切,但我很乐意找到替代方案。 FB的API有点独特,不能像很多REST的API那样表示成死板的数据结构;他们称之为他们的图表 API,它在使用中更加动态,并且将其视为刚性 API 到目前为止一直很痛苦。

(另外,感谢所有社区帮助我 Haskell!)

更新:在底部"dynamic strategy"添加了一些评论。

在类似的情况下,我使用 single-character 帮助器取得了良好的效果:

json1 :: Value
json1 = o[ "batch" .=
           [ o[ "method" .= s"GET", "name" .= s"oldmsg",
                   "url" .= s"..." ]
           , o[ "method" .= s"POST", "name" .= s"newmsg",
                   "url" .= s"...", "body" .= s"..." ]
           ]
         ]
  where o = object
        s = String

请注意 non-standard 语法(one-character 帮助器和参数之间没有 space)是有意的。这对我和其他阅读我的代码的人来说是一个信号,表明这些是技术性的 "annotations" 来满足类型检查器的要求,而不是更常见的函数调用,实际上 正在做 一些事情。

虽然这会增加一些混乱,但在阅读代码时很容易忽略注释。它们在编写代码时也很容易忘记,但类型检查器会捕捉到这些,因此很容易修复。

在您的特定情况下,我认为一些更结构化的助手 do 是有意义的。类似于:

softMove :: Text -> Text -> Text -> Value
softMove accessToken msgId groupId = object [
  "access_token" .= accessToken
  , "batch" .= [
        get "oldmsg" (msgId <> "?fields=from,message,id")
      , get "imp" "{result=oldmsg:$.from.id}?fields=impersonate_token"
      , post "newmsg" (groupId <> "...") "..."
      , post "oldcomment" "{result=oldmsg:$.id}/comments" "..."
      , post "newcomment" "{result=newmsg:$.id}/comments" "..."
      ]
  ]
  where get name url = object $ req "GET" name url
        post name url body = object $ req "POST" name url 
                             <> ["body" .= s body]
        req method name url = [ "method" .= s method, "name" .= s name, 
                                "relative_url" .= s url ]
        s = String

请注意,您可以根据在特定情况下生成的 特定 JSON 定制这些助手,并在 where 子句中本地定义它们.您无需提交代码中涵盖所有 JSON use-cases 的大块 ADT 和函数基础结构,如果 JSON 的结构更加统一,您可能会这样做应用程序。

评论 "Dynamic Strategy"

关于使用 "dynamic strategy" 是否是正确的方法,它可能取决于比在 Stack Overflow 问题中实际共享的上下文更多的上下文。但是,退一步说,Haskell 类型系统在帮助清楚地对问题域建模方面很有用。在最好的情况下,这些类型感觉很自然,可以帮助您编写正确的代码。当他们停止这样做时,您需要重新考虑您的类型。

您使用更传统的 ADT-driven 方法解决此问题时遇到的痛苦(类型的刚性、Maybes 的扩散以及 "tyranny of small differences")表明这些类型是糟糕的模型 至少对于你在这种情况下尝试做的事情来说是这样。 特别是,考虑到你的问题是生成相当简单的 JSON directives/commands外部 API,而不是对也恰好允许 JSON serialization/deserialization 的结构进行大量数据操作,将数据建模为 Haskell ADT 可能有点矫枉过正。

我最好的猜测是,如果你真的想正确地模拟 FB Workplace API,你不会想在 JSON 级别上进行。相反,您将使用 MessageCommentGroup 类型在更高的抽象级别上执行此操作,并且您最终想要动态生成 JSON无论如何,因为您的类型不会直接映射到 API.

所期望的 JSON 结构

将您的问题与生成 HTML 进行比较可能会很有见地。首先考虑 lucid(基于 blaze)或 shakespeare 模板包。如果你看一下它们是如何工作的,它们不会尝试通过使用像 data Element = ImgElement ... | BlockquoteElement ... 这样的 ADT 生成 DOM 然后将它们序列化为 HTML 来构建 HTML。据推测,作者认为这种抽象并不是真正必要的,因为 HTML 只需要 生成 ,而不是 分析 。相反,他们使用函数 (lucid) 或准引号 (shakespeare) 来构建表示 HTML 文档的动态数据结构。所选择的结构足够严格以确保某些种类的有效性(例如,正确匹配开始和结束元素标签)而不是其他(例如,没有人阻止你在中间粘贴 <p> child你的 <span> 元素)。

当你在一个更大的网络应用程序中使用这些包时,你在比 HTML 元素更高的抽象层次上对问题域建模,并且你以很大程度上动态的方式生成 HTML 因为您的问题域模型中的类型与 HTML 元素之间没有明确的 one-to-one 映射。

另一方面,有一个 type-of-html 确实 对单个元素建模,因此尝试在其中嵌套 <tr> 是类型错误<td> 等等。开发这些类型可能需要很多工作,而且有很多不灵活之处 "baked in",但是 trade-off 是另一个级别的类型安全。另一方面,这似乎对 HTML 比对特定挑剔的 JSON API.

更容易。