发布时间

使用mongoose实现多租户应用系统

作者
  • avatar
    作者名字
    Kavin Wang
    Twitter

本文为一个翻译的内容,原文参考本文末。

多租户是指软件的操作架构和部署模式,其中一个软件的多个运行实例,或者多个应用程序共享同一个运行环境。这些租户或者实例,它们在逻辑上是互相隔离的,而在物理上是放在一起的。

软件多租户:是指一种软件架构,代表运行在服务器上的一套软件,同时为多个租户提供服务。

图片

构建多租户系统是复杂的,其复杂度远远超过多用户系统的构建。

构建多租户系统有两种模型:实例复制和数据隔离。

在实例复制模型中,系统为每个租户创建一个新实例。这种做法很容易实现,但很难扩展。比如一旦有上百名租户同时登录,做起来简直是一场噩梦。

在数据隔离模型中,租户之间共享同一套应用程序,但每个租户的数据存储在单独的存储空间中。单独的存储空间可能是单独的数据库,也可能是同一数据库中的不同的数据schema。

图片

数据隔离有两种类型:

  • 同一个数据库中,为不同的租户提供不同的schema。
  • 为每个不同的租户,都提供一个独立的数据库。

独立的schema,就是每个租户在一个数据库中有不同schema的同样的套表。

图片

本文中,我们将讨论数据隔离的多数据库模式,就是为每个租户提供一个独立的数据库。

下面的代码创建一个mongodb的连接,并导出这个连接,以备将来的代码使用。

mongodb.js
const mongoose = require('mongoose');

const mongoOptions = {
  useNewUrlParser: true,
  useCreateIndex: true,
  useUnifiedTopology: true,
  useFindAndModify: false,
  autoIndex: true,
  poolSize: 10,
  bufferMaxEntries: 0,
  connectTimeoutMS: 10000,
  socketTimeoutMS: 30000,
};

const connect = () => mongoose.createConnection(process.env.MONGODB_URL, mongoOptions);

const connectToMongoDB = () => {
  const db = connect(process.env.MONGODB_URL);
  db.on('open', () => {
    console.info(`Mongoose connection open to ${JSON.stringify(process.env.MONGODB_URL)}`);
  });
  db.on('error', (err) => {
    console.info(`Mongoose connection error: ${err} with connection info ${JSON.stringify(process.env.MONGODB_URL)}`);
    process.exit(0);
  });
  return db;
};

exports.mongodb = (connectToMongoDB)();

为每一个租户创建一个mongodb连接:

multitenancy.js
const { mongodb } = require('./mongodb');

/**
 * Creating New MongoDb Connection obect by Switching DB
 */
const getTenantDB = (tenantId, modelName, schema) => {
  const dbName = `tenant_${tenantId}`;
  if (mongodb) {
    // useDb will return new connection
    const db = mongodb.useDb(dbName, { useCache: true });
    console.info(`DB switched to ${dbName}`);
    db.model(modelName, schema);
    return db;
  }
  throw new Error("something error!")
};

/**
 * Return Model as per tenant
 */
exports.getModelByTenant = (tenantId, modelName, schema) => {
  console.info(`getModelByTenant tenantId : ${tenantId}.`);
  const tenantDb = getTenantDB(tenantId, modelName, schema);
  return tenantDb.model(modelName);
};

Mongoose提供了useDb函数,用于在一个已经存在的连接对象上创建一个新的连接对象,它有两个参数,第一个是DB名称,另一个是选项,参考文档是这样描述的:

Connection.prototype.useDb()Parameters:
name      «String» The database name
{options} «Object» Configsoptions = {useCache: false} «Boolean»  False by defaultIf set true, cache results so calling useDb() multiple times with the same name only creates 1 connection object.Returns:
«Connection» New Connection Object

我们使用同一个连接池的连接,来创建一个新的连接对象,这个对象会使用新的数据库名称,并使用{useCache: true}这个选项,为同一个租户创建一个单独的连接,同一个租户后面的多次数据库调用,都会使用这个连接。每次提供不同的租户,都会创建一个独立的针对这个租户的数据库连接。

如果要取得租户数据库的表模型,我们得为相应的表创建一个模型schema。然后就可以使用这个模型来进行数据库操作了。

signatureModal.js
const mongoose = require('mongoose');

const signatureSchema = new mongoose.Schema({
  nodeId: {
    type: mongoose.Schema.Types.ObjectId,
    required: true,
  },
  requestBy: {
    type: String,
    required: true,
  },
  requestTime: {
    type: Date,
    default: Date.now,
    required: true,
  },
  isActive: {
    type: Boolean,
    default: true,
    required: true,
  },
  labId: {
    type: String,
    required: true,
  },
  comment: String,
});

module.exports = signatureSchema;

在数据存取层面,我们要先取得租户的数据库连接,然后进一步取得表的模型,然后用这个模型进行CRUD操作。所以,每次调用,都必须提供tenantId,这个tenantId在底层来讲,就是为了切换要操作使用的数据库。


const { getModelByTenant } = require('.//multitenancy');
const { signatureSchema } = require('./signatureModal');

exports.addSignature = async (signaturesBody, tenantId) => {
  log.info(`Add signature called with body ${signaturesBody}`);
  const Signature = getModelByTenant(tenantId, 'signature', signatureSchema);
  // Execute takes a promise and return {null,response} if resolved and return {err,null} if rejected.
  const { err, response } = await execute(Signature.create(signaturesBody));
  if (err || !response) {
    log.error(`Signature creation failed signatureDao.js ${err.message}`);
    throwError(500, codes.signatureAddFailed);
  }
  log.info(`Signature created with response : ${response}`);
  return response;
};

在这种方法中,我们使用的是在应用程序初始化时创建的相同连接池。具有不同租户的多个请求将创建不同的连接对象,如果没有来自同一租户的新请求,这些连接对象将在30秒后超时。


我们还可以实施另一种方法,这将对有限的租户有所帮助。在此,我们可以创建一个全局对象,其中键为tenantId/tenantName,值为连接对象。在每个请求之前,我们将检查此全局对象中是否存在连接,否则我们将创建一个连接并将其存储在那里,以便在下一个来自该租户的请求中重用。

const { Mongoose } = require('mongoose');
const mongoConfigs = require('../../../config/mongo.conf');

const multitenantPool = {};

const getTenantDB = function getConnections(tenantId, modelName, schema) {
  // Check connections lookup
  const mCon = multitenantPool[tenantId];
  if (mCon) {
    if (!mCon.modelSchemas[modelName]) {
      mCon.model(modelName, schema);
    }
    return mCon;
  }

  const mongoose = new Mongoose();
  const url = process.env.MONGODB_URL.replace(/brightlab/, `brightlab_${tenantId}`);
  mongoose.connect(url, mongoConfigs);
  multitenantPool[tenantId] = mongoose;
  mongoose.model(modelName, schema);
  mongoose.connection.on('error', err => console.debug(err));
  mongoose.connection.once('open', () => console.info(`mongodb connected to ${url}`));
  return mongoose;
};

exports.getModelByTenant = (tenantId, modelName, schema) => {
  console.info(`getModelByTenant tenantId : ${tenantId}.`);
  const tenantDb = getTenantDB(tenantId, modelName, schema);
  return tenantDb.model(modelName);
};

在上述实现中,如果租户数量增加,可能会出现CPU使用率问题,因为应用程序将始终有大量打开的连接。这将降低性能,但它可以在租户较少的情况下使用,并且永远不会动态增长。


翻译不准确处,请参考 原文