在 上篇文章 中,我们提出了一个实现模块化的方案,本篇文章我们就来介绍一下,如何把这个方案落实到代码上。
但是在开始动手之前,我们先回个头,聊一聊目前 screeps 开源方面的一些困境。
screeps 开源困境
screeps 不乏一些成熟的框架或是基础架构,但是大多数人不会去用这些框架而依旧选择自己写。是因为这些框架不好么?并不是,能被开源出来给其他人用足以说明这些框架开发者的水平已经超过了绝大多数玩家了。
导致这些框架受众小的根本原因是:Screeps 是个游戏!
对于新手来说,我根本不懂这个框架能带来的好处,甚至不知道怎么用。对于能看懂这个框架的老玩家来说,已经在自己的架构上积累了很多代码了,没有必要再迁移到一个自己不了解的平台上。
所以,新人不会用,老人没必要用。开发一个基础框架作为“基座”来接入其他功能的模块化方案就此进入了死胡同。
我也接触过很多拥有丰富编程经验的人,认为只要把自己当下的基础框架抽象出来,再丰富一下通用模块,大家就可以一起齐心协力,在此基础之上开发出一个真正的模块化 screeps bot。但是结果都失败了。这里并非有贬低他人之意,正是和这些人的沟通让我了解到许多相关的设计和思路,说本文这套方案是大家一起想出来的也丝毫不为过。
所以我们的在真正动手之前需要明确的一个重要指标就是:保持简单,越简单越好。
事实证明确实是这样,不需要引入任何东西,不需要了解什么复杂概念和所谓的设计模式。不需要任何配置,几乎没有任何限制。这套方案与其说是一个框架,倒不如说是一个“代码规范”。
ok,废话已经说了够多了,咱们现在正式开始!
第一步:写好架子
现在假设我们需要开发一个战争模块,首先写下最基本的架子:
// 创建战争模块
const createWarController = function (context) {
}
没了?对没了,一个模块的基础框架就这些,这就是一个简单的函数嘛,名字叫做创建战争模块,接受的参数一个叫做 context 的参数。
现在我们把 上篇文章 中搞出来的设计图掏出来瞅一瞅:
对应一下,我们上面写的 createWarController
函数,就对应着其中的模块,函数的参数 context
就代表着该模块依赖于哪些接口(橙色部分),而函数的返回值,就是该模块可以提供的接口(蓝色部分)。
第二步:写下这个模块的依赖
现在我们先从橙色部分入手,想一下战争模块都应该依赖哪些东西:
内存:内存肯定得要吧,总要保存一些数据,所以应该有个函数,调用一下应该返回一个对象,然后我们就可以读写这个对象。
Spawn 管理:打仗总要孵化,而且孵化四人小队的时候还需要生命对齐之类的,这部分比较复杂,干脆极端一点,搞两个函数,一个用来“借用”所有的 spawn,调用之后这些 spawn 就不能干其他的了。另一个用来“归还”这些被自己锁定的 spawn。
运维状态:又想了想,如果我们把 spawn 完全收为己用了,那房间的运营单位死完了没人填能量,不就完犊子了么,所以我们还需要有个函数来获取当前房间里有多少运维单位,没人了咱们就归还 spawn,让他们生完咱再锁定 spawn 继续干活。
boost 相关:打仗必然少不了强化,所以强化相关的肯定不能少。
暂时就想到这么多,接下来我们就要把这些东西放在 context
里,这样才能给函数内部的代码使用。然后我们就能写出下面的代码:
使用 ts 的 interface 描述,非必须,可以结合注释阅读:
/**
* 战争模块的依赖项
*/
interface WarControllerContext {
/**
* 获取战争模块存储信息
* 函数,调用后返回内存对象
*/
getMemory: () => WarModuleMemory
/**
* 锁定一个房间的 spawn
* 调用该方法后该房间的 spawn 不应执行任何其他操作
* 若无法锁定可以返回 false
*/
lendSpawn: () => boolean
/**
* 归还一个房间的 spawn
*/
remandSpawn: () => void
/**
* 获取一个房间的运营单位
* 调用后返回当前正在执行运维任务的 creep 数组
*/
getRoomManager: () => Creep[]
/**
* 添加一个 boost 任务
* 应返回该任务的唯一索引
*/
addBoostTask: () => number
/**
* 获取 boost 任务的状态
*/
getBoostState: () => boolean
/**
* 让一个 creep 按照指定 boost 任务进行强化
* 函数应返回是否强化成功
*/
boostCreep: (creep: Creep) => boolean
/**
* 结束 boost 任务
*/
finishBoost: () => void
}
由于我们依赖的是其他模块的 功能,所以可以看到,上面所有的依赖的类型都是 函数。调用之后别的模块就会执行对应的工作,并返回给我们执行结果。
注意哦,在这里函数很常用,但是也可以传入其他类型,比如一个字符串的房间名,总之就是传入“依赖的东西”。
这里我们使用了 typescript 的 Interface
描述了战争模块的依赖,它就相当于本模块的说明书,想要使用这个模块,必须在调用 createWarController
创建战争模块时传入这些功能,否则就不能使用。你不用 ts 也可以,但是要记得用足够详细的注释告诉使用者(未来的你自己)这个模块需要那些依赖,名字叫什么,接受什么参数,应该返回啥。
如果你比较细心的话就会发现,我们上面写的相当潦草,比如发布 boost 任务一个参数都不需要?你总的告诉我这个任务需要那些化合物吧?没有关系,要记得我们刚开始,这种细节以后要用到了再补充就行。这一步的关键在于告诉我们:“context 不是空的,他装了很多我们需要的功能”。
实际上,我开发新模块时都是边写边回头添加这些依赖。
第三步:写下这个模块都能提供什么功能
接下来要做的事,就是写下战争模块能提供给外界的接口,即下图中的蓝色部分:
想了想也很简单:启动战争、关闭战争、查看战争状态、发布小队,我们哗啦啦的就写下了这些代码:
// 创建战争模块
const createWarController = function (context) {
/**
* 启动战争
*/
const startWar = function () { }
/**
* 结束战争
*/
const endWar = function () { }
/**
* 发布一个小队
* 应该传入一个小队类型,拿着这个小队类型去配置身体部件和强化资源
*/
const addSquad = function () { }
/**
* 查看当前战争状态
*/
const show = function () { }
/**
* 执行 tick 逻辑
*/
const run = function () { }
return { startWar, endWar, addSquad, show, run }
}
注意,我们除了刚才提到的那些功能外,还额外创建了一个 run
方法,这个方法包含了需要每 tick 执行的逻辑,也就是说,其他的方法都是提供给别的模块调用的,而 run 方法则是放在 loop 方法里定时调用,即所谓的 入口函数。
在创建了这些方法后,我们把他们放在一个对象里 return 了出去,这样就可以提供给其他模块使用了。
第四步:完善细节
好了,刚才我们写了很多“设计性”的代码,现在是时候把它们填充成真正可用的功能了。
注意!这一步或许是限制最严格的了,还记得我们想实现模块化的根本么:不要在模块内部依赖外界。
所以,我们在实现功能的时候需要时刻记住:我们只能使用 context
里的东西,其他的什么都不能用!当然这个在实际开发时可可以通融通融,比如我们可以使用一些全局的常量、或者通用的 util 函数,总结一下就是静态、不可变的。但是记住,你通融的越多,你的模块复用性就越差。
而最低容忍度的红线是下面这两条:
不要直接引用其他模块!以及 避免隐式依赖!
避免直接引用其他模块是必须的,因为这正是我们要解决的最大问题。而最隐晦、最难以被发现的依赖永远是隐式依赖,特别是没有使用 ts 的时候,有些代码你不读它根本发现不到它还用到了其他模块。这也与我们 “不需要了解模块细节就可以使用” 的理念背道而驰。
不要在模块内使用或修改全局变量
全局变量是最出名的隐式依赖。很多人会因为想少写代码而把模块挂到全局 global 上,这无可厚非,但是如果你想写出可以提供给其他人用的模块,请不要使用全局变量。把对象返回出去即可,使用者自己把对象放在全局上是他的事,一定要尽力避免自己的模块对全局造成污染。
所以,我们在实现功能的一般流程就是:从 context 里取出要调用的东西 > 在返回的函数里调用它们,就像下面这样:
const createWarController = function (context) {
// 从 context 中获取需要的依赖功能
const { lendSpawn, remandSpawn, getRoomManager } = context
const addSquad = function () {
// ...
// 房间还有没有运维单位,有的话就锁定 spawn 来孵化小队
const managers = getRoomManager()
if (managers.length > 0) lendSpawn()
else remandSpawn()
// ...
}
return { addSquad }
}
在模块内部,我们不需要关系这个功能是谁提供的,如何运行的,我们只需要知道调用了这个功能之后就可以获取到想要的效果就可以了。
在模块的细节实现中,我们会不断重复:需要一个新功能 > context 里没有 > 在 context 接口里加上 > 回来调用 > 继续开发新功能 的循环。注意,当需要一个新功能的时候,我们不是去寻找哪个模块可以提供这个能力,然后直接把对应模块引入进来。而是在 context 里注明需要这个功能,并定义好这个接口的形状,然后回来继续开发。这可以让我们的开发思维流更顺畅。
第五步:实例化模块
经过一段时间的写码后,假设我们已经大致上完成了模块的开发。是时候让这个模块和其他代码相互协作,真正的运行起来了。接下来要写的,也就是我们上篇文章中提到的 “胶水层” 代码。
首先,我们找个新文件引入我们的 createWarController
,还记得我们刚才往 context 里添加的那一大堆东西么,现在把他们对应的模块也引入进来,然后塞在 context 里传进来:
// 引入模块创建器
const createWarController = require('src/module/war')
// 引入其他依赖
const spawnController = require('src/app/spawnController')
const labController = require('src/app/labController')
const transportController = require('src/app/transportController')
// 实例化战争模块
const warController = createWarController({
getMemory: () => {
if (!Memory.wars) Memory.wars = {}
return Memory.wars
},
lendSpawn: spawnController.lendSpawn,
remandSpawn: spawnController.remandSpawn,
getRoomManager: transportController.getManager,
addBoostTask: labController.addBoostTask,
getBoostState: labController.getBoostState,
boostCreep: labController.boostCreep,
finishBoost: labController.finishBoost
})
可以看到,这里几乎没有进行任何实质性的工作,所有的代码都是把某个模块的某个接口,按照说明书插在对应的插口上。这个过程是很上瘾的,你将会体验到组装电脑或是乐高积木的感觉。
注意,这里引入的模块接口并没有执行,我们只是把他作为一个变量,转发给战争模块,这些接口的实际调用位置是在模块内部。
当我们把所以需要的接口都插好了之后,就可以拿到真正的战争模块了,这个对象会包含我们之前创建的那些 startWar、addSquad 方法。接下来就可以把这些方法暴露出去给其他模块使用,或者将其挂载到 global 上供我们手操使用。
const warController = createWarController({ /** ... */ })
// 导出出去,这样就可以供其他模块使用
module.exports = warController
// 把操作用简写挂到全局方便手操调用
global.war = warController.startWar
global.endwar = warController.endWar
global.seewar = warController.show
global.attack = warController.addSquad
// 引入到 loop 或者其他模块中
const warController = require('src/app/war')
module.exports.loop = function () {
warController.run()
}
截止到目前,我们已经走完了一个模块从创建到使用的全过程。
回过头来看一看,我们整套流程里只有一个强制设定,那就是“在模块内只使用参数 context 里包含的内容”,除此之外,我们只用到了函数,接口和对象这三个编程人熟悉的不能再熟悉的东西。
但正是这种简单到几乎不能再简单、几乎完全没有心智负担的设计,就可以实现我们的终极目标:只要我需要的你都传进来了,那么我就能保证这个模块里包含的功能都是 100% 正确可用的。
无论这个模块有多高级,内部实现有多复杂,使用一个模块的方式永远是:调用一个函数,把一些函数当做参数传进来,返回一个对象,这个对象就是你想要的模块。模块越复杂,对于调用者只是需要传进去的参数变多了而已。
模块化可以避免历史问题么?
现在让我们回到文章开头,看看过去的那些问题会不会在这个模块化方案上重现:
问题一:需要额外的基础框架支持,需要引入 / 配置相关代码
显而易见,我们不仅不需要引入任何东西,甚至不需要 ctrl cv 模板代码。更不需要做什么配置(当然有 ts 会更好),用游戏自带的 ide 都能随手写出来。
问题二:新手看不懂
我们来品一品理解这个方案需要多少基础知识:
- 你要会写函数,并理解参数和返回值是什么。
- 你需要知道函数可以放在一个变量里,所以可以把函数放在参数里或者 return 出去。
不难吧,这两条真的是基础中的基础了,如果你不知道的话,复制扔百度里花个十分钟半小时也看完了。
问题三:写法受限
没有的事,因为承载我们代码的 createxxx 就是一个普普通通的函数啊。你可以用任何你了解的编程知识实现功能,只需要记得最后把这些功能(函数)返回出去就好了。
所以,哪怕你闭着眼睛写出一坨屎,只要你没有越过上面说的红线,对于外边的调用者而言,也同样是调用个函数就完事了。
问题四:需要了解各种概念,记住各种 api 名字
我们整篇文章只提到了一个规定,那就是 “模块内部只使用 context 里提供的内容”(请记住它,我重复了很多次想说明这条规则的重要性),除此之外什么都没有限制,没有 api,也不需要规定名字。
你的模块创建函数可以叫做 XXXFactory
也可以叫做 XXXgenerator
,同样,参数也可以不叫作 context
,你可以叫做 sideEffect
、dependences
等等。
这个方案同样不需要你记住任何编程概念,当然,如果不用人话讲,可以把我们的方案叫做:使用闭包创建了基于组合式 OOP 对象的高阶纯函数。
但是我们今天不是来找骂的,所以我们跳过这部分的解读,并且就算完全听不懂也不妨碍用这套方案写代码,你只需要记住,给这套方案背书的,都是编程领域最纯粹,最基础的原则与思想,并不局限于 screeps 特有,是可以应用到任何编程工作上的。
如果大家有兴趣的话,以后可以开一篇文章分享一下这套方案背后的编程思想。
模块化的优势
除此之外,我们还能获得各种用得着用不着的好处:
更低的耦合,更清晰的边界
不同模块之间的边界更加清晰,出现问题后只需要在模块创建是传入的依赖函数上打个 log 看一下,我传给你的东西没问题,那么肯定是你模块内部的锅。
// 创建战争模块
const warController = createWarController({
// ....
addBoostTask: data => {
// data 有问题,说明 bug 肯定出在战争模块内部
console.log(data);
const result = labController.addBoostTask(info);
// 返回值有问题,说明 bug 肯定出在强化模块内部
console.log(result);
return result;
},
})
更强的模块兼容性
在没有模块化的时候,模块之间都是直接依赖,所以接口的形状必须完全对死,但是现在我们多了一个隔离层:context 参数,就可以在这里放一些”垫片“来让双方的接口可以对应起来,比如下面这个例子:
战争模块需要获取当前房间的运营单位还有多少 tick 可以活,但是房间运维模块只返回了运维单位的名字数组:
// 只能获取运维单位的名字数组
transportController.getCreepNames
const war = createWarController({
getManagerTick: /** 需要获取当前运维单位还有多少 tick 可以活 */
})
这时候,我们就可以在传递 context 参数的时候 “抹平” 这个差异:
const war = createWarController({
getManagerTick: () => {
// 获取 creep 对应的单位,并统计其总剩余存活时长
const creeps = transportController.getCreepNames().map(name => Game.creeps[name])
return creeps.reduce((tick, creep) => creep.ticksToLive + tick, 0)
}
})
看,我们既没有侵入物流模块,也不需要修改战争模块的代码,只需要多抹一些“胶水”,这些模块就可以一起工作了。
更友好的单测体验
在写单测的时候,如果我们模块内部直接 import 了其他代码的话,就会自动加载对应的模块,然后一传十十传百,最终卡在某个代码需要但是我们的 mock 环境里不存在的物件上,这种想测试模块 A,但是我要先去解决模块 E、甚至模块G、H、I 的依赖问题,非常痛苦。并且,对于 import 的 mock 也是很不爽的体验。
用了这套方案之后,我们就可以很轻松的 mock 出一个最小化的环境。因为所有需要的东西都在参数里给你写明了,并且只需要最基础的函数测试就可以完成单测。
通过 npm 共享模块
按照这种方案书写的代码,我们可以借助一些诸如 rollup 的打包器轻松封装成独立包,然后发布到 npm 上,别人只需要 npm install 一下就可以引入到自己的项目里,然后调用你的 createXXX
并传入自己的依赖,就可以直接运行你写的模块了。
总结
本篇文章,我们介绍了一种模块化的方案:module = createModule(context)
。通过函数闭包将模块内部与外界隔离,并把模块依赖的功能通过参数 context 传入进来供模块内部使用。最终,我们会生成一些新的函数,承载着该模块可以提供的功能,并 return 出去给其他模块使用。
可以这么理解:有一个函数(模块创建器),这个函数接受一堆函数(依赖项),然后捏合成一些新的函数返回出去(模块实例)。
要注意,模块内部也是可以包含小模块的,没必要看我这里规定要拿函数承载功能就把所有的代码都写着一个函数里,这是为了不同模块之间可以更好的协作,至于模块内部,你想怎么搞都可以。
当然,我们不能无脑夸,任何技术都有两面性。我将会在本系列的 最后一篇文章(摸鱼中) 里,介绍自己在实践这个模块化方案里遇到的一些问题和解决方案,以及一些技术要点。
最后强调一遍!看懂本文所需要的知识真的非常基础,如果你看完还是一知半解的话,不要灰心,捡能看懂的用!群里问我就完事了。