跳到主要内容

函数式里的依赖注入:Reader Monad

· 阅读需 9 分钟

经常搞六边形架构的人也知道,为了保持核心业务逻辑的纯粹和独立,我们会把像数据库、外部 API 调用这些“副作用”放在“端口”和“适配器”里,然后通过 DI 的方式注入到应用层。可以说,经典的面向对象和分层架构,离不开 DI。

然后,当我想在 MoonBit 里做点事情的时候,我发现我不能呼吸了。

我们也想讲究一个入乡随俗,但是在 moonbit 这种函数味儿很浓郁的场地,没有类,没有接口,更没有我们熟悉的那一套 DI 容器。那我怎么做 DI?

我当时就在想,软件工程发展到至今已经约 57 年,真的没有在函数式编程里解决 DI 的方法吗?

有的兄弟,有的。只是它在函数式编程里也属于一种 monad:Reader Monad

什么是 Monad

普通的函数就像一个流水线,你丢进去一袋面粉,然后直接跑到生产线末端,等着方便面出来。但这条流水线需要自动处理中间的所有复杂情况:

  • 没放面粉/“没有下单,期待发货”(null)
  • 面团含水量不够把压面机干卡了(抛出异常)
  • 配料机需要读取今天的生产配方,比如是红烧牛肉味还是香菇炖鸡味(读取外部配置)
  • 流水线末端的打包机需要记录今天打包了多少包(更新计数器)

Monad 就是专门管理这条复杂流水线的“总控制系统”。它把你的数据和处理流程的上下文一起打包,确保整个流程能顺畅、安全地进行下去。

在软件开发中,Monad 这一家子有几个常见的成员:

  • Option:处理“可能没有”的情况。盒子里要么有东西,要么是空的
  • Result:处理“可能会失败”的情况。盒子要么是绿的(成功),里面装着结果;要么是红的(失败),里面装着错误信息
  • State Monad:处理“需要修改状态”的情况。这个盒子在产出结果的同时,还会更新盒子侧面的一个计数器。或者说就是 React 里的 useState
  • Future(Promise):处理“未来才有”的情况。这个盒子给你一张“提货单”,承诺未来会把货给你
  • Reader Monad: 盒子可以随时查阅“环境”,但不能修改它

Reader Monad

Reader Monad 的思想,最早可以追溯到上世纪90年代,在 Haskell 这种纯函数式编程语言的圈子里流行起来。当时大家为了坚守“函数纯度”这个铁律(即函数不能有副作用),就必须找到一种优雅的方式来让多个函数共享同一个配置环境,Reader Monad 就是为了解决这个矛盾而诞生的。

如今,它的应用场景已经非常广泛:

  • 应用配置管理:用来传递数据库连接池、API密钥、功能开关等全局配置
  • 请求上下文注入:在 Web 服务中,把当前登录的用户信息等打包成一个环境,供请求处理链上的所有函数使用
  • 实现六边形架构:在六边形(或端口与适配器)架构中,它被用来在核心业务逻辑(Domain/Application Layer)和外部基础设施(Infrastructure Layer)之间建立一道防火墙

简单来说,Reader Monad 就是一个专门处理只读环境依赖的工具。它要解决的就是这些问题:

  • 参数钻孔 (Parameter Drilling):我们不想把一个 Properties 层层传递
  • 逻辑与配置解耦:业务代码只关心“做什么”,而不用关心“配置从哪来”。这使得代码非常干净,且极易测试

核心方法

一个 Reader 库通常包含以下几个核心部分。

Reader::pure

就像是把一颗糖直接放进一个标准的午餐盒里。它把一个普通的值,包装成一个最简单的、不依赖任何东西的 Reader 计算。

pure 通常是流水线的打包机,它把你计算出的最终结果(一个普通值)重新放回 Reader “流水线”上,所谓“移除副作用”。

typealias @reader.Reader

// `pure` 创建一个不依赖环境的计算
let 
?
pure_reader
: Reader[
String
String
,
Int
Int
] =
(Int) -> ?
Reader::pure
(100)
test { // 无论环境是什么 (比如 "hello"),结果都是 100
(a : Int, b : Int, msg? : String, loc~ : SourceLoc = _) -> Unit raise Error

Asserts that two values are equal. If they are not equal, raises a failure with a message containing the source location and the values being compared.

Parameters:

  • a : First value to compare.
  • b : Second value to compare.
  • loc : Source location information to include in failure messages. This is usually automatically provided by the compiler.

Throws a Failure error if the values are not equal, with a message showing the location of the failing assertion and the actual values that were compared.

Example:

  assert_eq(1, 1)
  assert_eq("hello", "hello")
assert_eq
(
?
pure_reader
.
(String) -> Int
run
("hello"), 100)
}

Reader::bind

这是流水线的“连接器”。例如把“和面”这一步和“压面”这一步连接起来,并确保它们能连成一条“生产线”。

为什么需要它? 为了自动化!bind 让这个过程全自动,你只管定义好每个步骤,它负责传递。

fnalias 
() -> ?
@reader.ask
// 步骤1: 定义一个 Reader,它的工作是从环境(一个Int)中读取值 let
?
step1
: Reader[
Int
Int
,
Int
Int
] =
() -> ?
ask
()
// 步骤2: 定义一个函数,它接收一个数字,然后返回一个新的 Reader 计算 fn
(n : Int) -> ?
step2_func
(
Int
n
:
Int
Int
) -> Reader[
Int
Int
,
Int
Int
] {
(Int) -> ?
Reader::pure
(
Int
n
(self : Int, other : Int) -> Int

Multiplies two 32-bit integers. This is the implementation of the * operator for Int.

Parameters:

  • self : The first integer operand.
  • other : The second integer operand.

Returns the product of the two integers. If the result overflows the range of Int, it wraps around according to two's complement arithmetic.

Example:

  inspect(42 * 2, content="84")
  inspect(-10 * 3, content="-30")
  let max = 2147483647 // Int.max_value
  inspect(max * 2, content="-2") // Overflow wraps around
*
2)
} // 使用 bind 将两个步骤连接起来 let
?
computation
: Reader[
Int
Int
,
Int
Int
] =
?
step1
.
((Int) -> ?) -> ?
bind
(
(n : Int) -> ?
step2_func
)
test { // 运行整个计算,环境是 5 // 流程: step1 从环境得到 5 -> bind 把 5 交给 step2_func -> step2_func 计算 5*2=10 -> pure(10)
(a : Int, b : Int, msg? : String, loc~ : SourceLoc = _) -> Unit raise Error

Asserts that two values are equal. If they are not equal, raises a failure with a message containing the source location and the values being compared.

Parameters:

  • a : First value to compare.
  • b : Second value to compare.
  • loc : Source location information to include in failure messages. This is usually automatically provided by the compiler.

Throws a Failure error if the values are not equal, with a message showing the location of the failing assertion and the actual values that were compared.

Example:

  assert_eq(1, 1)
  assert_eq("hello", "hello")
assert_eq
(
?
computation
.
(Int) -> Int
run
(5), 10)
}

Reader::map

就像是给午餐盒里的三明治换个标签。它只改变盒子里的东西(比如把薄荷塘换成酒心巧克力),但不动午餐盒本身。

很多时候我们只是想对结果做个简单转换,用 map 比用 bind 更直接,意图更清晰。

// `map` 只转换结果,不改变依赖
let 
?
reader_int
: Reader[
Unit
Unit
,
Int
Int
] =
(Int) -> ?
Reader::pure
(5)
let
?
reader_string
: Reader[
Unit
Unit
,
String
String
] =
?
reader_int
.
((Unit) -> String) -> ?
map
(
Unit
n
=> "Value is \{
Unit
n
}")
test {
(a : String, b : String, msg? : String, loc~ : SourceLoc = _) -> Unit raise Error

Asserts that two values are equal. If they are not equal, raises a failure with a message containing the source location and the values being compared.

Parameters:

  • a : First value to compare.
  • b : Second value to compare.
  • loc : Source location information to include in failure messages. This is usually automatically provided by the compiler.

Throws a Failure error if the values are not equal, with a message showing the location of the failing assertion and the actual values that were compared.

Example:

  assert_eq(1, 1)
  assert_eq("hello", "hello")
assert_eq
(
?
reader_string
.
(Unit) -> String
run
(()), "Value is 5")
}

ask

ask 就像是流水线上的一个工人,随时可以抬头看一眼挂在墙上的“生产配方”。这是我们真正读取环境的唯一手段。

bind 只负责在幕后传递,但当你想知道“配方”里到底写了什么时,就必须用 ask 把它“问”出来。

// `ask` 直接获取环境
let 
?
ask_reader
: Reader[
String
String
,
String
String
] =
() -> ?
ask
()
let
String
result
:
String
String
=
?
ask_reader
.
(String) -> String
run
("This is the environment")
test {
(a : String, b : String, msg? : String, loc~ : SourceLoc = _) -> Unit raise Error

Asserts that two values are equal. If they are not equal, raises a failure with a message containing the source location and the values being compared.

Parameters:

  • a : First value to compare.
  • b : Second value to compare.
  • loc : Source location information to include in failure messages. This is usually automatically provided by the compiler.

Throws a Failure error if the values are not equal, with a message showing the location of the failing assertion and the actual values that were compared.

Example:

  assert_eq(1, 1)
  assert_eq("hello", "hello")
assert_eq
(
String
result
, "This is the environment")
}

而我们接下来会经常用到的 asks,只是对 ask().map() 的封装。

DI 对比 Reader Monad

搞个经典例子:开发一个 UserService,它需要一个 Logger 来记录日志,还需要一个 Database 来获取数据。

普通的 DI 我这里用我第二喜欢的 TypeScript 举例:

interface Logger {
  info(message: string): void
}
interface Database {
  getUserById(id: number): { name: string } | undefined
}

// 业务类通过构造函数声明其依赖
class UserService {
  constructor(
    private logger: Logger,
    private db: Database
  ) {}

  getUserName(id: number): string | undefined {
    this.logger.info(`Querying user with id: ${id}`)
    const user = this.db.getUserById(id)
    return user?.name
  }
}

// 创建依赖实例并注入
const myLogger: Logger = { info: (msg) => console.log(`[LOG] ${msg}`) }
const myDb: Database = {
  getUserById: (id) => (id === 1 ? { name: 'MoonbitLang' } : undefined)
}

const userService = new UserService(myLogger, myDb)
const userName = userService.getUserName(1) // "MoonbitLang"

// 一般来说我们会用一些库管理注入,不会手动实例化。例如 InversifyJS 亦或者是……Angular

Reader Monad

fnalias 
((Unit) -> String) -> ?
@reader.asks
struct User {
String
name
:
String
String
} trait
trait Logger {
  info(Self, String) -> Unit
}
Logger
{
(Self, String) -> Unit
info
(

type parameter Self

Self
,
String
String
) ->
Unit
Unit
} trait
trait Database {
  getUserById(Self, Int) -> User?
}
Database
{
(Self, Int) -> User?
getUserById
(

type parameter Self

Self
,
Int
Int
) ->
struct User {
  name: String
}
User
?
} struct AppConfig {
&Logger
logger
: &
trait Logger {
  info(Self, String) -> Unit
}
Logger
&Database
db
: &
trait Database {
  getUserById(Self, Int) -> User?
}
Database
} fn
(id : Int) -> ?
getUserName
(
Int
id
:
Int
Int
) -> Reader[
struct AppConfig {
  logger: &Logger
  db: &Database
}
AppConfig
,
String
String
?] {
((Unit) -> String) -> ?
asks
(
Unit
config
=> {
Unit
config
.
&Logger
logger
.
(&Logger, String) -> Unit
info
("Querying user with id: \{
Int
id
}")
let
User?
user
=
Unit
config
.
&Database
db
.
(&Database, Int) -> User?
getUserById
(
Int
id
)
User?
user
.
(self : User?, f : (User) -> String) -> String?

Maps the value of an Option using a provided function.

Example

  let a = Some(5)
  assert_eq(a.map(x => x * 2), Some(10))

  let b = None
  assert_eq(b.map(x => x * 2), None)
map
(
User
obj
=>
User
obj
.
String
name
)
}) } struct LocalDB {} impl
trait Database {
  getUserById(Self, Int) -> User?
}
Database
for
struct LocalDB {
}
LocalDB
with
(LocalDB, id : Int) -> User?
getUserById
(_,
Int
id
) {
if
Int
id
(self : Int, other : Int) -> Bool

Compares two integers for equality.

Parameters:

  • self : The first integer to compare.
  • other : The second integer to compare.

Returns true if both integers have the same value, false otherwise.

Example:

  inspect(42 == 42, content="true")
  inspect(42 == -42, content="false")
==
1 {
(User) -> User?
Some
({
String
name
: "MoonbitLang" })
} else {
User?
None
} } struct LocalLogger {} impl
trait Logger {
  info(Self, String) -> Unit
}
Logger
for
struct LocalLogger {
}
LocalLogger
with
(LocalLogger, content : String) -> Unit
info
(_,
String
content
) {
(input : String) -> Unit

Prints any value that implements the Show trait to the standard output, followed by a newline.

Parameters:

  • value : The value to be printed. Must implement the Show trait.

Example:

  println(42)
  println("Hello, World!")
  println([1, 2, 3])
println
("\{
String
content
}")
} test "Test UserName" { let
AppConfig
appConfig
=
struct AppConfig {
  logger: &Logger
  db: &Database
}
AppConfig
::{
&Database
db
:
struct LocalDB {
}
LocalDB
::{ },
&Logger
logger
:
struct LocalLogger {
}
LocalLogger
::{ } }
(a : String, b : String, msg? : String, loc~ : SourceLoc = _) -> Unit raise Error

Asserts that two values are equal. If they are not equal, raises a failure with a message containing the source location and the values being compared.

Parameters:

  • a : First value to compare.
  • b : Second value to compare.
  • loc : Source location information to include in failure messages. This is usually automatically provided by the compiler.

Throws a Failure error if the values are not equal, with a message showing the location of the failing assertion and the actual values that were compared.

Example:

  assert_eq(1, 1)
  assert_eq("hello", "hello")
assert_eq
(
(id : Int) -> ?
getUserName
(1).
(AppConfig) -> Unit
run
(
AppConfig
appConfig
).
() -> String
unwrap
(), "MoonbitLang")
}

可以发现,getUserName 函数同样不持有任何依赖,它只是一个“计算描述”。

这个特性让 Reader Monad 成为了实现六边形架构的天作之合。在六边形架构里,核心原则是 “依赖倒置” ——核心业务逻辑不应该依赖具体的基础设施。

getUserName 的例子就是最好的体现。AppConfig 就是一个 Ports 集合

getUserName 这个核心业务逻辑,它只依赖 AppConfig 这个抽象,完全不知道背后到底是 MySQL 还是 PostgreSQL,还是一个假实现:一个 Mock DB

但它不能解决什么问题?状态修改。

Reader Monad 的环境永远是“只读”的。一旦注入,它在整个计算过程中都不能被改变。

如果你需要一个可变的状态,找它的兄弟 State Monad 吧。

也就是说,它的好处很明显:它可以在任意地方读取配置;

当然它的坏处也很明显:它只会读取。

简单的 i18n 工具库

经常搞前端的人都知道,我们如果要搞 i18n,大概率会用上 i18next 这类库。它的核心玩法,通常是把一个 i18n 实例通过 React Context 注入到整个应用里,任何组件想用翻译,直接从 Context 里拿就行。所以这其实也可以是一种依赖注入。

回归初心了属于是,本来寻找 DI(Context) 的目的就是为了给 cli 工具支持 i18n。当然这里只是一个简单的演示。

首先,先安装依赖

moon add colmugx/reader

接着,我们来定义 i18n 库需要的环境和字典类型。

typealias String as Locale

typealias String as TranslationKey

typealias String as TranslationValue

typealias 
type Map[K, V]

Mutable linked hash map that maintains the order of insertion, not thread safe.

Example

  let map = { 3: "three", 8 :  "eight", 1 :  "one"}
  assert_eq(map.get(2), None)
  assert_eq(map.get(3), Some("three"))
  map.set(3, "updated")
  assert_eq(map.get(3), Some("updated"))
Map
[
String
TranslationKey
,
String
TranslationValue
] as Translations
typealias
type Map[K, V]

Mutable linked hash map that maintains the order of insertion, not thread safe.

Example

  let map = { 3: "three", 8 :  "eight", 1 :  "one"}
  assert_eq(map.get(2), None)
  assert_eq(map.get(3), Some("three"))
  map.set(3, "updated")
  assert_eq(map.get(3), Some("updated"))
Map
[
String
Locale
,
type Map[K, V]

Mutable linked hash map that maintains the order of insertion, not thread safe.

Example

  let map = { 3: "three", 8 :  "eight", 1 :  "one"}
  assert_eq(map.get(2), None)
  assert_eq(map.get(3), Some("three"))
  map.set(3, "updated")
  assert_eq(map.get(3), Some("updated"))
Translations
] as Dict
struct I18nConfig { // 这里只是方便演示添加了 mut mut
String
lang
:
String
Locale
Map[String, Map[String, String]]
dict
:
type Map[K, V]

Mutable linked hash map that maintains the order of insertion, not thread safe.

Example

  let map = { 3: "three", 8 :  "eight", 1 :  "one"}
  assert_eq(map.get(2), None)
  assert_eq(map.get(3), Some("three"))
  map.set(3, "updated")
  assert_eq(map.get(3), Some("updated"))
Dict
}

接下来是翻译函数 t

fn 
(key : String) -> ?
t
(
String
key
:
String
TranslationKey
) -> Reader[
struct I18nConfig {
  mut lang: String
  dict: Map[String, Map[String, String]]
}
I18nConfig
,
String
TranslationValue
] {
((Unit) -> String) -> ?
asks
(
Unit
config
=>
Unit
config
.
Map[String, Map[String, String]]
dict
.
(self : Map[String, Map[String, String]], key : String) -> Map[String, String]?

Get the value associated with a key.

get
(
Unit
config
.
String
lang
)
.
(self : Map[String, String]?, f : (Map[String, String]) -> String) -> String?

Maps the value of an Option using a provided function.

Example

  let a = Some(5)
  assert_eq(a.map(x => x * 2), Some(10))

  let b = None
  assert_eq(b.map(x => x * 2), None)
map
(
Map[String, String]
lang_map
=>
Map[String, String]
lang_map
.
(self : Map[String, String], key : String) -> String?

Get the value associated with a key.

get
(
String
key
).
(self : String?, default : String) -> String

Return the contained Some value or the provided default.

unwrap_or
(
String
key
))
.
(self : String?, default : String) -> String

Return the contained Some value or the provided default.

unwrap_or
(
String
key
))
}

完事了,看起来很简单是不是

接下来,假设我们的 CLI 工具需要根据操作系统的 LANG 环境变量来显示不同语言的欢迎信息。

fn 
(content : String) -> ?
welcome_message
(
String
content
:
String
String
) -> Reader[
struct I18nConfig {
  mut lang: String
  dict: Map[String, Map[String, String]]
}
I18nConfig
,
String
String
] {
(key : String) -> ?
t
("welcome").
((Unit) -> Unit) -> ?
bind
(
Unit
welcome_text
=>
(String) -> Unit
Reader::pure
("\{
Unit
welcome_text
} \{
String
content
}"))
} test { let
Map[String, Map[String, String]]
dict
:
type Map[K, V]

Mutable linked hash map that maintains the order of insertion, not thread safe.

Example

  let map = { 3: "three", 8 :  "eight", 1 :  "one"}
  assert_eq(map.get(2), None)
  assert_eq(map.get(3), Some("three"))
  map.set(3, "updated")
  assert_eq(map.get(3), Some("updated"))
Dict
= {
"en_US": { "welcome": "Welcome To" }, "zh_CN": { "welcome": "欢迎来到" }, } // 假设你的系统语言 LANG 是 zh_CN let
I18nConfig
app_config
=
struct I18nConfig {
  mut lang: String
  dict: Map[String, Map[String, String]]
}
I18nConfig
::{
String
lang
: "zh_CN",
Map[String, Map[String, String]]
dict
}
let
?
msg
=
(content : String) -> ?
welcome_message
("MoonbitLang")
(a : String, b : String, msg? : String, loc~ : SourceLoc = _) -> Unit raise Error

Asserts that two values are equal. If they are not equal, raises a failure with a message containing the source location and the values being compared.

Parameters:

  • a : First value to compare.
  • b : Second value to compare.
  • loc : Source location information to include in failure messages. This is usually automatically provided by the compiler.

Throws a Failure error if the values are not equal, with a message showing the location of the failing assertion and the actual values that were compared.

Example:

  assert_eq(1, 1)
  assert_eq("hello", "hello")
assert_eq
(
?
msg
.
(I18nConfig) -> String
run
(
I18nConfig
app_config
), "欢迎来到 MoonbitLang")
// 切换语言
I18nConfig
app_config
.
String
lang
= "en_US"
(a : String, b : String, msg? : String, loc~ : SourceLoc = _) -> Unit raise Error

Asserts that two values are equal. If they are not equal, raises a failure with a message containing the source location and the values being compared.

Parameters:

  • a : First value to compare.
  • b : Second value to compare.
  • loc : Source location information to include in failure messages. This is usually automatically provided by the compiler.

Throws a Failure error if the values are not equal, with a message showing the location of the failing assertion and the actual values that were compared.

Example:

  assert_eq(1, 1)
  assert_eq("hello", "hello")
assert_eq
(
?
msg
.
(I18nConfig) -> String
run
(
I18nConfig
app_config
), "Welcome To MoonbitLang")
}

欢迎来到 MoonbitLang