56

什么是依赖注入的惯用 Haskell 解决方案?

例如,假设您有一个 interface frobby,并且您需要传递一个符合frobbyaround 的实例(这些实例可能有多种变体,例如foobar)。

典型的操作是:

  • 接受一些值X并返回一些值的函数Y。例如,这可能是一个数据库访问器,接受一个 SQL 查询和一个连接器并返回一个数据集。您可能需要实现 postgres、mysql 和模拟测试系统。

  • 接受一些值Z并返回与 相关的闭包的函数Z,专用于给定foobar样式,在运行时选择。

一个人解决了以下问题:

http://mikehadlow.blogspot.com/2011/05/dependency-injection-haskell-style.html

但我不知道这是否是管理此任务的规范方法。

4

4 回答 4

129

我认为正确的答案是,我可能会因为这样说而收到一些反对意见:忘记术语依赖注入。把它忘了吧。这是来自 OO 世界的流行语,但仅此而已。

让我们解决真正的问题。请记住,您正在解决一个问题,而该问题是手头的特定编程任务。不要让你的问题“实现依赖注入”。

我们将以记录器为例,因为这是许多程序都希望拥有的基本功能,并且有许多不同类型的记录器:一种记录到标准错误,一种记录到文件,数据库,和一个什么都不做的人。要统一所有它们,您需要一个类型:

type Logger m = String -> m ()

您还可以选择更高级的类型来节省一些击键:

class PrettyPrint a where
    pretty :: a -> String

type Logger m = forall a. (PrettyPrint a) => a -> m ()

现在让我们使用后一种变体定义一些记录器:

noLogger :: (Monad m) => Logger m
noLogger _ = return ()

stderrLogger :: (MonadIO m) => Logger m
stderrLogger x = liftIO . hPutStrLn stderr $ pretty x

fileLogger :: (MonadIO m) => FilePath -> Logger m
fileLogger logF x =
    liftIO . withFile logF AppendMode $ \h ->
        hPutStrLn h (pretty x)

acidLogger :: (MonadIO m) => AcidState MyDB -> Logger m
acidLogger db x = update' db . AddLogLine $ pretty x

您可以看到这是如何构建依赖关系图的。这acidLogger取决于MyDB数据库布局的数据库连接。将参数传递给函数是在程序中表达依赖关系的最自然的方式。毕竟,函数只是一个依赖于另一个值的值。行动也是如此。如果您的操作依赖于记录器,那么它自然是记录器的函数:

printFile :: (MonadIO m) => Logger m -> FilePath -> m ()
printFile log fp = do
    log ("Printing file: " ++ fp)
    liftIO (readFile fp >>= putStr)
    log "Done printing."

看看这有多容易?在某些时候,当您忘记 OO 教给您的所有废话时,这会让您意识到您的生活会变得多么轻松。

于 2013-01-15T00:53:55.973 回答
15

使用pipes. 我不会说它是惯用的,因为该库仍然相对较新,但我认为它完全解决了您的问题。

例如,假设您要包装某个数据库的接口:

import Control.Proxy

-- This is just some pseudo-code.  I'm being lazy here
type QueryString = String
type Result = String
query :: QueryString -> IO Result

database :: (Proxy p) => QueryString -> Server p QueryString Result IO r
database = runIdentityK $ foreverK $ \queryString -> do
    result <- lift $ query queryString
    respond result

然后我们可以对数据库的一个接口进行建模:

user :: (Proxy p) => () -> Client p QueryString Result IO r
user () = forever $ do
    lift $ putStrLn "Enter a query"
    queryString <- lift getLine
    result <- request queryString
    lift $ putStrLn $ "Result: " ++ result

你像这样连接它们:

runProxy $ database >-> user

然后,这将允许用户从提示中与数据库进行交互。

然后我们可以使用模拟数据库切换数据库:

mockDatabase :: (Proxy p) => QueryString -> Server p QueryString Result IO r
mockDatabase = runIdentityK $ foreverK $ \query -> respond "42"

现在我们可以很容易地为模拟数据库切换数据库:

runProxy $ mockDatabase >-> user

或者我们可以切换出数据库客户端。例如,如果我们注意到一个特定的客户端会话触发了一些奇怪的错误,我们可以像这样重现它:

reproduce :: (Proxy p) => () -> Client p QueryString Result IO ()
reproduce () = do
    request "SELECT * FROM WHATEVER"
    request "CREATE TABLE BUGGED"
    request "I DON'T REALLY KNOW SQL"

...然后像这样连接起来:

runProxy $ database >-> reproduce

pipes允许您将流式或交互行为拆分为模块化组件,以便您可以随意混合和匹配它们,这就是依赖注入的本质。

要了解更多信息pipes,请阅读Control.Proxy.Tutorial中的教程。

于 2013-01-14T23:42:14.867 回答
5

为了建立 ertes 的答案,我认为所需的签名printFileprintFile :: (MonadIO m, MonadLogger m) => FilePath -> m (),我将其读作“我将打印给定的文件。为此,我需要做一些 IO 和一些日志记录。”

我不是专家,但这是我对这个解决方案的尝试。对于如何改进这一点,我将不胜感激。

{-# LANGUAGE FlexibleInstances #-}

module DependencyInjection where

import Prelude hiding (log)
import Control.Monad.IO.Class
import Control.Monad.Identity
import System.IO
import Control.Monad.State

-- |Any function that can turn a string into an action is considered a Logger.
type Logger m = String -> m ()

-- |Logger that does nothing, for testing.
noLogger :: (Monad m) => Logger m
noLogger _ = return ()

-- |Logger that prints to STDERR.
stderrLogger :: (MonadIO m) => Logger m
stderrLogger x = liftIO $ hPutStrLn stderr x

-- |Logger that appends messages to a given file.
fileLogger :: (MonadIO m) => FilePath -> Logger m
fileLogger filePath value = liftIO logToFile
  where
      logToFile :: IO ()
      logToFile = withFile filePath AppendMode $ flip hPutStrLn value


-- |Programs have to provide a way to the get the logger to use.
class (Monad m) => MonadLogger m where
    getLogger :: m (Logger m)

-- |Logs a given string using the logger obtained from the environment.
log :: (MonadLogger m) => String -> m ()
log value = do logger <- getLogger
               logger value

-- |Example function that we want to run in different contexts, like
--  skip logging during testing.
printFile :: (MonadIO m, MonadLogger m) => FilePath -> m ()
printFile fp = do
    log ("Printing file: " ++ fp)
    liftIO (readFile fp >>= putStr)
    log "Done printing."


-- |Let's say this is the real program: it keeps the log file name using StateT.
type RealProgram = StateT String IO

-- |To get the logger, build the right fileLogger.
instance MonadLogger RealProgram where
    getLogger = do filePath <- get
                   return $ fileLogger filePath

-- |And this is how you run printFile "for real".
realMain :: IO ()
realMain = evalStateT (printFile "file-to-print.txt") "log.out"


-- |This is a fake program for testing: it will not do any logging.
type FakeProgramForTesting = IO

-- |Use noLogger.
instance MonadLogger FakeProgramForTesting where
    getLogger = return noLogger

-- |The program doesn't do any logging, but still does IO.
fakeMain :: IO ()
fakeMain = printFile "file-to-print.txt"
于 2014-05-26T14:32:39.927 回答
4

另一种选择是使用存在量化数据类型。我们以XMonad为例。frobby布局有一个 ( ) 接口——LayoutClass类型类:

-- | Every layout must be an instance of 'LayoutClass', which defines
-- the basic layout operations along with a sensible default for each.
-- 
-- ...
-- 
class Show (layout a) => LayoutClass layout a where

    ...

和存在数据类型Layout

-- | An existential type that can hold any object that is in 'Read'
--   and 'LayoutClass'.
data Layout a = forall l. (LayoutClass l a, Read (l a)) => Layout (l a)

可以包装任何(foobar)接口实例LayoutClass。它本身就是一个布局:

instance LayoutClass Layout Window where
    runLayout (Workspace i (Layout l) ms) r = fmap (fmap Layout) `fmap` runLayout (Workspace i l ms) r
    doLayout (Layout l) r s  = fmap (fmap Layout) `fmap` doLayout l r s
    emptyLayout (Layout l) r = fmap (fmap Layout) `fmap` emptyLayout l r
    handleMessage (Layout l) = fmap (fmap Layout) . handleMessage l
    description (Layout l)   = description l

现在可以仅通过接口方法Layout通用地使用数据类型。LayoutClass在运行时将选择实现接口的适当布局,在XMonad.Layoutxmonad-contribLayoutClass中有一堆。而且,当然,可以在不同的布局之间动态切换:

-- | Set the layout of the currently viewed workspace
setLayout :: Layout Window -> X ()
setLayout l = do
    ss@(W.StackSet { W.current = c@(W.Screen { W.workspace = ws })}) <- gets windowset
    handleMessage (W.layout ws) (SomeMessage ReleaseResources)
    windows $ const $ ss {W.current = c { W.workspace = ws { W.layout = l } } }
于 2013-01-15T17:16:25.127 回答