真正的Haskell实战——当Haskell遇上CRUD业务
记录我第一次拿Haskell写项目。2025年了,Haskell这门神仙语言写业务好用吗?
确定框架
拿Haskell写后端的人确实不多,我花了一点时间整理了一下:
服务器框架:
数据库框架:
- hdbc:官方和《Haskell实战》推荐,但点进Github主页一看已经成活化石了,少则两三年,最离谱的hdbc-sqlite已经十年没有维护了
- persistent:前面提到的yesod生态的一部分,没什么雷点,而且文档比起其他的好一些,决定采用。
- beam:本来是打算用这个的,这个库是ORM风格的,而且文档也不错,星标也不少。结果下下来一看没法编译,mysql后端缺乏维护,json依赖限定了一个远古版本,和scotty有冲突。postgres后端比较逆天,疑似是往Hackage发了个坏的版本,考虑到工期比较紧最后没用,不过其实这个库应该在Haskell生态里算做的不错的。附注:后来又有人遇到相同的问题作者给重新发版修了。
- postgresql-simple,mysql-simple:两哥们差不多,四五年没更新了,最重要的是找不到哪怕一点点文档,只有一些包浆教程
- opaleye,hasql,squeal-postgresql:几个比较像的库,提供了类型安全的SQL DSL,很有Haskell风格。但我看语法有点恶心(各种
.==
,~.
)而且文档生态似乎比persistent差一些,就没选。还有一个原因是我这个项目比较简单,我觉得应该用不到太复杂的SQL。
小试牛刀
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部分逻辑:
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查询
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的模板镜像。
重构——解嵌套
一开始的版本(纯匹配)
case blah of
Nothing -> error1
Just x -> do
case foo of
Nothing -> error2
Just y -> f x y
然后加了一个函数抽象
mapMaybeM blah error1 $ \x -> do
mapMaybeM foo error2 $ \y -> do
f x y
在QQ群问了问有没有更好的办法,一开始给了一个 callCC
的方案,后来我又自己试了试MaybeT
,发现Monad的组合性不太好,一次只能看见一层Monad,如果要用多个需要lift
。还有群友提议用hoist-error
,或者scotty
内置、似乎提倡的异常机制,但是我对拿异常写控制流心存顾虑,而且可能性能不太好。最后群友发现scotty
的ActionT
自带类似MonadCont
的机制,直接用那个就好了。
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模拟一下:
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)
不过后来想了想用异常也能实现。