Перенос унаследованного приложения с MongoDB на HarperDB

Несколько недель назад я выступил на канале HarperDB с докладом о том, как мы можем перенести устаревшее приложение с MongoDB на HarperDB. Если вы не видели это выступление, вы можете посмотреть его здесь:

Этот пост будет письменным руководством по этому процессу. Так что если вы не можете посмотреть видео или хотите сохранить содержание для другого раза, то это то, что вам нужно.

  • Цель
  • Установить
  • Настройка базы данных
  • Перенос кода
    • Понимание приложения
    • Создание клиента
    • Перенос репозиториев
    • Последние штрихи
  • Тестирование
  • Пользовательские функции

Цель

Я создал этот учебник, потому что это реальный сценарий. Многие люди ежедневно работают с унаследованными приложениями, и некоторые из изменений, которые им приходится делать, включают миграцию с одного технологического стека на другой.

Вот почему это приложение было выбрано специально, потому что это реальный пример того, как мы можем перенести унаследованное приложение с одного технологического стека на другой. Оно старое, использует старые библиотеки и устанавливает наши стандарты в отношении того, чего мы не хотим касаться.

В данном случае задача состоит в том, чтобы выполнить полную миграцию, не трогая много кода и не затрагивая тот код, который мы не хотим трогать. К счастью, это приложение реализовано с использованием MVC-архитектуры, так что есть слои поверх слоев, что позволяет абстрагировать большую часть функциональности в отдельные места и сделать код более читабельным. Кроме того, это позволяет нам изменять только тот код, который мы хотим изменить, а не все приложение.

Настройка

Я не буду объяснять, что такое HarperDB и как она работает, но я покажу вам, как начать работу с основ.

Итак, первое, что вам нужно сделать, это создать учетную запись на сайте HarperDB. Это даст вам возможность использовать Harper Studio, которая будет инструментом, который мы будем использовать для создания и управления базой данных.

Второе — это клонирование репозитория приложения, приложение имеет три ветви:

После клонирования, первое, что вам нужно сделать, это перейти в директорию backend и запустить npm install. Это позволит установить все зависимости, необходимые для работы приложения.

Хорошо, если у вас также установлен Docker, поскольку мы будем возиться с фронтендом, бэкендом и базой данных. И все это мы будем использовать внутри контейнера с помощью Docker Compose.

Настройка базы данных

Первое, что нам нужно сделать, это настроить базу данных, в данном случае Harper. Поэтому перейдем к файлу docker-compose.yml, сейчас он выглядит следующим образом:

version: '3.7'

services:
  backend:
    build: ./backend
    environment:
      DATABASE_MONGODB_URI: mongodb://database:27017
      DATABASE_MONGODB_DBNAME: ship_manager
      NODE_ENV: ${NODE_ENV:-development}
    ports:
      - '3000:3000'
    depends_on:
      - database

  # ... frontend stuff we don't care about ...

  database:
    image: mvertes/alpine-mongo
    ports:
      - '27017:27017'
    volumes:
      - mongodb:/data/db

volumes:
  mongodb:
Войти в полноэкранный режим Выйти из полноэкранного режима

Нам нужно изменить базу данных для использования Harper и бэкенд, чтобы отразить это изменение. Сначала мы изменим services.database на следующий вид:

database:
  image: harperdb/harperdb
  ports:
    - '9925:9925'
    - '9926:9926'
  volumes:
    - db:/opt/harperdb/hdb
  environment:
    - HDB_ADMIN_USERNAME=admin
    - HDB_ADMIN_PASSWORD=admin
    - CUSTOM_FUNCTIONS=true
Вход в полноэкранный режим Выйти из полноэкранного режима

Сначала мы изменим образ, чтобы загрузить последнюю версию Harper. Затем откроем порты для подключения к базе данных и для пользовательских функций (которые работают на 9926); после этого установим тома, чтобы не потерять данные в случае удаления контейнера; и, наконец, установим переменные окружения для базы данных.

Я использую здесь обычные пароли, но идеальным вариантом было бы задать их во время выполнения.

Затем изменим services.backend следующим образом:

backend:
  build: ./backend
  environment:
    DATABASE_URI: http://database:9925
    DATABASE_DBNAME: ship_manager
    DATABASE_USERNAME: admin
    DATABASE_PASSWORD: admin
    NODE_ENV: ${NODE_ENV:-development}
  ports:
    - '3000:3000'
  depends_on:
    - database
Вход в полноэкранный режим Выход из полноэкранного режима

Единственное реальное изменение, которое мы здесь сделали, это изменение URI базы данных для использования порта базы данных, а также задание имени пользователя и пароля для базы данных.

И в конце нам нужно изменить имя нашего тома — было mongodb — на db.

volumes:
  db:
Вход в полноэкранный режим Выйдите из полноэкранного режима

Итоговый файл выглядит следующим образом:

version: '3.7'

services:
  backend:
    build: ./backend
    environment:
      DATABASE_URI: http://database:9925
      DATABASE_DBNAME: ship_manager
      DATABASE_USERNAME: admin
      DATABASE_PASSWORD: admin
      NODE_ENV: ${NODE_ENV:-development}
    ports:
      - '3000:3000'
    depends_on:
      - database

  frontend:
    build: ./frontend
    ports:
      - '80:80'
    depends_on:
      - backend

  database:
    image: harperdb/harperdb
    ports:
      - '9925:9925'
      - '9926:9926'
    volumes:
      - db:/opt/harperdb/hdb
    environment:
      - HDB_ADMIN_USERNAME=admin
      - HDB_ADMIN_PASSWORD=admin
      - CUSTOM_FUNCTIONS=true

volumes:
  db:
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь перейдите в студию HarperDB, откройте свою организацию и добавьте новый Instance. В модальном меню выберите «Register User-installed Instance» и заполните следующую информацию:

  • Имя: ship-manager
  • Имя пользователя и пароль: admin
  • Хост: localhost
  • Порт: 9925
  • SSL: нет

Не нажимайте пока «Instance Deails», нам нужно сначала раскрутить наши контейнеры. Поэтому перейдите в терминал и введите docker compose up -d database для раскрутки только экземпляра базы данных, подождите несколько секунд и запустите docker compose logs database для проверки журнала, он должен быть готов. Затем вы можете нажать «Instance Details», выбрать бесплатный уровень и подтвердить.

Соединение будет установлено в течение нескольких минут:

Как только статус будет «OK», откройте БД и создайте новую схему (то же самое, что вы использовали в переменной окружения бэкенда DATABASE_DBNAME).

После создания, мы создадим две таблицы, одну с именем ports и другую с именем ships, обе они будут иметь «Hash Attribute» как _id:

Далее перейдите на вкладку «функции» на черной полосе в верхней части страницы и создайте новый проект под названием api:

Это позволит включить и активировать пользовательские функции.

Это все, что нам нужно сделать, теперь мы можем приступить к кодированию миграции.

Перенос кода

Чтобы перенести существующий код, нам нужно понять немного архитектуры, лежащей в его основе. У вас есть документация в файле README, но она не объясняет всех деталей. Поэтому мы пройдемся по коду и посмотрим, что нам нужно сделать.

Понимание приложения

Приложение разделено на три части в backend/src:

У нас также есть два важных файла, точка входа нашего приложения в backend/src/app.ts и файл конфигурации в backend/src/app-config.ts. Сначала нам нужно изменить файл app-config.ts, чтобы отразить изменения, которые мы внесли в базу данных, вот что мы должны написать:

import env from 'sugar-env'

export const config = {
  cors: {
    exposedHeaders: ['x-content-range']
  },
  database: {
    harperdb: {
      uri: env.get('DATABASE_URI')!,
      dbName: env.get('DATABASE_DBNAME')!,
      username: env.get('DATABASE_USERNAME')!,
      password: env.get('DATABASE_PASSWORD')!
    }
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Следующее, что мы сделаем, это изменим файл app.ts, чтобы отразить то, что мы хотим получить в итоге. Вот текущий файл:

import routes from './routes'
import mongodb from '../data/connections/mongodb'
// Other imports...

export const app = expresso(async (app: Express, appConfig: typeof config) => {
  const connection = await mongodb.createConnection(appConfig.database.mongodb)

  const portRepository = new PortRepository(connection)
  const portService = new PortService(portRepository)

  const shipRepository = new ShipRepository(connection)
  const shipService = new ShipService(shipRepository, portService)

  // Routes...
})
Вход в полноэкранный режим Выйти из полноэкранного режима

Как вы можете видеть, мы импортируем соединение с MongoDB и передаем его репозиториям, так что это та часть, которую нам нужно изменить, нам нужно удалить mongo полностью и заменить его клиентом harperdb.

Мы хотим сделать это следующим образом: у нас будет класс HarperDBClient, который будет эквивалентом соединения mongodb, и мы будем использовать его, чтобы дать хранилищам действительное соединение с API Harper.

Этот клиент должен принимать только конфигурацию HarperDB, представленную в app-config.ts, и давать нам действительное соединение с API. Итак, давайте напишем это:

import routes from './routes'
import type { config } from '../app-config'
import { HarperDBClient } from '../data/clients/HarperDBClient'
// Other imports...

export const app = expresso(async (app: Express, appConfig: typeof config) => {
  const client = new HarperDBClient(appConfig.database.harperdb)

  const portRepository = new PortRepository(client)
  const portService = new PortService(portRepository)

  const shipRepository = new ShipRepository(client)
  const shipService = new ShipService(shipRepository, portService)

  // Routes...
})
Войти в полноэкранный режим Выйти из полноэкранного режима

И мы можем полностью удалить импорт mongodb и заменить его классом HarperDBClient.

После этого мы можем начать кодировать наш клиент!

Создание клиента

Чтобы начать создание нашего клиента, нам нужно сначала удалить файл mongodb в разделе data/connections и заменить его на HarperDBClient в разделе data/clients. Этот файл будет классом, который реализует интерфейс HarperDBClient.

Первым делом мы установим пакет axios с помощью npm install axios. Затем мы начнем с создания класса, который получает конфигурацию HarperDB и реализует интерфейс HarperDBClient:

import { config } from '../../app-config'
import Axios, { AxiosInstance } from 'axios'

export class HarperDBClient {
  #client: AxiosInstance
  #schema: string = ''

  constructor(connectionConfig: typeof config.database.harperdb) {
    this.#client = Axios.create({
      baseURL: connectionConfig.uri,
      url: '/',
      auth: {
        username: connectionConfig.username,
        password: connectionConfig.password
      },
      headers: {
        'Content-Type': 'application/json'
      }
    })
    this.#schema = connectionConfig.dbName
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь мы просто создаем начального клиента, который будет использоваться для всех внутренних вызовов ReST API.

Примечание: Вы можете использовать import type { config } from '../app-config' для импорта только типов.

Теперь мы создадим первую функцию, которая будет использоваться для перечисления всех сущностей данной сущности. Это будет сделано с помощью нашего старого доброго SQL. Но мы хотим также красиво его напечатать! Поэтому давайте создадим функцию, которая будет возвращать список сущностей:

async SQLFindAll<Entity> (tableName: string, projection: string = '*', whereClause: string = '') {
  const { data } = await this.#client.post<Entity[]>('/', {
    operation: 'sql',
    sql: `SELECT ${projection} FROM ${this.#schema}.${tableName} ${whereClause ? `WHERE ${whereClause}` : ''}`
  })
  return data
}
Войти в полноэкранный режим Выйти из полноэкранного режима

В этой функции мы получаем параметр type, который является сущностью, которую мы возвращаем. Остальные параметры задают имя таблицы, поля, которые мы хотим вернуть, и условие where.

Все вызовы API Harper относятся к корневому маршруту, и все они являются POST-запросами. Что действительно определяет наше действие, так это полезная нагрузка запроса, поэтому для вызова мы будем использовать метод post на client. Который вернет список заданных сущностей.

Следующее, что мы сделаем, это создадим функцию, которая будет возвращать одну сущность, это довольно похоже на то, что было описано выше, но в этом случае мы будем использовать встроенную функцию HarperDB под названием search_by_hash:

async NoSQLFindByID<Entity> (recordID: string | number, tableName: string, projection: string[] = ['*']) {
  const { data } = await this.#client.post<Entity[]>('/', {
    operation: 'search_by_hash',
    table: tableName,
    schema: this.#schema,
    hash_values: [recordID],
    get_attributes: projection
  })
  return data[0]
}
Войти в полноэкранный режим Выход из полноэкранного режима

В этой функции мы просто получим идентификатор записи, имя таблицы и поля, которые мы хотим вернуть. Как вы можете видеть, полезная нагрузка запроса сильно изменилась. Мы также получим список полей для возврата, но по умолчанию мы будем использовать * для получения всех полей.

Еще один важный момент, который следует отметить: несмотря на то, что мы возвращаем одну сущность, Harper возвращает в ответе массив этих сущностей. Поэтому нам нужно будет разрезать массив, чтобы получить первый элемент.

Для следующей функции нам нужно будет создать несколько сложных типов, это функции update и upsert, разница между ними и остальными в том, что они имеют разный тип возврата для каждого вызова. Поэтому мы создадим базовый тип и будем расширять его по мере необходимости.

Давайте добавим это в начало нашего файла:

interface HarperNoSQLReturnTypeBase {
  message: string
  skipped_hashes: string[]
}

interface HarperNoSQLUpsertType extends HarperNoSQLReturnTypeBase {
  upserted_hashes: any[]
}

interface HarperNoSQLUpdateType extends HarperNoSQLReturnTypeBase {
  updated_hashes: any[]
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь у нас есть базовый и расширенный типы, осталось создать тип, который соединит их вместе, и выбрать нужный тип в зависимости от вызова функции:

type HarperNoSQLReturnType<T> = T extends 'upsert'
  ? HarperNoSQLUpsertType
  : T extends 'update'
  ? HarperNoSQLUpdateType
  : never
Вход в полноэкранный режим Выйти из полноэкранного режима

Этот тип будет проверять, является ли данный параметр типа upsert или update, и возвращать правильный тип на основе этого.

И мы можем использовать его в наших функциях:

async NoSQLUpsert (records: Object[], tableName: string) {
  const { data } = await this.#client.post<HarperNoSQLReturnType<'upsert'>>('/', {
    operation: 'upsert',
    table: tableName,
    schema: this.#schema,
    records
  })
  return data
}

async NoSQLUpdate (records: Record<string, any>, tableName: string) {
  const { data } = await this.#client.post<HarperNoSQLReturnType<'update'>>('/', {
    operation: 'update',
    table: tableName,
    schema: this.#schema,
    records
  })
  return data
}
Войти в полноэкранный режим Выход из полноэкранного режима

Хотя мы не будем использовать функцию update, я подумал, что было бы неплохо добавить ее сюда, чтобы мы могли увидеть, как она работает.

Вот и все, наш клиент теперь готов к использованию! Вот как это выглядит:

import { config } from '../../app-config'
import Axios, { AxiosInstance } from 'axios'

interface HarperNoSQLReturnTypeBase {
  message: string
  skipped_hashes: string[]
}

interface HarperNoSQLUpsertType extends HarperNoSQLReturnTypeBase {
  upserted_hashes: any[]
}

interface HarperNoSQLUpdateType extends HarperNoSQLReturnTypeBase {
  updated_hashes: any[]
}

type HarperNoSQLReturnType<T> = T extends 'upsert'
  ? HarperNoSQLUpsertType
  : T extends 'update'
  ? HarperNoSQLUpdateType
  : never

export class HarperDBClient {
  #client: AxiosInstance
  #schema: string = ''

  constructor(connectionConfig: typeof config.database.harperdb) {
    this.#client = Axios.create({
      baseURL: connectionConfig.uri,
      url: '/',
      auth: {
        username: connectionConfig.username,
        password: connectionConfig.password
      },
      headers: {
        'Content-Type': 'application/json'
      }
    })
    this.#schema = connectionConfig.dbName
  }

  async SQLFindAll<Entity>(tableName: string, projection: string = '*', whereClause: string = '') {
    const { data } = await this.#client.post<Entity[]>('/', {
      operation: 'sql',
      sql: `SELECT ${projection} FROM ${this.#schema}.${tableName} ${whereClause ? `WHERE ${whereClause}` : ''}`
    })
    return data
  }

  async NoSQLUpsert(records: Object[], tableName: string) {
    const { data } = await this.#client.post<HarperNoSQLReturnType<'upsert'>>('/', {
      operation: 'upsert',
      table: tableName,
      schema: this.#schema,
      records
    })
    return data
  }

  async NoSQLUpdate(records: Record<string, any>, tableName: string) {
    const { data } = await this.#client.post<HarperNoSQLReturnType<'update'>>('/', {
      operation: 'update',
      table: tableName,
      schema: this.#schema,
      records
    })
    return data
  }

  async NoSQLFindByID<Entity>(recordID: string | number, tableName: string, projection: string[] = ['*']) {
    const { data } = await this.#client.post<Entity[]>('/', {
      operation: 'search_by_hash',
      table: tableName,
      schema: this.#schema,
      hash_values: [recordID],
      get_attributes: projection
    })
    return data[0]
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Перенос репозиториев

Если вы внимательно посмотрите на каталог data/repositories, то увидите, что там есть два хранилища, одно для сущности Ship и одно для сущности Port. Если вы откроете один из них, то увидите, что они в основном похожи друг на друга:

import { Db } from 'mongodb'
import { MongodbEventRepository } from '@irontitan/paradox'
import { Port } from '../../domain/port/entity'

export class PortRepository extends MongodbEventRepository<Port> {
  constructor(connection: Db) {
    super(connection.collection(Port.collection), Port)
  }

  async getAll(): Promise<Port[]> {
    const documents = await this._collection.find({ 'state.deletedAt': null }).toArray()
    return documents.map(({ events }) => {
      const port = new Port()
      return port.setPersistedEvents(events)
    })
  }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Единственное изменение — это имя сущности. Так почему бы нам не воспользоваться наследованием классов, чтобы сделать это проще? Давайте создадим класс BaseRepository, который будет расширяться классами PortRepository и ShipRepository.

Во-первых, нам нужно соблюсти требования библиотек поиска событий, которые мы используем, paradox — это библиотека, которая имеет класс MongodbEventRepository, который мы можем расширить в оригинальном коде, поскольку мы больше не используем Mongo, нам нужно проверить, как библиотека расширяет код, и если вы посмотрите на ее код, то увидите, что она использует параметр type для указания типа сущности, и расширяет класс EventRepository с этим типом:

export abstract class MongodbEventRepository<TEntity extends IEventEntity> extends EventRepository<TEntity>
Вход в полноэкранный режим Выход из полноэкранного режима

Мы не можем расширить класс EventRepository напрямую, поскольку он предназначен для баз данных NoSQL, поэтому мы расширим сущность напрямую:

export class BaseRepository<Entity extends IEventEntity> {}
Вход в полноэкранный режим Выход из полноэкранного режима

Наш конструктор прост, мы просто создадим три защищенные переменные. Одной из них будет сущность, с которой мы работаем, потому что нам нужно знать, какой тип класса создавать; второй будет клиент базы данных, который является нашим клиентом HarperDB; и последней будет имя таблицы.

import { HarperDBClient } from '../clients/HarperDBClient'
import { IEventEntity } from '@irontitan/paradox'
import { IEntityConstructor } from '@irontitan/paradox/dist/interfaces/IEntityConstructor'

export class BaseRepository<Entity extends IEventEntity> {
  protected database: HarperDBClient
  protected tableName: string
  protected entity: IEntityConstructor<Entity>
  constructor(client: HarperDBClient, tableName: string, entity: IEntityConstructor<Entity>) {
    this.database = client
    this.tableName = tableName
    this.entity = entity
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Затем нам нужно взглянуть на функции, которые используются сервисами, мы увидим, что у нас есть три основных: getAll, save и findById. Давайте создадим их в нашем базовом репозитории, вот правила:

Начиная с самого простого, getAll, мы просто вызовем функцию SQLFindAll на клиенте базы данных, и передадим имя таблицы и проекцию.

async getAll (): Promise<Entity[]> {
  const documents = await this.database.SQLFindAll<{ events: Entity['events'] }>(this.tableName, 'events', `search_json('deletedAt', state) IS NULL`)
  return documents.map((document) => new this.entity().setPersistedEvents(document.events))
}
Вход в полноэкранный режим Выход из полноэкранного режима

В конце функции нам нужно получить список событий, которые мы вернули, и добавить их в свою сущность, затем вернуть сущность, которая будет содержать редуктор для применения событий к сущности.

Единственная загвоздка здесь — это типы, поскольку нас интересуют только события, мы будем использовать { events: Entity['events'] } в качестве типа документа. А затем мы воспользуемся функцией search_json, чтобы отфильтровать удаленные сущности.

Эта вторая часть важна, потому что при использовании событийного сорсинга мы никогда не удаляем что-то по-настоящему, мы просто добавляем событие delete, которое заполняет поле deletedAt на сущности. Поэтому, если мы хотим получить все сущности, нам нужно отфильтровать удаленные.

Далее переходим к функции findById. Это будет немного сложнее, поскольку мы используем MongoDB с ObjectIDs, поэтому в нашем коде ожидаются такие объекты. Поэтому нам нужно продолжать их использовать.

Хорошей практикой является удаление всех OID из кода и использование других типов идентификаторов, таких как UUID, чтобы мы могли легко переключаться между базами данных. В основном потому, что Harper не понимает OID как объект, а как строку.

Наша функция должна будет вызвать функцию NoSQLFindByID на клиенте базы данных, и мы передадим имя таблицы, ID записи, проекцию, и она должна вернуть null в случае, если сущность не существует.

async findById (id: string | ObjectId): Promise<Entity | null> {
  if (!ObjectId.isValid(id)) return null

  const document = await this.database.NoSQLFindByID<Entity>(id.toString(), this.tableName, ['state', 'events'])
  if (!document) return null

  return new this.entity().setPersistedEvents(document.events)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Последняя функция — это функция save, которая будет немного сложнее. Нам нужно будет вызвать функцию NoSQLUpsert на клиенте базы данных, и мы передадим имя таблицы и сущность, которую нужно вставить. Но мы не можем просто использовать сущность напрямую, нам нужно клонировать ее, поэтому мы установим lodash.clonedeep с помощью npm install lodash.clonedeep.

Затем мы импортируем его как import cloneDeep from 'lodash.clonedeep'. И используем его следующим образом:

async save (entity: Entity): Promise<Entity> {
  const localEntity = cloneDeep(entity)
  const document = {
    _id: entity.id,
    state: localEntity.state,
    events: localEntity.persistedEvents.concat(localEntity.pendingEvents)
  }
  const result = await this.database.NoSQLUpsert([document], this.tableName)
  if (!result.upserted_hashes.includes(document._id.toString())) throw new Error(result.message)
  return localEntity.confirmEvents()
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы создаем документ внутри функции, затем объединяем ожидающие события (события, которые происходят с сущностью, но еще не сохранены в базе данных) с сохраненными событиями (событиями, которые уже сохранены в базе данных). Затем мы вызовем функцию NoSQLUpsert на клиенте базы данных, передадим имя таблицы и документ, который нужно вставить.

Мы также можем проверить ID вставленного документа, чтобы быть уверенными, и, в конце концов, подтвердим события, что приведет к объединению ожидающих событий с сохраненными событиями и очистке массива ожидающих событий.

Окончательный код выглядит следующим образом:

import { HarperDBClient } from '../clients/HarperDBClient'
import { ObjectId } from 'mongodb'
import { IEventEntity } from '@irontitan/paradox'
import { IEntityConstructor } from '@irontitan/paradox/dist/interfaces/IEntityConstructor'
import cloneDeep from 'lodash.clonedeep'

export class BaseRepository<Entity extends IEventEntity> {
  protected database: HarperDBClient
  protected tableName: string
  protected entity: IEntityConstructor<Entity>
  constructor(client: HarperDBClient, tableName: string, entity: IEntityConstructor<Entity>) {
    this.database = client
    this.tableName = tableName
    this.entity = entity
  }

  async findById(id: string | ObjectId): Promise<Entity | null> {
    if (!ObjectId.isValid(id)) return null

    const document = await this.database.NoSQLFindByID<Entity>(id.toString(), this.tableName, ['state', 'events'])
    if (!document) return null

    return new this.entity().setPersistedEvents(document.events)
  }

  async save(entity: Entity): Promise<Entity> {
    const localEntity = cloneDeep(entity)
    const document = {
      _id: entity.id,
      state: localEntity.state,
      events: localEntity.persistedEvents.concat(localEntity.pendingEvents)
    }
    const result = await this.database.NoSQLUpsert([document], this.tableName)
    if (!result.upserted_hashes.includes(document._id.toString())) throw new Error(result.message)
    return localEntity.confirmEvents()
  }

  async getAll(): Promise<Entity[]> {
    const documents = await this.database.SQLFindAll<{ events: Entity['events'] }>(
      this.tableName,
      'events',
      `search_json('deletedAt', state) IS NULL`
    )
    return documents.map((document) => new this.entity().setPersistedEvents(document.events))
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Затем нам просто нужно расширить этот класс в других репозиториях, например, так:

import { HarperDBClient } from '../clients/HarperDBClient'
import { BaseRepository } from './BaseRepository'
import { Port } from '../../domain'

export class PortRepository extends BaseRepository<Port> {
  constructor(client: HarperDBClient) {
    super(client, 'ports', Port)
  }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

И репозиторий корабля будет выглядеть следующим образом:

import { Ship } from '../../domain/ship/entity'
import { HarperDBClient } from '../clients/HarperDBClient'
import { BaseRepository } from './BaseRepository'

export class ShipRepository extends BaseRepository<Ship> {
  constructor(client: HarperDBClient) {
    super(client, 'ships', Ship)
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Последние штрихи

Как я уже упоминал, мы используем MongoDB с ObjectIDs, поэтому мы ожидаем появления таких объектов в нашем коде. Но Harper понимает эти OID не как объекты, а как строки.

Проблема в том, что в библиотеке ObjectId есть две функции, equals и toHexString, и они не существуют в строках, поэтому нам нужно изменить каждое вхождение этих функций на .toString().

Если вы поищете в своем редакторе слово: .equals вы найдете три файла с 4 вхождениями. Мы заменим их на .toString() и сравнение с equals превратится в обычное старое сравнение ===.

domain/port/events/ShipDockedEvent.ts:

Перед:

import { Event } from '@irontitan/paradox'
import { Port } from '../entity'
import { ObjectId } from 'mongodb'

interface IEventCreationParams {
  shipId: ObjectId
}

export class ShipDockedEvent extends Event<IEventCreationParams> {
  // ...

  static commit(state: Port, event: ShipDockedEvent): Port {
    if (!state.dockedShips.find((shipId) => shipId.equals(event.data.shipId))) state.dockedShips.push(event.data.shipId)
    state.updatedAt = event.timestamp
    state.updatedBy = event.user
    return state
  }
}
Войти в полноэкранный режим Выход из полноэкранного режима

После:

import { Event } from '@irontitan/paradox'
import { Port } from '../entity'
import { ObjectId } from 'mongodb'

interface IEventCreationParams {
  shipId: ObjectId
}

export class ShipDockedEvent extends Event<IEventCreationParams> {
  // ...

  static commit(state: Port, event: ShipDockedEvent): Port {
    if (!state.dockedShips.find((shipId) => shipId.toString() === event.data.shipId.toString()))
      state.dockedShips.push(event.data.shipId)
    state.updatedAt = event.timestamp
    state.updatedBy = event.user
    return state
  }
}
Войти в полноэкранный режим Выход из полноэкранного режима

/domain/ship/events/ShipUndockedEvent.ts:

До:

import { Event } from '@irontitan/paradox'
import { Port } from '../entity'
import { ObjectId } from 'mongodb'

interface IEventCreationParams {
  shipId: ObjectId
  reason: string
}

export class ShipUndockedEvent extends Event<IEventCreationParams> {
  // ...

  static commit(state: Port, event: ShipUndockedEvent): Port {
    state.dockedShips = state.dockedShips.filter((shipId) => !event.data.shipId.equals(shipId))
    state.updatedAt = event.timestamp
    state.updatedBy = event.user
    return state
  }
}
Войти в полноэкранный режим Выход из полноэкранного режима

После:

import { Event } from '@irontitan/paradox'
import { Port } from '../entity'
import { ObjectId } from 'mongodb'

interface IEventCreationParams {
  shipId: ObjectId
  reason: string
}

export class ShipUndockedEvent extends Event<IEventCreationParams> {
  // ...

  static commit(state: Port, event: ShipUndockedEvent): Port {
    state.dockedShips = state.dockedShips.filter((shipId) => event.data.shipId.toString() !== shipId.toString())
    state.updatedAt = event.timestamp
    state.updatedBy = event.user
    return state
  }
}
Войти в полноэкранный режим Выход из полноэкранного режима

services/PortService.ts:

До:

import { ObjectId } from 'mongodb'
import { Port, Ship } from '../domain'
import { PortRepository } from '../data/repositories/PortRepository'
import { PortNotFoundError } from '../domain/port/errors/PortNotFoundError'
import { IPortCreationParams } from '../domain/structures/IPortCreationParams'

export class PortService {
  // ...

  async undockShip(ship: Ship, reason: string, user: string): Promise<void> {
    if (!ship.currentPort) return

    const port = await this.repository.findById(ship.currentPort)
    if (!port) return
    if (!port.dockedShips.find((dockedShip) => dockedShip.equals(ship.id as ObjectId))) return

    port.undockShip(ship, reason, user)

    await this.repository.save(port)
  }

  async dockShip(ship: Ship, user: string): Promise<void> {
    if (!ship.currentPort) return

    const port = await this.repository.findById(ship.currentPort)

    if (!port) throw new PortNotFoundError(ship.currentPort.toHexString())
    if (port.dockedShips.find((dockedShip) => dockedShip.equals(ship.id as ObjectId))) return

    port.dockShip(ship, user)
    await this.repository.save(port)
  }

  // ...
}
Войти в полноэкранный режим Выход из полноэкранного режима

После:

import { ObjectId } from 'mongodb'
import { Port, Ship } from '../domain'
import { PortRepository } from '../data/repositories/PortRepository'
import { PortNotFoundError } from '../domain/port/errors/PortNotFoundError'
import { IPortCreationParams } from '../domain/structures/IPortCreationParams'

export class PortService {
  // ...

  async undockShip(ship: Ship, reason: string, user: string): Promise<void> {
    if (!ship.currentPort) return

    const port = await this.repository.findById(ship.currentPort)
    if (!port) return
    if (!port.dockedShips.find((dockedShip) => dockedShip.toString() === ship.id?.toString())) return

    port.undockShip(ship, reason, user)

    await this.repository.save(port)
  }

  async dockShip(ship: Ship, user: string): Promise<void> {
    if (!ship.currentPort) return

    const port = await this.repository.findById(ship.currentPort)

    if (!port) throw new PortNotFoundError(ship.currentPort.toString())
    if (port.dockedShips.find((dockedShip) => dockedShip.toString() === ship.id?.toString())) return

    port.dockShip(ship, user)
    await this.repository.save(port)
  }

  // ...
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Тестирование

Теперь мы закончили! Давайте протестируем приложение, выполнив файл docker compose с помощью команды docker compose up и перейдя на localhost:

Давайте создадим новый порт и посмотрим, как он выглядит:

И проверим harper:

Пользовательские функции

Чтобы включить пользовательские функции, мы изменим вкладку functions в Harper Studio, чтобы включить следующий код:

'use strict'

// eslint-disable-next-line no-unused-vars,require-await
module.exports = async (server, { hdbCore }) => {
  server.route({
    url: '/ships',
    method: 'GET',
    preParsing: (request, _, done) => {
      request.body = {
        operation: 'sql',
        sql: 'SELECT events FROM ship_manager.ships WHERE search_json("deletedAt", state) IS NULL'
      }
      done()
    },
    preValidation: hdbCore.preValidation,
    handler: hdbCore.request
  })

  server.route({
    url: '/ports',
    method: 'GET',
    handler: (request) => {
      request.body = {
        operation: 'sql',
        sql: 'SELECT events FROM ship_manager.ports WHERE search_json("deletedAt", state) IS NULL'
      }
      return hdbCore.requestWithoutAuthentication(request)
    }
  })
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы поместим это в файл примера в проекте, который мы создали ранее:

Это добавит новый маршрут к серверу, который позволит нам запрашивать корабли и порты напрямую, без необходимости SQL запроса в /ships или /ports.

После сохранения файла мы вернемся к нашему клиенту HarperDB и изменим функцию findAll, чтобы вызвать новый порт и новый маршрут сущности:

async SQLFindAll<Entity> (tableName: string) {
  const url = `${this.#client.defaults.baseURL?.replace('9925', '9926')}/api/${tableName}`
  const { data } = await Axios.get<Entity[]>(url, { auth: this.#client.defaults.auth })
  return data
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь мы можем запустить наш сервер с помощью docker compose up --build=backend и перейти на localhost, чтобы убедиться, что все работает, как ожидалось.

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *