函数式里的依赖注入:Reader Monad
经常搞六边形架构的人也知道,为了保持核心业务逻辑的纯粹和独立,我们会把像数据库、外部 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