跳转到主要内容

Nest 使用笔记

第四章:Module 进阶——动态、全局、生命周期与循环依赖

打开 learnhub 的真实 app.module.ts、PostModule、RedisModule、MinioModule,讲透 @Module 的四字段、forRootAsync/useFactory 的动态模块、@Global 的取舍、OnModuleInit/OnModuleDestroy 的优雅降级、循环依赖。每段代码都能在 learnhub 里指到对应文件。

  • Nest
  • Module

第三章讲了 provider 跨模块要 exports/imports。这一章解决五个真实问题:模块怎么打包成一个可装配单元、连数据库这种需要配置的模块怎么传参、Redis/MinIO 这种基础设施怎么做成全局省得每个模块都 import、服务启动和关闭时怎么优雅地建连和断连、两个 service 互相依赖时怎么办。每段代码都从 learnhub 真实在跑的模块里抠出来。

先搞懂:Module 是什么 / 为什么需要 / 企业级怎么用

Module 是什么:一个用 @Module(...) 装饰的类,把一组 controller、provider、子模块导入、导出打包成一个可装配单元。Nest 应用就是一棵模块树——根 AppModuleimports 里挂上各业务模块,各业务模块再挂自己的子模块或基础设施模块,Nest 启动时按这棵树搭 IoC 容器。

为什么需要:几百个 service 和 controller 的项目,全堆在根 AppModule 里没法维护——一个文件几千行、改一个功能翻半天、团队没法并行。模块化按业务域切(learnhub 切成 PostModule / CommentModule / AuthModule / UserModule / FavoriteModule …),每个模块自己管自己的 controller/service/provider,对外只 exports 别人需要的东西,内部实现藏在模块里。

企业级怎么用(下面每一条这一章都在 learnhub 里真做一遍):

  • 严格按业务域分模块,模块内的 provider 默认私有,要给别的模块用必须显式 exports
  • 跨模块的基础设施(Redis、MinIO、ES、DB 连接)用 @Global + 动态模块,注册一次全局可用;
  • 需要配置才能初始化的模块(TypeORM、Mongoose、GraphQL)走 forRootAsync + useFactory,依赖 ConfigService
  • 启动/关闭要做的事(建 bucket、断连接)放 OnModuleInit / OnModuleDestroy 钩子;
  • 循环依赖优先重构避免,forwardRef 是兜底。

这一章你会做出什么

  • 打开 learnhub 的 post.module.ts,讲清 @Module 的四字段各干啥。
  • 看懂 app.module.ts 里的 TypeOrmModule.forRootAsync 为什么是 async、useFactory 怎么拿到 ConfigService
  • RedisModule / MinioModule 里看 @Global + token provider 的真实写法,并理解它的代价。
  • MinioService / RedisService 里看 OnModuleInit / OnModuleDestroy 怎么做优雅降级。
  • 看 learnhub 怎么主动避免 PostServiceRankingService 的循环依赖,以及 forwardRef 作为兜底怎么写。
  • 最后回到根 AppModule,把 imports / 全局 AOP providers / 空的 configure() 占位串起来,预告第五章 AOP。

第一步:@Module 的四字段——imports / providers / controllers / exports

先看 learnhub 里一个典型的业务模块——帖子模块:

// learnhub/src/modules/post/post.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Post } from './entities/post.entity';
import { Tag } from '../tag/entities/tag.entity';
import { PostController } from './post.controller';
import { PostService } from './post.service';
import { UserModule } from '../user/user.module';
import { RankingModule } from '../ranking/ranking.module';

@Module({
  imports: [TypeOrmModule.forFeature([Post, Tag]), UserModule, RankingModule],
  controllers: [PostController],
  providers: [PostService],
})
export class PostModule {}

四个字段(这里只用到三个,第四个 exports 马上讲):

  • controllers:这个模块对外暴露的 HTTP 入口,Nest 会把它们注册成路由。PostController 定义了 /api/v1/posts 下的发帖、列表、详情、改、删。
  • providers:本模块要造出来并由 IoC 容器管理的服务。PostService 就在这。默认模块私有——别的模块拿不到。
  • imports:本模块要用别的模块的导出,就得 import 进来。这里 UserModulePostService 提供 UserService(校验帖子作者是不是 admin),RankingModule 提供 RankingService(记浏览量)。TypeOrmModule.forFeature([Post, Tag]) 是动态模块的写法,下面专门讲。
  • exports:本模块要把哪些 provider 暴露给别的模块用。PostModule 没有 exports,所以 PostService 别的模块注入不到——这是合理的,帖子业务逻辑只在帖子模块和它自己的 controller 里用。

注意providers 默认私有是新人最容易踩的坑——「我明明 providers: [XxxService] 写了,怎么别处注入报 Nest can't resolve dependencies」,原因就是没 exports。一条规则:想让别的模块用,必须既 providersexports。@Global 模块也不例外,下面会看到 RedisModule 照样要 exports: [RedisService]

第二步:动态模块——forRootAsync + useFactory,配置走 ConfigService

@Module({...}) 是静态的——内容写死。但很多模块初始化要传参:数据库连接要 host/port/password,Redis 要 host/port。这些参数还得从 ConfigService 拿,而 ConfigService 自己也是个 provider,要等 IoC 容器起来才能注入——静态 @Module 拿不到。

Nest 的解法是动态模块:模块类上写一个静态方法(约定叫 register / forRoot / forFeature),返回一个 DynamicModule 对象,Nest 启动时调这个方法,拿到返回值再当成普通模块装配。异步版本(forRootAsync / registerAsync)多一个 useFactory,工厂函数的参数就是 inject 里声明的依赖。learnhub 接 MySQL 用的是 TypeOrmModule.forRootAsync

// learnhub/src/app.module.ts
TypeOrmModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    type: 'mysql',
    host: config.get<string>('mysql.host'),
    port: config.get<number>('mysql.port'),
    username: config.get<string>('mysql.username'),
    password: config.get<string>('mysql.password'),
    database: config.get<string>('mysql.database'),
    autoLoadEntities: true,   // 自动收集各模块 forFeature 注册的 Entity
    synchronize: false,       // 永远 false,靠 migration
    logging: config.get('nodeEnv') === 'development',
    timezone: '+08:00',
    charset: 'utf8mb4',
  }),
}),

useFactory 的返回值就是 TypeORM 接 MySQL 的所有配置。inject: [ConfigService] 告诉 Nest:等 IoC 容器造出 ConfigService 后,把它作为参数传给工厂函数。imports: [ConfigModule] 是因为动态模块自己有个小 IoC 作用域,要让 ConfigService 在这个作用域里可见得显式 import(即便根 AppModule 已经 import 了全局的 ConfigModule,这是惯例写法,确保依赖可用)。

为什么不用静态 forRoot?因为 forRoot 的参数是同步求值的——你写 forRoot({ host: config.get(...) }) 那一刻 config 还不存在(IoC 容器没起来),直接报错。forRootAsync 把「等配置就绪 → 再造连接」这件事交给 Nest 自己调度。learnhub 的 MongooseModule.forRootAsyncGraphQLModule.forRootAsync 都是同一套路:inject: [ConfigService] + useFactory

方法名约定(Nest 社区默契,第三方包基本都遵守):

  • forRoot(options) / forRootAsync(options)全局只调一次,配置整个应用共享的东西(DB 连接、GraphQL schema、定时任务调度器)。在根 AppModule 里调。
  • forFeature(...)每个业务模块调自己的,配合 forRoot 用。比如 TypeOrmModule.forFeature([Post, Tag]) 告诉 TypeORM「这个模块要用 Post 和 Tag 两个实体」。
  • register(options) / registerAsync(options)每次注册配置不同,比如多租户每个租户一套配置。
  • 都有 xxxAsync 异步版本,对应 useFactory + inject 模式。

第三步:forFeature + autoLoadEntities——实体注册

第二步的 forRootAsync 只配了连接,没说哪些实体要映射成表。TypeORM 注册实体有两种方式:

  1. forRoot 里列全:entities: [User, Post, Comment, Tag, ...]。每加一个实体要回根模块改一次,模块化形同虚设。
  2. autoLoadEntities: true(learnhub 用的):各模块在自己的 imports 里用 forFeature 注册本模块用到的实体,TypeORM 启动时自动收集。

learnhub 选第二种——根 AppModuleforRootAsyncautoLoadEntities: true,各业务模块自己 forFeature

// learnhub/src/modules/post/post.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([Post, Tag]), UserModule, RankingModule],
  controllers: [PostController],
  providers: [PostService],
})
export class PostModule {}

forFeature([Post, Tag]) 干两件事:把 PostTag 登记进 autoLoadEntities 的收集清单(启动后 TypeORM 知道这俩是实体),同时把 Repository<Post>Repository<Tag> 注册成当前模块可注入的 provider。PostService 构造函数里就能拿到:

// learnhub/src/modules/post/post.service.ts
@Injectable()
export class PostService {
  constructor(
    @InjectRepository(Post) private readonly postRepo: Repository<Post>,
    @InjectRepository(Tag) private readonly tagRepo: Repository<Tag>,
    @InjectDataSource() private readonly dataSource: DataSource,
    // ...
  ) {}
}

@InjectRepository(Post) 是「按实体拿它的 Repository」;@InjectDataSource() 拿到 DataSource 本身(事务要用,见第八章)。注意 Service 注入的是 RepositoryDataSource不直接 import 任何 SQL

思考:为什么 TypeORM 在根模块用 forRootAsync、在业务模块用 forFeature?——因为它们管的是两件不同粒度的事。forRootAsync 配的是整个应用一份的连接参数(host/port/password 这种全局配置),只在根模块调一次;forFeature 配的是本模块要用哪些实体,跟随模块加载——新加一个 CommentModule 就在自己模块里 forFeature([Comment]),根模块不用动。模块化的核心就是:全局配置和局部注册分开,新加业务模块不需要回根模块改任何东西。

第四步:@Global——全局模块的诱惑和代价

到这步有个新问题:Redis、MinIO、ES、Prisma、Etcd、Amqp 这种基础设施,几乎每个业务模块都要用。按第三步的规矩老老实实 imports,每个模块都得写一行 imports: [RedisModule],十几个模块就重复十几次。

Nest 的解法是 @Global()——标记一个模块为全局,它 exports 的 provider 全局可见,不用任何模块再 import。learnhub 的 Redis 模块:

// learnhub/src/modules/redis/redis.module.ts
@Global()
@Module({
  imports: [ConfigModule],
  providers: [
    {
      provide: REDIS_CLIENT,
      inject: [ConfigService],
      useFactory: (config: ConfigService) =>
        new Redis({
          host: config.get<string>('redis.host') || '127.0.0.1',
          port: config.get<number>('redis.port') || 6379,
          retryStrategy: (times) => Math.min(times * 500, 3000),
          maxRetriesPerRequest: null,
          enableOfflineQueue: true,
        }),
    },
    RedisService,
  ],
  exports: [RedisService],
})
export class RedisModule {}

这个模块做了三件事:

  1. @Global() 让它全局可见——根 AppModule.imports 只 import 一次,任何模块直接注入 RedisService 就能用。所以 post.module.tsimports 里压根没有 RedisModule,但 PostService 通过 RankingService(间接用 Redis)照常用。
  2. 用一个 token provider 把 ioredis 的 Redis 实例注册成 REDIS_CLIENT(字符串常量,见 redis.constants.ts)。这是 DI token 的典型用法——第三方类(Redis)不是 @Injectable,要进 IoC 容器就得用字符串 token 包一层。
  3. useFactory 注入 ConfigService,从配置读连接参数——和 TypeORM 的 forRootAsync 是同一个套路。

MinioModuleMINIO_CLIENT)、SearchModuleES_CLIENT)、EtcdModuleAmqpModulePrismaModule 全是这套写法:@Global() + token provider + useFactory 注入 ConfigServiceexports 自己的 service。

注意:@Global 是双刃剑。便利的另一面是隐藏了依赖来源——读 PostService 代码时看到一堆 service,你得全局搜一下才知道哪个是 @Global 来的、哪个是 imports 来的;显式 imports: [RedisModule] 至少在模块顶部告诉你「这个模块依赖 Redis」。规则:真正全局的基础设施用 @Global(Redis、MinIO、DB 连接、日志、配置中心),只在 1-2 个模块用的 service 老老实实 imports——否则等于全局变量满天飞,模块边界名存实亡。

第五步:生命周期钩子——OnModuleInit / OnModuleDestroy 做优雅降级

Nest 启动和关闭时按顺序触发一组 hook,service 实现 OnModuleInit / OnModuleDestroy 等接口,对应钩子就会被调:

Hook触发时机典型用途
onModuleInit本模块所有 provider 实例化后初始化连接、建资源
onApplicationBootstrap所有模块 init 完、即将监听端口前订阅消息、预热缓存
onModuleDestroy应用关闭时(SIGTERM / app.close()断开连接、flush 缓冲
onApplicationShutdown关闭最末尾关 DataSource

learnhub 用这组钩子做了一个生产里很关键的模式——优雅降级:基础设施(MinIO/Redis/ES)没起来时,应用照样启动,等真正调用到对应接口时再报错,而不是整个应用 boot 失败。开发时只起 MySQL 不起 MinIO 也能跑大部分接口,生产上某个基础设施短暂故障也不至于拖垮整个服务启动。

MinIO 是典型——启动时检查 bucket 不存在就建,但 MinIO 挂了不抛、只警告:

// learnhub/src/modules/upload/minio.service.ts
@Injectable()
export class MinioService implements OnModuleInit {
  private readonly logger = new Logger(MinioService.name);
  private readonly bucket: string;

  constructor(
    @Inject(MINIO_CLIENT) private readonly client: Client,
    private readonly config: ConfigService,
  ) {
    this.bucket = this.config.get<string>('minio.bucket') || 'learnhub-assets';
  }

  async onModuleInit(): Promise<void> {
    try {
      const exists = await this.client.bucketExists(this.bucket);
      if (!exists) {
        await this.client.makeBucket(this.bucket);
        this.logger.log(`bucket 已创建:${this.bucket}`);
      }
      try {
        await this.client.setBucketPolicy(this.bucket, publicReadPolicy(this.bucket));
      } catch {
        /* 部分 minio 版本策略语法差异,失败忽略 */
      }
    } catch (e) {
      this.logger.warn(`MinIO 不可用,上传功能将不可用:${(e as Error).message}`);
    }
  }
  // ... putObject / presignedPut / presignedGet / publicUrl
}

关键就在 try/catch——bucketExists / makeBucket 失败(MinIO 没起、网络不通)只是 logger.warn不重新抛出onModuleInit 不抛,应用就继续 boot,监听端口、跑 controller。上传接口真正被调用时才会因为 client.putObject 失败报错——但那是接口级失败,不影响其他功能。

Redis 服务是反方向的钩子——关闭时主动断开连接:

// learnhub/src/modules/redis/redis.service.ts
@Injectable()
export class RedisService implements OnModuleDestroy {
  constructor(@Inject(REDIS_CLIENT) private readonly client: Redis) {}

  /** 应用关闭时断开连接,避免测试/停机时连接泄漏 */
  async onModuleDestroy(): Promise<void> {
    await this.client.quit().catch(() => undefined);
  }

  // ... incr / get / zincrby / ztopWithScores / ...
}

注意 .quit().catch(() => undefined)——关闭时 Redis 已经挂了的话 quit 会 reject,这里 catch 掉避免关闭流程报错。优雅降级是双向的:启动时基础设施挂了不阻断 boot,关闭时基础设施挂了不阻断 shutdown

onApplicationBootstrap learnhub 暂时没用(订阅 MQ、预热缓存这类场景用得上);onModuleDestroy / onApplicationShutdown 还要配合 main.tsapp.enableShutdownHooks() 才能在收到 SIGTERM 时触发——容器编排 K8s 发 SIGTERM 时优雅退出就靠它,部署章节细讲。

第六步:循环依赖——forwardRef 是兜底,更好的做法是重构

两个 service 互相注入就会循环:A 的构造函数要 B,B 的构造函数要 A,IoC 容器不知道先造哪个,启动直接报 Nest can't resolve dependencies of AaaService (BbbService, ...)

learnhub 里有一个本来会循环的真实场景:PostService 记录帖子浏览量要调 RankingService;而 RankingService 定时把攒在 Redis 的浏览量 flush 进数据库时,要更新 PostviewCount——直观写法是 RankingService 注入 PostServiceupdate,于是 PostService ↔ RankingService 循环。但 learnhub 的 RankingService 故意不注入 PostService

// learnhub/src/modules/ranking/ranking.module.ts
/**
 * - 注册 Post 实体(flush 时 increment viewCount);
 * - exports RankingService:PostService 记录浏览量要用;
 * - 依赖 RedisService(全局,无需 import)。
 * 单向依赖:PostModule → RankingModule(RankingModule 不反向依赖 PostModule)。
 */
@Module({
  imports: [TypeOrmModule.forFeature([Post])],
  controllers: [RankingController],
  providers: [RankingService],
  exports: [RankingService],
})
export class RankingModule {}

注意 imports: [TypeOrmModule.forFeature([Post])]——RankingService flush 时直接拿 Repository<Post>increment('viewCount')只依赖 Post 实体和 Repository,不依赖 PostServiceranking.service.ts 文件头注释写得明白:「解耦:本服务只依赖 Redis + Post 实体(flush 用),不反向依赖 PostService,避免循环依赖」。

这是生产里处理循环依赖的首选做法:把循环拆开。A 和 B 都依赖的公共逻辑,抽到一个两边都能注入的 C(这里是 Repository<Post> + Redis),而不是 A↔B 互相调对方的方法。

如果实在拆不开(比如两个 service 必须互相调对方的业务方法),Nest 提供 forwardRef延迟引用——告诉容器「这个依赖先别立刻解析,等两边都造出来再回填」:

// 兜底写法(learnhub 里不需要,因为循环已经被上面那行注释的设计拆掉了)
import { Inject, forwardRef, Injectable } from '@nestjs/common';

@Injectable()
export class PostService {
  constructor(
    @Inject(forwardRef(() => RankingService)) private ranking: RankingService,
  ) {}
}

@Injectable()
export class RankingService {
  constructor(
    @Inject(forwardRef(() => PostService)) private posts: PostService,
  ) {}
}

forwardRef(() => XxxService) 两边都得加,只加一边还是会报错。注意forwardRef兜底不是常规手段——它让依赖图变成运行时才能解析,启动变慢、错误信息更难懂。能像 learnhub 这样重构拆开就别用 forwardRef

第七步:回到根 AppModule——imports / 全局 AOP / configure 全貌

每个机制都讲过了,最后把 learnhub 的根 AppModule 完整看一遍——前面看到的 forRootAsync / @Global / 全局 AOP providers / 空的 configure() 都在这一处汇总:

// learnhub/src/app.module.ts
@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env', load: [configuration] }),
    TypeOrmModule.forRootAsync({ /* 第二步讲过 */ }),
    ScheduleModule.forRoot(),
    MongooseModule.forRootAsync({ /* 和 TypeORM 同套路 */ }),
    // 业务模块
    AuthModule, UserModule, RbacModule, PostModule, CommentModule,
    TagModule, FavoriteModule,
    // @Global 基础设施模块(第四步讲过)
    MinioModule, UploadModule, RedisModule, RankingModule,
    SearchModule, PrismaModule, AmqpModule, EtcdModule,
    // GraphQL(code-first,schema 自动生成)
    GraphQLModule.forRootAsync<ApolloDriverConfig>({ driver: ApolloDriver, useFactory: () => ({ /* ... */ }) }),
    AnalyticsModule, LoggerModule, SseModule, I18nModule,
  ],
  controllers: [AppController],
  providers: [
    { provide: APP_FILTER, useClass: HttpExceptionFilter },
    { provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
    { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
    { provide: APP_INTERCEPTOR, useClass: BehaviorLogInterceptor },
    { provide: APP_GUARD, useClass: JwtAuthGuard },
    { provide: APP_GUARD, useClass: PermissionGuard },
  ],
})
export class AppModule implements NestModule {
  // 阶段1 占位:阶段18 会在这里注册全局/路由 Middleware
  configure(_consumer: MiddlewareConsumer) {}
}

三个地方值得再说一遍:

  • imports 里的分组有讲究:先全局配置(ConfigModule)、再各 DB/调度(TypeOrmModule / MongooseModule / ScheduleModule)、再业务模块、再 @Global 基础设施、最后 GraphQL 和日志。新人加模块时按这个分组找位置,模块树长期不会乱。
  • providersAPP_FILTER / APP_INTERCEPTOR / APP_GUARD 是 Nest 的全局 AOP 注册 token——useClass 指定的类会被实例化并注册成全应用生效的过滤器 / 拦截器 / 守卫。一个请求进来,先被 JwtAuthGuard / PermissionGuard(认证 + 鉴权)拦,进 controller 前后被 LoggingInterceptor / TransformInterceptor / BehaviorLogInterceptor(日志、统一返回结构、行为记录)包,出错被 HttpExceptionFilter 统一处理。这就是下一章 AOP 的全部内容,这里先认个脸。
  • 最后那个空的 configure(_consumer: MiddlewareConsumer) {} 不是多余的AppModule 实现 NestModule 接口、提供 configure,是 Nest 注册全局 Middleware 的入口——consumer.apply(SomeMiddleware).forRoutes('*') 就写在这里。learnhub 当前还没用全局 Middleware(守卫和拦截器已经覆盖了它的常见用途),所以是个空占位,注释里写明「阶段18 会在这里填」。第五章讲 AOP 五件套时会回来动它。

这一章的成果

  1. @Module 的四字段(imports / providers / controllers / exports)各管什么;provider 默认私有,要让别处用必须既 providersexports
  2. 动态模块 forRootAsync + useFactory + inject 怎么把 ConfigService 注入进连接配置;forRoot / forFeature / register 各自的语义和适用场景。
  3. autoLoadEntities: true + 各模块 forFeature 为什么比在根模块列全 entities 更模块化。
  4. @Global + token provider 的写法(REDIS_CLIENT / MINIO_CLIENT / ES_CLIENT),以及它的双刃——基础设施用 @Global,业务 service 老老实实 imports
  5. OnModuleInit / OnModuleDestroy 的优雅降级模式:基础设施挂了不阻断 boot、关闭时断连不阻断 shutdown。
  6. 循环依赖首选重构拆开(learnhub 的 RankingService 不反向依赖 PostService),forwardRef 是兜底。
  7. AppModule 的三个全局 AOP 注册 token(APP_FILTER / APP_INTERCEPTOR / APP_GUARD)和空的 configure() 占位,预告了下一章 AOP。

常见问题

  • Nest can't resolve dependencies of XxxService(YyyService, ...)YyyService 没注入进来。三步排查——XxxModule.imports 里有没有 import 提供 YyyService 的模块;那个模块有没有 exports: [YyyService]YyyService 是不是 @Global 模块导出的(如果是就不用 import,但那个 @Global 模块本身得在根 AppModule.imports 里出现过一次)。
  • 动态模块 forRootAsyncNest can't resolve dependencies of useFactory(ConfigService)inject: [ConfigService] 声明了但 imports: [ConfigModule] 没写,或 ConfigModule 不是 isGlobal: true。工厂函数的依赖要显式声明。
  • forFeature([Post]) 注入了 Repository<Post> 但启动后表没建:根 forRootAsyncautoLoadEntities 是不是 true(没有的话 forFeature 注册的实体会被忽略),或者 synchronize: false 且没跑 migration(生产正常情况,要 npm run migration:run,见第八章)。
  • @Global 模块 exports 了但别处注入不到:检查这个 @Global 模块是不是真的被根 AppModule.imports import 过一次——@Global 不是「不需要 import」,而是「import 一次后全局可用」。
  • forwardRef 加了一边还报错:循环依赖要两边都加 forwardRef,只加一边容器还是解析不动。更建议先想能不能像 RankingService 那样重构拆开。
  • onModuleDestroy 不触发:默认情况下 Nest 不监听 SIGTERM / SIGINT。要在 main.tsapp.enableShutdownHooks(),关闭信号才会触发 destroy 钩子链。

下一章讲 AOP 五件套——Middleware / Guard / Pipe / Interceptor / ExceptionFilter。这一章结尾那个空的 configure(_consumer) 就是 Middleware 的注册入口,APP_GUARD / APP_INTERCEPTOR / APP_FILTER 也都会展开成真实代码——把日志、鉴权、参数校验、统一返回、异常处理这些横切关注点从业务里抽出来。