Typescript 和 Node.js 的存储库模式
如果您使用 Node.js,您可能通过 ORM 与数据库(MongoDB、PostgreSQL 等)进行交互。
但有时典型的 ORM 不能满足我们的需求。
例如,当我们需要在 PostgreSQL 中编写带有聚合的嵌套查询时。或者,当使用 ORM 生成的查询性能不理想时。
这时,我们通常会开始直接向数据库编写查询。
但是解决方案如何才能像 ORM 一样为我们带来良好的开发人员生产力,并获得像纯 SQL 代码一样与数据库交互的灵活 API。
如果您遇到过这种情况,那么这篇文章适合您!
存储库模式
大多数情况下,我们需要一些抽象,以便我们能够进行诸如 CRUD(创建、读取、更新和删除操作)之类的典型操作。而存储库模式则为我们提供了这个抽象的数据层,以便与任何数据库进行交互。
要求:
- Node.js
- TypeScript 4.4.0+
- PostgreSQL 13.4+
- Knex 0.95.11+
- VSCode
为什么选择 Knex?
为了提高开发人员的生产力并使其能够创建可预测的查询,我们将使用查询生成器,它是 ORM 和纯 SQL 查询的结合体。
在实际项目中,数据库架构会随着时间的推移而发生变化,而 Knex 提供了出色的迁移 API并支持 TypeScript。
设置环境
在我们开始之前,我们需要安装我们的包,我将使用 Yarn。
yarn add knex pg && yarn add -D typescript
实施
首先,我将实现find方法来展示它的外观。现在需要创建接口来涵盖我们的操作,例如创建和读取。
interface Reader<T> {
find(item: Partial<T>): Promise<T[]>
findOne(id: string | Partial<T>): Promise<T>
}
之后我们需要为任何数据库方言存储库定义基本接口。
type BaseRepository<T> = Reader<T>
在这里我们可以创建我们的数据库存储库,就我而言,我将在查询构建器角色中使用带有 Knex 的 SQL 数据库,但如果您想使用 MongoDB,只需用 MondoDB 包替换 Knex。
import type { Knex } from 'knex'
interface Reader<T> {
find(item: Partial<T>): Promise<T[]>
}
type BaseRepository<T> = Reader<T>
export abstract class KnexRepository<T> implements BaseRepository<T> {
constructor(
public readonly knex: Knex,
public readonly tableName: string,
) {}
// Shortcut for Query Builder call
public get qb(): Knex.QueryBuilder {
return this.knex(this.tableName)
}
find(item: Partial<T>): Promise<T[]> {
return this.qb
.where(item)
.select()
}
}
警告:
请勿使用此类箭头函数。
因为将来它会破坏使用super.find() 调用的重写方法。
find = async (item: Partial<T>): Promise<T> => {
// code...
}
现在,我们为特定实体创建存储库文件。
import { BaseRepository } from 'utils/repository'
export interface Product {
id: string
name: string
count: number
price: number
}
// now, we have all code implementation from BaseRepository
export class ProductRepository extends KnexRepository<Product> {
// here, we can create all specific stuffs of Product Repository
isOutOfStock(id: string): Promise<boolean> {
const product = this.qb.where(id).first('count')
return product?.count <= 0
}
}
现在让我们使用我们创建的存储库。
import knex from 'knex'
import config from 'knex.config'
import { Product, ProductRepository } from 'modules/product'
const connect = async () => {
const connection = knex(config)
// Waiting for a connection to be established
await connection.raw('SELECT 1')
return connection
}
(async () => {
// connecting to database
const db = await connect()
// initializing the repository
const repository = new ProductRepository(db, 'products')
// call find method from repository
const product = await repository.find({
name: 'laptop',
});
console.log(`product ${product}`)
if (product) {
const isOutOfStock = await repository.isOutOfStock(product.id);
console.log(`is ${product.name}'s out of stock ${isOutOfStock}`)
}
})()
让我们实现 CRUD 的其余方法。
import type { Knex } from 'knex'
interface Writer<T> {
create(item: Omit<T, 'id'>): Promise<T>
createMany(item: Omit<T, 'id'>[]): Promise<T[]>
update(id: string, item: Partial<T>): Promise<boolean>
delete(id: string): Promise<boolean>
}
interface Reader<T> {
find(item: Partial<T>): Promise<T[]>
findOne(id: string | Partial<T>): Promise<T>
exist(id: string | Partial<T>): Promise<boolean>
}
type BaseRepository<T> = Writer<T> & Reader<T>
export abstract class KnexRepository<T> implements BaseRepository<T> {
constructor(
public readonly knex: Knex,
public readonly tableName: string,
) {}
// Shortcut for Query Builder call
public get qb(): Knex.QueryBuilder {
return this.knex(this.tableName)
}
async create(item: Omit<T, 'id'>): Promise<T> {
const [output] = await this.qb.insert<T>(item).returning('*')
return output as Promise<T>
}
createMany(items: T[]): Promise<T[]> {
return this.qb.insert<T>(items) as Promise<T[]>
}
update(id: string, item: Partial<T>): Promise<boolean> {
return this.qb
.where('id', id)
.update(item)
}
delete(id: string): Promise<boolean> {
return this.qb
.where('id', id)
.del()
}
find(item: Partial<T>): Promise<T[]> {
return this.qb
.where(item)
.select()
}
findOne(id: string | Partial<T>): Promise<T> {
return typeof id === 'string'
? this.qb.where('id', id).first()
: this.qb.where(id).first()
}
async exist(id: string | Partial<T>) {
const query = this.qb.select<[{ count: number }]>(this.knex.raw('COUNT(*)::integer as count'))
if (typeof id !== 'string') {
query.where(id)
} else {
query.where('id', id)
}
const exist = await query.first()
return exist!.count !== 0
}
}
现在,我们只需从我们的代码中调用该存储库。
import knex from 'knex'
import config from 'knex.config'
import { Product, ProductRepository } from 'modules/product'
const connect = // See implementation above...
(async () => {
// connecting to database
const db = await connect()
// initializing the repository
const repository = new ProductRepository(db, 'products')
// call find method from repository
const product = await repository.create({
name: 'laptop',
count: 23,
price: 2999,
});
console.log(`created product ${product}`)
const isOutOfStock = await repository.isOutOfStock(product.id);
console.log(`is ${product.name}'s out of stock ${isOutOfStock}`)
})()
依赖注入
在实际项目中,我们有一些依赖注入库,在我的例子中是Awilix。
现在我们需要实现存储库与外部 DI 解决方案的集成。
// Knex connection file
import knex from 'knex'
import config from 'knex.config'
import { container } from 'utils/container'
import { asValue } from 'awilix'
export default () => new Promise(async (resolve, reject) => {
try {
const connection = knex(config)
await connection.raw('SELECT 1')
container.register({
knex: asValue(connection),
})
resolve(connection)
} catch (e) {
reject(e)
}
})
现在,当我们连接到数据库时,让我们对 ProductRepository 进行一些更改。
import { asClass } from 'awilix'
import { container, Cradle } from 'utils/container'
import { BaseRepository } from 'utils/repository'
export interface Product {
id: string
name: string
count: number
price: number
}
// now, we have all code implementation from BaseRepository
export class ProductRepository extends KnexRepository<Product> {
constructor({ knex }: Cradle) {
super(knex, 'products')
}
// here, we can create all specific stuffs of Product Repository
isOutOfStock(id: string): Promise<boolean> {
const product = this.qb.where(id).first('count')
return product?.count <= 0
}
}
container.register({
productRepository: asClass(ProductRepository).singleton(),
})
我们有非常酷的数据库抽象布局。
我们将其称为 Controller/Handler,在本例中称为 Fastify 处理器。我将跳过 Product 服务的实现,直接注入 ProductRepository,并代理调用 findOne(id) 方法。
import { FastifyPluginCallback } from 'fastify'
import { cradle } from 'utils/container'
export const handler: FastifyPluginCallback = (fastify, opts, done) => {
fastify.get<{
Params: {
id: string
}
}>('/:id', async ({ params }) => {
const response = await cradle.productService.findOne(params.id)
return response
})
done()
}
结论
本文探讨了如何在 Node.js 中使用 TypeScript 实现 Respository 模式。它是一个非常灵活且可扩展的数据层,可以使用任何 SQL/NoSQL 数据库。
但这还不是全部😄
因为我们需要研究如何添加如下功能:
- 订阅实体事件,例如 BeforeInsert、AfterInsert、BeforeDelete、AfterDelete 等。
- 选择特定字段
- 例如,用于防止选择用户密码哈希的隐藏字段
- 交易支持
但它更多的是关于如何创建和开发你自己的 ORM。这超出了 Repository 模式文章的讨论范围。
文章来源:https://dev.to/fyapy/repository-pattern-with-typescript-and-nodejs-25da