极简高效:打造自定义 ORM 框架,轻松实现云数据库操作与数据格式规范化
前言
本文将介绍如何结合 class-transformer
和 class-validator
手动实现一个极简版 ORM 实现,以替代 TypeORM 库。通过这个封装,我们可以实现自定义数据库接入、统一的读写 API、数据映射以及数据验证。
背景
最近在使用 Nestjs 重构一个老项目时,遇到了以下几个问题:
- 老项目的数据库表字段定义混乱,包括单驼峰、下划线、双拼以及拼写错误等。
- 数据库的值直接透传给前端。
- 在写入数据库前,无法对数据进行校验。
因此,在重构过程中,我们希望能够规范数据操作逻辑。原本计划使用 TypeORM 库来实现,但 TypeORM 库并不支持我们的数据库(云开发数据库)
TypeORM 库和 ORM 框架
对象关系映射(Object Relational Mapping,简称 ORM)模式是一种为了解决面向对象与关系数据库存在的互不匹配现象的技术。
TypeORM 是一个基于 TypeScript 的 ORM 框架,支持运行在 Node.js。TypeORM 作为 Node.js 中老牌的 ORM 框架,无论是接口定义,还是代码实现方面都简单易懂、可读性高,也很容易对接多种数据源。我们将参考 TypeORM 来实现我们自己的 ORM 封装
准备
为了实现我们的极简版 ORM 封装,我们需要引入两个库:class-transformer
和 class-validator
。class-transformer
用于在类实例和普通对象之间转换数据,class-validator
用于对类实例进行验证。@cloudbase/node-sdk
是用来操作我们的云数据库
首先,安装这三个库:
npm install class-transformer class-validator @cloudbase/node-sdk
实施
我们数据库的有张 User 表,格式如下:
User {
_id: string;
childId_ids: string[]; // 单驼峰夹杂着下划线
address: string;
avator: string; // 单词拼写错误
create_time: string; // 下划线
default_childId: string; // 单驼峰夹杂着下划线
otherName: string; // 单驼峰
...
}
可以看到,命名规则非常不统一。我们的需求是:在不改变数据表字段名的情况下(以避免影响其他业务),将对外输出的字段名统一为单驼峰格式
。
简单的 ORM 封装
我们开始实现我们的极简版 ORM 封装。首先,我们需要创建一个基础类 BaseEntity
,它将包含我们的映射和验证逻辑。
// 这里注意,plainToClass、classToPlain 两个方法已经过期不用了
import { instanceToPlain, plainToInstance } from 'class-transformer';
import { validate, ValidationError } from 'class-validator';
export abstract class BaseEntity {
// 将对象转换成实例
static fromObject<T extends BaseEntity>(this: new () => T, obj: object): T {
return plainToInstance(this, obj);
}
// 将实例转换对象
toObject(): object {
return instanceToPlain(this);
}
// 校验当前数据
async validate(): Promise<ValidationError[]> {
return validate(this);
}
}
在这个基础类中,我们定义了三个方法:
fromObject
:将普通对象转换为类实例。toObject
:将类实例转换为普通对象。validate
:对类实例进行验证。
实现类和数据库表的映射
接下来,我们将处理字段名称。正如前文所述,原始数据库中的字段命名非常不规范。我们希望在接口返回给前端时,字段名经过格式化,遵循统一的 单驼峰命名规则
。同时,在将数据存入表时,需要 将单驼峰命名还原为原始命名
。
我们创建一个用户类 User
,继承 BaseEntity
,并添加一些属性和验证规则。
import { IsNotEmpty } from 'class-validator';
import { Expose } from 'class-transformer';
import { BaseEntity } from './BaseEntity';
export class User extends BaseEntity {
@Expose({ name: '_id' })
id: string;
@Expose({ name: 'childId_ids' })
children: string[];
address: string;
@Expose({ name: 'avator' })
avatar: string;
@Expose({ name: 'create_time' })
createTime: string;
@Expose({ name: 'default_childId' })
defaultChildId: string;
@IsNotEmpty()
otherName: string;
...
}
现在,我们可以使用这个用户类来进行数据映射和验证:
const user = User.fromObject({
_id: '213123123',
childId_ids: ['asdf'],
address: 'test',
avator: 'xxx',
create_time: '2023-09',
default_childId: '123456789',
otherName: 'string',
});
console.log(user);
// 输出 {
// address:'test',
// avatar:'xxx',
// children:['asdf'],
// createTime:'2023-09',
// defaultChildId:'123456789',
// id:'213123123',
// otherName:'string'
// }
user.validate().then(errors => {
if (errors.length > 0) {
console.error('验证失败:', errors);
} else {
console.log('验证通过');
}
});
这样,我们已经解决了将数据库中的数据字段格式化,并映射到 User
类实例的问题。但是,当调用方提供标准的单驼峰命名数据时,我们需要将其还原为原始表字段,例如在创建新用户或更新用户信息时:
- 我们需要将数据库的原始字段数据统一转换为单驼峰格式,以便返回给调用方。
- 同时,我们还需要将用户提供的单驼峰数据还原为原始字段格式,以便存储到数据库中。
/** 用户的 单驼峰 数据,要还原成 原始字段,给数据库 */
// 外部数据传入
const input = {
address: 'test',
avatar: 'xxx',
children: ['asdf'],
createTime: '2023-09',
defaultChildId: '123456789',
id: '213123123',
otherName: 'string',
};
const user = User.fromObject(input);
// 给数据库
console.log(user.toObject());
// 输出 {
// address:'test'
// avatar:undefined
// children:undefined
// createTime:undefined
// defaultChildId:undefined
// id:undefined
// otherName:'string'
// }
/** 将数据库的原始字段数据,是需要统一转成 单驼峰,给调用方 */
// 数据库 数据
const db = {
_id: '213123123',
childId_ids: ['asdf'],
address: 'test',
avator: 'xxx',
create_time: '2023-09',
default_childId: '123456789',
otherName: 'string',
}
const user = User.fromObject(db);
// 给调用方
console.log(user.toObject());
// 输出 {
// address: 'test',
// avator: 'xxx',
// childId_ids: ['asdf'],
// create_time: '2023-09',
// default_childId: '123456789',
// otherName: 'string',
// }
从结果来看,与我们的预期恰好相反,我们将继续处理这个问题。
由于我们是通过 装饰器
来完成字段名的变更,因此为了解决这个问题,我们需要对 BaseEntity
进行修改。
export abstract class BaseEntity {
// ...之前的方法
static fromObject<T extends BaseEntity>(
this: new () => T,
obj: object,
// 是否跳过装饰器
ignoreDecorators = false,
): T {
return plainToInstance(this, obj, { ignoreDecorators });
}
toObject(ignoreDecorators = true): object {
return instanceToPlain(this, { ignoreDecorators });
}
}
/** 用户的 单驼峰 数据,要还原成 原始字段,给数据库 */
// 外部数据传入
const input = {
address: 'test',
avatar: 'xxx',
children: ['asdf'],
createTime: '2023-09',
defaultChildId: '123456789',
id: '213123123',
otherName: 'string',
};
const user = User.fromObject(input, true);
// 给数据库
console.log(user.toObject(false));
// 输出 {
// _id: '213123123',
// address: 'test',
// avator: 'xxx',
// childId_ids: ['asdf'],
// create_time: '2023-09',
// default_childId: '123456789',
// otherName: 'string',
// }
/** 将数据库的原始字段数据,是需要统一转成 单驼峰,给调用方 */
// 数据库 数据
const db = {
_id: '213123123',
childId_ids: ['asdf'],
address: 'test',
avator: 'xxx',
create_time: '2023-09',
default_childId: '123456789',
otherName: 'string',
}
const user = User.fromObject(db);
// 给调用方
console.log(user.toObject());
// 输出 {
// address: 'test',
// avatar: 'xxx',
// children: ['asdf'],
// createTime: '2023-09',
// defaultChildId: '123456789',
// id: '213123123',
// otherName: 'string',
// }
经过上述调整后,现在的实现完全符合我们的预期。
解耦云数据库
由于我们使用的并非标准数据库,为了实现松耦合,我们将云数据库的操作封装成一个类。如果之后需要接入其他数据库,只需更改此类的实现即可。现在,我们新建一个 CustomDatabase
文件。
import * as tcb from '@cloudbase/node-sdk';
export class CustomDatabase {
private db;
// dbName 是待操作的表名
constructor(dbName?: string) {
const app = tcb.init({
env: {your-env},
secretId: {your-secretId},
secretKey: {your-secretKey},
});
this.db = app.database().collection(dbName);
}
async find({ id }): Promise<object> {
// 实现查询方法,使用 @cloudbase/node-sdk 的查询方法
const { data } = await this.db.where({ _id: id }).get();
return data
}
async insert(data: object): Promise<void> {
// 实现插入方法,使用 @cloudbase/node-sdk 的插入方法
// ...
}
async update(id: string, data: object): Promise<void> {
// 实现更新方法,使用 @cloudbase/node-sdk 的更新方法
return this.db.doc(id).update(data);
}
async delete(conditions: object): Promise<void> {
// 实现删除方法,使用 @cloudbase/node-sdk 的删除方法
// ...
}
}
依赖注入
依赖注入是一种设计模式,用来连接模块并且管理好他们之间的依赖关系
我们通过新建一个 CustomRepository
类,来连接和管理 实体类
与 数据库操作类
之间的关系。这里只实现了 实体类 的注入,CustomDatabase 还是耦合在 Repository 中,感兴趣的小伙伴可以在此基础上继续实现
import { CustomDatabase } from './CustomDatabase';
import { BaseEntity } from './BaseEntity';
export class CustomRepository<T> {
public dbCollection;
private cls;
constructor(cls: BaseEntity) {
this.cls = cls;
// 获取类名称
const name = cls.name;
// 获取数据库名称,下划线链接
const dbName = name
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
this.dbCollection = new CustomDatabase(dbName);
}
// 获取单个实例
async find(id): Promise<T> {
const data = await this.dbCollection.find({ id });
const entities = this.cls.fromObject(data);
return entities?.[0];
}
}
现在,你可以在你的实体类中使用这些方法来执行数据库操作:
import { CustomRepository } from './CustomRepository';
export class UserService {
private userRepository = new CustomRepository<User>(User);
async find(id: string) {
const user = await this.userRepository.find(id);
return user.toObject();
// 输出
// {
// address: 'N',
// avatar: '',
// children: [],
// createTime: '2023/3/10',
// defaultChildId: '',
// id: 'xxxxxx0488a35094e9ad334xxxx',
// otherName: 'xxx',
// ...
// }
}
}
总结
通过我们的极简版 ORM 框架,我们实现了统一的读写 API、数据映射和数据验证。无论你使用什么数据库,你都可以轻松地将这个极简版 ORM 框架应用于你的项目中。
希望这个极简版 ORM 实现对大家有所帮助!
转载自:https://juejin.cn/post/7281113851714355215