第 15 章 - 同步的转换器(Transformers for synchronizing)
本章内容包括:
- 理解为什么普通 monad 需要组合
- 使用
ReaderT在线程化环境中传递配置和依赖 - 使用
StateT与WriterT维护状态和收集日志 - 使用
RWST组合读取环境、状态更新和输出记录 - 为应用程序构建命令行接口
前面几章中,我们已经见过很多效果:读取文件、解析错误、写出图像、捕获异常。每个效果单独看都不难,但真实程序通常需要把多个效果放在同一个动作中。例如,一个应用程序可能要读取配置、访问文件系统、记录日志、维护内存状态,并在失败时返回错误。
如果每种能力都用一个单独的 monad 表示,很快就会遇到一个问题:这些能力如何组合?IO 能做外部交互,Either 能编码失败,Reader 能读取环境,State 能维护状态,Writer 能收集输出。可是一个真实动作往往需要同时拥有这些能力。
本章会介绍 monad 转换器。它们允许我们把一种 monad 的能力叠到另一种 monad 上。我们会用这些工具构建一个小型同步应用程序:它扫描一个目录,找出文件,并把它们复制到目标目录。这个程序不复杂,但足以展示如何组织依赖、状态、日志和命令行选项。
15.1 Monad 转换器(Monad transformers)
先回顾一个简单问题。假设有一个读取配置的函数:
data Config = Config
{ sourceDir :: FilePath,
targetDir :: FilePath
}
copyConfigured :: Config -> IO ()
copyConfigured config = do
putStrLn ("Copying from " <> sourceDir config)
putStrLn ("Copying to " <> targetDir config)
这个函数能工作,但随着应用扩大,所有函数都需要显式接收 Config。这会让参数列表不断膨胀,也会让真正重要的业务参数被环境参数淹没。
Reader 模式解决的正是这个问题。它把“读取共享环境”变成一种上下文能力。普通 Reader 是纯的,而真实应用还需要 IO,所以我们使用 ReaderT。
15.1.1 使用 ReaderT 读取环境(Reading an environment with ReaderT)
ReaderT 的定义可以近似理解为:
newtype ReaderT r m a = ReaderT
{ runReaderT :: r -> m a
}
它描述一个依赖环境 r、最终在基础 monad m 中产生 a 的计算。这里的 T 表示 transformer,也就是转换器。
如果把基础 monad 选为 IO,就得到一种可以读取配置并执行 IO 的应用 monad:
代码清单 15.1 定义应用环境与 App 类型
module App.Types where
import Control.Monad.Reader
data AppEnv = AppEnv
{ envSourceDir :: FilePath,
envTargetDir :: FilePath,
envDryRun :: Bool
}
newtype App a = App
{ unApp :: ReaderT AppEnv IO a
}
runApp :: AppEnv -> App a -> IO a
runApp env action =
runReaderT (unApp action) env
为了让 App 更好用,需要为它派生一些实例。可以使用 GeneralizedNewtypeDeriving 扩展:
代码清单 15.2 为 App 派生常用实例
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module App.Types where
import Control.Monad.IO.Class
import Control.Monad.Reader
data AppEnv = AppEnv
{ envSourceDir :: FilePath,
envTargetDir :: FilePath,
envDryRun :: Bool
}
newtype App a = App
{ unApp :: ReaderT AppEnv IO a
}
deriving
( Functor,
Applicative,
Monad,
MonadIO,
MonadReader AppEnv
)
runApp :: AppEnv -> App a -> IO a
runApp env action =
runReaderT (unApp action) env
现在可以在 App 中使用 ask 读取完整环境,也可以用 asks 读取其中一个字段。
代码清单 15.3 从 ReaderT 环境中读取配置
module App.Log where
import App.Types
import Control.Monad.Reader
describeRun :: App String
describeRun = do
source <- asks envSourceDir -- #1
target <- asks envTargetDir -- #2
dryRun <- asks envDryRun -- #3
pure $
"source=" <> source
<> ", target=" <> target
<> ", dry-run=" <> show dryRun
- #1 读取源目录
- #2 读取目标目录
- #3 读取是否为试运行
如果需要执行普通 IO,可以用 liftIO:
代码清单 15.4 在 App 中执行 IO
module App.Log where
import App.Types
import Control.Monad.IO.Class
logInfo :: String -> App ()
logInfo message =
liftIO (putStrLn ("[info] " <> message))
这里的重点是:App 不是一个全新的运行时。它只是对 ReaderT AppEnv IO 的小包装,让函数签名更表达应用语义。
ReaderT 模式很常见。许多 Haskell 应用会定义一个 App 或 Handler 类型,里面包装 ReaderT Env IO,然后在整个程序中使用这个类型来传递配置、数据库连接池、日志函数或其他共享依赖。
15.1.2 StateT 与 WriterT
读取环境只是其中一种效果。有时还需要在计算过程中维护状态。例如,同步程序可能要统计已复制文件数量、跳过文件数量和失败数量。
StateT 可以为基础 monad 添加状态能力。它的形状可以近似理解为:
newtype StateT s m a = StateT
{ runStateT :: s -> m (a, s)
}
它接收初始状态 s,在基础 monad m 中产生结果 a 和新状态。
代码清单 15.5 使用 StateT 统计同步结果
module Sync.Stats where
import Control.Monad.State
data SyncStats = SyncStats
{ copiedFiles :: Int,
skippedFiles :: Int,
failedFiles :: Int
}
deriving (Show)
emptyStats :: SyncStats
emptyStats =
SyncStats
{ copiedFiles = 0,
skippedFiles = 0,
failedFiles = 0
}
markCopied :: StateT SyncStats IO ()
markCopied =
modify $ \stats ->
stats {copiedFiles = copiedFiles stats + 1}
markSkipped :: StateT SyncStats IO ()
markSkipped =
modify $ \stats ->
stats {skippedFiles = skippedFiles stats + 1}
可以运行这个状态计算:
runStateT markCopied emptyStats
结果类型是:
IO ((), SyncStats)
也就是说,动作本身没有有趣返回值,但它产生了更新后的统计信息。
WriterT 用于收集输出。这里的输出不是标准输出,而是一个通过 Monoid 组合的值,例如日志列表。
代码清单 15.6 使用 WriterT 收集日志
module Sync.Log where
import Control.Monad.Writer
type Log = [String]
copyPlan :: WriterT Log IO ()
copyPlan = do
tell ["scan source directory"]
tell ["copy changed files"]
tell ["remove stale files"]
运行后可以拿到结果和日志:
runWriterT copyPlan
其结果类型是:
IO ((), Log)
WriterT 很适合收集少量结构化信息。不过,要小心把它用于大量日志。长列表的追加可能导致性能问题。真实应用中的日志通常会直接写入 IO,或使用专门日志库。
现在已经有了三个能力:
ReaderT读取环境StateT维护状态WriterT收集输出
理论上可以把它们一层层叠起来:
type App = ReaderT AppEnv (StateT SyncStats (WriterT Log IO))
这种写法能工作,但顺序会影响运行函数和结果形状。转换器栈越深,理解成本就越高。对于同时需要 reader、writer 和 state 的情况,库已经提供了组合版本:RWST。
15.1.3 使用 RWST 堆叠多个转换器(Stacking multiple transformers with RWST)
RWST 同时提供 Reader、Writer 和 State 三种能力。它的完整名字可以读作 Reader-Writer-State Transformer。
简化后,它的运行形状类似:
runRWST :: RWST r w s m a -> r -> s -> m (a, s, w)
它接收环境 r 和初始状态 s,最后在基础 monad m 中产生结果 a、新状态 s 和日志 w。
代码清单 15.7 用 RWST 定义同步 monad
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module Sync.Types where
import Control.Monad.IO.Class
import Control.Monad.RWS.Strict
data SyncEnv = SyncEnv
{ syncSource :: FilePath,
syncTarget :: FilePath,
syncDryRun :: Bool
}
data SyncStats = SyncStats
{ statsCopied :: Int,
statsSkipped :: Int,
statsFailed :: Int
}
deriving (Show)
type SyncLog = [String]
newtype Sync a = Sync
{ unSync :: RWST SyncEnv SyncLog SyncStats IO a
}
deriving
( Functor,
Applicative,
Monad,
MonadIO,
MonadReader SyncEnv,
MonadWriter SyncLog,
MonadState SyncStats
)
现在一个动作可以同时读取环境、写日志和更新状态。
代码清单 15.8 在 RWST 中组合多种能力
module Sync.Actions where
import Control.Monad.RWS.Strict
import Sync.Types
markCopied :: FilePath -> Sync ()
markCopied path = do
tell ["copied " <> path] -- #1
modify $ \stats -> -- #2
stats {statsCopied = statsCopied stats + 1}
shouldCopy :: FilePath -> Sync Bool
shouldCopy _ = do
dryRun <- asks syncDryRun -- #3
pure (not dryRun)
- #1 写入日志
- #2 更新状态
- #3 读取环境
运行 Sync 也很直接:
代码清单 15.9 运行 RWST 应用动作
module Sync.Run where
import Control.Monad.RWS.Strict
import Sync.Types
emptyStats :: SyncStats
emptyStats =
SyncStats
{ statsCopied = 0,
statsSkipped = 0,
statsFailed = 0
}
runSync :: SyncEnv -> Sync a -> IO (a, SyncStats, SyncLog)
runSync env action =
runRWST (unSync action) env emptyStats
RWST 很方便,但不是所有程序都应该默认使用它。如果只需要读取环境,ReaderT 更清晰。如果只需要状态,StateT 更清晰。只有当这几个能力确实经常一起出现时,组合版本才值得使用。
15.2 应用程序实现(Implementing an application)
现在来实现一个小型同步应用。它的目标是:
- 从源目录递归列出文件
- 计算每个文件在目标目录中的对应路径
- 在试运行模式下只记录计划
- 在普通模式下复制文件
- 统计复制、跳过和失败数量
先定义领域类型。
代码清单 15.10 同步应用的类型
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
module Sync.Types where
import Control.Monad.IO.Class
import Control.Monad.RWS.Strict
data SyncEnv = SyncEnv
{ syncSource :: FilePath,
syncTarget :: FilePath,
syncDryRun :: Bool
}
data SyncStats = SyncStats
{ statsCopied :: Int,
statsSkipped :: Int,
statsFailed :: Int
}
deriving (Show)
emptyStats :: SyncStats
emptyStats =
SyncStats 0 0 0
type SyncLog = [String]
newtype Sync a = Sync
{ unSync :: RWST SyncEnv SyncLog SyncStats IO a
}
deriving
( Functor,
Applicative,
Monad,
MonadIO,
MonadReader SyncEnv,
MonadWriter SyncLog,
MonadState SyncStats
)
接着添加一些状态更新辅助函数。
代码清单 15.11 更新同步统计
module Sync.Stats where
import Control.Monad.State
import Sync.Types
modifyCopied :: Sync ()
modifyCopied =
modify $ \stats ->
stats {statsCopied = statsCopied stats + 1}
modifySkipped :: Sync ()
modifySkipped =
modify $ \stats ->
stats {statsSkipped = statsSkipped stats + 1}
modifyFailed :: Sync ()
modifyFailed =
modify $ \stats ->
stats {statsFailed = statsFailed stats + 1}
目录遍历可以沿用上一章的思路,但这次把异常转成失败结果,避免整个程序中断。
代码清单 15.12 列出源目录文件
module Sync.Files where
import Control.Exception
import System.Directory
import System.FilePath
import System.IO.Error
listFilesRecursive :: FilePath -> IO (Either IOException [FilePath])
listFilesRecursive root = do
result <- try (listDirectory root)
case result of
Left err -> pure (Left err)
Right names -> do
nested <- mapM visit names
pure (Right (concat nested))
where
visit name = do
let path = root </> name
isDir <- doesDirectoryExist path
if isDir
then do
result <- listFilesRecursive path
case result of
Left _ -> pure []
Right files -> pure files
else pure [path]
这个函数仍然有可以改进的地方:doesDirectoryExist 也可能失败。但作为第一个版本,它已经展示出我们想要的形状:外部错误被保留下来,而不是混进纯逻辑。
为了保持目录结构,需要把源路径映射到目标路径。假设源目录是 src,目标目录是 dst,源文件是 src/a/b.txt,那么目标文件应该是 dst/a/b.txt。
代码清单 15.13 计算目标路径
module Sync.Paths where
import System.FilePath
targetPathFor :: FilePath -> FilePath -> FilePath -> FilePath
targetPathFor sourceRoot targetRoot sourcePath =
targetRoot </> makeRelative sourceRoot sourcePath
现在实现复制单个文件。
代码清单 15.14 同步单个文件
module Sync.Actions where
import Control.Exception
import Control.Monad.IO.Class
import Control.Monad.RWS.Strict
import Sync.Paths
import Sync.Stats
import Sync.Types
import System.Directory
import System.FilePath
import System.IO.Error
syncOne :: FilePath -> Sync ()
syncOne sourcePath = do
sourceRoot <- asks syncSource
targetRoot <- asks syncTarget
dryRun <- asks syncDryRun
let targetPath = targetPathFor sourceRoot targetRoot sourcePath
if dryRun
then do
tell ["would copy " <> sourcePath <> " -> " <> targetPath]
modifySkipped
else do
result <- liftIO $
try $ do
createDirectoryIfMissing True (takeDirectory targetPath)
copyFile sourcePath targetPath
case result of
Left (err :: IOException) -> do
tell ["failed " <> sourcePath <> ": " <> show err]
modifyFailed
Right () -> do
tell ["copied " <> sourcePath <> " -> " <> targetPath]
modifyCopied
这段代码在模式匹配中给 err 写了类型,需要 ScopedTypeVariables。也可以通过给 result 添加显式类型避免扩展:
result <-
liftIO
( try $ do
createDirectoryIfMissing True (takeDirectory targetPath)
copyFile sourcePath targetPath
)
:: Sync (Either IOException ())
实际项目中任选一种即可。为了保持示例简洁,后续代码会用辅助函数隐藏这个细节。
代码清单 15.15 提取安全复制函数
module Sync.Actions where
import Control.Exception
import Control.Monad.IO.Class
import Control.Monad.RWS.Strict
import Sync.Paths
import Sync.Stats
import Sync.Types
import System.Directory
import System.FilePath
import System.IO.Error
copyFileSafe :: FilePath -> FilePath -> IO (Either IOException ())
copyFileSafe sourcePath targetPath =
try $ do
createDirectoryIfMissing True (takeDirectory targetPath)
copyFile sourcePath targetPath
syncOne :: FilePath -> Sync ()
syncOne sourcePath = do
sourceRoot <- asks syncSource
targetRoot <- asks syncTarget
dryRun <- asks syncDryRun
let targetPath = targetPathFor sourceRoot targetRoot sourcePath
if dryRun
then do
tell ["would copy " <> sourcePath <> " -> " <> targetPath]
modifySkipped
else do
result <- liftIO (copyFileSafe sourcePath targetPath)
case result of
Left err -> do
tell ["failed " <> sourcePath <> ": " <> show err]
modifyFailed
Right () -> do
tell ["copied " <> sourcePath <> " -> " <> targetPath]
modifyCopied
最后组合整个同步流程。
代码清单 15.16 同步整个目录
module Sync.App where
import Control.Monad.IO.Class
import Control.Monad.RWS.Strict
import Sync.Actions
import Sync.Files
import Sync.Stats
import Sync.Types
syncAll :: Sync ()
syncAll = do
source <- asks syncSource
filesResult <- liftIO (listFilesRecursive source)
case filesResult of
Left err -> do
tell ["failed to list source directory: " <> show err]
modifyFailed
Right files ->
mapM_ syncOne files
运行这个程序会得到三个东西:动作结果、最终统计和日志。
代码清单 15.17 运行同步程序
module Sync.Run where
import Control.Monad.RWS.Strict
import Sync.Types
runSync :: SyncEnv -> Sync a -> IO (a, SyncStats, SyncLog)
runSync env action =
runRWST (unSync action) env emptyStats
从外面看,同步程序已经相当整洁。内部函数可以读取配置、记录日志、更新统计和执行 IO。这些能力来自转换器栈,但业务代码不必到处显式传参。
这种设计的代价是类型变得更抽象。调试时需要理解当前 monad 提供哪些能力,以及某个函数到底能不能执行 IO、修改状态或写日志。因此,不要为了展示技巧而使用转换器。只有当它们确实简化了依赖传递和效果组合时,才值得引入。
15.3 提供命令行接口(Providing a CLI)
同步逻辑已经有了,现在需要从命令行读取参数。我们希望程序支持下面的调用方式:
sync-tool --source src --target dst
sync-tool --source src --target dst --dry-run
可以手动解析 getArgs,但随着参数变多,手写解析很容易出错。这里使用 optparse-applicative。它通过组合小解析器来定义命令行接口,并能自动生成帮助信息。
15.3.1 使用 optparse-applicative 解析参数(Parsing arguments with optparse-applicative)
先定义命令行选项类型。
代码清单 15.18 CLI 选项类型
module Cli.Options where
data Options = Options
{ optionSource :: FilePath,
optionTarget :: FilePath,
optionDryRun :: Bool
}
接着定义解析器。
代码清单 15.19 解析命令行选项
module Cli.Options where
import Options.Applicative
data Options = Options
{ optionSource :: FilePath,
optionTarget :: FilePath,
optionDryRun :: Bool
}
optionsParser :: Parser Options
optionsParser =
Options
<$> strOption
( long "source"
<> metavar "DIR"
<> help "Source directory"
)
<*> strOption
( long "target"
<> metavar "DIR"
<> help "Target directory"
)
<*> switch
( long "dry-run"
<> help "Show planned operations without copying files"
)
strOption 解析字符串选项,switch 解析布尔开关。Options 构造器通过 applicative 风格把三个字段组合起来。
现在把解析器变成完整程序信息。
代码清单 15.20 构建 CLI 入口
module Cli.Options where
import Options.Applicative
data Options = Options
{ optionSource :: FilePath,
optionTarget :: FilePath,
optionDryRun :: Bool
}
optionsParser :: Parser Options
optionsParser =
Options
<$> strOption
( long "source"
<> metavar "DIR"
<> help "Source directory"
)
<*> strOption
( long "target"
<> metavar "DIR"
<> help "Target directory"
)
<*> switch
( long "dry-run"
<> help "Show planned operations without copying files"
)
parseOptions :: IO Options
parseOptions =
execParser $
info
(optionsParser <**> helper)
( fullDesc
<> progDesc "Synchronize files from one directory to another"
<> header "sync-tool"
)
helper 会添加 --help 支持。execParser 会读取真实命令行参数;如果参数无效,它会打印错误信息并退出程序。
15.3.2 枚举自定义类型(Enumerating custom types)
布尔值适合简单开关,但有时命令行需要多个模式。例如,同步程序可以支持三种冲突处理策略:
- 覆盖目标文件
- 跳过已有文件
- 失败并报告错误
可以用代数数据类型表达:
代码清单 15.21 定义冲突策略
module Sync.Conflict where
data ConflictStrategy
= Overwrite
| SkipExisting
| FailOnConflict
deriving (Eq, Show)
然后写一个解析函数:
代码清单 15.22 从字符串解析冲突策略
module Sync.Conflict where
data ConflictStrategy
= Overwrite
| SkipExisting
| FailOnConflict
deriving (Eq, Show)
parseConflictStrategy :: String -> Maybe ConflictStrategy
parseConflictStrategy value =
case value of
"overwrite" -> Just Overwrite
"skip" -> Just SkipExisting
"fail" -> Just FailOnConflict
_ -> Nothing
optparse-applicative 可以通过 eitherReader 使用这种解析函数:
代码清单 15.23 解析枚举选项
module Cli.Conflict where
import Options.Applicative
import Sync.Conflict
conflictReader :: ReadM ConflictStrategy
conflictReader =
eitherReader $ \value ->
case parseConflictStrategy value of
Just strategy -> Right strategy
Nothing ->
Left "expected one of: overwrite, skip, fail"
conflictOption :: Parser ConflictStrategy
conflictOption =
option
conflictReader
( long "on-conflict"
<> metavar "MODE"
<> value Overwrite
<> help "Conflict handling: overwrite, skip, or fail"
)
value Overwrite 表示如果用户没有提供该选项,就使用默认值。
15.3.3 Enum 类
如果一个类型的构造器是有限且有顺序的,可以派生 Enum 和 Bounded。
代码清单 15.24 派生 Enum 和 Bounded
data ConflictStrategy
= Overwrite
| SkipExisting
| FailOnConflict
deriving (Bounded, Enum, Eq, Show)
Enum 允许在构造器之间转换:
fromEnum Overwrite
fromEnum SkipExisting
toEnum 2 :: ConflictStrategy
Bounded 提供最小值和最大值:
minBound :: ConflictStrategy
maxBound :: ConflictStrategy
它们组合起来可以列出所有构造器:
代码清单 15.25 列出所有策略
allStrategies :: [ConflictStrategy]
allStrategies =
[minBound .. maxBound]
这对生成帮助文本很有用。为了把策略转换成命令行中的字符串,可以再定义一个函数:
代码清单 15.26 显示命令行枚举值
strategyName :: ConflictStrategy -> String
strategyName strategy =
case strategy of
Overwrite -> "overwrite"
SkipExisting -> "skip"
FailOnConflict -> "fail"
strategyHelp :: String
strategyHelp =
"one of: " <> unwords (map strategyName allStrategies)
也可以反过来用 allStrategies 实现解析:
代码清单 15.27 通过枚举列表解析策略
parseConflictStrategy :: String -> Maybe ConflictStrategy
parseConflictStrategy value =
let matches strategy = strategyName strategy == value
in case filter matches allStrategies of
[strategy] -> Just strategy
_ -> Nothing
这能减少重复,但仍然要小心:命令行字符串是用户界面的一部分,不一定应该直接等于 Haskell 构造器名称。构造器可以改名,命令行参数最好保持稳定。
15.3.4 App 的命令行接口(A CLI for App)
现在把 CLI 与同步应用连接起来。
代码清单 15.28 从选项构建同步环境
module Cli.Run where
import Cli.Options
import Sync.Types
optionsToEnv :: Options -> SyncEnv
optionsToEnv options =
SyncEnv
{ syncSource = optionSource options,
syncTarget = optionTarget options,
syncDryRun = optionDryRun options
}
最后写 main:
代码清单 15.29 同步工具入口
module Main (main) where
import Cli.Options
import Cli.Run
import Sync.App
import Sync.Run
import Sync.Types
printLog :: SyncLog -> IO ()
printLog =
mapM_ putStrLn
printStats :: SyncStats -> IO ()
printStats stats = do
putStrLn ("copied: " <> show (statsCopied stats))
putStrLn ("skipped: " <> show (statsSkipped stats))
putStrLn ("failed: " <> show (statsFailed stats))
main :: IO ()
main = do
options <- parseOptions
let env = optionsToEnv options
(_, stats, logLines) <- runSync env syncAll
printLog logLines
printStats stats
这个 main 很薄。它只负责:
- 解析命令行参数
- 构造应用环境
- 运行应用逻辑
- 打印结果
这是一种很有用的结构。程序入口处处理外部世界,核心逻辑留在自己的应用 monad 中。这样可以让功能更容易测试,也更容易替换界面。例如,未来可以为同一个同步核心添加配置文件、HTTP API 或图形界面,而不必重写同步逻辑。
本章的同步工具还可以继续改进。它目前没有比较文件修改时间,也没有删除目标目录中多余文件,也没有并行复制。不过,结构已经足够表达真实应用的骨架:环境、状态、日志、外部错误和 CLI 都各有归属。
总结
- Monad 转换器用于把一种 monad 的能力叠加到另一种 monad 上。
ReaderT r m a表示能读取环境r,并在基础 monadm中产生结果a的计算。- ReaderT 模式适合在应用中传递配置、连接池、日志函数和其他共享依赖。
StateT添加状态能力,适合维护计数器、缓存或逐步构建的上下文。WriterT添加输出收集能力,适合收集少量日志或解释器输出。RWST同时组合 Reader、Writer 和 State,适合这些能力经常一起出现的场景。- 转换器能让业务函数签名更整洁,但也会提高类型和运行方式的理解成本。
optparse-applicative可以用组合式解析器描述命令行接口,并自动生成帮助信息。- 对有限的命令行模式,可以用代数数据类型建模,并用
Enum、Bounded或自定义解析函数处理。 - 一个清晰的
main应该尽量薄,把解析、环境构造、核心逻辑和结果展示分开。