控制反转
截止到目前2023年1月,Java语言基本被Spring框架一统天下,将来有没人能挑战它天下第一的宝座也未可知。
Spring有个非常经典的思想:控制反转(IoC),依赖注入(DI)。
折射到我们现在代码中,就是UserService的初始化实例的时机,我们是直接在user.service.ts中创建实例:
export const userService = new UserService();
而如果是控制反转,就是不会让service自己创建,而是由它的上层(控制层)或者容器(底层框架)来创建,甚至怎么创建,是单例模式还是每次都创建一个新的实例,是同步创建还是延迟创建又或者根本不创建,这些service本身不需要知道。这就是创建对象的控制权进行了转移。
DI(依赖注入)其实是IoC的另一种说法。控制的什么反转了?就是获得依赖对象的方式反转了—— 原来获取依赖对象是直接向service取,现在需要向容器来获取(就像原来财政拨款每个省份都是各找各妈,现在只有一个央妈了)。
这个中央集权的思想很有意思,像是工厂模式的升级版本,生产零件的主动权由工厂统一控制,而不是具体车间,这种宏观调控的制度优势已经被历史实践证明,已无需赘言。
如果你还难理解它的好处,那我再打个比方。
A车间(AService)生产环节中需要用到B车间(BService)的一个部件(getXX方法)。
在以前A车间必须等B车间开工(创建实例)后他才能开工,而现在只需要向工厂报备他有这层依赖,工厂要用到A车间时就会调度让B车间开发,如果没用到A车间,也不会让B车间提前开工,以免浪费。
更牛逼的是,以前B生产的这个部件不管质量怎样,A车间也只能捏着鼻子认了,现在工厂可以对这个部件二次加工,达到A车间的标准;甚至可以给A车间换上新车间C的部件,A以为是B车间改进了生产技术,殊不知这只是领导的艺术,等A车间用上一段没问题后再把B车间裁撤掉来节省成本。
所以,控制反转还有个很直观的优点是方便单元测试。
正因为它有这么多优势,Node.js的nestjs框架也参考了Spring,实现了这种模式。同样的,Deno的oak_nest也是如此。
下来,我们使用oak_nest来改造代码。
oak_nest改造
oak_nest在oak框架的基础上,实现了nestjs的部分功能,基本的控制器、安全守卫、装饰器完全能满足绝大多数场景需要。
import_map.json
删除deps.ts,新增一个import_map.json(其实只用deps也可以,只是一步到位,后面开发更方便些)。内容如下:
{
"imports": {
"@/": "./src/",
"deno_class_validator": "https://deno.land/x/deno_class_validator@v1.0.0/mod.ts",
"oak_exception": "https://deno.land/x/oak_exception@v0.1.2/mod.ts",
"oak_nest": "https://deno.land/x/oak_nest@v1.14.0/mod.ts",
"nanoid": "https://deno.land/x/nanoid@v3.0.0/mod.ts"
}
}
以后所有第三方的引用都在这里控制,每个都需要加上版本号,不然团队内某个成员能够运行成功,另一个可能就失败了,这样的服务上线后简直是灾难。所以严格禁止除此文件外的地方包含第三方引用。
其实,官方提供的deno lock可以锁定依赖,每次deno run时对此进行校验,这样才是最稳妥的。不过目前有个问题是,除官方的CDN(deno.land)之外,第三方CDN即使版本锁定,生成的hash值也有可能有变化。读者在生产中可试用,或许随着Deno的迭代会解决这个问题。(以上这段写于2022年6月,新版本Deno我未再做相关测试)
对应地在.vscode/setting.json中增加
"deno.importMap": "./import_map.json"
也可以在vscode的Deno插件中选择可视化配置,这里就不详述了。
deno.jsonc
额外添加一个importMap的配置:
{
"name": "deno_blog",
...
"importMap": "./import_map.json"
}
model.ts
修改src/model.ts:
import { nanoid } from "nanoid";
import { Inject } from "oak_nest";
export const InjectModel = (Cls: Constructor) =>
Inject(() => {
return new Model(Cls.name.toLowerCase(), Cls);
});
// 剩下不变
新增的这个InjectModel函数是一个装饰器,将要在每个service中使用。
user.service
修改src/user/user.service.ts:
import { Injectable } from "oak_nest";
import { InjectModel, Model } from "../model.ts";
import { User } from "./user.schema.ts";
@Injectable() // 使用装饰器
export class UserService {
constructor(@InjectModel(User) private readonly userModel: Model<User>) {}
// 其它不变
}
// 删除最后一行
// export const userService = new UserService();
在构造函数中使用上面定义的InjectModel装饰器,后面紧跟的参数只需要定义即可,初始化工作都由框架自动完成。
user.dto.ts
修改src/user/user.dto.ts:
import { IsNumber, IsOptional, IsString, Min } from "deno_class_validator";
export class CreateUserDto {
@IsString()
author: string;
@IsNumber()
@Min(0)
age: number;
}
export class UpdateUserDto {
@IsString()
@IsOptional()
author?: string;
@IsNumber()
@Min(0)
@IsOptional()
age?: number;
}
user.controller.ts
删除src/user/user.route.ts,新建src/user/user.controller.ts:
import { NotFoundException } from "oak_exception";
import { Body, Controller, Delete, Get, Params, Post, Put } from "oak_nest";
import { CreateUserDto, UpdateUserDto } from "./user.dto.ts";
import { UserService } from "./user.service.ts";
@Controller("/user")
export class UserController {
constructor(private readonly userService: UserService) {}
@Get("/")
async getAll() {
return await this.userService.getAll();
}
@Get("/:id")
async getUserById(@Params("id") id: string) {
const user = await this.userService.getUserById(id);
if (user) {
return user;
} else {
throw new NotFoundException("user not found");
}
}
@Post("/")
async createUser(@Body() params: CreateUserDto) {
return await this.userService.addUser(params);
}
@Put("/:id")
async updateUser(@Params("id") id: string, @Body() params: UpdateUserDto) {
return await this.userService.updateUser(id, params);
}
@Delete("/:id")
async deleteUser(@Params("id") id: string) {
return await this.userService.removeUser(id);
}
}
留意看上面代码,这个文件是这次改造的重点,以后控制层代码就放在一个个的controller中了,不需要再用路由了。
如果你对比看它和原来的user.route.ts代码,会发现它使用了一个个的装饰器,极大地简化了代码。
- @Controller定义了路由路径。
- constructor构造函数中定义了一个参数UserService,而它会被框架自动注入。
- 除了增删改查这4个装饰器外,获取url中参数的两个装饰器@Params和@Query也是经常要用到的,区别是前者是路径中动态参数,一个是?后面的搜索参数。
- @Body返回json参数,如果紧接着的参数声明里使用了上面dto文件里使用deno_class_validator规则定义的class类,则会自动进行校验。失败则会抛出400错误,可以在全局异常中捕获处理。
另外提一句,NotFoundException来自oak_exception这个包,它封装了几个常见的网络异常,与普通错误的区别仅仅是携带了status状态码,如NotFoundException的状态码为404。
schema.ts
修改src/schema.ts:
import { Reflect } from "oak_nest";
// 其它不变
user.module.ts
新建src/user/user.module.ts:
import { Module } from "oak_nest";
import { UserController } from "./user.controller.ts";
@Module({
controllers: [
UserController,
],
})
export class UserModule {}
文件间依赖关系的整合都靠一个个的Module,需要在controllers把这个模块用到的控制器都引入进来。
app.controller.ts
新建src/app.controller.ts:
import { Controller, Get } from "oak_nest";
@Controller("")
export class AppController {
@Get("/")
version() {
return "hello world";
}
}
app.module.ts
新建src/app.module.ts
import { Module } from "oak_nest";
import { AppController } from "./app.controller.ts";
import { UserModule } from "./user/user.module.ts";
@Module({
imports: [
UserModule,
],
controllers: [
AppController,
],
})
export class AppModule {}
这个module是总领全局的,我们在imports中引入了UserModule,如果还有其它module也可以引入进来管理。
main.ts
修改src/main.ts:
import { NestFactory } from "oak_nest";
import { AppModule } from "./app.module.ts";
const app = await NestFactory.create(AppModule);
...
app.use(app.routes());
...
最终目录结构
这次我们删除了4个文件:deps.ts、src/routes.ts、src/utils.ts、src/user/user.route.ts。 最终目录结构如下:
重新运行服务:deno task dev
在控制台观察:
路由和对应的方法都用不同的颜色打印出来了。
作业
- 如果你对现在的教程还满意的话,到GitHub上点个星吧?求赞。也可以关注我的公众号《前端与Deno》,本书已在上面完本啦。
- 现在我们的服务启动的端口号是8000,如果想要换个端口号是不是还要修改代码?怎么在不改动代码的情况下,启动时能切换端口号呢?