背景
微服务的概念已经过去了好久,微前端也已经实践了一段时间,在去年不同的分享会上都有听到各家公司关于微前端的实践,总体来说,微前端是因为前端架构的不断演进,结合后端微服务的理念而创造出来的,用于解决不同前端框架,甚至相同框架的不同版本,如何结合的问题。
微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立运行、独立开发、独立部署。
之前和前端框架架构的同学也聊了一下,一直思考的是,微前端目前如何落地,能解决什么问题,似乎目前这个场景更多的是解决历史系统的迁移问题,在新系统上,前端,特别是在一个统一的体系中,很少会出现跨多语言,多架构的场景。更多的是直接重构掉,做一个新的。
既然前端能微前端,后端能不能微呢?微服务在某些场景下,解决了不同语言的互调问题,相对带来的,其实还有配套的管理和调度系统,而前面说到,在单一语言领域内,用户可能更熟悉开发一个大系统,这就造成了系统越来越大,越来越复杂的情况。
在类似阿里的体系中,前端写的 Node.js 系统非常多,特别是中后台系统,搭配管理端和前台,业务可以很复杂,前端可以拆分,而后端基本上都是一个,要微服务化,或者 FaaS 化,总有道不明的风险,这里的风险更多的是人员成本,人们总是说“明明跑的好好的,为什么要升级”。
常见的中后台应用逻辑
这么说我们并不是想开历史的倒车,让熟悉传统开发的用户一步到服务化,甚至 Serverless 还是有一些困难的,特别是面对 http 的传统应用,路由的思维以及方便的开发环境已经非常成熟,不管是开发体验或者底层概念上都很难转变。
我们设想过很多种方法让应用变轻,变薄,变得可维护,可扩展。我们在 Midway 中尝试使用了IoC 的方式进行解耦,使用装饰器和注入替换传统的实例化和调用。
import { provide, controller, inject, get } from 'midway';
@provide()
@controller('/user')
export class UserController {
@inject('userService')
service: IUserService;
@get('/:id')
async getUser(ctx): Promise<void> {
const id: number = ctx.params.id;
const user: IUserResult = await this.service.getUser({id});
ctx.body = {success: true, message: 'OK', data: user};
}
}
使用了 IoC 之后,代码不依赖于框架,只依赖于装饰器。
但是这样并没有把应用拆小,应用还是原来的应用,逻辑还在。
传统的分布式调用是把系统拆成很多服务,但是这样做在没有成熟的配套前会牺牲可维护性,排查问题也会变得困难,同时资源的开销也会难以评估。
那么,是否有办法既方便的拆分应用,又不影响原本的开发调试,资源评估,甚至是可维护性呢?
答案可能是有的。
我们一向擅长用简单的方式解决复杂的问题,但这次有几个问题。
- 本地开发的问题,不能影响开发
- 部署的问题,尽量减少成本消耗
- 我要拆得简单,容易理解和维护
- 面向未来扩展,可迁移到微服务乃至 FaaS 场景
面对这么多问题,我们只能一一来思考和解决。
如何拆分
恰好在去年的 jsconf 2019 上,我们提出过一种把应用变为函数的拆分方法,把路由层(Router,Controller)单单独拆分,同时将,业务逻辑(Service)垂直拆分的设想。
有谁还记得去年的 jsconf 2019 的拆分模型呢。
这种拆分方式将应用分为两类(Controller + Service),同时又将业务本身按照领域进行了划分。假如在这个基础上,我们把路由也进行领域(垂直划分),是非常自然的,而原始目录结构中的 controller
目录和 service
目录也很好的印证了这个想法。
第二个问题是,拆分了之后,代码的组织方式,开发模式有什么变化呢?
开发方式的变化
用户是非常懒和挑剔的,任何增加成本减少收益的事情都是不会干的。如果调整了代码组织方式,并且还要修改代码,甚至调整原来习惯的开发方式的话,都会骂娘的。
代码分离之后,管理方式还好说,现在可以用 git 分仓库管理或者用类似 lerna 的工具来同仓库管理。但是开发方式是实打实的体验,如何在尽可能不修改的情况下来拆分逻辑,增加扩展性呢?
这就需要框架加载方式的支持了。
我们以一个 midway 项目举例,使用了 lerna 管理 monorepo,模拟拆分的情况。
我们简化一下示例目录结构,大致如下。
.
├── README.md
└── packages
├── api
│ └── src
│ ├── app
│ │ └── controller
│ │ └── controller.ts
│ └── config
│ └── config.default.ts
├── book
│ └── src
│ ├── app
│ │ └── controller
│ │ └── bookController.ts
│ ├── config
│ │ └── config.default.ts
│ └── service
│ └── bookService.ts
├── main
│ └── src
└── video
└── src
├── app
│ └── controller
│ └── videoController.ts
├── config
│ └── config.default.ts
└── service
└── videoService.ts
main
为主 app,而 api
、book
、video
都是对应的领域模块(子应用),包含对应领域的能力,比如提供 API,图书服务以及影视服务。
可以看到,我们的子包的目录结构和原本的大应用是相同的,而文件也就是原来领域抽象的文件(原来的代码写的好的话)。
当然,这样拆了之后,肯定不能运行。启动就会报错,找不到子包的文件,我们还需要对框架做一点点调整。
熟悉 Midway 框架的同学可能知道,IoC 是通过扫描目录文件,预先加载到内存中即可拿到实例,不过这个目录结构下,也是无法扫描到的。幸好我们的 IoC 容器支持传入自定义扫描路径,那么只需要做一点点小小的修改,让 IoC 容器能拿到子包的路径就行。
在 monorepo 下,子包也是一般的 npm 包,我们只要让用户定义就行了,比如有一个名叫 configuration.ts
的文件,内容大概是这样。
// main 提供所有的服务
@Configuration({
imports: [
'midway-macro-backends-api',
'midway-macro-backends-book',
'midway-macro-backends-video',
]
})
// api 提供 Restful API 服务
@Configuration({
imports: [
'midway-macro-backends-book',
'midway-macro-backends-video',
]
})
哇,这下我们只需要在框架里找到子包(lerna 下 resolve 能找到)的地址,加到扫描路径里就行了。按照原来的依赖,我们对相应子包(api)也增加依赖,用于提供 Restful API 服务。
.
├── README.md
└── packages
├── api
│ └── src
│ ├── ...
│ └── configuration.ts
├── book
│ └── src
│ └── ...
├── main
│ └── src
│ └── configuration.ts
└── video
└── src
└── ...
这下,我们的 main
以及 api
包都能独立开发,独立工作了,而且和原来的开发模式完全保持一致。
使用的时候,是通过导入 npm 包的形式来进行协作,使用的也是标准的 import
语法,以及 class
形式,没有增加特别的语义,无需去额外学习。
这就是梦想中的模式,我们似乎可以叫他“微后端”。
部署模型
开发模型上,我们把代码变成了子模块,通过 IoC 扫描的方式让代码的开发模式和以前完全一致,那部署呢?
由于主包没有什么太大的变化,发布构建的时候只要单独发布 main
包即可,这种方式特别适合 CRM 后台系统,多路由组合的情况。
最后
得益于 IoC 体系,我们能向其他场景去尝试输出这些能力。在 2019 年底我们开放了 midway-faas 体系的代码,这就出现了一些从 midway 代码转变为 midway-faas 或者复用的需求,那么,我们是不是能够将一部分 midway(service)代码,直接拷贝成一个新的模块(子包)的形式,让 midway-faas 的能够运行这些代码呢?
这其实是个 feature 预告,下一版本 midway,以及 midway-faas 已经支持了这种扩展模型,你可以随时的去扩展自己的可复用能力,也可以拆分自己的应用,构建垂直化领域模型代码,甚至是从 midway 模块迁移到 midway-faas 模块。
技术永无止境,一切皆有可能,尽在 midway。