リポジトリパターン.JSとネイティブのPostgreSQLドライバ


あまり古くなかったので、MongoDBやPostgreSQL、Mongoose、TypeOrmなどのデータベースで働いていました.最後のプロジェクトの一つでは、高負荷、ジオ分散システムを構築する必要があります.
面白い仕事😋
この種のシステムではPostgreSQLは最良の解決策ではありません.また、ボックスの複製不足のような多くの理由についても.そして、私たちは厳密にはベンダーロックAmazon Aurora . そして終わりの終わりは、選択が好意的になされましたCassandra , この記事では、レポジトリパターンのローレバー実装についてお話しますHBase 例えば.
さて、データベースCassandraを選んだが、どのように我々はデータ層を整理するデータベースと対話するか?🤨
我々はKNexを使用することはできません、それは単にCQLをサポートしていない、我々は良い選択肢を持っていない.そして、私は純粋なCQLが良い考えを使用しないことを明確に理解しています.
すべてのソースコードを見つけることができますhere .

我々はデータアクセス層から見たいと思う基本的な機能は何ですか?

  • 手作業を準備する
  • 良いタイプスクリプトサポート
  • 支援取引
  • カラムのエイリアス( code "createdstra at ", code "createdat "
  • 隠しコラム
  • 特定の列を選択
  • 簡単にするために、“OR”演算子サポートなしで簡単な実装を示します
  • サポート関係(単純さのために、私は記事の中でこのパラグラフをスキップしますが、追加するのは簡単です)
  • Insertイベントのサブスクリプション、後に挿入、Beforeedelete、アフター削除などを行います(関係と同じ)
  • 能力を簡単に拡張API
  • 移行(この記事の一部ではない)
  • 良い既製のソリューションがない場合、優れたオプションはリポジトリパターンを実装し、ヘルパーのようなクエリビルダを実装することです.

    要件

  • ノード.JS 17.5.0 +
  • PostgreSQL 14.2 +
  • NPM Packeges :

  • バージョン4.6.2 +
  • pg 8.7.3 +
  • ノードpg移行6.2.1 +
  • なぜpg?
    開発者の大きな円への記事の明快さのために、説明全体はPostgreSQLとPG パッケージ.
    そして、実際のプロジェクトでは、データベーススキーマは時間とともに変化するでしょう、そして、移行を実行することができるために、我々は使用しますNode PG migrate .

    環境設定


    始める前にパッケージをインストールする必要があります.
    yarn add pg && yarn add -D typescript @types/pg node-pg-migrate
    

    PGドライバで動作する低レバーヘルパー


    はじめに、リポジトリを実装する前に、いくつかのヘルパー関数を作成する必要があります.
    私たちはqueryRow データベースから1行だけ取得したい場合の関数.
    export const queryRow = async <T = any>(sql: string, values: any[] | null, tx?: PoolClient): Promise<T> => {
      // Get connection from PG Pool or use passed connection, will be explained below
      const client = await getConnect(tx)
    
      // I think will be better to separate handling query with passed values 
      if (Array.isArray(values)) {
        try {
          const res = await client.query(sql, values)
    
          return res.rows[0] as T
        } catch (e) {
          throw e
        } finally {
          // if we not have passed connection, we need close opened connection
          if (!tx) client.release()
        }
      }
    
      try {
        const res = await client.query(sql)
    
        return res.rows[0] as T
      } catch (e) {
        throw e
      } finally {
        if (!tx) client.release()
      }
    }
    
    そして我々はquery データベースによって返されたデータのリストで動作する関数.
    export const query = async <T = any>(sql: string, values?: any[] | null, tx?: PoolClient) => {
      const client = await getConnect(tx)
    
      if (Array.isArray(values)) {
        try {
          const res = await client.query(sql, values)
    
          return res.rows as T[]
        } catch (e) {
          throw e
        } finally {
          if (!tx) client.release()
        }
      }
    
      try {
        const res = await client.query(sql)
    
        return res.rows as T[]
      } catch (e) {
        throw e
      } finally {
        if (!tx) client.release()
      }
    }
    
    データベースで作業するか、SQLを生成する責任があるすべての関数をヘルパーに追加できます.
    例えばgetConnect , 私たちが接続を通過しなかったならば、それは見ます.
    export const getConnect = (tx?: PoolClient): Promise<PoolClient> => {
      if (tx) {
        return tx as unknown as Promise<PoolClient>
      }
      // pool it is global connection variable
      // !!! Warning !!!
      // Be very-very carefully when working with global variables
      // And you should not access this variable from business logic
      return pool.connect()
    }
    
    または、トランザクションで作業するときにSQLコードを生成する関数の例です.
    export const startTrx = async (pool: Pool) => {
      const tx = await pool.connect()
      await tx.query('BEGIN')
      return tx
    }
    export const commit = (pool: PoolClient) => pool.query('COMMIT')
    export const rollback = (pool: PoolClient) => pool.query('ROLLBACK')
    
    またはエラー処理を処理する際にエラーの種類を決定するのに役立つ関数です.
    export const isUniqueErr = (error: any, table?: string) => {
      if (table) {
        // 23505 it is one of PostgreSQL error codes, what mean it is unique error
        // Read more here: https://www.postgresql.org/docs/14/errcodes-appendix.html
        return error.code === '23505' && error.severity === 'ERROR' && error.table === table
      }
    
      return error.code === '23505' && error.severity === 'ERROR'
    }
    
    そしてついに

    リポジトリの実装


    まず、私はcreate ルックスを表示する方法.今のインターフェイスを作成する必要がありますどのような作成と読み込みのような操作をカバーします.
    interface Writer<T, C> {
      create(value: Partial<T>, tx?: C): Promise<T>
    }
    
    どこT それは実体/モデルタイピングであり、そしてC データベースクライアントタイプです.
    そして、任意のデータベースDialectリポジトリのベースインタフェースを定義する必要があります.
    export type BaseRepository<T, C> = Writer<T, C>
    
    ここでデータベースリポジトリを作成することができます.私の場合、PostgreSQLデータベースをpg driverで使用しますが、他のデータベースを使用する場合は、データベースのAPIを使用してロジックを実装する必要があります.
    import type { Pool, PoolClient } from 'pg'
    import type {
      BaseRepository,
      ColumnData,
    } from './types'
    import { buildAliasMapper, insertValues } from './queryBuilder'
    import { queryRow } from './utils'
    
    export class PGRepository<T> implements BaseRepository<T, PoolClient> {
      readonly table: string
      readonly pool: Pool
      readonly columnAlias: (col: keyof T) => string
      readonly allColumns: string
    
      constructor({
        pool,
        table,
        mapping,
      }: {
        table: string
        pool: Pool
        // model/entity alias mapping map, will be explained below
        mapping: Record<keyof T, ColumnData>
      }) {
        // About aliasMapper will be explained below
        const aliasMapper = buildAliasMapper<T>(mapping)
    
        this.pool = pool
        this.table = `"${table}"`
        // About aliasMapper will be explained below
        this.columnAlias = aliasMapper
    
        // String with all of columns (SQL - '*'), it is will computed on class initialization
        // Example of output: "id" AS "id", "name" AS "name", "email" AS "email", "created_at" AS "createdAt"
        // It is just for optimization
        this.allColumns = Object.entries(mapping).reduce((acc, [key, value]: [string, ColumnData]) => {
          // skip hidden columns
          if (typeof value === 'object' && value.hidden) {
            return acc
          }
    
          const sql = `${aliasMapper(key as keyof T)} AS "${key}"`
    
          return acc
            ? acc += `, ${sql}`
            : sql
        }, '')
      }
    
    
      async create(value: Partial<T>, tx?: PoolClient): Promise<T> {
        // Here we will transform JavaScript object, to SQL columns string
        const _cols: string[] = []
        const _values: any[] = []
    
        for (const key of Object.keys(value) as Array<keyof T>) {
          // Extract from alias mapper original database columns
          _cols.push(this.columnAlias(key))
          _values.push(value[key])
        }
        // End of transform
    
        const cols = _cols.join(', ')
        // insertValues will create string with value bindings, to prevent SQL-injections
        // Example of output: $1, $2, $3
        const values = insertValues(_values)
    
        const row = await queryRow<T>(
          `INSERT INTO ${this.table} (${cols}) VALUES (${values}) RETURNING ${this.allColumns}`,
          _values,
          tx,
        )
    
        return row
      }
    }
    
    警告
    このような矢印関数を使用しないでください.
    将来的には、オーバーライドするメソッドをsuper.create() calls .
    create = async (value: Partial<T>, tx?: PoolClient): Promise<T> => {
      // code...
    }
    
    カラムエイリアスマッパー
    上記の魔法機能を見ることができるconst aliasMapper = buildAliasMapper<T>(mapping) and insertValues , Buildaliasmapper関数のコードを見てみましょう.
    export type ID = string | number
    export type ColumnData = string | {
      name: string
      hidden?: boolean
    }
    
    export function buildAliasMapper<T extends AnyObject>(obj: Record<keyof T, ColumnData>) {
      // use ES6 Map structure for performance reasons
      // More here: https://www.measurethat.net/Benchmarks/Show/11290/4/map-vs-object-real-world-performance
      const _mapper = new Map<keyof T, string>()
    
      for (const [key, value] of Object.entries(obj)) {
        // Create mapping 
        // JS representation property name to PostgreSQL column name
        _mapper.set(key, typeof value === 'string'
          ? value
          : value.name)
      }
    
      // And return function what will get JS key and output PostgreSQL column name
      return (col: keyof T): string => `"${_mapper.get(col)!}"`
    }
    
    export const insertValues = (values: any[]) => values.map((_, index) => `$${index + 1}`).join(', ')
    
    buildAliasMapper 作品
    export interface User {
      id: number
      name: string
      email: string
      hash?: string
      createdAt: string
    }
    
    const aliasMapper = buildAliasMapper<User>({
      id: 'id',
      name: 'name',
      email: 'email',
      hash: {
        name: 'password_hash',
        hidden: true,
      },
      createdAt: 'created_at',
    })
    
    aliasMapper('createdAt') // output: "created_at" (with double quotes)
    
    なぜコンストラクタがプロパティを持っているのか理解できますmapping: Record<keyof T, ColumnData> そして、どのようにエイリアスマッピングが動作します.
    さて、特定のエンティティにリポジトリファイルを作成します.
    import type { Pool, PoolClient } from 'pg'
    import { PGRepository, queryRow, ID } from 'repository'
    
    export interface User {
      id: number
      name: string
      email: string
      hash?: string
      createdAt: string
    }
    
    export class UserRepository extends PGRepository<User> {
      constructor(pool: Pool) {
        super({
          pool,
          table: 'users',
          mapping: {
            id: 'id',
            name: 'name',
            email: 'email',
            hash: {
              name: 'password_hash',
              hidden: true,
            },
            createdAt: 'created_at',
          },
        })
      }
    
      async isTodayCreated(id: ID, tx?: PoolClient) {
        const user = await this.findOne(id, {
          select: ['createdAt'],
          tx,
        })
    
        if (!user) {
          throw new Error(`User with id '${id}' don't exists`)
        }
    
        const userDate = new Date(user.createdAt).getTime()
        const todayDate = new Date().getTime()
        const dayOffset = 3600 * 1000 * 24
    
        return userDate + dayOffset > todayDate
      }
    }
    
    データベースに接続します.
    import { Pool } from 'pg'
    import 'dotenv/config'
    
    const parsePostgresUrl = (url: string) => {
      const sl1 = url.split(':')
    
      const firstPart = sl1[1].replace('//', '')
      const splittedFirstPart = firstPart.split('@')
    
      const host = splittedFirstPart[1]
      const userCredentials = splittedFirstPart[0].split(':')
      const user = userCredentials[0]
      const password = userCredentials[1]
    
      const splittedSecondPart = sl1[2].split('/')
    
      const port = Number(splittedSecondPart[0])
      const database = splittedSecondPart[1]
    
      return {
        host,
        user,
        password,
        port,
        database,
      }
    }
    
    // Global connections pool variable
    // !!! Warning !!!
    // Be very-very carefully when working with global variables
    // And you should not access this variable from business logic
    export let pool: Pool
    
    export const connectPostgres = async () => {
      const config = parsePostgresUrl(process.env.DATABASE_URL!)
      const newPool = new Pool(config)
    
      await newPool.connect()
    
      pool = newPool
      return newPool
    }
    
    さあ、作成したリポジトリを使いましょう.
    import { connectPostgres } from 'db'
    import { UserRepository } from 'modules/product'
    
    (async () => {
        // connecting to database
        const pool = await connectPostgres()
    
        // initializing the repository
        const userRepository = new UserRepository(pool)
    
        // call create method from repository
        const user = await userRepository.create({
          name: 'fyapy',
          email: '[email protected]',
          hash: '123',
        });
        console.log(JSON.stringify(user, null, 2))
    
        if (user) {
          const isCreatedToday = await userRepository.isTodayCreated(user.id);
          console.log(`is user ${user.name} created today? ${isCreatedToday}`)
        }
    })()
    
    では、残りのCRUDメソッドのインターフェイスを作成しましょう.
    import type { PoolClient } from 'pg'
    
    export type AnyObject = Record<string, any>
    export type ColumnData = string | {
      name: string
      hidden?: boolean
    }
    
    export type ID = string | number
    
    interface Writer<T, C> {
      create(value: Partial<T>, tx?: C): Promise<T>
      createMany(values: Partial<T>[], tx?: C): Promise<T[]>
      update(id: ID, newValue: Partial<T>, tx?: C): Promise<T>
      delete(id: ID, tx?: C): Promise<boolean>
    }
    
    export interface FindOptions<T, C> {
      select?: Array<keyof T>
      tx?: C
    }
    
    interface Reader<T, C> {
      find(value: Partial<T>, options?: FindOptions<T, C>): Promise<T[]>
      findOne(id: ID | Partial<T>, options?: FindOptions<T, C>): Promise<T>
      exist(id: ID | Partial<T>, tx?: PoolClient): Promise<boolean>
    }
    
    export type BaseRepository<T, C> = Writer<T, C> & Reader<T, C>
    
    今、インターフェイスに従って、メソッドの実装を記述します.
    import { Pool, PoolClient } from 'pg'
    import { buildAliasMapper, insertValues } from './queryBuilder'
    import {
      BaseRepository,
      FindOptions,
      ID,
      ColumnData,
    } from './types'
    import { query, queryRow } from './utils'
    
    export class PGRepository<T> implements BaseRepository<T, PoolClient> {
      readonly table: string
      readonly primaryKey: string
      readonly pool: Pool
      readonly columnAlias: (col: keyof T) => string
      readonly cols: (...args: Array<keyof T>) => string
      readonly allColumns: string
      readonly where: (values: Partial<T>, initialIndex?: number) => string
    
      constructor({
        pool,
        table,
        mapping,
        // variable for storing id/primaryKey, for situations when out 'id' columns have name like 'postId'.
        // by default we think what primaryKey is 'id'
        primaryKey = 'id',
      }: {
        table: string
        pool: Pool
        primaryKey?: string
        mapping: Record<keyof T, ColumnData>
      }) {
        const aliasMapper = buildAliasMapper<T>(mapping)
    
        this.pool = pool
        this.table = `"${table}"`
        this.columnAlias = aliasMapper
        this.primaryKey = primaryKey
    
        // select SQL-generator for only specific columns
        // example payload: ['createdAt']
        // output: '"created_at" as "createdAt"'
        this.cols = (...args: Array<keyof T>) => args.map(key => `${aliasMapper(key)} AS "${key}"`).join(', ')
        // Almost the same as this.cols, only predefined and for all columns except hidden columns
        this.allColumns = Object.entries(mapping).reduce((acc, [key, value]: [string, ColumnData]) => {
          if (typeof value === 'object' && value.hidden) {
            return acc
          }
    
          const sql = `${aliasMapper(key as keyof T)} AS "${key}"`
    
          return acc
            ? acc += `, ${sql}`
            : sql
        }, '')
        // SQL-generator for WHERE clause
        this.where = (values: Partial<T>, initialIndex = 0) => {
          const sql = Object.keys(values).reduce((acc, key, index) => {
            const condition = `${aliasMapper(key as keyof T)} = $${index + initialIndex + 1}`
    
            return acc === ''
              ? `${acc} ${condition}`
              : `${acc}AND ${condition}`
          }, '')
    
          return `WHERE ${sql}`
        }
      }
    
    
      async create(value: Partial<T>, tx?: PoolClient): Promise<T> {
        const _cols: string[] = []
        const _values: any[] = []
    
        for (const key of Object.keys(value) as Array<keyof T>) {
          _cols.push(this.columnAlias(key))
          _values.push(value[key])
        }
    
        const cols = _cols.join(', ')
        const values = insertValues(_values)
    
        const row = await queryRow<T>(
          `INSERT INTO ${this.table} (${cols}) VALUES (${values}) RETURNING ${this.allColumns}`,
          _values,
          tx,
        )
    
        return row
      }
    
      async createMany(values: Partial<T>[], tx?: PoolClient): Promise<T[]> {
        const _cols: string[] = []
        const _values: any[][] = []
    
        for (const value of values) {
          const keys = Object.keys(value) as Array<keyof T>
    
          for (const key of keys) {
            if (_cols.length !== keys.length) _cols.push(this.columnAlias(key))
    
            _values.push(value[key] as any)
          }
        }
    
        const cols = _cols.join(', ')
        const inlinedValues = values
          .map((_, index) => `(${_cols.map((_, cIndex) => {
            const offset = index !== 0
              ? _cols.length * index
              : 0
    
            return `$${cIndex + 1 + offset}`
          })})`)
          .join(', ')
    
        const rows = await query<T>(`
          INSERT INTO ${this.table} (${cols})
          VALUES ${inlinedValues}
          RETURNING ${this.allColumns}
        `, _values, tx)
    
        return rows
      }
    
      update(id: ID, newValue: Partial<T>, tx?: PoolClient): Promise<T> {
        const sqlSet = Object.keys(newValue).reduce((acc, key, index) => {
          const sql = `${this.columnAlias(key as keyof T)} = $${index + 2}`
    
          return acc
            ? `, ${sql}`
            : sql
        }, '')
    
        return queryRow<T>(
          `UPDATE ${this.table} SET ${sqlSet} WHERE "${this.primaryKey}" = $1 RETURNING ${this.allColumns}`,
          [id, ...Object.values(newValue)],
          tx,
        )
      }
    
      delete(id: ID, tx?: PoolClient): Promise<boolean> {
        return queryRow<boolean>(
          `DELETE FROM ${this.table} WHERE "${this.primaryKey}" = $1`,
          [id],
          tx,
        )
      }
    
      async find(value: Partial<T>, options: FindOptions<T, PoolClient> = {}): Promise<T[]> {
        const cols = options.select
          ? this.cols(...options.select)
          : this.allColumns
    
        const sql = `SELECT ${cols} FROM ${this.table} ${this.where(value)}`
    
        const res = await query<T>(sql, Object.values(value), options.tx)
    
        return res
      }
    
      async findOne(id: ID | Partial<T>, options: FindOptions<T, PoolClient> = {}): Promise<T> {
        const isPrimitive = typeof id !== 'object'
        const cols = options.select
          ? this.cols(...options.select)
          : this.allColumns
        const values = isPrimitive
          ? [id]
          : Object.values(id)
    
        let sql = `SELECT ${cols} FROM ${this.table}`
    
        if (isPrimitive) {
          sql += ` WHERE "${this.primaryKey}" = $1`
        } else {
          sql += ` ${this.where(id)}`
        }
    
        const res = await queryRow<T>(sql, values, options.tx)
    
        return res
      }
    
      async exist(id: ID | Partial<T>, tx?: PoolClient): Promise<boolean> {
        let sql = `SELECT COUNT(*)::integer as count FROM ${this.table}`
        const isPrimitive = typeof id !== 'object'
        const values = isPrimitive
          ? [id]
          : Object.values(id)
    
        if (isPrimitive) {
          sql += ` WHERE "${this.primaryKey}" = $1`
        } else {
          sql += ` ${this.where(id)}`
        }
    
        sql += ' LIMIT 1'
    
        const res = await queryRow<{ count: number }>(sql, values, tx)
    
        return res.count !== 0
      }
    }
    

    実世界プロジェクトにおける使用


    私は、それが本当のプロジェクトでどのように仕事であるかについて説明し始めることを提案しますmain.ts ファイル.
    ルーティングのためにfastify .
    しかし、例えばアーキテクチャを使うrepositories > handlers レイヤー.
    実際のプロジェクトの場合は、使用する必要がありますrepositories > services > handlers 将来容易なコード保全性のための層.すべてのリポジトリ呼び出しはサービスによってproxiedされる必要があります、ハンドラの直接呼び出しリポジトリはありません.
    import type { Pool } from 'pg'
    import fastify from 'fastify'
    import { connectPostgres } from 'db'
    import * as users from 'users'
    
    // DI context analog, here repositories dependencies
    // In this example I will skip services layer
    // but repositories need to be passed to services
    // and services will need to be passed to handlers
    export interface Repositories {
      pool: Pool
      userRepository: users.UserRepository
    }
    
    const main = async () => {
      const app = fastify({
        trustProxy: true,
      })
      const pool = await connectPostgres()
    
    
      const repositories: Repositories = {
        pool,
        userRepository: new users.UserRepository(pool),
      }
    
      // In real project here will be passed services
      app.register(users.setupRoutes(repositories), {
        prefix: '/users',
      })
    
    
      try {
        const url = await app.listen(process.env.PORT || 8080, '0.0.0.0')
    
        console.log(`Server started: ${url}`)
      } catch (error) {
        console.error('Server starting error:\n', error)
      }
    }
    
    main()
    
    コントローラ/ハンドラを作成しましょう.
    ユーザサービスの実現をスキップします.
    検証をスキップします.
    import type { FastifyPluginCallback } from 'fastify'
    import type { Repositories } from 'types'
    import { commit, isUniqueErr, rollback, startTrx } from 'repository'
    
    export const setupRoutes = ({
      pool,
      userRepository,
    }: Repositories): FastifyPluginCallback => (fastify, otps, done) => {
      // select all columns
      fastify.get<{
        Params: { id: string }
      }>('/:id/all', async ({ params }) => {
        const user = await userRepository.findOne(params.id)
    
        return {
          user: user ?? null,
        }
      })
      // select certain columns
      fastify.get<{
        Params: { id: string }
      }>('/:id', async ({ params }) => {
        const user = await userRepository.findOne(params.id, {
          select: ['id', 'name', 'email'],
        })
    
        return {
          user: user ?? null,
        }
      })
    
      fastify.post<{
        Body: {
          name: string
          email: string
          password: string
        }
      }>('/', async ({ body }, res) => {
        const tx = await startTrx(pool)
        try {
          const user = await userRepository.create({
            name: body.name,
            email: body.email,
            hash: body.password,
          }, tx)
    
          await commit(tx)
    
          res.status(201)
          return {
            user: user ?? null,
          }
        } catch (e) {
          await rollback(tx)
    
          if (isUniqueErr(e)) {
            res.status(400)
            return {
              message: 'User aleady exist!',
            }
          }
    
          throw e
        } finally {
          // don't forget to close connection
          tx.release()
        }
      })
    
      done()
    }
    

    ソースコード


    すべてのソースコードを見つけることができますhere .
    機能的なプログラミングのファンのためにも、私は、接頭辞でフォルダ/ファイルの私自身のバージョンを準備しましたfp .

    結論


    私の側からのアドバイス
  • より良い保全性のために強く、リポジトリのベースコードを別々のNPMパッケージに動かすことを勧めます.特にいくつかのバックエンドアプリケーションを持っているか、またはマイクロサービスアーキテクチャを持っている場合.
    機能を追加し、バグを見つけると修正後、すべてのプロジェクトに変更を必要とするNPMパッケージを使用すると、パッケージのバージョンを更新する必要があります.
  • 私はどこのような多くの機能をスキップor サポートlimit , INSERT INSERT、後に挿入、Beforeedelete、アフター削除などのようなエンティティイベントのサブスクリプション.

  • テストを書く!!!私は真剣に、データアクセス層は、それが重要なことであるので、すべてのCodeBaseの変更を行った後、すべての作品を確認する必要があります.
  • そして、あなたのような関数のためのより多くのコード最適化を実装することができますthis.cols or this.where しかし、最適化とコード可読性のバランスを保つ.
  • この記事では、低レベルのデータベースドライバでリポジトリパターンを実装する方法を説明しました.
    しかし、私はこのようなことにより、このソリューションを使用する前に確認してください.
  • あなたのデータベースの良いオープンソースのORM/QueryBuilder/ライブラリの選択肢を持っていない.
  • あなたは完全に彼らがやっている理由を理解し、なぜ開発者を経験している.
  • しかし、あなたが答えることができないならば、どうですかyes これらの質問に?
    君にとって難しすぎる仕事になったと思う.