Typescript 和 Node.js 的存储库模式

2025-05-28

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
Enter fullscreen mode Exit fullscreen mode

实施

首先,我将实现find方法来展示它的外观。现在需要创建接口来涵盖我们的操作,例如创建和读取。

interface Reader<T> {
  find(item: Partial<T>): Promise<T[]>
  findOne(id: string | Partial<T>): Promise<T>
}
Enter fullscreen mode Exit fullscreen mode

之后我们需要为任何数据库方言存储库定义基本接口。

type BaseRepository<T> = Reader<T>
Enter fullscreen mode Exit fullscreen mode

在这里我们可以创建我们的数据库存储库,就我而言,我将在查询构建器角色中使用带有 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()
  }
}
Enter fullscreen mode Exit fullscreen mode

警告:
请勿使用此类箭头函数。
因为将来它会破坏使用super.find() 调用的重写方法。

find = async (item: Partial<T>): Promise<T> => {
  // code...
}
Enter fullscreen mode Exit fullscreen mode

现在,我们为特定实体创建存储库文件。

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
  }
}
Enter fullscreen mode Exit fullscreen mode

现在让我们使用我们创建的存储库。

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}`)
    }
})()
Enter fullscreen mode Exit fullscreen mode

让我们实现 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
  }
}
Enter fullscreen mode Exit fullscreen mode

现在,我们只需从我们的代码中调用该存储库。

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}`)
})()
Enter fullscreen mode Exit fullscreen mode

依赖注入

在实际项目中,我们有一些依赖注入库,在我的例子中是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)
  }
})
Enter fullscreen mode Exit fullscreen mode

现在,当我们连接到数据库时,让我们对 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(),
})
Enter fullscreen mode Exit fullscreen mode

我们有非常酷的数据库抽象布局。

我们将其称为 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()
}
Enter fullscreen mode Exit fullscreen mode

结论

本文探讨了如何在 Node.js 中使用 TypeScript 实现 Respository 模式。它是一个非常灵活且可扩展的数据层,可以使用任何 SQL/NoSQL 数据库。

但这还不是全部😄
因为我们需要研究如何添加如下功能:

  • 订阅实体事件,例如 BeforeInsert、AfterInsert、BeforeDelete、AfterDelete 等。
  • 选择特定字段
  • 例如,用于防止选择用户密码哈希的隐藏字段
  • 交易支持

但它更多的是关于如何创建和开发你自己的 ORM。这超出了 Repository 模式文章的讨论范围。

文章来源:https://dev.to/fyapy/repository-pattern-with-typescript-and-nodejs-25da
PREV
隆重推出...我的新网站!✨
NEXT
读取高效数据结构简介最小化读取开销哈希表红黑树跳过列表比较参考