可能性を感じる「クライアント完結型ウェブアプリ」 & カスタム検索エンジンを自作する話
開発::util
序
この記事は題材としてアダルトコンテンツに関わる内容を多分に含む。
この記事を独立して書くつもりは当初なく、Mozcdict-ext関連で書くつもりでいたのだけれど、結構深い内容で量も多いため、今回書くことにした。
何らかの理由で専用インターフェイスを持つアプリケーションが欲しいとき、最も手軽な方法がウェブページである。 Electronのようなwebベースのアプリケーションも簡単ではあるが、色々と縛りが発生してしまい面倒だし、他のユーザーに共有して使わせる場合はなおのこと面倒だ。
だが、ウェブアプリケーションにすると今度はサーバーに縛られる。 ウェブの世界は少数のステークホルダーが望むようにコントロールされており、自由度が低く応用が効かない。 私は正直、かなりくだらないものだと思っている。
だが、最近私はそんなウェブアプリケーションをもうちょっといい感じに使える手法を編み出しつつある。 それがタイトルにある、「クライアント完結型」である。
ウェブアプリケーションは当然ながらHTTP、あるいはHTTPを下地にしたプロトコルでサーバーとやりとりすることをその核としている。アプリケーションが何かをすればAPIを叩き、サーバーはデータベース(だいたいはRDBMS(オエー))にアクセスしてごにょごにょして返すのである。
これを、私は良いものだと思ってはいない。 この形式が「最適であるケース」はかなり狭いはずだ。
私が作っているアプリケーションはより牧歌的、かつ個人的なものだ。 ウェブアプリケーションで考えられる様々な要素は必要ない。 そもそもサーバーなどというものがなければ、ローカルで動かすアプリケーションはネットワークセキュリティなどという概念すら持たないのだ。
アプリケーション動作図
アダルトコンテンツであるため、モザイクをかけている。
コンセプトとキー
クライアント完結を目指す場合重要になるのが、「なにかしらがJSONファイルを生成する」である。 場合によってはクッキーやローカルストレージを使ったり、そもそもスクリプトのリソースの中に埋め込んだりといった方法でデータロードを必要としないアプリケーションにすることもできるが、それだとできることがだいぶ限られてくる。 ところが、JSONファイルをロードすることで「データを受け取る」ことが可能になり、できることはかなり広がる。
それをデータ書き込みをウェブサーバーアプリケーションが行うならあまり意味のないところだが、JSONファイルになっていればいいのであれば、SSH経由のスクリプト、ローカルなプログラム、ジョブスケジューラなど様々な選択肢がある。 柔軟に考えれば、これによってかなり可能性は広がるのだ。
ただ、これはウェブサーバー上の静的ファイルである場合と、ローカルファイルである場合にかなり事情が異なってくる。
ウェブサーバー上にあるのであれば、import
やfetch()
によって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'
= Dir.glob("../Voice/_*/_*/*") + Dir.glob("../Voice/_*/[^_]*") + Dir.glob("../Voice/[^_]*")
list
DB = {}
.each do |i|
listnext unless File.directory? i
= File.basename(i)
title if DB[title]
abort "title #{title} is already exist."
end
DB[title] = i
end
= (Psych.unsafe_load File.read "meta.yaml") rescue {}
meta
DB.each do |k, v|
= v.sub(%r!^\.\./Voice/!, "").split("/")
parts = nil
circle = nil
series if parts.length == 3
= parts[0][1..]
circle = parts[1][1..]
series elsif parts.length == 2
= parts[0][1..]
circle end
if meta[k]
[k].merge!({
meta"path" => v,
"circle" => circle,
"series" => series,
})
else
[k] = {
meta"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 title: Hash.new {|h, k| h[k] = []},
circle: Hash.new {|h, k| h[k] = []},
series: Hash.new {|h, k| h[k] = []},
}
= Dir.glob("../_VoiceByActress/*")
artists
.each do |one|
artistsnext unless File.directory? one
next if File.symlink? one
= File.basename one
name = Dir.children one
titles .each do |title|
titlesif title =~ /^__/。
[:series][series].push name
artistelsif title =~ /^_/
# Circle
= title[1..]
circle [:circle][circle].push name
artistelse
[:title][title].push name
artistend
end
end
= Psych.unsafe_load File.read "meta.yaml"
meta
.each do |k, v|
meta##### Complete Duration
unless v["duration"]
= Hash.new {|h, k| h[k] = 0}
voice_dirs Dir.glob("#{v["path"]}/**/*.wav").each do |i|
[File.dirname i] += 1
voice_dirsend
unless voice_dirs.empty?
= voice_dirs.max_by {|k, v| v}
voice_dir = nil
duration IO.popen(["voice-duration.zsh", *Dir.glob("#{voice_dir[0]}/*.wav")]) do |io|
= io.read.to_i
duration end
["duration"] = duration
vend
end
##### Complete Description
if !v["description"] or v["description"].empty?
= Dir.glob("#{v["path"]}/**/*.txt")
txtfiles unless txtfiles.empty?
["description"] = NKF.nkf("-w", File.read(txtfiles.first))
vend
end
##### Complete Actress
["actress"] ||= []
vif artist[:title][k]
["actress"].concat artist[:title][k]
vend
if artist[:circle][v["circle"]]
["actress"].concat artist[:circle][v["circle"]]
vend
if artist[:series][v["series"]]
["actress"].concat artist[:series][v["series"]]
vend
end
File.open("meta.js", "w") do |f|
= JSON.dump meta
json .puts("var meta = ", json)
fend
そもそもmeta.yaml
は手動編集するためのファイルであり、mkmeta.rb
は手動編集するもととなるファイルを作るものである。
対してmkjson.rb
で作られるmeta.js
はアプリケーションが直接使うものであり、最終的なデータになる。
そのため、duration
やdescription
など、ないデータは可能な限り補完するという処理も行われる。
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>
form
のonChange
を拾えば検索したい項目は見えてくる。
データは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) {
.add(j)
artists
}for (const j of meta[i].tags) {
.add(j)
tags
}.add(meta[i].circle)
circles
}
for (const i of artists) {
if (i == null) {continue}
const opt = document.createElement("option")
const val = document.createTextNode(i)
.value = i
opt.appendChild(val)
opt.appendChild(opt)
aopts
}for (const i of circles) {
if (i == null) {continue}
const opt = document.createElement("option")
const val = document.createTextNode(i)
.value = i
opt.appendChild(val)
opt.appendChild(opt)
copts
}for (const i of tags) {
if (i == null) {continue}
const opt = document.createElement("option")
const val = document.createTextNode(i)
.value = i
opt.appendChild(val)
opt.appendChild(opt)
topts
}
}
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 }
.push(i)
entities
}= entities.sort((a,b) => {
entities 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")
.innerHTML = `<img src="${meta[i].path.replace("?", "%3F").replace("#", "%23")}/thumb.jpg" />`
cover.appendChild(cover)
trconst title = document.createElement("td")
.innerHTML = `<a href="${meta[i].path.replace("?", "%3F").replace("#", "%23")}">${i}</a>`
title.appendChild(title)
trconst circle = document.createElement("td")
.innerHTML = meta[i].circle
circle.appendChild(circle)
trconst series = document.createElement("td")
.innerHTML = meta[i].series
series.appendChild(series)
trconst actress = document.createElement("td")
.innerHTML = meta[i].actress.join(", ")
actress.appendChild(actress)
trconst date = document.createElement("td")
.innerHTML = meta[i].btime
date.appendChild(date)
trconst tags = document.createElement("td")
.innerHTML = meta[i].tags.join(", ")
tags.appendChild(tags)
trconst duration = document.createElement("td")
.innerHTML = meta[i].duration || ""
duration.appendChild(duration)
trconst rate = document.createElement("td")
if (!meta[i].rate) { meta[i].rate = 0 }
.innerHTML = ("★".repeat(meta[i].rate) + "☆".repeat(5 - meta[i].rate))
rate.appendChild(rate)
trconst description = document.createElement("td")
if (meta[i].description) {
const description_body = document.createElement("span")
.title = meta[i].description
description_body.appendChild(document.createTextNode("🗒"))
description_body.appendChild(description_body)
description
}.appendChild(description)
tr
.appendChild(tr)
tbody
}.id = "ListBody"
tbody.replaceWith(tbody)
list_body
}
initialize_metadata()
create_table()
.addEventListener("change", function(e) {
sfcreate_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
に(キーだけを)入れ直している。
そして、entities
をbtime
で並び替えている。
これは、「並び替え処理」を入れるとなかなか面倒なことになるため、並び替えをしなくていいようにした。
並び替えられる要素というと、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 = "../Voice/".length
offset
Dir.glob("../Voice/**/*").each do |i|
= File::Stat.new i
f [f.ino] = i[offset..]
dbend
File.open "ino.yaml", "w" do |f|
YAML.dump db, f
end
ino.yaml
はリネームを行う前に作っておかなければならない。
次にリネームを行ったらinode-diff.rb
によって差分を検出する。
#!/bin/ruby
require 'yaml'
= YAML.load File.read "ino.yaml"
db = {}
diff = "../Voice/".length
offset
Dir.glob("../Voice/**/*").each do |i|
= File::Stat.new i
f = f.ino
ino = i[offset..]
fp if db[ino] != fp
[db[ino]] = fp
diffend
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'
= YAML.load File.read("diff.yaml")
diff
= {
db title: {},
series: {},
circle: {}
}
# "File rename" is only used by NASUtils sync renamer.
.reject! do |k, v|
diffFile.directory? v
!end
# Compile for actress
.each do |k, v|
diffif k =~ %r!^_[^/]*/_[^/]*$!
# Series
[:series]["__" + File.basename(k).sub(/^_/, "")] = v
dbelsif k =~ %r!^_[^/]*$!
# Circle
[:circle][File.basename(k)] = v
dbelse
[:title][File.basename(k)] = v
dbend
end
# Update actress
Dir.glob("../_VoiceByActress/*/*").each do |i|
#p File.basename(i)[0, 2]
if File.basename(i)[0, 2] == "__"
# Series
if db[:series][File.basename i]
system "rm", "-v", i
system "ln", "-sv", "../../Voice/#{db[:series][File.basename i]}", [File.dirname(i), "__#{File.basename(db[:series][File.basename i])}"].join("/")
end
elsif File.basename(i)[0, 1] == "_"
# Circle
if db[:series][File.basename i]
system "rm", "-v", i
system "ln", "-sv", "../../Voice/#{db[:circle][File.basename i]}", [File.dirname(i), "_#{File.basename(db[:circle][File.basename i])}"].join("/")
end
else
# Title
if db[:title][File.basename i]
system "rm", "-v", i
system "ln", "-sv", "../../Voice/#{db[:title][File.basename i]}", [File.dirname(i), File.basename(db[:title][File.basename i])].join("/")
end
end
end
# Update Meta
= Psych.unsafe_load File.read("meta.yaml")
meta .each do |k, v|
diffif meta[File.basename k]
[File.basename k]["path"] = "../Voice/#{v}"
metaend
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ファイルのサイズはちょっと画質のいい画像よりもずっと小さい。 そのため、データサイズとしては一度にロードしても問題はないはずだ。
こうしなければならない理由はいくつかあり、
- データを送信する時点では応答が確定していない。サーバー側でさらなるアルゴリズムやバックエンドサービスに投げることで応答が生成される
- どうしてもシナリオの全体像が秘匿されなければならない。例えば一度だけ挑戦でき、報酬があるようなクイズであれば、全体像がわかれば不正が可能になってしまう
といったケースが考えられるが、実際はほとんどの場合支障がない。 というよりも、実用的なチャットボットはほとんどの場合、すでにあるコンテンツへの異なる検索パスであり、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などで採用している。