LiveViewで作る差分抽出プログラム

はじめに

差分抽出プログラムを作成します。
というとなんだか難しそうな感じですが、実はElixirではそのものズバリの関数があります。
それが以下です。

  1. List.myers_difference/2
  2. List.myers_difference/3
  3. String.myers_difference/2

この関数名でピンと来た方は、過去に差分抽出問題を追いかけたことがある人ですね。
自分も、以前あるプログラムで差分抽出を行うために、いろいろと調べたことがありました。
そこで見つけたのが、この論文です。
当時この論文を見ながら機能を実装したことを思い出しました。
この論文の作者が EUGENE W. MYERS という方です。 List.myers_difference/2でもこの論文に触れているので、この関数もO(ND)アルゴリズムをベースとして実装されているものと思われます。

ちなみに上記関数の1は、2つのリストに対する最短編集スクリプトを求めるものです。
これは、例えば2つのディレクトリの差分を求めたりするのに使用します。
2は入れ子になったリストに適用するものです。 3は文字列の比較に使用します。

今回は3を使用し、2つの文字列の差分抽出をするプログラムを作成します。

これからつくるもの

これから作るのは、以下のようなアプリです。

liveview_diff

2つの文字列入力欄があり、ボタンを押下すると下に結果が現れます。
使用する関数の仕様に従って、比較は文字単位で行います。
なので、英文を入力しても単語単位とはならず、文字単位になります。
単語単位にしたい場合は、String.myers_difference/2 ではなく List.myers_difference/2 を使用すると良いでしょう。

動作可能なデモを Heroku Renderに置きました。
ソースコードはこちらにあります。

バージョン情報

記事公開時点でのそれぞれのバージョンは以下です。

項目バージョン
erlang22.3
elixir1.10.2-otp-22
Phoenix1.4.16
Phoenix.LliveView0.8.0

手順

以下の手順で、構築していきます。

  1. Phoenixプロジェクトの作成
  2. Phoenix.LiveViewの組み込み
  3. TailwindCSSの組み込み
  4. アプリの実装

Phoenixプロジェクトの作成

$ mix phx.new lv_diff --no-ecto

Phoenix.LiveViewの組み込み

以下の記事のように組み込みます。

Phoenix.LiveViewの組み込み

Phoenix.LiveViewのバージョンは、今回も 0.8 を用いています。

Update: バージョン0.10を用いた最新のインストール方法は、Phoenix.LiveViewの最新インストール方法で紹介しています。

TailwindCSSの組み込み

前回での組み込みに加えて、出力サイズの最適化も行います。

基本機能の組み込み

以下の記事のように組み込みます。

TailwindCSSの組み込み

出力ファイルの最適化

使用していないクラスを出力対象から除外し、サイズを小さくするように変更します。

Purgecssのインストール

プロジェクトルートから、以下のコマンドでPurgeCSSをインストールします。
もし assets ディレクトリにいる場合は、--prefix assets の記述は省略します。

# @ project root
npm install @fullhuman/postcss-purgecss --save-dev --prefix assets

postcss.config.jsへの組み込み

インストールしたモジュールをpostcss.config.jsに組み込みます。
時間がかかる処理なので、プロダクションモードの時のみ行うようにします。

// assets/postcss.config.js
module.exports = {
  plugins: [
    require('tailwindcss'),
    require('autoprefixer'),
    process.env.NODE_ENV === 'production' && require('@fullhuman/postcss-purgecss')({      content: [ "../**/*.html.eex", "../**/*.html.leex", "../**/views/**/*.ex", "./js/**/*.js" ],      defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || []    })  ]
};

アプリの実装

アプリの実装を行います。
フォーム入力や、結果出力に Phoenix.LiveView を使用します。

Router

まずはルーティングを記述します。
DiffLiveはこれから作成します。

# lib/lv_diff_web/router.ex
scope "/", LvDiffWeb do
  pipe_through :browser

  live "/", DiffLiveend

DiffLive

Router で記述した DiffLive を作成します。
ハイライトで示した位置にファイルを作成します。

├── lib
│   ├── lv_diff_web
│   │   ├── live
│   │   │   └── diff_live.ex

作成したファイルに、まずは render/1mount/3 を実装します。
ハイライトしている箇所については、これから作成します。

# lib/lv_diff_web/live/diff_live.ex
defmodule LvDiffWeb.DiffLive do
  use Phoenix.LiveView

  def render(assign) do
    Phoenix.View.render(LvDiffWeb.DiffLiveView, "index.html", assign)  end

  def mount(_arg1, _arg2, socket) do
    {:ok, assign}
  end

end

View周りの作成

以下のハイライトしている箇所のように2ファイル作成します。

├── lib
│   ├── lv_diff_web
│   │   ├── templates
│   │   │   └── diff_live
│   │   │       └── index.html.leex│   │   └── views
│   │       └── diff_live_view.ex

入力フォームの作成

DiffLiveViewの作成

まずは、デフォルトの実装を行います。

# lib/lv_diff_web/views/diff_live_view.ex
defmodule LvDiffWeb.DiffLiveView do
  use LvDiffWeb, :view
end
テンプレート側の実装

テンプレートでは、差分を求める2つのテキストを入力するインターフェースを作ります。
以下では、[Calc Diff]ボタンを押下すると、:calc_diff イベント発生し、:calc_diff というキーでフォームデータを取得できるようにしています。
ちなみに、phx_submit: で指定しているのがイベント名、form_for で指定しているのがデータのキーです。

# lib/lv_diff_web/templates/diff_live/index.html.leex
<div class="w-full max-w-xl p-2 mt-4 mx-auto">
  <%= f = form_for :calc_diff,"#",
   [phx_submit: :calc_diff, class: "bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"]   %>
    <%= label f, "Text1", [class: "diff_label"] %>
    <%= text_input f, :text1, [value: @text1, id: :text1, phx_hook: "comment_input", class: "diff_input focus:diff_input"] %>

    <%= label f, "Text2", [class: "diff_label mt-4"] %>
    <%= text_input f, :text2, [value: @text2, id: :text2, phx_hook: "comment_input", class: "diff_input focus:diff_input"] %>

    <%= submit "Calc Diff", [class: "diff_submit hover:diff_submit focus:diff_submit"] %>  </form>

また、フォームの各要素のスタイルに関しては、TailwindCSS@apply を用いて以下のように定義しています。

/* /assets/css/app.css */
@tailwind base;
@tailwind components;

.diff_label {    @apply block text-gray-700 text-sm font-bold mb-2;}.diff_input {    @apply shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight;}.diff_input:focus {    @apply outline-none shadow-outline;}.diff_submit {    @apply bg-indigo-500 text-white font-bold py-2 px-4 rounded mt-4;}.diff_submit:hover {    @apply bg-indigo-500 bg-indigo-700;}.diff_submit:focus {    @apply bg-indigo-500 outline-none shadow-outline;}
@tailwind utilities;
フォームイベントのハンドリング

DiffLiveでフォームイベントのハンドリングを実装します。

イベントハンドラの実装

handle_event は以下のようになります。

# lib/lv_diff_web/live/diff_live.ex
def handle_event("calc_diff", %{"calc_diff" => %{"text1" => text1, "text2"=> text2}}, socket) do
  {:noreply, assign(socket, ses: String.myers_difference(text1, text2), text1: text1, text2: text2) }
end

上記ではパターンマッチにより text1text2 を取得し、それを myers_difference/2 に渡しています。
これだけで2つの文字列の差分が抽出できます。
その結果を socket.assigns.ses にセットしています。
text1 text2 には、入力された文字を指定して、表示を維持するようにしています。
ちなみに、ses とは最短編集スクリプト(Shortest Edit Script)の事で、Text1 で示された文字列から Text2 で示された文字列に変換するための手順になります。

マウント時の処理実装

mount時には以下のように初期化しています。
text1 text2 にはデフォルトの文字列を、ses には空のリストを指定しています。

# lib/lv_diff_web/live/diff_live.ex
def mount(_arg1, _arg2, socket) do
  text1 = "今日は暑いですね"
  text2 = "今日は寒かったですね。"
  {:ok, assign(socket, ses: [], text1: text1, text2: text2)}
end

結果表示の作成

DiffLive.handle_event/3socket.assigns に指定された ses を表示する部分を作ります。

出力結果の仕様検討

myers_difference/2 の仕様を見ると以下のように記載されています。

myers_difference(t(), t()) :: [{:eq | :ins | :del, t()}]

これは、「UTF-8にエンコードされたバイナリを2つ受け取り、:eqか:insか:delとUTF-8にエンコードされたバイナリとのキーワードリストを返す。」を意味します。
関数の挙動をコマンドラインで確認してみます。

$ iex
Erlang/OTP 22 [erts-10.7] [source] [64-bit] [smp:12:12] [ds:12:12:10] [async-threads:1] [hipe]

Interactive Elixir (1.10.2) - press Ctrl+C to exit (type h() ENTER for help)
ex(1)> text1 = "今日は暑いですね"
"今日は暑いですね"
iex(2)> text2 = "今日は寒かったですね。"
"今日は寒かったですね。"
iex(3)> String.myers_difference(text1, text2)
[
  eq: "今日は",
  del: "暑い",
  ins: "寒かった",
  eq: "ですね",
  ins: "。"
]

期待通りの結果が返ってきました。
この結果は、text1からtext2へ変換するために、「 ”今日は” はそのまま利用し、”暑い” は削除し、”寒かった” を挿入し、”ですね” をそのまま利用し、”。” を挿入する。」という意味になります。
これらを踏まえて、以下のように出力できるようにします。

DifferenceText1Text2
Both今日は今日は
Text1 only暑い_
Text2 only_寒かった
Bothですねですね
Text2 only_
結果表示のためのテンプレート

socket.assigns.ses にデータがあるときのみテーブルを表示するようにします。
データが存在していたら、DiffLiveView.render_diff_items/1 を呼び出しリストをDOMに変換します。

# lib/lv_diff_web/templates/diff_live/index.html.leex
<div class="flex items-center bg-gray-200 mt-10 ">
  <%= unless Enum.empty?(@ses) do %>
    <table class="table-auto mx-auto shadow-md">
    <tr class="bg-blue-200">
      <th class="px-4 py-2 text-center">Difference</th>
      <th class="px-4 py-2 w-48 text-center">Text1</th>
      <th class="px-4 py-2 w-48 text-center">Text2</th>
    </tr>
     <%= LvDiffWeb.DiffLiveView.render_ses(@ses) %>    </table>
  <% end %>
</div>
View周りの実装

テンプレートから以下の関数が呼び出されます。

def render_ses( ses ) do
  Enum.map(ses, fn x -> x |> render_edit_item() end)
end

ここでは、一つ一つの要素について render_edit_item/1 を呼び出します。 それぞれ出力形式を変えたいので、パターンマッチにより処理を振り分けます。
キーワードとそれに対応したtextから仕様検討で決めたように文字列のリストを作ります。
ここからtr要素を作っていくことになります。

defp render_edit_item({:eq, text}) do
  render_tr(["Both", text, text], "bg-gray-100")
end

defp render_edit_item({:ins, text}) do
  render_tr(["Text2 only", "", text], "bg-teal-100")
end

defp render_edit_item({:del, text}) do
  render_tr(["Text1 only", text, ""], "bg-red-100")
end

render_tr/2 では、{ eq: "今日は"} の行を例とすると、以下の変換を行うことになります。

  1. { eq: "今日は"}["Both","今日は","今日は"] という文字列のリストに変換
  2. 文字列のリストをtd要素のリストに変換
  3. td要素のリストをtr要素に変換

実装は以下になります。

defp render_tr(str_list, class) do
  str_list
  |> str_list_to_td_list()
  |> td_list_to_tr(class)
end

str_list_to_td_list/1 では content_tag を用いてtd要素を作っています。

defp str_list_to_td_list(str_list) do
  Enum.map(str_list, fn x -> content_tag(:td, x, [class: @td_classes]) end)
end

ここで、@td_classTailwindCSS 用のクラスを指定しています。

@td_classes "border px-4 py-2 text-center"

td_list_to_tr/2 では、content_tag を用いて与えられたtd要素を子要素としてtr要素を作ります。

defp td_list_to_tr(td_list, classes) do
  content_tag(:tr, td_list , class: classes)
end

これで全実装が完了しました。

おわりに

Elixirmyers_difference が用意されているのは、何気に便利ですね。
差分に応じて何かを行いたい場合は、そのまま実現できてしまいます。
Elixir のパターンマッチを使えばソースコードもシンプルになり、保守性の良いコードが書けます。
また、Phoenix.LiveView を使えばコンテンツを動的に変化させられるので、アプリ色の強い課題にもよく合います。

動作可能なデモは Heroku Renderにあります。
ソースコードはこちらにあります。