当前位置: 首页 > news >正文

MoonBit Perals Vol.05: 函数式里的依赖注入:Reader Monad

函数式里的依赖注入: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, Int] = Reader::pure(100)test {// 无论环境是什么 (比如 "hello"),结果都是 100assert_eq(pure_reader.run("hello"), 100)}
Reader::bind

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

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


fnalias @reader.ask// 步骤1: 定义一个 Reader,它的工作是从环境(一个Int)中读取值let step1 : Reader[Int, Int] = ask()// 步骤2: 定义一个函数,它接收一个数字,然后返回一个新的 Reader 计算fn step2_func(n : Int) -> Reader[Int, Int] {Reader::pure(n * 2)}// 使用 bind 将两个步骤连接起来let computation : Reader[Int, Int] = step1.bind(step2_func)test {// 运行整个计算,环境是 5// 流程: step1 从环境得到 5 -> bind 把 5 交给 step2_func -> step2_func 计算 5*2=10 -> pure(10)assert_eq(computation.run(5), 10)}
Reader::map

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

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


// `map` 只转换结果,不改变依赖let reader_int : Reader[Unit, Int] = Reader::pure(5)let reader_string : Reader[Unit, String] = reader_int.map(n => "Value is \{n}")test {assert_eq(reader_string.run(()), "Value is 5")}
ask

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

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


// `ask` 直接获取环境let ask_reader : Reader[String, String] = ask()let result : String = ask_reader.run("This is the environment")test {assert_eq(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 @reader.asksstruct User {name : String}trait Logger {info(Self, String) -> Unit}trait Database {getUserById(Self, Int) -> User?}struct AppConfig {logger : &Loggerdb : &Database}fn getUserName(id : Int) -> Reader[AppConfig, String?] {asks(config => {config.logger.info("Querying user with id: \{id}")let user = config.db.getUserById(id)user.map(obj => obj.name)})}struct LocalDB {}impl Database for LocalDB with getUserById(_, id) {if id == 1 {Some({ name: "MoonbitLang" })} else {None}}struct LocalLogger {}impl Logger for LocalLogger with info(_, content) {println("\{content}")}test "Test UserName" {let appConfig = AppConfig::{ db: LocalDB::{  }, logger: LocalLogger::{  } }assert_eq(getUserName(1).run(appConfig).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 Localetypealias String as TranslationKeytypealias String as TranslationValuetypealias Map[TranslationKey, TranslationValue] as Translationstypealias Map[Locale, Translations] as Dictstruct I18nConfig {// 这里只是方便演示添加了 mutmut lang : Localedict : Dict}

接下来是翻译函数 t


fn t(key : TranslationKey) -> Reader[I18nConfig, TranslationValue] {asks(config => config.dict.get(config.lang).map(lang_map => lang_map.get(key).unwrap_or(key)).unwrap_or(key))}

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

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


fn welcome_message(content : String) -> Reader[I18nConfig, String] {t("welcome").bind(welcome_text => Reader::pure("\{welcome_text} \{content}"))}test {let dict : Dict = {"en_US": { "welcome": "Welcome To" },"zh_CN": { "welcome": "欢迎来到" },}// 假设你的系统语言 LANG 是 zh_CNlet app_config = I18nConfig::{ lang: "zh_CN", dict }let msg = welcome_message("MoonbitLang")assert_eq(msg.run(app_config), "欢迎来到 MoonbitLang")// 切换语言app_config.lang = "en_US"assert_eq(msg.run(app_config), "Welcome To MoonbitLang")}

欢迎来到 MoonBitLang

http://www.dtcms.com/a/329754.html

相关文章:

  • JPrint免费的Web静默打印控件:PDF打印中文乱码异常解决方案
  • 什么是JSP和Servlet以及二者的关系
  • window显示驱动开发—多平面覆盖 VidPN 呈现
  • MVCC底层实现原理
  • Flask入门:从零搭建Web服务器
  • 雅思大作文笔记
  • iOS 签名证书在版本迭代和iOS上架中的全流程应用
  • Docker 在 Linux 中的额外资源占用分析
  • 智汇河套,量子“风暴”:量子科技未来产业发展论坛深度研讨加速产业成果转化
  • 信息学奥林匹克竞赛(NOI/NOIP/CSP) 学习进度自查表
  • 使用 Python Selenium 和 Requests 实现歌曲网站批量下载实战
  • 嵌入式学习 day50 IMX6ULL裸机开发 - 时钟
  • Linux 5.15.189-rt87 实时内核安装 NVIDIA 显卡驱动
  • 手机充电线贴标机如何使用
  • 内存可见性和伪共享问题
  • php7 太空船运算符
  • 在实验室连接地下车库工控机及其数据采集设备
  • 《零基础入门AI:深度学习中的视觉处理(卷积神经网络(CNN)学习)》
  • Github desktop介绍(GitHub官方推出的一款图形化桌面工具,旨在简化Git和GitHub的使用流程)
  • SpringMVC 6+源码分析(六)参数处理
  • 18. parseInt 的参数有几个
  • C语言——深入理解指针(三)
  • 【LeetCode】6. Z 字形变换
  • 美团搜索推荐统一Agent之性能优化与系统集成
  • Linux小白加油站,第二周
  • 云手机未来的发展趋势如何?
  • docker load镜像后 名字和标签异常解决
  • Centos 用户管理
  • [Oracle数据库] Oracle的表维护
  • 使用dify搭建hr简历助手-上传简历-对接飞书ai表格