ダイニー独自の型安全なエラーハンドリングに TypeORM のトランザクション管理を取り入れる
こんにちは、ダイニーで業務委託としてソフトウェアエンジニアをしている @odan3240 です。
業務委託として開発体験の向上に繋がるタスクを担当しており、今回は odavid/typeorm-transactional-cls-hooked をプロダクトに導入した話を紹介します。
TypeORM と TypeORM でのトランザクションの扱い方
TypeORM は Node.js 向けの ORM です。ダイニーは RDB に PostgreSQL を採用しており、OR マッパーに TypeORM を使用しています。
この TypeORM では RDB のトランザクションを扱うことができるため、複数の SQL の実行をアトミックな操作にすることができます。
トランザクションの使用方法は transaction 関数の第一引数のコールバック関数の引数に、トランザクション用の EntityManager が渡されるので、この EntityManager 経由でクエリを発行することです。
import { getManager } from "typeorm";
async function func1() {
await getManager().transaction(async transactionalEntityManager => {
await transactionalEntityManager.save(users);
await transactionalEntityManager.save(photos);
});
}
トランザクションを使用するときは必ず EntityManager 経由でクエリを発行する必要があるのが扱いにくい点です。例えば複数のメソッドをまたいで同じトランザクションでクエリを発行したい場合は、EntityManager のインスタンスをバケツリレーする必要があります。
import { getManager, EntityManager } from "typeorm";
async function func2() {
// func1 とは別のトランザクションで photos が保存されてしまう
await getManager().save(photos);
}
async function func3(entityManager: EntityManager) {
// func1 と同じトランザクションで photos を保存するには
// await func3(transactionalEntityManager) と呼び出す必要がある
await entityManager.save(photos);
}
async function func1() {
await getManager().transaction(async transactionalEntityManager => {
await transactionalEntityManager.save(users);
await func2();
});
}
typeorm-transactional-cls-hooked を使うとトランザクションを透過的に扱える
typeorm-transactional-cls-hooked とは TypeORM のこの問題を解決するライブラリです。トランザクションを複数のメソッドで透過的に扱うための @Transactional メソッドデコレータを提供しています。
@Transactional がついているメソッド経由で呼び出されたメソッド内部で発行されるクエリは同じトランザクション内部で実行されます。
class HogeService {
async func2() {
// func1 のトランザクションと同じトランザクションでクエリが実行される
await this.photoRepository.save(photos);
}
@Transactional()
async func1() {
await this.userRepository.save(users);
await func2();
}
}
ダイニーに typeorm-transactional-cls-hooked を導入する上での課題
ダイニーのバックエンドではエラーハンドリングに Result 型を使用していたため、typeorm-transactional-cls-hooked を導入するのが困難な状態でした。
Result 型とは、エラークラスのインスタンスを throw するのではなく、関数の返り値として返す実装パターンのことです。
class ZeroDivideError extends Error {}
function div(a: number, b: number): number | ZeroDivideError {
if (b === 0) return new ZeroDivideError();
return a / b;
}
Result 型の利点は関数の呼び出し元に型情報が残る点にあります。
throw されたエラーを try...catch 文で補足するとエラーの型は unknown 型になってしまい型情報が失われてしまいます。一方で、Result 型を使うと呼び出し元の返り値を受け取る変数の型が number | ZeroDivideErrorとなります。これによりエラーハンドリングが漏れていないことを型レベルで保証することができます。
typeorm-transactional-cls-hooked はメソッドデコレータを付与したメソッドが例外を throw するとトランザクションを自動的にロールバック仕組みです。
まとめると、この「typeorm-transactional-cls-hooked はエラー時に例外を throw することを期待していること」と「ダイニーのバックエンドではエラーを Result 型として return すること」が導入を難しくしていました。
解決策
1. Result 型の関数から例外を throw する関数に変換する関数
2. 例外を throw する関数から、 Result 型の関数に変換する関数
の2つのヘルパー関数を用意しました。
これらのヘルパー関数を用いて
- ダイニーのバックエンドの Result 型の関数を、1. の関数を用いて変換
- 変換した関数を typeorm-transactional-cls-hooked 内部の関数に渡す
- 内部の関数から返ってきた例外を throw する関数を、2. の関数を用いて変換
と処理する独自の @Transactional デコレータを実装することで解決しました。
ソースコードの一部は次の通りです。
// descriptor.value にデコレータを付与したメソッドが格納されている
const originalMethod = descriptor.value;
// result 型の関数 => 例外を throw する関数に変換
const wrappedMethod = wrapResultTypeToException(originalMethod);
// 変換した例外を throw する関数をトランザクションを管理する内部関数に渡す
const inTransactionMethod = wrapInTransaction(wrappedMethod, {
...options,
name: methodName,
});
// 例外を throw する関数 => result 型の関数に変換
const unwrappedMethod = wrapExceptionToResultType(inTransactionMethod);
// 変換した関数で元のメソッドを上書きする
descriptor.value = unwrappedMethod;
既存コードとの共存
ダイニーのバックエンドには entityManagers をバケツリレーしている関数が多数存在します。今回のような新しい仕組みをコードベースに導入する際には、一度にリファクタリングを行うと活発に機能の開発が進んでいる main ブランチとの間に差分が発生してマージが難しいなどの問題があります。そのため、既存コードと共存できる仕組みを用意しながら機能実装と同時に少しずつ新しい仕組みで置き換えていく必要があります。
つまり次の2つのパターンについて、何かしらの実装を用意する必要があります。
- @Transactional が付与されている関数 → 従来のバケツリレーの関数
- 従来のバケツリレーの関数 → @Transactional が付与されている関数
## @Transactional が付与されている関数 → 従来のバケツリレーの関数
このケースは事前に typeorm-transactional-cls-hooked から提供されている関数の patchTypeORMRepositoryWithBaseRepository を呼び出しておけば、特別なにかする必要はありません。
patchTypeORMRepositoryWithBaseRepository は TypeORM の Repository クラスにパッチを当てる関数です。パッチが当てられた Repository クラスは SQL を発行するための entityManager を参照するときに、@Transactional デコレータによって提供された entityManager が存在すればそちらを優先するようになります。
## 従来のバケツリレーの関数 → @Transactional が付与されている関数
このケースは @Transactional デコレータにバケツリレーで使用している entityManager を認識させる必要があるため、工夫が必要です。
今回はこのために setupTransaction という関数を用意しました。
import { EntityManager } from "typeorm";
import { NAMESPACE_NAME } from "typeorm-transactional-cls-hooked";
import { setEntityManagerForConnection } from "typeorm-transactional-cls-hooked/dist/common";
export async function setupTransaction(
entityManager: EntityManager,
callback: () => Promise<unknown>,
) {
// getNamespace は initializeTransactionalContext が一度も呼び出されていない場合に undefined を返すが
// dinii-self-backend ではこれが呼び出されていることを仮定して良いので non-null assertion を使用している
const context = getNamespace(NAMESPACE_NAME)!;
await context.runAndReturn(async () => {
setEntityManagerForConnection("default", context, entityManager);
await callback();
});
}
この setupTransaction は次のように使用します。
export class HogeService {
@Transactional()
async func2() {
await this.photoRepository.save(photos);
}
async func1(entityManager: EntityManager) {
await setupTransaction(entityManager, async () => {
await func2();
});
}
}
第一引数にバケツリレーで受け取った entityManager を渡し、第二引数のcallback の中で @Transactional が付与されたメソッドを呼び出すと、同一のトランザクションでクエリが実行されるようになります。
おわりに
ダイニーのバックエンドの関数は Result 型を返すため型安全なエラーハンドリングが可能な仕組みです。しかしこれは関数のエラーハンドリングとして例外の throw を前提としている typeorm-transactional-cls-hooked とは相性が悪いものでした。
そこで、このミスマッチを解消する関数を用意して typeorm-transactional-cls-hooked への橋渡しにすることで、この問題を解決しました。
ダイニーではこのような技術的な課題を解決したいエンジニアを絶賛募集中です。
ダイニーの開発チームが少しでも気になった方は是非お声がけください!
興味がある方は 採用ページ から。