最初に、データベースを操作する際のいくつかの問題と機能の概要を説明し、抽象化に穴を開けます。 次に、免疫に基づいたより単純な抽象化を分析します。
読者はActive Record 、 Data Maper 、 Identity Map、およびUnit of Workパターンにある程度精通していることを前提としています。
問題と解決策は、捨ててすぐに書き直すことのできない十分に大きなプロジェクトのコンテキストで考慮されます。
アイデンティティマップ
最初の問題は、アイデンティティを維持する問題です。 アイデンティティは、エンティティを一意に識別するものです。 データベースでは、これが主キーであり、メモリではリンク(ポインタ)です。 リンクが1つのオブジェクトのみを指していると便利です。
ruby ActiveRecordライブラリの場合、これはそうではありません。
post_a = Post.find 1 post_b = Post.find 1 post_a.object_id != post_b.object_id # true post_a.title = "foo" post_b.title != "foo" # true
つまり メモリ内の2つの異なるオブジェクトへの2つの参照を取得します。
したがって、異なるエンティティによって表されている同じエンティティで誤って作業を開始すると、変更を失う可能性があります。
Hibernateにはセッションがあり、実際には、メモリ内のオブジェクトへのエンティティ識別子のマッピングを保存する1次キャッシュがあります。 同じエンティティを再リクエストすると、既存のオブジェクトへのリンクが取得されます。 つまり HibernateはIdentity Mapパターンを実装します。
長い取引
しかし、識別子で選択しないとどうなりますか? オブジェクトの状態とデータベースの状態が同期しないようにするために、Hibernateは選択を要求する前にフラッシュします。
つまり データベース内のダーティオブジェクトをダンプして、要求が合意されたデータを読み取るようにします。
このアプローチでは、ビジネストランザクションの進行中にデータベーストランザクションを開いたままにする必要があります。
ビジネストランザクションが長い場合、データベース自体の接続を担当するプロセスもアイドル状態になります。 たとえば、これは、ビジネストランザクションがネットワーク経由でデータを要求したり、複雑な計算を実行したりする場合に発生する可能性があります。
N + 1
おそらく、ORM抽象化の最大の「穴」はN + 1クエリの問題です。
ActiveRecordライブラリのルビーの例:
posts = Post.all # select * from posts posts.each do |post| like = post.likes.order(id: :desc).first # SELECT * FROM likes WHERE post_id = ? ORDER BY id DESC LIMIT 1 # ... end
ORMは、プログラマーを単純にメモリ内のオブジェクトを操作するという考えに導きます。 ただし、ネットワーク経由で利用可能なサービス、および接続の確立とデータ転送で動作します
時間がかかります。 要求が50ミリ秒実行された場合でも、20の要求が1秒間実行されます。
追加データ
上記のN + 1の問題を回避するには、次のように記述します。
リクエスト :
SELECT * FROM posts JOIN LATERAL ( SELECT * FROM likes WHERE post_id = posts.id ORDER BY likes.id DESC LIMIT 1 ) as last_like ON true;
つまり 投稿の属性に加えて、最後の類似のすべての属性も選択されます。 このデータはどのエンティティにマッピングされますか? この場合、投稿などからカップルを返すことができます。 結果には、必要なすべての属性が含まれます。
しかし、フィールドの一部のみを選択した場合や、モデルに含まれていないフィールドを選択した場合(たとえば、出版の好きな数)はどうでしょうか? それらをエンティティに表示する必要がありますか? たぶん、彼らにデータだけを残しますか?
状態とアイデンティティ
jsコードを考えてください:
const alice = { id: 0, name: 'Alice' };
ここでは、オブジェクト参照にalice
という名前が付けられています。 なぜなら これは定数であるため、Aliceを別のオブジェクトと呼ぶ方法はありません。 同時に、オブジェクト自体は可変のままでした。
たとえば、既存の識別子を割り当てることができます。
const bob = { id: 1, name: 'Bob' }; alice.id = bob.id;
エンティティには、データベース内のリンクとプライマリキーという2つのIDがあります。 また、定数は、保存した後でもアリスボブの作成を停止することはできません。
オブジェクト、つまりalice
と呼ばれるリンクは、2つの役割を果たします:アイデンティティと状態を同時にモデル化します。 状態は、特定の時点でのエンティティを説明する値です。
しかし、これら2つの責任を分離し、状態に不変の構造を使用するとどうなりますか?
function Ref(initialState, validator) { let state = initialState; this.deref = () => state; this.swap = (updater) => { const newState = updater(state); if (! validator(state, newState) ) throw "Invalid state"; state = newState; return newState; }; } const UserState = Immutable.Record({ id: null, name: '' }); const aliceState = new UserState({id: 0, name: 'Alice'}); const alice = new Ref( aliceState, (oldS, newS) => oldS.id === newS.id ); alice.swap( oldS => oldS.set('name', 'Queen Alice') ); alice.swap( oldS => oldS.set('id', 1) ); // BOOM!
Ref
制御された交換を可能にする、不変の状態のコンテナ。 Ref
、オブジェクトに名前を付けるようにIDをモデル化します。 私たちはヴォルガ川と呼んでいますが、いつでも変化しない状態を持っています。
保管
次のAPIを検討してください。
storage.tx( t => { const alice = t.get(0); const bobState = new UserState({id: 1, name: 'Bob'}); const bob = t.create(bobState); alice.swap( oldS => oldS.update('friends', old => old.push(bob.deref.id)) ); });
t.get
およびt.create
はRef
インスタンスを返します。
ビジネストランザクションt
を開き、アリスを彼女の識別子で見つけ、ボブを作成し、アリスがボブを彼女の友人と見なすことを示します。
オブジェクトt
はref
の作成を制御します。
t
は、エンティティ識別子とそれを含むref
状態とのマッピングを自身の中に保存できます。 つまり アイデンティティマップを実装できます。 この場合、 t
はキャッシュとして機能し、Aliceのリクエストが繰り返されると、データベースへのリクエストはありません。
トランザクションの終了時にデータベースに書き込む必要がある変更を追跡するために、エンティティの初期状態を記憶できます。 つまり 作業ユニットを実装できます。 または、オブザーバーサポートがRef
に追加されると、 ref
変更されるたびにデータベースへの変更をリセットできます。 これらは、変更を修正するための楽観的で悲観的なアプローチです。
楽観的なアプローチでは、エンティティの状態バージョンを追跡する必要があります。
データベースから変更するときは、バージョンを覚えておく必要があります。変更をコミットするときは、データベース内のエンティティのバージョンが最初のものと変わらないことを確認してください。 それ以外の場合は、ビジネストランザクションを繰り返す必要があります。 このアプローチにより、グループの挿入および削除操作と非常に短いデータベーストランザクションの使用が可能になり、リソースが節約されます。
悲観的なアプローチでは、データベーストランザクションはビジネストランザクションと完全に一貫しています。 つまり ビジネストランザクションが完了するたびに、プールから接続を撤回する必要があります。
APIを使用すると、エンティティを1つずつ抽出できますが、これはあまり最適ではありません。 なぜなら Identity Mapパターンを実装したら 、APIにpreload
メソッドを入力できます。
storage.tx( t => { t.preload([0, 1, 2, 3]); const alice = t.get(0); // from cache });
問い合わせ
長いトランザクションが必要ない場合、任意のキーで選択を行うことはできません。 メモリにダーティオブジェクトが含まれている場合があり、選択すると予期しない結果が返されます。
Queryを使用して、トランザクション外のデータ(状態)を取得し、トランザクション中にデータを再読み取りできます。
const aliceId = userQuery.findByEmail('alice@mail.com'); storage.tx( t => { const alice = t.getOne(aliceId); });
したがって、責任の分担があります。 クエリの場合、検索エンジンを使用して、レプリカを使用して読み取りをスケーリングできます。 また、ストレージAPIは常にメインストレージ(マスター)と連携します。 当然、レプリカには古いデータが含まれているため、トランザクションでデータを再読み取りするとこの問題が解決します。
コマンド
データを読み取らずに操作を実行できる状況があります。 たとえば、すべての顧客のアカウントから月額料金を差し引きます。 または、競合が発生した場合にデータを挿入および更新(アップサート)します。
パフォーマンスの問題が発生した場合、Storage and Queryのバンドルをこのようなコマンドに置き換えることができます。
コミュニケーションズ
エンティティが互いにランダムに参照する場合、それらを変更するときに一貫性を確保することは困難です。 関係は単純化、合理化、不必要な放棄を試みます。
集計は、関係を整理する方法です。 各集約には、ルートエンティティとネストされたエンティティがあります。 外部エンティティは、集約のルートのみを参照できます。 ルートは、ユニット全体の整合性を保証します。 トランザクションは集合体の境界を越えることはできません。つまり、集合体全体がトランザクションに関与しています。
集約は、たとえば、Lent(ルート)とその翻訳で構成されます。 または注文とその位置。
このAPIは、集合体全体で機能します。 同時に、アグリゲート間の参照整合性はアプリケーションにあります。 APIは、リンクの遅延読み込みをサポートしていません。
しかし、私たちは関係の方向を選択できます。 1対多の関係を考慮してくださいUser-Post。 ユーザーIDを投稿に保存できますが、便利ですか? 投稿識別子の配列をユーザーに保存すると、さらに多くの情報を取得できます。
おわりに
データベースを操作するときの問題を強調し、免疫を使用するオプションを示しました。
記事の形式では、トピックを詳細に明らかにすることはできません。
このアプローチに興味がある場合は、アーキテクチャーに重点を置いてゼロからWebアプリケーションを作成することを説明する、 最初から私の本アプリに注意を払ってください。 SOLID、Clean Architecture、データベースの操作パターンを理解しています。 本のコードサンプルとアプリケーション自体はClojure言語で書かれており、これには免疫の概念とデータ処理の利便性が吹き込まれています。