Chienomi

可能性を感じる「クライアント完結型ウェブアプリ」 & カスタム検索エンジンを自作する話

開発::util

  • TOP
  • Articles
  • 開発
  • 可能性を感じる「クライアント完結型ウェブアプリ」 & カスタム検索エンジンを自作する話

この記事は題材としてアダルトコンテンツに関わる内容を多分に含む。

この記事を独立して書くつもりは当初なく、Mozcdict-ext関連で書くつもりでいたのだけれど、結構深い内容で量も多いため、今回書くことにした。

何らかの理由で専用インターフェイスを持つアプリケーションが欲しいとき、最も手軽な方法がウェブページである。 Electronのようなwebベースのアプリケーションも簡単ではあるが、色々と縛りが発生してしまい面倒だし、他のユーザーに共有して使わせる場合はなおのこと面倒だ。

だが、ウェブアプリケーションにすると今度はサーバーに縛られる。 ウェブの世界は少数のステークホルダーが望むようにコントロールされており、自由度が低く応用が効かない。 私は正直、かなりくだらないものだと思っている。

だが、最近私はそんなウェブアプリケーションをもうちょっといい感じに使える手法を編み出しつつある。 それがタイトルにある、「クライアント完結型」である。

ウェブアプリケーションは当然ながらHTTP、あるいはHTTPを下地にしたプロトコルでサーバーとやりとりすることをその核としている。アプリケーションが何かをすればAPIを叩き、サーバーはデータベース(だいたいはRDBMS(オエー))にアクセスしてごにょごにょして返すのである。

これを、私は良いものだと思ってはいない。 この形式が「最適であるケース」はかなり狭いはずだ。

私が作っているアプリケーションはより牧歌的、かつ個人的なものだ。 ウェブアプリケーションで考えられる様々な要素は必要ない。 そもそもサーバーなどというものがなければ、ローカルで動かすアプリケーションはネットワークセキュリティなどという概念すら持たないのだ。

アプリケーション動作図

アダルトコンテンツであるため、モザイクをかけている。

コンセプトとキー

クライアント完結を目指す場合重要になるのが、「なにかしらがJSONファイルを生成する」である。 場合によってはクッキーやローカルストレージを使ったり、そもそもスクリプトのリソースの中に埋め込んだりといった方法でデータロードを必要としないアプリケーションにすることもできるが、それだとできることがだいぶ限られてくる。 ところが、JSONファイルをロードすることで「データを受け取る」ことが可能になり、できることはかなり広がる。

それをデータ書き込みをウェブサーバーアプリケーションが行うならあまり意味のないところだが、JSONファイルになっていればいいのであれば、SSH経由のスクリプト、ローカルなプログラム、ジョブスケジューラなど様々な選択肢がある。 柔軟に考えれば、これによってかなり可能性は広がるのだ。

ただ、これはウェブサーバー上の静的ファイルである場合と、ローカルファイルである場合にかなり事情が異なってくる。 ウェブサーバー上にあるのであれば、importfetch()によってJSONファイルを取得することができるが、ローカルでは様々な制約によりそういった方法がとれない。 ブラウザのJavaScriptをローカルな環境で活かせるようになっていないのだ。

だが、ここで考え方を変えよう。 JSONは非常に厳格なフォーマットであり、JSONオブジェクトは完全に閉じている。 つまり、前方への安全な追加が可能なのだ。

よって、

print 'var data = ' + $json;

のようなコードによって、JavaScriptファイルにすることができ、script要素のsrc属性で読めるようになる。 この手法は、第三者の手を一切介さないローカルスクリプトにおいては(汚いが)かなり使える手段である。

ただしこの場合、アプリケーションのライフタイム中に再度読み直すのは難しい。そうしたことが必要なら、さらに汚い手法で読み直せるようにしなくてはいけない。 ただ、そうした場合にリロードさせるのが困難なローカルアプリケーションはあまりないだろう。

本アプリケーションの概要

音声作品、というコンテンツジャンルがある。 このカテゴリにおけるワードはどれも領域がまたがっていて、意図するものを正確に伝えるのが難しいのだが、ここでは「声優による(多かれ少なかれ)性的要素をもったオーディオ作品」を指している。

DLsiteは音声作品のメッカと言っていい。 毎日たくさんの音声作品がリリースされ、セールもよくやっている。 そして、音声作品を聴くようになると、大して聴いてもいないのに、セールのたびに気になった作品を購入してしまうようになるのだ――

つまり、「あまり認識はしてないが、手元にはたくさんのコンテンツアイテムがある」という状態になる。 少ないうちはファイルを整頓していれば把握できるが、大して聴いてもいないのにどんどん増えていくとやがて把握できなくなる。 これはどんなデータでも同じだが、このような状態になれば「ファイルの整頓」など大して役に立たない。 その場合、検索こそが肝であり、用途にあった小さな検索エンジンを自作する必要性が出てくる。

検索エンジンを作る話をすると、すぐに「それをインストールすればどんなデータもたちどころに発見できる」ような考えをする人が多いのだが、そんな魔法はない。 Googleのような巨大な検索エンジンだって、データベースに適切なデータ構造でデータを保持しているから検索できるのだ。 検索エンジンを作るには、まずデータベースが必要になる。

データが増加する時点で検索の必要性は明らかなので、いままでも私はデータを整頓し、データベースとして機能するようにしてきた。 というより、ファイルヒエラルキーを直接に使った検索機能を今までも提供してきたのだ。

しかし、もはやそれでは収まらない。動的なフィルタリングとデータの参照の両方ができる必要がある。 より本格的な検索エンジンが必要になった。

データベース

まず、従来の構造を振り返ろう。

従来は

Voice/$title, Voice/_$circle/$title, Voice/_$circle/_$series/$titleの構造で本体データが保持されていた。

キャスト単位のインデックスとして、_VoiceByActressがあり、_VoiceByActress/$name/$title, _VoiceByActress/$name/_$circle, _VoiceByActress/$name/__$seriesというシンボリックリンクが貼られている。

また、購入日でソートするため、_VoiceByDateには$date-$titleというシンボリックリンクが貼られている。

そして拡張データとして、_VoiceExtendInfo/$title/info.yamlというファイルもある。

今回直接に問題になったのは、_VoiceByDateをbtimeで生成していることで、新しいディスクに移し替えたことで生成できなくなってしまったのだ。

これらの情報を組み合わせてTadで見ていたのだが、Tadで一覧してから、「これを聴こう」と思っても開くのがなかなか手間という問題がある。 フィルタリングはできるが、参照ができないのだ。

つまりは、より強力なシステムが必要である。

このデータベースのポイントは、「titleがキーである」ということだ。 階層構造によらずtitleはイミュータブルかつユニークであるという原則を守ることで、異なるデータから探すのが簡単だ。 _VoiceByActressは参照機会が多いので今のままの形が便利だし(入れるのも楽だし)、パスはよく変わるものだから、同定に使う要素はtitleが一番いい。

なお、ダウンロードして展開したファイルが「きれいなタイトル名」になっているとは限らない。 作品IDになっている場合や、「納品データ」「本編」のようなファイル名になっていることもあり、「作品フォルダをタイトル名にする」という作業がデータベース登録のためにはさまっている。

この構造によって、データをつなぐことは可能であり、さらにこのファイル置きだけで、タイトル、サークル、シリーズ、キャストの4要素は(データとして存在すれば)確定できる。

従来は拡張データもあまり扱いやすくはなかったため、Dateを含め作品に対する他のデータはすべてmeta.yamlに集約することにした。 このmeta.yamlのベースを自動生成し、必要な項目は手で追記していくことでデータを補充できる。 そして、meta.yamlと他のデータ置きの情報を組み合わせてmeta.jsを生成すれば、ウェブページから取り扱うことができる。

ベースYAMLを生成する

まずはmeta.yamlだ。

ファイルパスは最も厳正なるものなので、これをベースに生成される要素はすでに存在するエントリに対しても上書きする。 そして、存在しないエントリは空の項目を含む雛形を与え、追加する。

Dateは手書き要素だが、初期化の段階ではbtimeを使う。

#!/bin/ruby
require 'yaml'
require 'date'

list = Dir.glob("../Voice/_*/_*/*") + Dir.glob("../Voice/_*/[^_]*") + Dir.glob("../Voice/[^_]*")

DB = {}

list.each do |i|
  next unless File.directory? i
  title = File.basename(i)
  if DB[title]
    abort "title #{title} is already exist."
  end

  DB[title] = i
end

meta = (Psych.unsafe_load File.read "meta.yaml") rescue {}

DB.each do |k, v|
  parts = v.sub(%r!^\.\./Voice/!, "").split("/")
  circle = nil
  series = nil
  if parts.length == 3
    circle = parts[0][1..]
    series = parts[1][1..]
  elsif parts.length == 2
    circle = parts[0][1..]
  end

  if meta[k]
    meta[k].merge!({
      "path" => v,
      "circle" => circle,
      "series" => series,
    })
  else
    meta[k] = {
      "path" => v,
      "btime" => File.birthtime(v).to_date,
      "circle" => circle,
      "series" => series,
      "tags" => [],
      "duration" => nil,
      "rate" => nil,
      "description" => "",
    }
  end 
end

File.open("meta.yaml", "w") do |f|
  YAML.dump meta, f
end

既存の値は基本的に維持するため、作品追加した場合は再実行するだけで良い。

メタデータのマージ

_VoiceExtendedInfo に今まで書いたメタデータがある。

書いたものを探すのは大変だが、簡単な方法がある。 自動生成されるテンプレートは98バイトのデータであるため、100バイトを超えるファイルは記入済みのメタデータとみなすことができる。

print -l */info.yaml(L+100)

数はあまりなかったため、ひとつずつ手移しした。

JSON/JavaScriptへ変換する

キャストの情報はメタデータは別のところにあるものなので、編集対象であるmeta.yamlに重複して持たせないほうが良い。 このため、キャスト情報をマージして、先頭に var meta =を追加して出力する。

#!/bin/ruby
require 'json'
require 'yaml'
require 'nkf'

artist = {
  Hash.new {|h, k| h[k] = []},
  Hash.new {|h, k| h[k] = []},
  Hash.new {|h, k| h[k] = []},
}

artists = Dir.glob("../_VoiceByActress/*")

artists.each do |one|
  next unless File.directory? one
  next if File.symlink? one
  name = File.basename one
  titles = Dir.children one
  titles.each do |title|
    if title =~ /^__/
      artist[][series].push name
    elsif title =~ /^_/
      # Circle
      circle = title[1..]
      artist[][circle].push name
    else
      artist[][title].push name
    end
  end
end

meta = Psych.unsafe_load File.read "meta.yaml"

meta.each do |k, v|
  ##### Complete Duration
  unless v["duration"]
    voice_dirs = Hash.new {|h, k| h[k] = 0}
    Dir.glob("#{v["path"]}/**/*.wav").each do |i|
      voice_dirs[File.dirname i] += 1
    end
    unless voice_dirs.empty?
      voice_dir = voice_dirs.max_by {|k, v| v}
      duration = nil
      IO.popen(["voice-duration.zsh", *Dir.glob("#{voice_dir[0]}/*.wav")]) do |io|
        duration = io.read.to_i
      end
      v["duration"] = duration
    end
  end

  ##### Complete Description
  if !v["description"] or v["description"].empty?
    txtfiles = Dir.glob("#{v["path"]}/**/*.txt")
    unless txtfiles.empty?
      v["description"] = NKF.nkf("-w", File.read(txtfiles.first))
    end
  end

  ##### Complete Actress
  v["actress"] ||= []
  if artist[][k]
    v["actress"].concat artist[][k]
  end
  if artist[][v["circle"]]
    v["actress"].concat artist[][v["circle"]]
  end
  if artist[][v["series"]]
    v["actress"].concat artist[][v["series"]]
  end
end

File.open("meta.js", "w") do |f|
  json = JSON.dump meta
  f.puts("var meta = ", json)
end

そもそもmeta.yamlは手動編集するためのファイルであり、mkmeta.rbは手動編集するもととなるファイルを作るものである。 対してmkjson.rbで作られるmeta.jsはアプリケーションが直接使うものであり、最終的なデータになる。

そのため、durationdescriptionなど、ないデータは可能な限り補完するという処理も行われる。

voice-duration.zsh

#!/bin/zsh

typeset -f duration=0

soxi -D "$@" | while read
do
  (( duration += REPLY ))
done

typeset -i di
(( di = duration / 60 ))

print $(( di ))

というコードだが、「wavファイルが最も多いディレクトリ=本編ディレクトリ」であるとみなし、これらのwavファイルの時間を合計している。

全体を合計していないのは、フォーマット違いで同じ内容のデータを含んでいることが多いためである。 また、ないデータの補完という話でしかなく、それほど重要なデータでもないため、ないなら諦めている。

descriptionはタイトルやジャケットだけではどういう作品か判断できないときに使う作品の要約だ。 作品販売ページから手動で抜粋するのが理想的ではあるが、READMEが付属する作品もあり、ファイル名はまちまちであることから「.txtなファイルがあればそれを採用する」という雑な方法で埋めている。 これでもだいたいうまるし、ダメなら手で追加すればいいだけなのでそれでも実践的には困らない。

多くの場合長すぎて不便だが、そこは仕方ない。 ファイルの名前や文字エンコーディングが不定なので、任意の.txtファイルをNKFを通すことで解決している。

フロントアプリケーション

これでデータベースはできた。データの参照はHTMLファイルから直接読み込むから、普通のウェブアプリケーションのようにサーバーは介さない。なので、HTMLとJavaScriptで完結である。

HTMLファイルに必要なのは

  • 検索フォーム
  • 一覧テーブル
  • スクリプトの読み込み

である。

実用的であることが何よりなので、修飾はしていない。

<html>
  <head>
    <title>音声作品検索</title>
  </head>
  <body>
    <fieldset>
      <legend>検索</legend>
      <form id="SearchForm">
      </label>
      <label>タグ
        <select id="TagOptions">
          <option value="">---</option>
        </select>
      </label>
      <label>評価下限 <input type="text" size="2" id="SearchRate" /></label>
        <label>出演者
          <select id="ArtistOptions">
            <option value="">---</option>
          </select>
        </label>
        <label>サークル
          <select id="CircleOptions">
            <option value="">---</option>
          </select>
        </label>
        <label>キーワード <input type="text" size="16" id="SearchKw" /></label>
      </form>
    </fieldset>
    <table border="1">
      <thead>
        <tr>
          <th>カバー</th>
          <th>作品名</th>
          <th>サークル</th>
          <th>シリーズ</th>
          <th>出演者</th>
          <th>btime</th>
          <th>タグ</th>
          <th>長さ</th>
          <th>評価</th>
          <th>概要</th>
        </tr>
      </thead>
      <tbody id="ListBody">
      </tbody>
    </table>
  </body>
  <script src="./meta.js"></script>
  <script src="./app.js"></script>
</html>

formonChangeを拾えば検索したい項目は見えてくる。 データはtbodyのところに生えればよい。

フロントJavaScript解説

コード

さて、短いがなかなか手ごわいJavaScriptに移ろう。 今回は、私が公開するものでは初めてのモダンJavaScriptである。

var artists = new Set()
var circles = new Set()
var tags = new Set()
var aopts = document.getElementById("ArtistOptions")
var copts = document.getElementById("CircleOptions")
var topts = document.getElementById("TagOptions")
var s_rate = document.getElementById("SearchRate")
var s_kw = document.getElementById("SearchKw")
var sf = document.getElementById("SearchForm")

function initialize_metadata() {
  for (const i in meta) {
    const actresses = meta[i].actress
    for (const j of actresses) {
      artists.add(j)
    }
    for (const j of meta[i].tags) {
      tags.add(j)
    }
    circles.add(meta[i].circle)
  }

  for (const i of artists) {
    if (i == null) {continue}
    const opt = document.createElement("option")
    const val = document.createTextNode(i)
    opt.value = i
    opt.appendChild(val)
    aopts.appendChild(opt)
  }
  for (const i of circles) {
    if (i == null) {continue}
    const opt = document.createElement("option")
    const val = document.createTextNode(i)
    opt.value = i
    opt.appendChild(val)
    copts.appendChild(opt)
  }
  for (const i of tags) {
    if (i == null) {continue}
    const opt = document.createElement("option")
    const val = document.createTextNode(i)
    opt.value = i
    opt.appendChild(val)
    topts.appendChild(opt)
  }
}

function create_table(cond={}) {
  const list_body = document.getElementById("ListBody")
  const tbody = document.createElement("tbody")
  let entities = []
  for (const i in meta) {
    if (cond.tag && !meta[i].tags?.includes(cond.tag)) { ;continue }
    if (cond.actress && !meta[i].actress?.includes(cond.actress)) { continue }
    if (cond.circle && !meta[i].circle?.includes(cond.circle)) { continue }
    if (cond.rate && (meta[i].rate || 0) < cond.rate ) { continue }
    if (cond.keyword && !i.includes(cond.keyword) && !meta[i].description?.includes(cond.keyword)) { continue }
    entities.push(i)
  }
  entities = entities.sort((a,b) => {
    if (meta[a].btime < meta[b].btime) { return -1 }
    else if (meta[a].btime > meta[b].btime) { return 1 }
    else { return 0 }
  })
  for (const i of entities) {
    const tr = document.createElement("tr")
    const cover = document.createElement("td")
    cover.innerHTML = `<img src="${meta[i].path.replace("?", "%3F").replace("#", "%23")}/thumb.jpg" />`
    tr.appendChild(cover)
    const title = document.createElement("td")
    title.innerHTML = `<a href="${meta[i].path.replace("?", "%3F").replace("#", "%23")}">${i}</a>`
    tr.appendChild(title)
    const circle = document.createElement("td")
    circle.innerHTML = meta[i].circle
    tr.appendChild(circle)
    const series = document.createElement("td")
    series.innerHTML = meta[i].series
    tr.appendChild(series)
    const actress = document.createElement("td")
    actress.innerHTML = meta[i].actress.join(", ")
    tr.appendChild(actress)
    const date = document.createElement("td")
    date.innerHTML = meta[i].btime
    tr.appendChild(date)
    const tags = document.createElement("td")
    tags.innerHTML = meta[i].tags.join(", ")
    tr.appendChild(tags)
    const duration = document.createElement("td")
    duration.innerHTML = meta[i].duration || ""
    tr.appendChild(duration)
    const rate = document.createElement("td")
    if (!meta[i].rate) { meta[i].rate = 0 }
    rate.innerHTML = ("★".repeat(meta[i].rate) + "☆".repeat(5 - meta[i].rate))
    tr.appendChild(rate)
    const description = document.createElement("td")
    if (meta[i].description) {
      const description_body = document.createElement("span")
      description_body.title = meta[i].description
      description_body.appendChild(document.createTextNode("🗒"))
      description.appendChild(description_body)
    }
    tr.appendChild(description)

    tbody.appendChild(tr)
  }
  tbody.id = "ListBody"
  list_body.replaceWith(tbody)
}

initialize_metadata()
create_table()

sf.addEventListener("change", function(e) {
  create_table({
    tag: topts.value,
    actress: aopts.value,
    circle: copts.value,
    rate: s_rate.value,
    keyword: s_kw.value
  })
})

コード構造

まず、最初にタグ, キャスト, サークルの一覧がないと検索が大変なので、optionに入れるためにデータをさらって候補を用意する。 あまり知られていないが、このようなケースではSetが非常に有用だ。

この初期化処理は1度しか行わないためベタ書きでもいいが、わかりづらくなるため、関数にまとめている。

SetはJavaScriptの組み込みオブジェクトで、およそ配列のように扱える。 ただし、addした場合、常に値が一意になるように追加される。つまり、もしすでに追加した値を追加しようとした場合は追加されない。 new Set(array)として、配列オブジェクトをSetへと変換することもできる。

Set自体はFirefox 13, Google Chrome 38, Safari 8から実装されたモダンJavaScriptの一部だと言える。 Setが組み込みになっている言語は少ないが、JavaScriptは新し目とはいえ組み込みの機能だ。

create_tableのほうは関数にしなければならない。 これは、一覧を生成するものだが、最初に生成する上に、検索を行うたびに生成しなければならない。 異なるタイミングで実行されるものだから、関数として独立している必要がある。

関数に検索条件を与えるようにして、与えられている条件だけでマッチングを行うようにすればシンプルだ。 もっと空間効率を犠牲にして時間効率を高める(難しい)手法もあるが、時間効率を気にするほど処理が重いわけではないので、このままいく。

検索時に参照しやすいように、検索フォームの個別の部品をdocument.getElementById()で取得してElementとして持っておく。

create table

できる人がなかなかいないと言われているDOM操作を使っている。 今回の場合、innerHTMLを使うより楽だ。

基本的には、作品ごとにまずtr要素を作り、表の順序に従ってtd要素を作っては中身に適切な値を入れてtrに追加、最後に全体をまとめるtbody要素に追加し、既存のtbody要素と置き換える、という流れであり、読み解けば難しいものではない。 もっと完結に書くことも可能だが、単調なコードにすることで読みやすくしている。

Elementへの参照を保持するのではなく、毎回document.getElementByIdで取得しているのは、話を複雑にしないためである。 もちろん、新たに作ったtbody Elementオブジェクト手元にあるので、置き換え要素の変数を書き換えるという形でも良い。

一般的に、DOM操作はかなり遅く、innerHTMLを使うほうがずっと速いと言われている。 ただ、innerHTMLは文字列として解釈されるものなので、意図せぬことが起きやすい。 そこでDOM操作にしているのだが、今回やってみた感じ、「遅い」と感じることはなかった。

中身の処理は軽くないが、作品数が1万にも満たないためループが短いことと、エンジンの処理性能が上がっていること、そしてそもそもコンピュータの処理性能が上がっていることが理由だろう。 全く気にならないし、一瞬である。

その前部分を見てみよう。まず、全体のデータベースであるmetaオブジェクトをイテレートし、検索条件に一致しないものはスキップしてentitiesに(キーだけを)入れ直している。

そして、entitiesbtimeで並び替えている。 これは、「並び替え処理」を入れるとなかなか面倒なことになるため、並び替えをしなくていいようにした。 並び替えられる要素というと、title, duration, rateあたりが他にあるが、実際に(フィルタ機能ではなく)並び替えたいケースはほとんどbtimeであるため、最初からbtimeで並べる前提にした。

そしてこの並び替えたものを先頭からtrで作ってはtbodyに追加していくわけだ。

画像

パッケージ画像の有無は判断しやすさに直結する。 このプログラムではパッケージは単純に$titlepath/thumb.jpgとして存在するものとして扱っている。

これ各作品に対してそのようなファイルを生成する前提だが、画像ファイルの置かれ方は作品によって全く異なるので、手動でやるしかなく、手動でやるのも大変だ。

だが、ある程度規則はある。 多くの作品ではアートワークの高解像度版があり、それはパッケージ画像の文字なしの高解像度版であることが多い。 例外もあるし、そもそも画像ファイルがないケースもあるが、この法則は広く通用するので、かなりの部分を自動化することができる。

次のコードはZshの魔術を使い、Voiceディレクトリで実行することで「もっともファイルサイズの大きい画像ファイルからthumb.jpgを生成する」という処理をする。

#!/bin/zsh

for title in _*/_*/*(/) _*/[^_]*(/) [^_]*(/)
do
  (
    print $title
    cd $title
    images=(**/*.(png|jpg|jpeg)(NoL))
    if [[ -e thumb.jpg ]]
    then
      print Already exists.
    elif [[ -z $images ]]
    then
      print No image.
    else
      convert -resize 100x100 $images[-1] thumb.jpg
      jpegoptim --max=70 thumb.jpg
    fi
  )
done

リストコンテキストにグロブを書くとグロブが展開されてファイルのワードリストになる。

Zshではグロブのあとに()で囲うことでGlob Qualifierとして機能し、これでグロブの動作を制御することができる。

Nはそのグロブに限ってNULL_GLOBオプションを有効にする。 通常はグロブに一致するファイルがないとZshはエラーにするが、NULL_GLOBが有効な場合は無に置き換える。 そのため、[[ -z $images ]]で「グロブに一致するものがなかった場合」を判定できる。

oLはファイルサイズ順に並び替える。なので、$images[-1]は最も大きいファイルになる。

他の方法

見て分かるように非常に簡素なアプリケーションであるが、使い勝手は素晴らしい。 DLsiteで検索するよりもずっと優れた体験だ。

ポイントを押さえればこうやってちょちょっと書けてしまうよという話なのだが、こうしなければならないというわけではない。 JavaScriptから見ればグローバルなmetaというシンボルでデータベースにアクセスできるので、例えばReactで書くことだって難しい話ではない。 Reactに慣れていて、Reactアプリケーションを手早くつくるノウハウを持っている人であればそういう選択もできるだろう。

個人的には安定して動作し、サーバーがいらず、手早く使えて、メンテナンスの手間がかからないといった点からバニラで書くのが最もスマートだと思っている。

今回重要なのは、「ウェブアプリケーションは大部分をクライアントにもたせる余地がある」ということだ。 よりクライアントに重きを置くことで、ウェブアプリケーションにおける「縛り」からの解放が可能だ。

Reactを使った場合にサーバーが必要なのは、トランスパイルのためであり、buildしてしまって静的に使うことも可能だ。

横道: リネームへの対応

一般的にデータベースのキーを変更することはないが、この場合キーはファイル名なのでどうしても変更したい欲望に駆られてしまうこともあるだろう。 そこで、その状況にも対応する。

まず、ファイルシステムにおけるファイルのキーはiノード番号である。 inode-create.rbによって、ino.yamlという全ファイルのiノード番号との対応関係を示すデータベースを生成する。

#!/bin/ruby
require 'yaml'

db = {}
offset = "../Voice/".length

Dir.glob("../Voice/**/*").each do |i|
  f = File::Stat.new i
  db[f.ino] = i[offset..]
end

File.open "ino.yaml", "w" do |f|
  YAML.dump db, f
end

ino.yamlはリネームを行う前に作っておかなければならない。 次にリネームを行ったらinode-diff.rbによって差分を検出する。

#!/bin/ruby
require 'yaml'

db = YAML.load File.read "ino.yaml"
diff = {}
offset = "../Voice/".length

Dir.glob("../Voice/**/*").each do |i|
  f = File::Stat.new i
  ino = f.ino
  fp = i[offset..]
  if db[ino] != fp
    diff[db[ino]] = fp
  end
end

File.open("diff.yaml", "w") do |f|
  YAML.dump diff, f
end

これでdiff.yamlが生成される。 diff.yaml元ファイルパス: 変更後ファイルパスという連想配列になっており、diff.yaml自体はNasutilsの追跡リネーマーのソースでもある。 つまり、ライブラリが他のファイルシステム上にも存在する場合(実際、私ならNAS上にデータが存在しているので)もNasutilsを使って名前変更を合わせることができる。

inode-update.rbは、diff.yamlを用いてデータベースを更新し、meta.yamlもアップデートする。 Voiceディレクトリ以下はすでに名前が変更されているので、取り扱わない。

#!/bin/ruby
require 'yaml'
require 'date'

diff = YAML.load File.read("diff.yaml")

db = {
  {},
  {},
  {}
}

# "File rename" is only used by NASUtils sync renamer.
diff.reject! do |k, v|
  !File.directory? v
end

# Compile for actress
diff.each do |k, v|
  if k =~ %r!^_[^/]*/_[^/]*$!
    # Series
    db[]["__" + File.basename(k).sub(/^_/, "")] = v
  elsif k =~ %r!^_[^/]*$!
    # Circle
    db[][File.basename(k)] = v
  else
    db[][File.basename(k)] = v
  end
end

# Update actress
Dir.glob("../_VoiceByActress/*/*").each do |i|
  #p File.basename(i)[0, 2]
  if File.basename(i)[0, 2] == "__"
    # Series
    if db[][File.basename i]
      system "rm", "-v", i
      system "ln", "-sv", "../../Voice/#{db[][File.basename i]}", [File.dirname(i), "__#{File.basename(db[][File.basename i])}"].join("/")
    end
  elsif File.basename(i)[0, 1] == "_"
    # Circle
    if db[][File.basename i]
      system "rm", "-v", i
      system "ln", "-sv", "../../Voice/#{db[][File.basename i]}", [File.dirname(i), "_#{File.basename(db[][File.basename i])}"].join("/")
    end
  else
    # Title
    if db[][File.basename i]
      system "rm", "-v", i
      system "ln", "-sv", "../../Voice/#{db[][File.basename i]}", [File.dirname(i), File.basename(db[][File.basename i])].join("/")
    end
  end
end

# Update Meta
meta = Psych.unsafe_load File.read("meta.yaml")
diff.each do |k, v|
  if meta[File.basename k]
    meta[File.basename k]["path"] = "../Voice/#{v}"
  end
end
File.open("meta.yaml", "w") do |f|
  YAML.dump meta, f
end

この技法の核はなにか

そう、この記事には前代未聞の新発見があるわけでも、あらゆることを解決する銀の弾丸が示されているわけでも、再利用可能なコード群が提供されているわけでもない。

あまり使われていない関数であるfetch()の利用や、「完全なJSONはそれ単独で独立していて、前方に代入を追加してもsafeである」という事実の認識はいくらか発見を提供するかもしれないが、せいぜいその程度で、それぞれの内容は「そんなこと知っているよ」というようなものの集合だろう。

ではそれによって実現されているものが、誰もがやるようなありふれたものか? といえばそうではないだろう。 多くの人はその成果物を目にして、驚きを禁じ得ないはずだ。

だが、その正体をつかむのは難しい。 この技法自体は応用が効くものであるにも関わらず、「JSONファイルをロードする」以外の特徴的要素がなく、技法を説明することが困難だろう。

強いていうならば、この技法で重要なのは、それこそ「JSONファイルをロードすること」なのだが、それに付随する

  • ウェブアプリケーションは、一般的なモデルに縛られる必要はなく、大部分をブラウザ内で行うこともできる
  • JSONファイルをロードすることは、(アプリケーション)サーバーに依存していない
  • JSONファイルがあればいいだけなので、その生成/参照方法は様々に存在し、実際に使うものも1つである必要はない

といったことに注目することである。

曖昧で抽象的なように見えるが、実際に私がこの技法を用いて開発するのは第二弾であり、そして第三弾も既に用意している。 この技法、つまり考え方をするだけでも、様々なものが生み出せるようになるのだ。

なお、アプリケーション要素は少ないように見えるが、PureBuilder SimplyをReactと組み合わせるのも、原理的には似たものがある。

“Little Commet”

私がこの手法を編み出すキッカケとなったソフトウェアが “Little Commet” である。

Little Commetは非常にシンプルなチャットボットであり、チャットウィンドウに選択肢が表示され、それを押していくだけのものだ。 なので、実際のところチャットというわけではなく、電話での音声ガイダンスからあるレトロな手法なのだが、ビジネス用途のチャットボットだと、どれだけ多機能にしても結局こういう形式になることが非常に多い。

素直な形式でいえば、サーバーから次の選択肢を取得し、クライアントが選択された内容をサーバーに送信し、また次の選択肢をサーバーから受け取る、というものになるだろう。

だが、そうする必然性は果たしてあるだろうか。 相当に巨大なシナリオだとしても、JSONファイルのサイズはちょっと画質のいい画像よりもずっと小さい。 そのため、データサイズとしては一度にロードしても問題はないはずだ。

こうしなければならない理由はいくつかあり、

  1. データを送信する時点では応答が確定していない。サーバー側でさらなるアルゴリズムやバックエンドサービスに投げることで応答が生成される
  2. どうしてもシナリオの全体像が秘匿されなければならない。例えば一度だけ挑戦でき、報酬があるようなクイズであれば、全体像がわかれば不正が可能になってしまう

といったケースが考えられるが、実際はほとんどの場合支障がない。 というよりも、実用的なチャットボットはほとんどの場合、すでにあるコンテンツへの異なる検索パスであり、JSONから探してくれるならありがたいぐらいだ。

そこで、シナリオ全体をJSONとしてロードするLittle Commetが作られた。 Little Commetという名前は、私がInflatonで作っていたStellaに対して、そのOSSバージョンがOpen Starlightであり、それの極小版ということでLittle Commetとなった。

その特徴は単にシナリオ全体をプリロードしてしまう以外に、Telegram Bot的なUIというのもある。 基本的にチャット風のUIだが、入力ウィンドウはなく、吹き出しは単なるセッションの履歴にすぎない。 そして、選択肢が下部固定で「カスタムキーボードによる入力」となっている。 だが、これは今回の話とはあまり関係がない。

シナリオ全体をプリロードする副次的な効用として、シナリオの完全性がある。 サーバー応答の場合、利用中にシナリオが更新されると進行中のシナリオの整合性がとれなくなるという問題があるが、シナリオ全体を予めロードしているため、このような問題が起きない。

また、チャットボット用のサーバーは流量が増えるとリクエスト数が増えるため、サーバーリソース(特にコネクション)が厳しいという問題があり、要求される性能の割にサーバーコストが高くなるという問題もある。 実際、StellaもOpen Starlightも(設計的に非常に軽くなってはいるが)機能の割にサーバーのことを気にせねばならない面がある。 対して、Little Commetはサーバーは小さなJSONファイルを静的にサーブするだけであるため、アプリケーションレベルDDoSの問題もなく、アプリケーションサーバーと比べると接続集中に対する耐性は極めて高い。

なおかつ、アプリケーションサーバーが運用できるようなサーバーが必要ではなく、なんならレンタルサーバーのような静的なウェブページをサービスできるだけのサーバーでさえ運用可能だ。

そう、Little Commetは画期的であり、だが非常にありきたりで凡庸である。 しかし時に、「発想の転換」というものは、大きな発展や新たな選択肢をもたらすものなのだ。

類似アプローチのソフトウェア

PureBuilder Simply + Reactは原理的には同じだ。 ただし、PureBuilder Simplyが生成するのはPandocを通した「文書」なので、アプリケーションはテンプレートエンジンのような動作となり、こうした「アプリケーション」のイメージからは遠いかもしれない。

ウェブベースのプレゼンテーションソフトウェア “Reveal.js” はアプリケーション自体はブラウザで動作するJavaScriptであり、サーバー通信を必要としない。 ただし、データ自体はHTMLの中に組み込まれており、元からデータベースアクセスを行うようなソフトウェアではないため、やはり少し毛色が違う。

HTMLをデータとして生成するアプローチは、私もAutoscrolling Textなどで採用している。