Skip to content

真正的Haskell实战——当Haskell遇上CRUD业务

记录我第一次拿Haskell写项目。2025年了,Haskell这门神仙语言写业务好用吗?

确定框架

拿Haskell写后端的人确实不多,我花了一点时间整理了一下:

  • 服务器框架:

    • yesod:看起来最老生态最好,但是一进他的Github仓库就看到它CI没过,印象太差遂未尝试
    • snap:没有CI不过,但是主页看到它用nix构建(不喜欢nix,硬盘杀手),且星标比scotty少一些,犹豫了一下选了scotty
    • scotty:粗看没有雷点,而且主页写了个例子看起来很简单,决定使用
    • servant:后来群友推荐的,但已经开始写了遂作罢。看起来还比较活跃,星标也很多。
  • 数据库框架:

    • hdbc:官方和《Haskell实战》推荐,但点进Github主页一看已经成活化石了,少则两三年,最离谱的hdbc-sqlite已经十年没有维护了
    • persistent:前面提到的yesod生态的一部分,没什么雷点,而且文档比起其他的好一些,决定采用。
    • beam:本来是打算用这个的,这个库是ORM风格的,而且文档也不错,星标也不少。结果下下来一看没法编译,mysql后端缺乏维护,json依赖限定了一个远古版本,和scotty有冲突。postgres后端比较逆天,疑似是往Hackage发了个坏的版本,考虑到工期比较紧最后没用,不过其实这个库应该在Haskell生态里算做的不错的。附注:后来又有人遇到相同的问题作者给重新发版修了。
    • postgresql-simplemysql-simple:两哥们差不多,四五年没更新了,最重要的是找不到哪怕一点点文档,只有一些包浆教程
    • opaleyehasqlsqueal-postgresql:几个比较像的库,提供了类型安全的SQL DSL,很有Haskell风格。但我看语法有点恶心(各种.==, ~.)而且文档生态似乎比persistent差一些,就没选。还有一个原因是我这个项目比较简单,我觉得应该用不到太复杂的SQL。

小试牛刀

haskell
sqliteConn :: Text
{-# NOINLINE sqliteConn #-}
sqliteConn = pack $ unsafePerformIO $ getEnv "DEEPSLEEP_DB_CONN"
main :: IO ()
main = do
  runSqlite sqliteConn $ do runMigration migrateAll
  scotty 3000 $ do
    middleware logStdoutDev
    get "/" $ do
      text "Hello world"
    get "/user/:name" userGetId

userGetId = do
  name <- pathParam "name"              -- http://localhost:3000/foo -> foo
  muser <- runSqlite sqliteConn $ do
    result <- getBy $ UniqueName name
    return $ fmap entityVal result      -- 想你了result.map
  case muser of
    Nothing -> status status404
    Just user -> json user


-- 将UUID介绍给Persist
-- https://github.com/yesodweb/persistent/issues/579
instance PersistField UUID where
  toPersistValue = PersistText . UUID.toText
  fromPersistValue (PersistText t) =
    case UUID.fromText t of
      Just x -> Right x
      Nothing -> Left "Invalid UUID"
  fromPersistValue x = Left $ pack $ "Expected PersistText, found " ++ show x
instance PersistFieldSql UUID where
  sqlType _ = SqlOther "uuid"

share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
User
    ident UUID
    name String
    UniqueName name
    deriving Show
    deriving Generic
    deriving ToJSON -- 似乎依赖deriving Generic
|]

整理一下CRUD部分逻辑:

haskell
getUser :: String -> IO (Maybe User)
getUser name = runSqlite sqliteConn $ do
  result <- getBy $ UniqueName name
  return $ fmap entityVal result

userRegister :: ActionT IO ()
userRegister = do
  -- 解析json
  b <- body
  -- 这个let是不是有点眼熟...
  let mname = case decode b of
        Just (UserRegisterReq name) -> Just name
        _ -> Nothing

  case mname of
    Nothing -> status status400
    Just name -> do
      -- 检查是否已有同名用户
      muser <- liftIO $ getUser name
      case muser of
        -- 没有
        Nothing -> do
          uuid <- liftIO nextRandom
          _ <- runSqlite sqliteConn $ do insert $ User uuid name
          status status200
        -- 有
        Just _ -> status status409

userGetId :: ActionT IO ()
userGetId = do
  name <- pathParam "name"
  muser <- liftIO $ getUser name
  case muser of
    Nothing -> status status404
    Just user -> json user

Monad小记

除了要注意各种Monad的嵌套和解包没啥难度,遇到看不懂的Monad报错丢给deepseek都能解决。

感觉Monad解包稍微有点束手束脚。到处要写do也有点烦(主要是格式化工具ormolu不是很符合我的审美)。

对Monad稍微有了些理解:Monad就是在限制你什么时候能干什么事,比如SQL查询必须通过runSqlite提供一个SQL Monad。

稍复杂一些的SQL查询

haskell
mfriend <-
runSqlite sqliteConn $
  selectFirst ([FriendUser1 ==. myUid, FriendUser2 ==. uid] ||. [FriendUser1 ==. uid, FriendUser2 ==. myUid]) []

friends <- runSqlite sqliteConn $ selectList ([FriendUser1 ==. myUid] ||. [FriendUser2 ==. myUid]) []

最后还是用上了||.==.的语法,有点不爽。

暂时没感觉到Haskell的优势,编译的时候链接时间有点长(没有热加载) 的理解稍微高了些。论简洁也没有简单到哪里去(毕竟CRUD生态早就很成熟了,感觉不同语言框架都抽象的差不多了)。

生态上的话,persistent用了模板,需要自己编译动态链接的hls,hlint更是直呼看不懂,源码编译hls有点慢(ghc-make里看到了很多shell脚本有点难绷),不过通过ghcup还算方便。初次编译项目时间不算短,CD上很慢不过平台给的容器性能确实也不好,DockerHub上有提供Haskell的模板镜像。

重构——解嵌套

一开始的版本(纯匹配)

haskell
case blah of
    Nothing -> error1
    Just x -> do
        case foo of
            Nothing -> error2
            Just y -> f x y

然后加了一个函数抽象

haskell
mapMaybeM blah error1 $ \x -> do
    mapMaybeM foo error2 $ \y -> do
        f x y

在QQ群问了问有没有更好的办法,一开始给了一个 callCC的方案,后来我又自己试了试MaybeT,发现Monad的组合性不太好,一次只能看见一层Monad,如果要用多个需要lift。还有群友提议用hoist-error,或者scotty内置、似乎提倡的异常机制,但是我对拿异常写控制流心存顾虑,而且可能性能不太好。最后群友发现scottyActionT自带类似MonadCont的机制,直接用那个就好了。

haskell
x <- case blah of
    Nothing -> error1 >> finish
    Just x -> pure x
y <- case foo of
    Nothing -> error2 >> finish
    Just y -> pure y
f x y

有一说一这个MonadCont相当不错,相当于提供了一个当前函数的exit,相当于你又能提前返回又能过类型检查,而且全程保持表达式风格。scala原生的话我只能想到用for模拟一下:

scala
val result = for {
  x <- blah match {
    case None => Left("error1")
    case Some(x) => Right(x)
  }
  y <- foo match {
    case None => Left("error2")
    case Some(y) => Right(y)
  }
} yield f(x, y)

不过后来想了想用异常也能实现。