从微前端到微后端,不可思议的前端架构思考

1,461 阅读7分钟

背景

微服务的概念已经过去了好久,微前端也已经实践了一段时间,在去年不同的分享会上都有听到各家公司关于微前端的实践,总体来说,微前端是因为前端架构的不断演进,结合后端微服务的理念而创造出来的,用于解决不同前端框架,甚至相同框架的不同版本,如何结合的问题。


微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立运行、独立开发、独立部署。

之前和前端框架架构的同学也聊了一下,一直思考的是,微前端目前如何落地,能解决什么问题,似乎目前这个场景更多的是解决历史系统的迁移问题,在新系统上,前端,特别是在一个统一的体系中,很少会出现跨多语言,多架构的场景。更多的是直接重构掉,做一个新的。

既然前端能微前端,后端能不能微呢?微服务在某些场景下,解决了不同语言的互调问题,相对带来的,其实还有配套的管理和调度系统,而前面说到,在单一语言领域内,用户可能更熟悉开发一个大系统,这就造成了系统越来越大,越来越复杂的情况。

在类似阿里的体系中,前端写的 Node.js 系统非常多,特别是中后台系统,搭配管理端和前台,业务可以很复杂,前端可以拆分,而后端基本上都是一个,要微服务化,或者 FaaS 化,总有道不明的风险,这里的风险更多的是人员成本,人们总是说“明明跑的好好的,为什么要升级”。

image.png

常见的中后台应用逻辑

这么说我们并不是想开历史的倒车,让熟悉传统开发的用户一步到服务化,甚至 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 之后,代码不依赖于框架,只依赖于装饰器。

但是这样并没有把应用拆小,应用还是原来的应用,逻辑还在。

传统的分布式调用是把系统拆成很多服务,但是这样做在没有成熟的配套前会牺牲可维护性,排查问题也会变得困难,同时资源的开销也会难以评估。

那么,是否有办法既方便的拆分应用,又不影响原本的开发调试,资源评估,甚至是可维护性呢?

答案可能是有的。

我们一向擅长用简单的方式解决复杂的问题,但这次有几个问题。

  1. 本地开发的问题,不能影响开发
  2. 部署的问题,尽量减少成本消耗
  3. 我要拆得简单,容易理解和维护
  4. 面向未来扩展,可迁移到微服务乃至 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,而 apibookvideo 都是对应的领域模块(子应用),包含对应领域的能力,比如提供 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。