Update: バージョン0.10を用いた最新のインストール方法は、Phoenix.LiveViewの最新インストール方法で紹介しています。
差分抽出プログラムを作成します。
というとなんだか難しそうな感じですが、実はElixirではそのものズバリの関数があります。
それが以下です。
この関数名でピンと来た方は、過去に差分抽出問題を追いかけたことがある人ですね。
自分も、以前あるプログラムで差分抽出を行うために、いろいろと調べたことがありました。
そこで見つけたのが、この論文です。
当時この論文を見ながら機能を実装したことを思い出しました。
この論文の作者が EUGENE W. MYERS という方です。
List.myers_difference/2でもこの論文に触れているので、この関数もO(ND)アルゴリズムをベースとして実装されているものと思われます。
ちなみに上記関数の1は、2つのリストに対する最短編集スクリプトを求めるものです。
これは、例えば2つのディレクトリの差分を求めたりするのに使用します。
2は入れ子になったリストに適用するものです。
3は文字列の比較に使用します。
今回は3を使用し、2つの文字列の差分抽出をするプログラムを作成します。
これから作るのは、以下のようなアプリです。
2つの文字列入力欄があり、ボタンを押下すると下に結果が現れます。
使用する関数の仕様に従って、比較は文字単位で行います。
なので、英文を入力しても単語単位とはならず、文字単位になります。
単語単位にしたい場合は、String.myers_difference/2
ではなく List.myers_difference/2
を使用すると良いでしょう。
動作可能なデモを Heroku Renderに置きました。
ソースコードはこちらにあります。
記事公開時点でのそれぞれのバージョンは以下です。
項目 | バージョン |
---|---|
erlang | 22.3 |
elixir | 1.10.2-otp-22 |
Phoenix | 1.4.16 |
Phoenix.LliveView | 0.8.0 |
以下の手順で、構築していきます。
$ mix phx.new lv_diff --no-ecto
以下の記事のように組み込みます。
Phoenix.LiveViewのバージョンは、今回も 0.8 を用いています。
前回での組み込みに加えて、出力サイズの最適化も行います。
以下の記事のように組み込みます。
使用していないクラスを出力対象から除外し、サイズを小さくするように変更します。
プロジェクトルートから、以下のコマンドでPurgeCSSをインストールします。
もし assets ディレクトリにいる場合は、--prefix assets
の記述は省略します。
# @ project root
npm install @fullhuman/postcss-purgecss --save-dev --prefix assets
インストールしたモジュールを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 を使用します。
まずはルーティングを記述します。
DiffLiveはこれから作成します。
# lib/lv_diff_web/router.ex
scope "/", LvDiffWeb do
pipe_through :browser
live "/", DiffLiveend
Router で記述した DiffLive を作成します。
ハイライトで示した位置にファイルを作成します。
├── lib
│ ├── lv_diff_web
│ │ ├── live
│ │ │ └── diff_live.ex
作成したファイルに、まずは render/1
と mount/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
以下のハイライトしている箇所のように2ファイル作成します。
├── lib
│ ├── lv_diff_web
│ │ ├── templates
│ │ │ └── diff_live
│ │ │ └── index.html.leex│ │ └── views
│ │ └── diff_live_view.ex
まずは、デフォルトの実装を行います。
# 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
上記ではパターンマッチにより text1
と text2
を取得し、それを 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/3
で socket.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へ変換するために、「 ”今日は” はそのまま利用し、”暑い” は削除し、”寒かった” を挿入し、”ですね” をそのまま利用し、”。” を挿入する。」という意味になります。
これらを踏まえて、以下のように出力できるようにします。
Difference | Text1 | Text2 |
---|---|---|
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>
テンプレートから以下の関数が呼び出されます。
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: "今日は"}
の行を例とすると、以下の変換を行うことになります。
{ eq: "今日は"}
を ["Both","今日は","今日は"]
という文字列のリストに変換実装は以下になります。
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_class
は TailwindCSS 用のクラスを指定しています。
@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
これで全実装が完了しました。
Elixir に myers_difference
が用意されているのは、何気に便利ですね。
差分に応じて何かを行いたい場合は、そのまま実現できてしまいます。
Elixir のパターンマッチを使えばソースコードもシンプルになり、保守性の良いコードが書けます。
また、Phoenix.LiveView を使えばコンテンツを動的に変化させられるので、アプリ色の強い課題にもよく合います。