Excel の「競合の解決」を Web アプリケーションで実装した話(再帰的Deferred)

Web アプリケーションを作ってると Excel の機能をそのまま実装してくれってよく言われますよね。 競合の解決を実装した話です。 クライアントサイドの実装についてがメインです。

この記事は Excel の「競合の解決」を実装するために jQuery の Deferred を再帰的に使ったよ って内容です。


競合の解決

Excel の仕様

ある一つのファイルを複数人で同時に編集した場合、競合が発生することがあります。 バージョン管理のコンフリクトと同じです。

Excel では競合が発生した場合、「競合の解決」ダイアログを表示し各セルごとにどの変更を適用するかを選択していきます。 詳しくは「Excel 競合の解決」なんかでググってください。 画像検索すればどのようなダイアログかもわかります。

実装

処理フロー

処理のフローはこんな感じです。

  1. ユーザが入力を submit
  2. サーバで競合のチェック
  3. 競合の情報を JSON で返す
  4. 「競合の解決」モドキを表示
  5. ユーザに解決させて再度 submit

競合チェックの詳細は省きますが、編集前・競合チェック時の DB・編集後のすべてのタイムスタンプが異なっていたら競合と判断します。

順番に行きますが、サーバサイドの話はすっ飛ばします。

競合の情報 JSON

競合の解決モドキを表示するために、競合の情報を JSON で作ります。 構造はこんな感じ。

{
  state: 'conflict',
  conflict: [
    {inputId:'value1', mineValue:'mine1', otherValue:'other1'},
    {inputId:'value2', mineValue:'mine2', otherValue:'other2'},
    {inputId:'value3', mineValue:'mine3', otherValue:'other3'}
  ]
}

state には競合しているかどうかの情報、 conflict には競合の情報を配列で用意します。 各要素にはどの項目 (inputId) が競合していて、自分の入力値 (mineValue) 、他人の入力値 (otherValue) の情報があります。

「競合の解決」モドキ

単純に自分と他人の変更を確認できるボックスと変更を反映するボタンを用意します。

<div>
    <div>あなたの変更</div>
    <div id="mine-change" class="conflicting"></div>
    <button id="mine-button">←この変更を反映</button>
</div>

<div>
    <div>他のユーザの変更</div>
    <div id="other-change" class="conflicting"></div>
    <button id="other-button">←この変更を反映</button>
</div>

ユーザによる解決

本記事のメインですね。

解決するときの処理フローはこんな感じです。

  1. 競合部分の表示とボタンの動作を設定
  2. ユーザがボタンをクリック
  3. クリックした方の変更を適用
  4. 解決していない競合が残っていたら最初に戻る

ここで厄介なのは「ユーザの操作で処理が進む」という処理。 ある関数の実行途中でユーザの入力を待って、入力されたら続きを実行する必要があります。 他は再帰で記述できるのでそんなに難しくないです。

非同期処理が必要であり、IE8 も考慮しなければならなかったので jQuery の Deferred を使いました。 競合解決関数はこんな感じ。

var conflictDialog = function(arr) {
  var dfd = $.Deferred();

  if(arr.length === 0) {
    return dfd.resolve();
  } else {
    var conflictData = arr.pop();

    arguments.callee(arr).done(function(){
      // 競合の表示
      $('#mine-change').text(conflictData.mineValue);
      $('#other-change').text(conflictData.otherValue);

      // ボタンクリックイベントのクリア
      $('#mine-button').off('click');
      $('#other-button').off('click');

      // ボタンクリックイベントの設定
      $('#mine-button').on('click', function() {
        $('#' + conflictData.inputId).val(conflictData.mineValue);
        return dfd.resolve();
      });
      $('#other-button').on('click', function() {
        $('#' + conflictData.inputId).val(conflictData.otherValue);
        return dfd.resolve();
      });
    });

    return dfd.promise();
  }
};

pop() で引数に与えられた配列の後ろから順に非同期処理終了後の処理を設定していきます。 arguments.callee(arr) によってスタックが積み上がっていくので、処理は配列の先頭から実行されます。

おわりに

Excel の「競合の解決」を jQuery の Deferred を使って実装した話でした。 Deferred を再帰的に使うというちょっと特殊な使い方ですね。 Deferred を再帰的に使うことに言及してる記事が少ないのでその足しにでもなればいいなぁ。

ES6 の Promise でも同じことができると思います。いずれやりたい。 でも多分末尾呼び出しの最適化はされないんだろうな。

おまけ

動作確認のためのコードです。 長いので続きを読むからどうぞ。

注意点として、ここに記載するコードは conflictDialog が正しく動作することを確認することのみです。 エラー処理がなかったり、競合情報を JSON ではなく配列として設定していたりします。

conflict.js

var conflictArr = [
  {inputId:'value1', mineValue:'mine1', otherValue:'other1'},
  {inputId:'value2', mineValue:'mine2', otherValue:'other2'},
  {inputId:'value3', mineValue:'mine3', otherValue:'other3'}
];

$(function(){
  if(conflictArr) {
    conflictDialog(conflictArr).done(function(){
      console.log('submit');
    });
  }
});

var conflictDialog = function(arr) {
  var dfd = $.Deferred();

  if(arr.length === 0) {
    return dfd.resolve();
  } else {
    var conflictData = arr.pop();

    arguments.callee(arr).done(function(){
      // 競合の表示
      $('#mine-change').text(conflictData.mineValue);
      $('#other-change').text(conflictData.otherValue);

      // ボタンクリックイベントのクリア
      $('#mine-button').off('click');
      $('#other-button').off('click');

      // ボタンクリックイベントの設定
      $('#mine-button').on('click', function() {
        $('#' + conflictData.inputId).val(conflictData.mineValue);
        return dfd.resolve();
      });
      $('#other-button').on('click', function() {
        $('#' + conflictData.inputId).val(conflictData.otherValue);
        return dfd.resolve();
      });
    });

    return dfd.promise();
  }
};

conflict.html

<!doctype html>
<html lang="ja">
<head>
   <meta charset="UTF-8">
   <title>conflict (jQuery)</title>
   <style type="text/css">
        .conflicting {
            border: 1px solid #000;
            width: 10em;
            height: 3em;
            display: inline-block;
            vertical-align: middle;
        }
        button {
            vertical-align: middle;
        }
    </style>
</head>

<body>

    <div>
        <div>
            <div>あなたが変更した箇所</div>
            <div id="mine-change" class="conflicting"></div>
            <button id="mine-button">←この変更を反映</button>
        </div>

        <div>
            <div>他のユーザが変更した箇所</div>
            <div id="other-change" class="conflicting"></div>
            <button id="other-button">←この変更を反映</button>
        </div>

        <div>
            <form>
                value1:<input type="text" id="value1" value="">
                value2:<input type="text" id="value2" value="">
                value3:<input type="text" id="value3" value="">
            </form>
        </div>
    </div>

    <script src="https://code.jquery.com/jquery-1.12.0.min.js"></script>
    <script src="./conflict.js"></script>
</body>
</html>