跳到主要内容

业务逻辑与依赖注入

zenweb 最核心的设计理念 依赖注入 功能,设计上参考了 Spring 方案并做了一些调整

定义一个最简单的业务类

src/service/user_service.ts
// 定义并导出类
export class UserService {
getByUsername(username: string) {
return "You name is: " + username;
}
}

在控制器中使用业务类实例

src/controller/user.ts
export class UserController {
@Get()
getByService(userService: UserService) { // 只需要在控制器方法参数中标识需要使用的类即可
return userService.getByUsername("Bob");
}
}

在业务类中注入业务类

src/service/auth_service.ts
import { Injectable } from "zenweb";
import { UserService } from "./user_service";

@Injectable // 使用 Injectable 注解标记可注入的类
export class AuthService {
constructor(
private userService: UserService,
) {}

login(username: string) {
return this.userService.getByUsername(username);
}
}

作用域级别

类作用域是指一个类的实例所处的上下文生命周期

zenweb 注入类的作用域一个分为一下三种:

  • singleton: 单例模式,对象只会初始化一次并一直存活在整个应用生命周期中
    • 此级别下对象不可注入 request 级别的对象
  • request: 请求模式,当前请求中只会被初始化一次,同一个请求中上下文中共享同一个实例对象
    • 被标记为 request 作用域的对象不能注入到 singleton 中
    • 默认级别
  • prototype: 原型模式,每次注入时都会被初始化,每一个注入对象都是新的
    • 因为每次都新建一个实例,通常情况下不使用
    • 任何级别都可以注入此级别的对象
    • 控制器的默认级别
与 Spring 的区别

默认作用域级别为 request,而并非 singleton, 因为在 web 项目中这更常用,也避免错误的使用 singleton 造成共享变量污染。

修改类的作用域

src/service/db_service.ts
import { Injectable } from "zenweb";

// 标记类为单例类型
@Injectable("singleton")
export class DbService {
// 通常来说数据库连接只需要一次即可,避免每次请求都连接数据浪费资源
conn = new DatabaseConnection();

query(sql: string) {
return this.conn.query(sql);
}
}

在非业务类中取得类实例

我们知道在业务类中可以通过 @Injectable@Inject 自动注入类实例

如果在 中间件 中,这种非业务类的方法中如何获得一个类实例呢?

其实在 Context 上下文对象中存在一个 injecter 属性,可以通过他来取得需要的对象实例。

function someMiddleware(): Middleware {
return async function (ctx, next) {
// 注意这里一定要使用 await
const someService = await ctx.injector.getInstance(SomeService);
// ...
return next();
}
}

类的异步初始化

依赖注入有以下几点需要注意:

  • 一般情况下推荐使用 @Injectable 和类构造器 constructor() 来注入业务类的依赖,这也是最推荐的方式

  • @Inject 依赖注入会先初始化一个类后再设置依赖属性到实例中

    • 这样就会造成一个问题,在 constructor() 构造器中无法调用到有效的依赖属性
  • 一个标准的 js class 是不可以把类构造器 constructor() 声明为异步的

    • 如果你需要在构造器内 await 一些方法是不成立的,如果需要异步初始化,可以在类中声明一个方法并使用 @Init 装饰器来告诉注入器此类需要初始化操作。
import { Injectable, Init } from "zenweb";

class TargetClass {}

@Injectable
export class SomeClass {
constructor(
private target: TargetClass,
) {}

constructor() {
console.log('constructor: %o', this.target); // 构造器里无法获得依赖类的实例
// output: constructor: undefined
}

// 使用 @Init 装饰器来指定类方法为初始化方法
// 在类需要初始化时依赖注入器会自动调用此方法
@Init
initMethod() {
console.log('initMethod: %o', this.target); // 此时已经可以取得依赖类的实例
// output: initMethod: TargetClass {}
}

// 你甚至可以定义多个初始化方法
// 并且可以使用异步方法
@Init
async otherInitMethod() {
await Promise();
}

// 你也可以像使用控制器方法那样,使用方法参数注入依赖
// 例如这个依赖我只想要在初始化方法内使用,不想暴露出来
@Init
likeControllerMethod(target: TargetClass) {
}
}

@Inject 属性注入

除了通过构造函数参数注入依赖外,还可以使用 @Inject 装饰器直接在类属性上进行注入。

采用这种方式注入的场景:类继承,父类构造器需要传参,不想手动初始化父构造器。

src/service/auth.ts
import { Context, Injectable, Inject } from 'zenweb';

@Injectable
class BaseService {
constructor(private someService: SomeService) {}
}

export class AuthService extends BaseService {
// 使用 @Inject 装饰器注入 Context
@Inject ctx!: Context;

async login(name: string, password: string) {
// 在方法中通过 this.ctx 访问请求上下文
const user = await User.find({ name }).get();
if (!user) {
throw new Error('用户名或密码错误');
}
return user;
}
}
注意

@Inject 属性注入是在对象初始化完成之后才设置的,因此在 constructor() 构造函数中无法访问到被 @Inject 标注的属性。如果需要在初始化时使用依赖,请使用 @Init 装饰器或构造函数参数注入。

与构造函数注入的对比

// 方式一:构造函数参数注入(推荐)
@Injectable
export class OrderService {
constructor(
private userService: UserService,
private paymentService: PaymentService,
) {}
}

// 方式二:@Inject 属性注入
@Injectable
export class OrderService {
@Inject userService!: UserService;
@Inject paymentService!: PaymentService;
}

两种方式的效果相同,选择依据:

  • 构造函数注入:依赖关系清晰,TypeScript 类型检查完整
  • @Inject 属性注入:适合后期添加依赖时使用,避免修改构造函数签名

在 Service 层使用全局模式 [v4.0+]

从 ZenWeb v4.0 开始,框架引入了全局模式(基于 asyncLocalStorage),你可以在 Service 层直接使用 $ 前缀的全局函数,无需通过 @Inject 注入 ctx,也不需要将 ctx 作为参数层层传递。

src/service/chat-room.ts
import { $query, $log, $mysql, $ctx } from 'zenweb';

export class ChatRoomService {
// 无需注入 ctx,直接使用全局函数
async create(name: string) {
const userId = $ctx.user!.id!;
$log.info('创建聊天室: %s, 操作者: %d', name, userId);

const result = await $mysql.sql`
INSERT INTO chat_room (name, creator_id) VALUES (${name}, ${userId})
`;
return result;
}

async getMessages(roomId: number, limit: number = 50) {
const messages = await $mysql.sql`
SELECT * FROM chat_room_message
WHERE room_id = ${roomId}
ORDER BY created_at DESC
LIMIT ${limit}
`;
return messages;
}
}

全局模式 vs 传统模式对比

传统模式:需要通过 @Inject 注入 ctx
import { Context, Inject } from 'zenweb';

export class ChatRoomService {
@Inject ctx!: Context;

async create(name: string) {
const userId = this.ctx.user!.id!;
// ...
}
}
全局模式:直接使用 $ctx 和 $mysql
import { $ctx, $log, $mysql } from 'zenweb';

export class ChatRoomService {
async create(name: string) {
const userId = $ctx.user!.id!;
$log.info('创建聊天室: %s', name);
// ...
}
}
提示

使用全局模式需要通过 $initCore() 初始化应用。详细信息请参考 全局模式与 $ 快捷方式

$getInstance 全局获取实例

$getInstance 提供了一种在任意位置获取注入容器中实例的方式。如果在请求上下文中调用,从请求级容器获取;否则从全局容器获取。

src/service/notification.ts
import { $getInstance } from 'zenweb';
import { EmailService } from './email';
import { SmsService } from './sms';

export class NotificationService {
async send(userId: number, message: string) {
// 在方法内部按需获取其他 Service 实例
// 无需通过构造函数提前注入所有可能的依赖
const emailService = await $getInstance(EmailService);
await emailService.send(userId, message);

const smsService = await $getInstance(SmsService);
await smsService.send(userId, message);
}
}

这个特性在以下场景特别有用:

  • 依赖关系复杂,不想在构造函数中注入过多参数
  • 按条件动态获取实例,避免不必要的初始化开销
  • 在非业务类(如工具函数)中需要访问 Service 实例

依赖注入作用域规则说明

ZenWeb 的依赖注入容器采用分层结构,不同作用域之间存在严格的依赖规则,以防止生命周期不匹配导致的问题。

三种作用域

作用域生命周期说明
singleton应用启动到关闭全局唯一实例,适合无状态服务
request单次 HTTP 请求同一请求中共享实例,默认作用域
prototype每次注入时每次获取都创建新实例

依赖规则矩阵

singleton 可以依赖:    singleton, prototype
request 可以依赖: singleton, request, prototype
prototype 可以依赖: singleton, request, prototype

常见错误

如果违反了作用域规则,注入器会抛出异常:

Error: 'request' level cannot be injected to 'singleton' level.

这意味着你在一个 singleton 作用域的类中注入了 request 作用域的类,这是不允许的,因为 singleton 类在整个应用生命周期中只创建一次,而 request 类在每次请求结束时会被销毁。

// 错误示例
@Injectable('singleton')
export class CacheManager {
constructor(
private userService: UserService, // UserService 默认是 request 作用域
) {}
}

// 修正方式一:将 UserService 改为 singleton
@Injectable('singleton')
export class UserService {
// 注意:此时不能注入 ctx 等 request 级别的对象
}

// 修正方式二:将 CacheManager 改为 request
@Injectable // 默认 request
export class CacheManager {
constructor(
private userService: UserService,
) {}
}

注入器查找流程

当调用 getInstance() 获取实例时,注入器按以下流程查找:

  1. 当前作用域容器中查找是否已存在实例,存在则直接返回
  2. 检查目标类的作用域是否与当前容器作用域匹配
  3. 如果不匹配且存在父容器,委托给父容器处理
  4. 如果不匹配且没有父容器,抛出作用域冲突异常
  5. 作用域匹配时,创建新实例并缓存(prototype 除外)