我如何在 NESTJS 中设置多租户

Posted

技术标签:

【中文标题】我如何在 NESTJS 中设置多租户【英文标题】:How can i setup multitenant in NESTJS 【发布时间】:2019-09-28 20:11:43 【问题描述】:

我想连接到基于子域(多租户)的任何数据库,但我不知道该怎么做。

我的代码在应用启动时运行,但我不知道如何根据子域更改数据源。

PS:我为每个请求创建了中间件,但我不知道如何更改源。

我的数据库有以下代码:

import  connect, createConnection  from 'mongoose';
import  SERVER_CONFIG, DB_CONNECTION_TOKEN  from '../server.constants';

 const opts = 
    useCreateIndex: true,
    useNewUrlParser: true,
    keepAlive: true,
    socketTimeoutMS: 30000,
    poolSize: 100,
    reconnectTries: Number.MAX_VALUE,
    reconnectInterval: 500,
    autoReconnect: true,
  ;
export const databaseProviders = [
  
    provide: DB_CONNECTION_TOKEN,
    useFactory: async () => 
      try 
        console.log(`Connecting to $ SERVER_CONFIG.db `);
        return await createConnection(`$SERVER_CONFIG.db`, opts);
       catch (ex) 
        console.log(ex);
      

    ,
  
];

我想根据子域(多租户)更改每个请求中的数据源

【问题讨论】:

【参考方案1】:

这是我与猫鼬一起使用的解决方案

    TenantsService 用于管理应用中的所有租户
@Injectable()
export class TenantsService 
    constructor(
        @InjectModel('Tenant') private readonly tenantModel: Model<ITenant>,
    ) 

    /**
     * Save tenant data
     *
     * @param CreateTenantDto createTenant
     * @returns Promise<ITenant>
     * @memberof TenantsService
     */
    async create(createTenant: CreateTenantDto): Promise<ITenant> 
        try 
            const dataToPersist = new this.tenantModel(createTenant);
            // Persist the data
            return await dataToPersist.save();
         catch (error) 
            throw new HttpException(error, HttpStatus.BAD_REQUEST);
        
    

    /**
     * Find details of a tenant by name
     *
     * @param string name
     * @returns Promise<ITenant>
     * @memberof TenantsService
     */
    async findByName(name: string): Promise<ITenant> 
        return await this.tenantModel.findOne( name );
    


    TenantAwareMiddleware 中间件从请求上下文中获取 tenant id。您可以在此处创建自己的逻辑以从请求标头或请求 url 子域中提取 tenant id。请求头提取方法如下所示。

如果您想提取子域,也可以通过调用req.subdomainsRequest 对象中提取子域来完成,这将为您提供子域列表,然后您可以从中获取您正在寻找的子域那个。

@Injectable()
export class TenantAwareMiddleware implements NestMiddleware 
    async use(req: Request, res: Response, next: NextFunction) 
        // Extract from the request object
        const  subdomains, headers  = req;

        // Get the tenant id from header
        const tenantId = headers['X-TENANT-ID'] || headers['x-tenant-id'];

        if (!tenantId) 
            throw new HttpException('`X-TENANT-ID` not provided', HttpStatus.NOT_FOUND);
        

        // Set the tenant id in the header
        req['tenantId'] = tenantId.toString();

        next();
    

    TenantConnection 此类用于使用 tenant id 创建新连接,如果现有连接可用,它将返回相同的连接(以避免创建额外的连接)。
@Injectable()
export class TenantConnection 
    private _tenantId: string;

    constructor(
        private tenantService: TenantsService,
        private configService: ConfigService,
    ) 

    /**
     * Set the context of the tenant
     *
     * @memberof TenantConnection
     */
    set tenantId(tenantId: string) 
        this._tenantId = tenantId;
    

    /**
     * Get the connection details
     *
     * @param ITenant tenant
     * @returns
     * @memberof TenantConnection
     */
    async getConnection(): Connection 
        // Get the tenant details from the database
        const tenant = await this.tenantService.findByName(this._tenantId);

        // Validation check if tenant exist
        if (!tenant) 
            throw new HttpException('Tenant not found', HttpStatus.NOT_FOUND);
        

        // Get the underlying mongoose connections
        const connections: Connection[] = mongoose.connections;

        // Find existing connection
        const foundConn = connections.find((con: Connection) => 
            return con.name === `tenantDB_$tenant.name`;
        );

        // Check if connection exist and is ready to execute
        if (foundConn && foundConn.readyState === 1) 
            return foundConn;
        

        // Create a new connection
        return await this.createConnection(tenant);
    

    /**
     * Create new connection
     *
     * @private
     * @param ITenant tenant
     * @returns Connection
     * @memberof TenantConnection
     */
    private async createConnection(tenant: ITenant): Promise<Connection> 
        // Create or Return a mongo connection
        return await mongoose.createConnection(`$tenant.uri`, this.configService.get('tenant.dbOptions'));
    


    TenantConnectionFactory 这是自定义提供程序,可为您提供 tenant id 并帮助创建连接
// Tenant creation factory
export const TenantConnectionFactory = [
    
        provide: 'TENANT_CONTEXT',
        scope: Scope.REQUEST,
        inject: [REQUEST],
        useFactory: (req: Request): ITenantContext => 
            const  tenantId  = req as any;
            return new TenantContext(tenantId);
        ,
    ,
    
        provide: 'TENANT_CONNECTION',
        useFactory: async (context: ITenantContext, connection: TenantConnection): Promise<typeof mongoose>  => 
            // Set tenant context
            connection.tenantId = context.tenantId;

            // Return the connection
            return connection.getConnection();
        ,
        inject: ['TENANT_CONTEXT', TenantConnection],
    ,
];
    TenantsModule - 在这里您可以看到作为提供程序添加的TenantConnectionFactory,并且正在导出以在其他模块中使用。
@Module(
  imports: [
    CoreModule,
  ],
  controllers: [TenantsController],
  providers: [
    TenantsService,
    TenantConnection,
    ...TenantConnectionFactory,
  ],
  exports: [
    ...TenantConnectionFactory,
  ],
)
export class TenantsModule 
    TenantModelProviders - 由于您的租户模型依赖于租户连接,因此您的模型必须通过提供程序定义,然后包含在您初始化它们的模块中。
export const TenantModelProviders = [
    
        provide: 'USER_MODEL',
        useFactory: (connection: Connection) => connection.model('User', UserSchema),
        inject: ['TENANT_CONNECTION'],
    ,
];
    UsersModule - 此类将使用模型。您还可以看到此处配置的中间件以作用于您的 Tenand db 路由。这种情况下,所有user 路由都是租户的一部分,将由租户数据库提供服务。
@Module(
  imports: [
    CoreModule,
    TenantsModule,
  ],
  providers: [
    UsersService,
    ...TenantModelProviders,
  ],
  controllers: [UsersController],
)
export class UsersModule implements NestModule 
  configure(context: MiddlewareConsumer) 
    context.apply(TenantAwareMiddleware).forRoutes('/users');
  

    UsersService - 从用户模块访问租户数据库的示例实现
@Injectable()
export class UsersService 

    constructor(
        @Inject('TENANT_CONTEXT') readonly tenantContext: ITenantContext,
        @Inject('USER_MODEL') private userModel: Model<IUser>,
    ) 
        Logger.debug(`Current tenant: $this.tenantContext.tenantId`);
    

    /**
     * Create a new user
     *
     * @param CreateUserDto user
     * @returns Promise<IUser>
     * @memberof UsersService
     */
    async create(user: CreateUserDto): Promise<IUser> 
        try 
            const dataToPersist = new this.userModel(user);
            // Persist the data
            return await dataToPersist.save();
         catch (error) 
            throw new HttpException(error, HttpStatus.BAD_REQUEST);
        
    

    /**
     * Get the list of all users
     *
     * @returns Promise<IUser>
     * @memberof UsersService
     */
    async findAll(): Promise<IUser> 
        return await this.userModel.find();
    


【讨论】:

使用范围请求会导致所有使用工厂的服务的性能发生变化?难道没有不涉及再次实例化服务的解决方案吗? @ontimond 我想你可以,但你使用的策略只会改变你获取租户 ID 的方式。发布连接创建过程将保持不变。【参考方案2】:

我们的 NestJS 设置也有一个多租户设置。 您可以有一个中间件,根据请求决定使用哪个数据源。在我们的示例中,我们使用了 TypeORM,它在 NestJS 中有很好的集成。 TypeORM 包中有一些有用的功能。

中间件

export class AppModule 
  constructor(private readonly connection: Connection) 
  

  configure(consumer: MiddlewareConsumer): void 
    consumer
      .apply(async (req, res, next) => 
        try 
          getConnection(tenant);
          next();
         catch (e) 
          const tenantRepository = this.connection.getRepository(tenant);
          const tenant = await tenantRepository.findOne( name: tenant );
          if (tenant) 
            const createdConnection: Connection = await createConnection(options);
            if (createdConnection) 
              next();
             else 
              throw new CustomNotFoundException(
                'Database Connection Error',
                'There is a Error with the Database!',
              );
            
          
        
      ).forRoutes('*');
   

这是我们中间件的一个例子。 TypeORM 在内部管理连接。因此,您首先要尝试为该特定租户加载连接。如果有,那就好,否则就创建一个。这里的好处是,一旦创建连接,在 TypeORM 连接管理器中仍然可用。这样,您始终可以在路线中建立联系。 在您的路线中,您需要为您的租户提供身份证明。在我们的例子中,它只是一个从 url 中提取的字符串。无论它是什么值,您都可以将其绑定到中间件中的请求对象。在您的控制器中,您再次提取该值并将其传递给您的服务。然后,您必须为您的租户加载存储库,然后一切顺利。

服务类

@Injectable()
export class SampleService 

  constructor() 

  async getTenantRepository(tenant: string): Promise<Repository<Entity>> 
    try 
      const connection: Connection = await getConnection(tenant);
      return connection.getRepository(Property);
     catch (e) 
      throw new CustomInternalServerError('Internal Server Error', 'Internal Server Error');
    
  

  async findOne(params: Dto, tenant: string) 

    const entityRepository: Repository<Entity> = await this.getTenantRepository(tenant);

    return await propertyRepository.findOne( where: params );

  

这就是我们应用程序中服务的样子。

希望这会激励你,让你解决问题:)

【讨论】:

我希望看到它的完整 GIT 存储库以及 getConnection 方法的示例以及 MiddlewareConsumer。【参考方案3】:

您应该使用带有工厂的提供程序在每个服务中注入连接详细信息,并相应地切换存储库。

这里是连接工厂(假设请求包含租户 id):

const connectionFactory = 
  provide: CONNECTION,
  scope: Scope.REQUEST,
  useFactory: (request: ExpressRequest) => 
    const  tenantId  = request;

    if (tenantId) 
      return getTenantConnection(tenantId);
    

    return null;
  ,
  inject: [REQUEST],
;

每个租户的连接可以这样得到:

export function getTenantConnection(tenantId: string): Promise<Connection> 
  const connectionName = `tenant_$tenantId`;
  const connectionManager = getConnectionManager();

  if (connectionManager.has(connectionName)) 
    const connection = connectionManager.get(connectionName);
    return Promise.resolve(connection.isConnected ? connection : connection.connect());
  

  return createConnection(
    ...(tenantsOrmconfig as PostgresConnectionOptions),
    name: connectionName,
    schema: connectionName,
  );

然后就可以在每个服务中使用连接了:

@Injectable()
export class CatsService 
  private readonly catsRepository: Repository<Cat>;

  constructor(
    @Inject(CONNECTION) connection: Connection,
  ) 
    this.catsRepository = connection.getRepository(Cat);
  

  create(createCatDto: CreateCatDto): Promise<Cat> 
    const cat = new Cat();
    cat.name = createCatDto.name;

    return this.catsRepository.save(cat);
  

  async findAll(): Promise<Cat[]> 
    return this.catsRepository.find();
  

请注意,本文提供了一个完整的存储库,其中包含所有相关的服务和设置,它进行了演练; https://thomasvds.com/schema-based-multitenancy-with-nest-js-type-orm-and-postgres-sql/.

【讨论】:

以上是关于我如何在 NESTJS 中设置多租户的主要内容,如果未能解决你的问题,请参考以下文章

如何在graphql突变中设置多对多关系?

如何识别来自不同租户nestjs多租户jwt的jwt令牌

如何在 NestJS 中设置参数?

如何在 NestJS 中设置仅 HTTP cookie

如何在@nestjs/mongoose 模式中设置枚举

如何在nestjs中设置更多“jwt”AuthGuard?