Free Arch: babel as a service
在昨天分享的《FreeArch: 将 React 教程的井字棋游戏搬到微信小程序》里提到的,通过实现一个 react-view,可以部分解决个人版微信小程序不能使用 webview 的缺憾。
虽然完全可以在前端使用 babel standalone,动态转译 tsx 代码。但是由于 babel-standlone 压缩后的体积,也超过了小程序的代码限制,所以放在前端,只能在开发模式使用,没有办法发布。
于是就要后端来提供 babel 服务了。可能网上有很多类似这样的服务,但为了更好的定制化,还是自己写一个吧。利用万能 BFF,分分钟部署一个。
在线演示
https://sls.pa-ca.me/nest/graphql
先看一下,直接转译一个最简单的:
考虑到最终使用场景,是需要转译 replit (其实代码保存在 GitHub)的源文件,因为还提供了指定源文件的 url 进行转译的功能:
万能 BFF 源代码
https://github.com/Jeff-Tian/serverless-space/tree/main/src/babel-service
项目背景
项目采用了 nestjs 框架,对外提供 GraphQL 服务。nestjs graphql 项目,基本上是 module -> resolver -> service 这样的分层架构。
实现步骤
对于要实现一个 babel service,在 nest 项目中就是添加一个 babel service module,然后再添加一个 babel resolver 接收前端的请求,最后由 service 完成转译。为了省事儿,准备直接在项目里放置一个 babel.min.js 文件,用 require 方式获取 Babel 对象。
测试先行
尽管大概思路是非常清楚和简单粗暴,但是实践起来,还是要测试先行。实际的 TDD 过程就不详解了,为了叙述省事儿,略过了很多细枝末节。但是先写测试,再写实现,是一个最简略的版本。
自动化单元测试
首先想到的是,我们的 service,应该具备转译指定的代码的能力,即输入源代码,输出转译后的代码;然后,需要有从 url 转译的能力;最后,是一个特别定制化的需求,可以在指定 url 之后,添加一点额外代码后再进行转译。一共是 3 个测试用例:
https://github.com/Jeff-Tian/serverless-space/blob/main/src/babel-service/babel.service.spec.ts
import { BabelService } from "./babel.service";
import { testTargetUrl, transformedText } from "../test/constants";
import axios from 'axios'
describe('babel', () => {
const mockHttpService = {
get: (url) => ({ toPromise: () => axios.get(url) })
} as any
const sut = new BabelService(mockHttpService);
it('transforms', async () => {
const res = await sut.transform('class A {}')
expect(res).toStrictEqual(transformedText)
})
it('transforms from url', async () => {
const res = await sut.transformFromUrl(testTargetUrl)
expect(res).toMatch(/"use strict";/)
})
it('transform from url with extra', async () => {
const res = await sut.transformFromUrl(testTargetUrl, "ReactDOM.render(, document.getElementById('root'))")
expect(res).toMatch(/"use strict";/)
})
})
实现上,首先把 babel.min.js 放在了项目目录里:
https://github.com/Jeff-Tian/serverless-space/blob/main/src/babel-service/babel.min.js
接着在 babel service 里引用它,并实现相应的测试用例中描述的功能:
https://github.com/Jeff-Tian/serverless-space/blob/main/src/babel-service/babel.service.ts
import {HttpService, Injectable} from "@nestjs/common";
const Babel = require('./babel.min.js')
()
export class BabelService {
constructor(private readonly httpService: HttpService) {
}
async transform(code) {
return Babel.transform(code.replace(/import.+;/g, '').replace(/export/g, ''), {
presets: ['env', 'react'],
plugins: []
})?.code?.replace(/"div"/g, '"view"').replace(/"ol"/g, '"view"')
}
async transformFromUrl(url, extra = '') {
const {data: code} = await this.httpService.get(url).toPromise()
return this.transform(code + extra)
}
}
自动化端到端测试
端到端测试,是描述了当前端发出的这样的请求,那么返回这样的响应的测试文件。这里写了两个用例,分别对应于单元测试中的前两个用例:
https://github.com/Jeff-Tian/serverless-space/blob/main/e2e/babel.e2e-spec.ts
import {INestApplication} from "@nestjs/common"
import {Test} from "@nestjs/testing"
import request from "supertest"
import {AppModule} from "../src/app.module"
import {testTargetUrl, transformedText} from '../src/test/constants'
jest.setTimeout(50000)
describe('Babel', () => {
let app: INestApplication
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.compile()
app = moduleRef.createNestApplication()
await app.init()
})
it('transforms', async () => {
return request(app.getHttpServer())
.post('/graphql')
.send({
query: `query transformTsx {
transform (sourceCode: "class A {}") {
text
}
}`
})
.expect({
data: {
transform: {
text: transformedText
}
}
})
.expect(200)
})
it('transforms from url', async () => {
return request(app.getHttpServer())
.post('/graphql')
.send({
query: `query transformTsx {
transform (url: "${testTargetUrl}") {
text
}
}`
})
.expect(res => {
expect(res.body).toMatchObject({data: {transform: {text: /"use strict";/}}})
})
.expect(200)
})
})
要实现这个,就定义了新的 GraphQL 的schema(使用 code first 方式,实现上是写了一个 model 文件,本质上是一个 typescript 类),并分别添加了 module 和 resolver。
在一开始跑第一个端到端测试时,失败是理所当然。后来添加了 module 和 resolver 之后,第一个用例还是失败了!这时才知道,忘了在入口的 module 中引入我们的 babel module。
https://github.com/Jeff-Tian/serverless-space/commit/f665e8afcf0354a8fe2363cfe6015483ee2554a5#diff-089f4f2474b64391c42b6e66aed33977e132058d92108f0a63234a7862e1f8b8
@Module({
- imports: [CatsModule, RecipesModule, YuqueModule, ZhihuModule, GraphqlPluginModule, GraphQLModule.forRoot(graphqlOptions)],
+ imports: [CatsModule, RecipesModule, YuqueModule, ZhihuModule, BabelModule, GraphqlPluginModule, GraphQLModule.forRoot(graphqlOptions)],
})
export class AppModule {
}
看,这就是自动化测试的价值。没有测试,很容易遗漏一些环节,导致发布上线后不能如期工具,最终只能由用户来报告问题了。自动化测试可以在代码部署前快速发现问题,但并不是说有了自动化测试之后,就不需要人肉测试了,但是人肉只需要做些冒烟验证工作。
人肉冒烟测试
在单元测试和端到端测试都通过之后,部署上线了。然后人肉验证,发现挂了!
这是该项目后期需要改进的地方。明明端到端测试都过了,上线后却挂了!不过这个概率很小,在以前的迭代过程中从来没有发生过这样的问题。
这次挂掉的原因是本次迭代里有一个骚操作,即在 src 下加入了一个 babel.min.js 文件。跑自动化测试时,是用的 src 下的文件。但是部署上线后,却是 dist 目录中的文件,并且这个 babel.min.js 文件在编译过程(nest build)中,并没有从 src 自动拷贝到 dist 目录下,导致上线后找不到这个文件!
以前没有出现过,因为总是添加 typescript 文件,它们都在 nest build 过程中自动变成 js 形式生成在 dist 目录下了。
为了迅速修复,在 package.json 的 build 脚本上添加了将 babel.min.js 拷贝到 dist 目录下的步骤:
https://github.com/Jeff-Tian/serverless-space/commit/e4ee6de47a146a772f4fe8548134d361ce3f1806#diff-7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519
- "build": "nest build",
+ "build": "nest build&&cp src/babel-service/babel.min.js dist/src/babel-service/babel.min.js",
再次线上冒烟测试,如演示部分所示,通过。
项目待改进点
加入自动化冒烟测试?这种测试是通过运行编译后的代码来运行。
要么,直接找到 nest 配置,可以自动将 js 文件在编译过程中拷贝到 dist 中的对应位置。
要么,不用这种 babel.min.js 文件,而且采用引用 babel npm 包的方式。