SCSKの畑です。 これまでのエントリで説明してきた Redshift テーブルデータのメンテナンスアプリケーションにテーブルデータの差分比較機能を実装しているのですが、バックエンド側についてどのように実装したのかを記載してみます。 アーキテクチャ図 性懲りもなく今回も。今回はバックエンド側の処理として実装しているため、以下図では AppSync から呼び出される Lambda が対象となります。ただ、Lambda ならではの話みたいなものは今回は正直ないです・・ 背景 テーブルデータの差分比較機能については、元々お客さんと詰めた 機能要件の一つ にありましたので実装は規定路線でしたが、フロントエンド/バックエンドのどちらで実装するかというのがちょっとした考えどころでした。本アプリケーションにおけるテーブルデータの差分比較機能は、 アプリケーション上でユーザが編集したデータと編集前のデータを比較 異なるバージョン間のテーブルデータを比較 の2パターンで使用されますが、前者の方が使用頻度は高いと思われること、及び(当然ながら)ユーザはアプリケーション上でテーブルデータを編集することの2点より、当初はフロントエンド側で実装する方針としました。しかし、具体的にどのようなアプローチで実装するのかなかなか定まりませんでした。 まず、 少し前のエントリ で紹介した Tablator 上で編集した項目(セル)の情報を保持できるためそれをそのまま使用しようかと考えていましたが、ライブラリの仕様上 削除した行データ&外部ファイルからインポートしたデータ の場合は対象とならないことが分かりました。また、異なるバージョン間のテーブルデータを比較する場合は実質的に S3 上のテーブルデータ同士を比較する処理となり、Tablator の機能は使用できないため見送ることに。 Tabulator - Editing Data Use built in cell editors to allow users to edit table data, or build out your own for fully customizable table input tabulator.info よって、真っ当に2つのテーブルデータを比較するような処理をフロントエンドでどう実装するかを考えたのですが、Javascript(Typescript)でその手の処理を実装したことがなかったため、ライブラリや比較手法の調査でこれという正解が見つかりませんでした。元々 Python の pandas ライブラリについては過去案件などで使い慣れていたため、pandas ライクに使用できるライブラリが良さそうということで調べたところ danfo.js なるものを見つけました。 Danfo.js Documentation | Danfo.js Danfo.js is an open-source, JavaScript library providing high-performance, intuitive, and easy-to-use data structures fo... danfo.jsdata.org 使い勝手も良さそうだったのですが、実はこの調査の過程で pandas であれば簡単に差分比較できる方法を見つけてしまっており・・残念ながらその方法が使えなさそうだったため、その分のロジックについて自前で実装することを考慮するとバックエンド側で Python/pandas を使用して実装してしまう方がトータルで効率良いのではないかということで、バックエンド側で実装する方針に変更しました。また、差分比較処理自体は相応に重い処理であるため、フロントエンド(クライアント)側で実装した場合の影響を憂慮したことも理由の一つです。バックエンド側で実装する分には、(極論) Lambda に頑張ってもらえれば良いだけなので・・ なお、バックエンド側で差分比較を実装するにあたり、フロントエンド側で編集したテーブルデータをバックエンド側に持ってくる処理が追加で必要となりましたが、ちょうど編集中のデータを S3 上に一時保存するような機能を実装したところだったため、同機能と合わせて差分比較を実施するようにすることで問題なく実装できました。異なるバージョン間のテーブルデータ比較については先述した通りS3 上のテーブルデータ同士を比較する処理であり、Lambda 上から直接処理できたため問題ありませんでした。 pandas を使用した実装方法 ということで本題なのですが、pandas.DataFrame.merge の indicator 引数を使用することでテーブルデータの差分比較を容易に実現することができました。 pandas.DataFrame.merge — pandas 2.2.3 documentation pandas.pydata.org 例として以下のような2つのテーブル間での差分比較を考えてみます。table_1 を編集前のデータ、table_2 を編集後のデータと想定し、同テーブルにおける PK 列を「pk」列とします。 [table_1] pk col_1 col_2 col_3 row_0 1 2 3 4 row_1 11 12 13 14 row_2 101 102 103 104 [table_2] pk col_1 col_2 col_3 row_0 2 3 4 5 row_1 11 12 13 14 row_2 101 102 1030 1040 よって、差分比較の結果としては、 table_1 の 1行目:table_1 にのみ存在するデータ(=編集で削除された行データ) table_2 の 1行目:table_2 にのみ存在するデータ(=編集で追加された行データ) table_1/table2 の 3行目:table_1 の 3行目の「col2」「col3」列の値が変更されたデータ のように差分が検出されることを期待しています。 具体的な実装例は以下の通りです。df_table_1 及び df_table_2 にそれぞれのテーブルに対応した DataFrame が定義されている前提として、DataFrame.merge で差分比較を実施しています。この関数自体は2つのテーブルを結合(マージ)する関数であり、結合条件は on 引数で、結合方法は how 引数で示されます。よって、以下の実装例は 全ての列を結合条件とした外部結合処理 となります。 純粋にテーブル間での差分比較を実施するために全ての列を結合条件としています。別の言い方をすると、結合条件に指定する列のみが差分比較の対象となり、それ以外の列における差異は無視されます。よって、ユースケースにより差分を検出したい列のみを結合条件に指定すると良いでしょう。 import pandas as pd df_table_1 = pd.DataFrame( [[1, 2, 3, 4], [11, 12, 13, 14], [101, 102, 103, 104]], columns=['pk', 'col_1', 'col_2', 'col_3'], index=['row_0', 'row_1', 'row_2'] ) df_table_2 = pd.DataFrame( [[2, 3, 4, 5], [11, 12, 13, 14], [101, 102, 1030, 1040]], columns=['pk', 'col_1', 'col_2', 'col_3'], index=['row_0', 'row_1', 'row_2'] ) df_compared = pd.merge(df_table_1, df_table_2, on=['pk', 'col_1', 'col_2', 'col_3'], how='outer', indicator=True) すると、実行結果として以下のような DataFrame が得られます。この _merge 列の内容が実質的に差分比較結果を示しており、それぞれ以下のような意味となります。つまり、both 以外の行については何らかの差分があるということになります。 both:両方のテーブルに存在している行データが一致している行 left_only:左側のテーブルにのみ存在している行 本実装例では table_1 が該当 right_only:右側のテーブルにのみ存在している行 本実装例では table_2 が該当 pk col_1 col_2 col_3 _merge 0 1 2 3 4 left_only 1 2 3 4 5 right_only 2 11 12 13 14 both 3 101 102 103 104 left_only 4 101 102 1030 1040 right_only 実行結果を見ますと、想定される差分比較結果の項目で示した内、1. と 2. の項目については想定通りに検出されていることが確認できます。一方で 3. の項目については left_only と right_only の両方が出力されています。 これは先述した外部結合のロジックを踏まえると自明なのですが、全列のデータを比較している以上 table_1 と table_2 で異なる行データとして出力されることから、即ち left_only と right_only の両方が出力されている行はデータの一部が変更されていることが分かります。ただ、この実行結果自体からはプログラム上でそれを簡単に判断することができません。よって、行データを一意に区別するための情報である PK 列で実行結果を GROUP BY することにより、各行ごとの差分比較結果を得ることができます。以下変更後のコードとなります。 本実装のロジック上、PK 列のみの行データ更新は実質的に行削除/行追加のセットとして認識されます。先述した通り、PK 列が行データを一意に区別するための情報であるためですが、これは一般的な RDBMS や KVS でもオペレーション上はそのような制約がある(PK列のみの変更はできず、行削除/行追加が必要)ことがあるため、差分比較のロジックとしても特に問題はないと判断しています。お客さんにも業務上そのようなオペレーションが基本的に発生せず、発生した場合は行削除/行追加のセットとして差分認識されても問題ないことを確認しています。 import pandas as pd df_table_1 = pd.DataFrame( [[1, 2, 3, 4], [11, 12, 13, 14], [101, 102, 103, 104]], columns=['pk', 'col_1', 'col_2', 'col_3'], index=['row_0', 'row_1', 'row_2'] ) df_table_2 = pd.DataFrame( [[2, 3, 4, 5], [11, 12, 13, 14], [101, 102, 1030, 1040]], columns=['pk', 'col_1', 'col_2', 'col_3'], index=['row_0', 'row_1', 'row_2'] ) df_compared = pd.merge(df_table_1, df_table_2, on=['pk', 'col_1', 'col_2', 'col_3'], how='outer', indicator=True).query(f'_merge != "both"').groupby(['pk']) for pk_cols, group in df_compared: print(f'pk_cols: {pk_cols}') print(group) その結果、以下のように PK の値ごとに差異のある行データがそれぞれ取得できていることが分かります。 pk_cols: (1,) pk col_1 col_2 col_3 _merge 0 1 2 3 4 left_only pk_cols: (2,) pk col_1 col_2 col_3 _merge 1 2 3 4 5 right_only pk_cols: (101,) pk col_1 col_2 col_3 _merge 3 101 102 103 104 left_only 4 101 102 1030 1040 right_only もちろんこのままでは3.のパターンについてどの列のデータに差異があるのか分からないため、Lambda 上では差異検出用のロジックを組んだ上で追加された行・削除された行・変更された行の3パターンに分けて最終的に JSON 形式で情報を出力するようにしていますが、そのあたりの実装は本情報を扱うアプリケーション/処理によって適宜変更されるものと思います。 なお、本実装の制約事項として、ロジック上テーブル定義(列定義)の異なる2つのデータを比較した場合はエラーが出力されてしまい比較できません。本アプリケーションにおいて列定義の変更はスコープ外としておりその観点では考慮の必要がありませんでしたが、アプリケーション外での列定義変更についてはユースケース上あり得るため、Lambda 上の実装では DataFrame.merge の実行前に列定義が同一であることを確認するようにしています。 そのような背景もあって、 テーブルデータのメンテナンス機能について補足したエントリ に記載している通り、テーブルの列定義はファイル単位で保持した上で、ファイルのハッシュ値のみで同一性を比較できるようにしています。 まとめ 本案件事例ではアプリケーション内の一機能として実装していますが、例えばデータベースの移行時や ETL/ELT 関連処理時においてデータベース外でテーブルデータの差分比較を実施したいケース自体はこれまでも度々あったため、備忘も兼ねてまとめてみた次第です。もちろん、データベース内で比較してしまうのが、処理速度やリソース面の観点も含めて一番手っ取り早い方法かと思いますが、ファイルベースで比較したいという機会も意外と多かったりするので・・ 本記事がどなたかの役に゙立てば幸いです。