Chienomi

ゆいちゃっと型HTMLチャットの極致 0Chat

概要

0chatは以前紹介したSimplest HTML ChatのCGI版である。

Simplest HTML Chatについては以前の記事で紹介したが、ゆいちゃっと的なHTMLチャットを題材に勉強していく中で、「これってHTMLを作るほうが軽いんじゃないの?」と思ったのを形にしたものを現代に再現したものである。

これが今のPureBuilder Simplyなどの事前生成戦略のベースになっている。

基本的な解説

ゆいちゃっとは2000年前後に隆盛を誇ったPerl/CGIのチャットスクリプトである。

チャットが流行っていた当時でも主流といえるものであり、多くのチャットスクリプトがゆいちゃっとの構成をリスペクトしていた。

基本的にはHTMLフレームを使用しており、上部フレームが発言用のフォーム、下部フレームがログを表示するページになっている。 通常は両方とも中身はCGIスクリプトである。ものによっては上下フレームで別のスクリプトを呼び出すものもあったが、ゆいちゃったの場合は両方ともchat.cgiになっている。 ただし、入室前は上フレームがenter.cgiとなっており、入室するとchat.cgiに切り替わる仕組みだ。

ログ側HTMLにはHTML metaでのリロードが組まれており、表示を更新する。 発言時の発言フォームの削除はJavaScriptで行っていたが、ゆいちゃっとには後から入ったもので、ユーザーコントリビュートであったようだ。

ログ形式は当時よくあった、エントリごとを<>で区切ったCSV的なファイルである。

非常に有名で隆盛を誇ったソフトウェアであるにも関わらず、その歴史や情報はどこにもまとめられておらず、Wikipediaにも項目がない。

私の考え

以前の記事でも述べたが、ポイントは以下の通りである

  • チャットアプリは複数人に短いスパンで繰り返し呼ばれるため非常に負荷が重い
  • サービス側で禁止されることもあるほどで、よく500や503に陥っていた
  • 同時に書き込まれるなどの問題が解消されていない場合があり、よくバグってもいた
  • ログの形式的に制限があり、ログそのものがとても扱いにくい
  • 更新されてるかどうかに関わらずコンテンツをとってくるポーリングを使うので、スクリプトは書き込む頻度より読み出しの頻度が圧倒的に高い

最後がポイントで、「スクリプトが圧倒的に “読み出し > 書き込み” なのであれば、それHTMLファイルで用意すれば軽いじゃん」というわけである。

実際にモダナイズしたポイントは次の通り

  • データベースはRubyのシリアライズドオブジェクト
  • 発言フレームは静的
  • ログフレームも静的
  • 発言フレームのformのtargetが動的
  • スクリプトはログHTMLを更新する。応答は204

CGI版

CGI版は単にCGIにしているわけではなく、より踏み込んだモダナイズを行っている。

両者のコードを比べてもらうとわかると思うのだが、Webrick版は本当にessentialな設計になっていて、可能な限り端的なコードで事前生成戦略がチャットでも(むしろチャットだからこそ)有効であることを示している。 それと比べるとCGI版は明らかにコードが長い。

CGI版の “0chat” は「デモ用だから実用するな」と強調しているけれども、これはDDOS攻撃、メッセージの連打、サーバー負荷と転送量など現代的な問題に対して対応していないからであって、セキュリティ的に問題があるとかいうわけではない。そこは一応最低限の配慮をしてあるので、少なくともレガシィなPerl/CGIチャットスクリプト並には動作する。

さらなるモダナイズで事前生成戦略の可能性を示したのは次の点だ。

  • 「前提として名前を入力させる」ための状態変遷をJavaScriptで行う
  • ログイン表示や、発言フレーム内でのユーザー名表示もJavaScriptで行う
  • メッセンジャーアプリで一般的な「ログは上から下、発言ウィンドウは下」仕様

ちなみに、StellaのUIはフレームは使っていないし、ログはパーシャルで取るので、別に私がそういう実装ができないわけではなく、あくまでも「レガシィなHTMLチャットなら事前生成戦略でイケる」という話をするためにそうしているだけだ。

0Chatの実装

発言フレームの状態変遷

発言フレーム内の全ての状態を非表示クラスのsectionブロックとし、現在の状態となるブロックのみをJavaScriptによって可視とする。変遷するときは全てのブロックを対象に一度非表示にしてからひとつだけを可視とする。

このため、JavaScriptが有効でないとそもそも機能しない。このチャットは全面的にJavaScriptに頼っている。

ログイン処理

名前入力ブロックの表示状態から抜ける処理において、「発言ブロック内のフォームに値をセットし、submitする」という方法でログイン表示を行っている。

このために厳密なものではないが、もともとHTMLチャットは「シャドー入室」が可能なものであったから、むしろそれをリスペクトしたと言える。厳密な入室を必要とするような管理は似つかわしくない。

普通に使っていると、名前表示から抜けるためにはこの処理を経由しなければならないので、エクスペリエンス上の問題はない。もちろん、回避する方法はいくらでもある。

ログの更新とウィンドウ位置

これは、実はめちゃくちゃ苦労した。

ポイントになったのはLocation.reload()である。Location.href=Location.replaceで同一のアドレスを再ロードしたときにscope.setTimeout()が再度呼び出されない、という問題に躓いていたのだ。 また、ハッシュリンクつきのアドレスを再ロードしたとしてもその要素に飛ぶことはない。

本来はHTML metaでrefreshするものだし、Webrick版ではそのようにしていたが、0ChatはどのみちJavaScriptなしには動かないので、JavaScriptで処理することにあまり躊躇いはなかった。

window.setTimeout((function() {
  window.location.reload(true)
  document.getElementById("lastmsg").scrollIntoView(true)
}), 10000 )

リロードしてスクロールする。 ちなみに、Location.reload()でリロードした場合は実行中のJavaScriptは有効なので、これはちゃんと機能する。

実際のコードでは、ページボトムにいなければスクロールしないようになっている。

window.setTimeout((function() {
  window.location.reload(true)
  if (onBottom) { document.getElementById("lastmsg").scrollIntoView(true) }
}), 10000 )

onBottomscrollに対するイベントとして「ページの下端にいるかどうか」をセットしている。 どうやってやっているかというと、

function takeoffFromBottom() {
  if (window.innerHeight - document.getElementById("lastmsg").getBoundingClientRect().y > -20) {
    onBottom = true
  } else {
    onBottom = false
  }
}

getBoundingClientRect().yはビューポート上端から要素の上端までの距離である。 だから、この値がビューポートの高さよりも大きければ、画面の下端よりも下にある、ということになる。 そのため、window.innerHeightから引いたときに負の値になるのであれば、要素は画面内にある。 ターゲットが完全な下端ではないので吸い込みはあるのだが、もう少しだけ吸い込むようにしている。

「移動するのはページ更新してからなんだから、新しい発言があったら下端じゃないじゃん」と思ったそこのアナタ。

前述のとおり、Location.reload()でリロードされたページに対してリロードする前のページで呼び出されたJavaScriptが実行される。JavaScriptはシングルスレッドで動作するから、リロードされたページのJavaScriptが実行されるのはこの実行が終了した後である。だから、onBottomのセットはリロード前のページで行われた値が使われることになるので大丈夫だ。

超モダンなレトロソフト

0Chatの画面

「アプリケーションとしての機能をクライアントサイドJavaScriptで実装する」というのはStellaで使用したアイディアで、個人的には結構おもしろいと思う。 まぁ、TwitterとかFacebookとか、JavaScriptで実装しているものもまぁまぁあるからそこまで斬新ではないかもしれないけれど。

ただ、通常はサーバーサイドで処理する「値の生成」をクライアントサイドでやろう、というのが面白いと思うのだ。 セキュリティ的に問題にならないことを前提として、だが。

このアプリケーションはRuby 2.7で書かれ、CSSを駆使しJavaScriptで駆動する現代的な手法を用いて(Vanilla JSだが)、テンプレートエンジンによる生成を活かして、「HTMLフレームを用いてログページをリロードする超古典的な」「CGIの」チャットを実装している。

バカバカしいようだが、存在価値はなかなか高いのではないだろうか。 それは、レトロなものを今に伝える、という意味もあるけれど、それよりもテクノロジーは流行的要素によって「これが正しい、こういうのがモダンだ」という流れに従って動いてきているから(特にwebまわりは)、そのときそのときのアプローチの実現手段が最善かどうか、という検証は、あまりされない。 実際、こういうHTMLチャットだってそれなりに長い間続いたが、どれも似たようなコードであり、例えばDBMを使うとか、UIを改善するとか、そういった「与えられた条件をハックする」というものは、ほとんど出てこなかった。

だから、「こういうときはこういうふうにします」なんていう世に流れる手法をなぞるのではなく、本質を考えて工夫して生み出して「これでどうだ!!」と言っていくことは、有意義なことなんじゃないかと私は思うのだ。

勘違いしないで欲しいのだが、(PureBuilder Simplyがそうであるように)こうした旧来からあるものを活かしたアプローチのほうが優れている、という話をしているわけではない。このアプローチが全体で見た時に最善ではないのは明らかであり、当該条件下でhackしたわけだが、その条件そのものが「筋悪な制約」なのは明らかなのである。

最大の問題は「頻繁に取得されるログのために頻繁にスクリプトが呼び出される」という問題だが、これは主にCGIの起動の仕組み上頻繁に呼ばれることが問題なのであり、CGIでなければそこまで問題ではない。 そして、デーモンプロセスとして起動しているチャットサーバーに対して接続するのであれば、問題になるのはどちらかといえば転送負荷である。だから、転送負荷軽減のためにポーリングなりロングポーリングなり、あるいは接続しっぱなしなりでパーシャル転送するほうがずっと良い。

であれば、ページそのものがアプリケーションによって生成されることはそれほど重大な問題ではない。 これは、最初に一度生成するだけであり、頻繁にページを生成するわけではないからだ。

これによって、ページ内に必要なパラメータ(例えばセッションID)を含められるほか、疑わしいアクセス(大量のリクエストを含む)を弾くことも容易になる。こうしたことを考えれば、現代的な技術とニーズを踏まえれば明らかにこっちの実装のほうが望ましい。

ただし、優れた点がないわけではない。こうした現代的な仕様はサーバーサイドに深く入り込んでおり、その構築は難易度が少々高い。個人レベルで展開するにはCGIは容易な手法であり、またCGIによって可能であることでベースとして利用できるサービスが増えるという面もある。

ただ、これもやや苦しい部分はある。この仕様のCGIでモダンな要求を満たすことはかなり難しく、サーバーサイドで制限するしかない。VPSで運営するのであれば、パフォーマンス面などを考えなければWebrickで簡単に実装可能であり、このほうが低負荷にすることもセキュアにすることも容易なのだ。 このあたりは制作者のレベルによるし、今回の0Chatに関しては「テクノロジーとアプローチを見せる」という以上の意味はないと言っていい。

余談 JavaScriptの関数のバインディング

例えば

$id = document.getElementById

のようにdocument.getElementByIdの別名(エイリアス)をつけようとやっても

$id("FooElement")

はエラーになる、という問題がある。 これについてちょっと調べたら、てんで的外れな説明ばかりが並んだので、ここで軽く解説しておこうと思う。

オブジェクト指向におけるメソッドというのは、特定のレシーバ(普通はオブジェクト)にbindされている。

// heyメソッドはfooオブジェクトに属している
foo.hey()

例えば次のRubyコード

class A
  def initialize
    @param = "AAA"
  end

  def hey
    puts @param
  end
end

a = A.new
a.hey
meth = a.method(:hey)
meth.()

の場合、metha.heyを代入して呼び出しているが、これはAAAと表示される。 このAはオブジェクトaのインスタンス変数@paramであり、単にheyメソッドを代入しているわけではなく、このメソッドには「レシーバがaである」という情報も含まれていることがわかる。 これが、「このメソッドはaにbindされている」ということである。1

JavaScriptにはインスタンス変数という概念がないので、オブジェクトプロパティにする必要がある。 そして、オブジェクトプロパティはレシーバなしに呼び出すことができないので、thisキーワードを使うことになる。

a = {
  a: "AAA",
  hey: function() { console.log("HEY " + this.a) }
}

thisが何を指すかは動的だ。例えば

a = {
  a: "AAA",
  hey: function() { console.log("HEY " + this.a) }
}

// a.hey() は HEY AAA と書く

b = { a: "BBB" }
b.hey = a.hey

// b.hey() は HEY BBB と書く

c = { c: "CCC" }
c.hey = a.hey

// c.hey() は this.a === c.a が存在しないためエラーになる

ということである。

グローバルなオブジェクトだとわかりにくいだろうから、仮にここではglobalオブジェクトを使おう。

global = {}
global.id = document.getElementById

とした場合、global.idとしてdocument.getElementById関数は 代入されるが、 getElementByIdの中で呼ばれるthisglobalオブジェクトになる

getElementByIdDocument.getElementByIdであり、プロトタイプチェーンにDocumentを含んでいなければならない。ところが、globalのプロトタイプチェーンにはDocumentが含まれていないので、getElementByIdが機能しない。

このようにそのメソッドのスコープが固定されていないものを「unboundなメソッド」という。 RubyではMethod#unbindを使ってunboundなメソッドを作ることはできるが、unboundなメソッドを起動することはできない。

JavaScriptの関数は通常unboundである。 Function.prototype.bind()メソッドを使うことで特定のオブジェクトにbindし、thisキーワードが指すものを固定することができる。 つまり、

global = {}
global.id = document.getElementById.bind(document)

とすれば、global.id()の中のthisは常にdocumentになる。


  1. この言い方は少し難しい。bindの過去分詞形はboundなので、「bindされている」なのか「boundされている」なのか。しかし、過去分詞形が普通に-ed形である単語で過去分詞形で言うことはあまりないので、「bindされている」という言い方にした。↩︎

Wrote on: 2020-02-07 12:09:00 +09:00