1.11 使用oak_nest重构

103 阅读6分钟

控制反转

截止到目前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());

...

image.png

最终目录结构

这次我们删除了4个文件:deps.ts、src/routes.ts、src/utils.ts、src/user/user.route.ts。 最终目录结构如下:

image.png

重新运行服务:deno task dev

在控制台观察:

image.png

路由和对应的方法都用不同的颜色打印出来了。

作业

  1. 如果你对现在的教程还满意的话,到GitHub上点个星吧?求赞。也可以关注我的公众号《前端与Deno》,本书已在上面完本啦。
  2. 现在我们的服务启动的端口号是8000,如果想要换个端口号是不是还要修改代码?怎么在不改动代码的情况下,启动时能切换端口号呢?