Chienomi

OneChat: 「完全にモダンな設計」のチャットシステム

開発::webapp

  • TOP
  • Articles
  • 開発
  • OneChat: 「完全にモダンな設計」のチャットシステム

OneChat、という名前から想像できるかもしれないが、先日公開した0Chatに対するアンサーソングとも言うべきソフトウェアである。

デモ版では極めてessentialに書くことで、古典的なチャットをなるべく効率よく動作させることができ、なおかつコードはとても短い――というのが主旨だった。

0Chatではこれを実用的に、なおかつより古典的なCGI形態で利用できるようにした。 0Chatは実際に限定公開インスタンスとして動作させており、支障のない実用性を得ている。

OneChatは現代的な仕様で、なおかつ現代的な要求に応えるべく制作された実用的なチャットシステムだ。 負荷の高い状況、通信容量の削減、セキュリティ的な問題への対応など、0Chatで対応に限界のあった要素にしっかりと対応している。

その分、「普通な」ソフトウェアなので見所に乏しいかもしれないが、ポイントはいくつか存在する。

ソフトウェアは既にGitHubで公開中だ

さらに、YouTubeで動画も公開している

分担制

チャットサーバーの役割は明確である。 基本的にアクションとしては

  • ユーザー登録
  • 発言の受付
  • ログの応答

の3種類である。

本格的な、マッシヴな環境向けの構成では、これらはそれぞれ別のサーバーとして動作させることを想定しているのだが、 少なくともテストサーバーではそうなっていない。 そこまでマッシヴな環境でなければ、これらが一体であることでメモリ上で処理されるため軽い。

なお、テスト版のためMutexで簡単に処理していたりする。 流量が少なければ十分な対応である。

さて、基本的な考え方として、サーバーはあくまでこの3つの作業、要はユーザーデータベースとログの読み書き だけを 行うものである。 いわば管理人のようなもので、当事者ではなく、純粋にチャット環境を保守・運営する役割を持っている。

入力を整形したり、表示を適切にするのはユーザーの自己責任である。

とはいえ、ユーザーの自己責任にまかせてしまうとまずいケースもある。 ただ、ユーザーは責任を追っているので、ちゃんと文字数のオーバーをしないようにしたり、不正な入力にならないように検証したりする。 そう、ここでいうユーザーはユーザー環境であり、HTMLやJavaScriptのことだ。

サーバー側では、「ユーザーが責任を果たさずに、許されていないことをした場合」に関しては処理することになっている。 つまり、穴になるような不正チェックはサーバーアプリケーションで行うということだ。

だが、例えばレスポンスのエスケープはユーザー環境の中で起きることなので、ユーザー環境で行うことになっている。

一般的なメッセンジャーと同じように自分のメッセージはローカルに追加して、サーバーからは取ってきたものは表示しないようになっている。 これも、「重複して表示してしまう」というのはユーザー環境の中で起きることなので、ユーザー環境のほうで自分自身のメッセージを除外するようにしている。

どの程度の間隔でポーリングするかといったことはユーザーの責任としてコントロールしなければならない。 だが、悪意ある者はまずそれを破ることを前提にするだろう。

こうした攻撃はファイアウォール、およびウェブサーバーで止めることになっている。 特にハッシュリミットによる制限が有効だ。こうした「内容の外側の」攻撃はアプリケーションサーバーは気にしなくて良いようにする、というルールになっている。 明確な分担があるのだ。

Vanilla JS

約200行のJavaScriptだが、モダンシステムを要求するようになっており、一切フレームワークを使わないVanilla JavaScript仕様である。

「JavaScriptの解説」と言いながら、フレームワークの話をする人は、私は大嫌いだ。

ただし、構文によるトラブルはチェックするのが難しいので、機能テストで足りるように配慮されている。

個人的な意見としては、比較的新しい機能だけで構成するのであれば、フレームワークを使うよりもずっと書きやすい。 特に人気のあるあのフレームワークなんかはJavaScriptの標準オブジェクトを受け入れなかったりするため、機能的にも一貫してフレームワークのものを使う必要がある。 そうなると、フレームワークの機能のほうがJavaScriptの組み込み機能より劣っているという問題に直面し、むしろ辛いことになる。

クロスプラットホーム性が重要だった頃はライブラリやフレームワークは不可欠だったけれど、今となってはJavaScript自体の機能が拡充されたこともあり、メリットに乏しいように感じられる。 Stellaや0Chatで登場した機能もふんだんに使用され、「いかにもJavaScript」なコードになっているように思う。

ちょっとしたポイントは、JavaScriptがオープンな構造を持っていることを利用して、(フォームなどにせず)必要なプロパティをずいずい追加していることだろうか。

重複排除方法

サーバーアプリケーションチェックで「名前がユニークである」ことを保証するようになっており、各ユーザーはサーバーが生成したセッションIDを利用するものの、セッションIDはユーザーとサーバー間でのみやりとりされる。

サーバーアプリケーションでは「セッションIDからユーザー名を引く」方式だが、ユーザー名は重複しないので、ログにはセッションIDは含まれず、ユーザー名が含まれ、自身とユーザー名が一致する場合は自身の発言であると判定している。 ちなみに、このためユーザーがログアウトした直後に同名のユーザーがログインした場合は、同名のユーザーの過去の発言が見えない、という問題はある。 そこで、初回取得フラグを使って初回取得時は無条件に追加するようにしている。 このコントロールが可能なのもクライアントサイドで処理するメリットで、サーバーサイドではほんの僅かでも負担のある処理が毎回発生することはスループット低下につながるが、クライアントサイドではわずかな処理が追加されても大したことはない、という点も大きい。 そもそも、頻度が全体を受け持つのとひとりを受け持つので全然違う。 これは一種の分散処理とも言えるだろう。

不確実性の高いJavaScriptでマスクすることは一般的な間隔としてありえないと思われやすいものだが、これは「自分自身の発言を自分に対して隠す」ものだからプライバシー上の問題もない。

軽量

「余計なことをしない」という方針で実装されているため、全体的に軽い。 チューニングした場合には遠く及ばないが、片手間に半日作業で書いたにしては結構よく動く。

分担制のおかげでクライアントサイドでやることとサーバーサイドでやることがくっきり分かれているのもいい。 サーバーサイドのアプリケーションをごっそり作り変えるのがそれほど難しくないのだ。

今公開しているのはあくまでテスト用なので、本番用だと全く違う設計になることが考えられるが、これは「インターフェイス間で何をする」というのが決まっているだけで、特にサーバーサイドにはすごく設計の自由度があるというのがポイントになっている。 実際、大規模向けにRack/Unicornという構成だけでなく、RindaとZeroMQを駆使した分散システムも考えており、これについてもクライアントサイドは変更が必要ない。

上から下まで

0ChatがCGIという枠組みの中でほとんどアプリケーションの知識だけで構成されている。 強いていうなら、CGIがそもそもどのように動作するのかといった知識が必要ではある。

CGIは古典的なプロセス起動の方式をとっており、環境変数として受け渡されたパラメータをセットした上でスクリプトを実行する。 Apacheはそれがスクリプトファイルである場合、自前で処理系を起動し、起動しようとするスクリプトを引数として渡す。

だから、フォークして、環境変数をセットして、execするという方式で、応答は標準出力から受け取る。 シンプルな仕組みだが、環境変数に伴う制限が少しあるのと、「セキュアでない」という見方もできる。 なによりも、forkとexecの(大きな)コストがかかる。Rubyのように起動に時間がかかるものは起動コストも大きい。

これを理解するには、環境変数、fork/exec、処理系がどのように起動してスクリプトを処理するか、標準入出力、パイプという知識が必要になり、Unixシステム回りの知識が求められることになる。 さらに、HTTPヘッダー/CGIヘッダーの知識もいるので少なくながらネットワーク・プロトコルの知識も必要である。

とはいえその程度のものだ。基本的にはアプリケーション、プログラミングにまとわる知識が構成される。

OneChatの場合はアプリケーションサーバーが必要で、サーバーの動作や並列プログラミング、TCP/Unix ドメインソケットに関する知識や、HTTPとTCPコネクションに関する知識も求められる。 もちろん、サーバー管理やサーバー動作に関する知識、パケットフィルタリングなど上下全部の知識を求められる。 個々の要求される知識量・難易度は高くはないが、全部の知識が求められるのは漫然と仕事をしているだけのエンジニアには厳しい部分もあるだろう。

実はサーバーサイドの実装にはかなり難しい要素がある。それはコンカレンシーだ。

純粋にサーバーソフトウェアを書く場合、永続するプロセスがどれか、生成されるプロセスの数はいくつか、なにが独立した情報か、ということは自分で書いているだけに把握している。 ところが、一般的にはアプリケーションサーバーによって動作するアプリケーションはそのようなデーモン型の設計になっていることは少なく、複数のセッションに渡って共有するということは普通はしない。 必要であれば、データベースに問い合わせるなり、キューシステムを使うなりするのが王道の構成だ。

だが、OneChatの設計としては可能であれば、そのようなコストはできるだけ払いたくない。 実はWebrickを使用したテスト仕様は、処理効率的には決して悪くない。 だが、大抵のアプリケーションサーバーは何らかの並列性を持っているのであり、その並列性が「なんなのか」を把握していなければならない。 単にRackにしただけでは解決しない問題なのだ。

Webrickはシングルプロセス・マルチスレッドのウェブサーバーである。 各コネクションをスレッドでさばくのだが、このためWebrickアプリケーションの(メモリ上の)オブジェクトを共有することができるが、競合が発生しうるので競合から保護する必要がある。 テストアプリケーションでは単純にMutexを使っているが、オンメモリで処理しようとする場合、Mutexを使うのは悪いアイディアではない。 このため、意外とテストアプリケーションもいい線行くのである。

有名なUnicornはどうだろうか。 Unicornは複数のワーカーを起動するマルチプロセス・シングルスレッドのサーバーである。 全体をひとつのUnicorn Masterがコントロールし、ウェブサーバーとはUnicorn Masterが接続される。つまり、間にさらにもうひとつ入っている状態だ。 ワーカー数は設定によってコントロールできる。ワーカー数を1にした場合、シングルプロセス・シングルスレッドとして扱うことができるので、一切の競合・共有を考える必要がない。

Thinはシングルプロセス・シングルスレッドで動作する。

Nginx Unitはマルチプロセス・シングルスレッドだが、プロセスは永続するとは限らない。

こうした違いによって使えるテクニックが変わっくる。 最も汎用性があり、マルチプロセスだろうがマルチスレッドだろうが問題ないのは、「別にデーモンが存在し、そのデーモンからデータをもらう」形式である。 いわゆるRequest/Reply方式で、メッセージ送信時はそもそも204を返す前提であることから一方的に送りつけるだけで良い。

これ自体は非常に簡単な話であり、凝った仕組みは 使わないほうが速い。

つまり、UNIXドメインソケットで接続し、ログを要求するか、もしくは書き込みを要求するかのどっちだ。 ログを要求した場合はログを受け取ることになる。

この場合、デーモン側の挙動は少し複雑だ。最大の問題は「並列性が必要かどうか」である。

微妙なところだが、ないほうが簡単であるし、ただログ取得に関しては並列化しても全く問題がない。 だから並列化すれば恩恵はあるはずだ。

このアプリケーションはほとんどが競合を発生する「データ操作」になっているので、実はシングルスレッドで動作するほうが合理的である。 だが、それは言い換えるならこのプログラムそのものが並列性に乏しいとも言える。 それにこのスレッド操作はあまり効率が良いとは言えない。結局、全体で見ればメリットに乏しい。このことから「その瞬間だけ」アクセスする、つまりデーモンは本当に「ログを返すか、ログに書き込むか」だけをするようにしておき、その箇所は並列化できないのだからそこをシングルスレッドにjoinする形にしてしまうのがベストである。

しかし、この実装は私としてはあまり気が進まない。最大の理由は、ログを「取得する必要がない」ときにもデーモンへの接続が発生するからである。 結果的に、デーモンへの接続そのものがボトルネックになってしまい、スループットの限界値を下げることになる。

だからモデル的にはシングルスレッドであるほうが良い。純粋にシングルスレッドなアプリケーションサーバーなんてあるんだろうか、と思うかもしれない。 実はうってつけのものがある。Spawn FastCGIだ。

実際に試してみた限りでも、Spawn FastCGIで実装するのがとても簡単だ。シングルスレッドで動作するので、テストサーバーにあるような競合処理もいらない。

ではFastCGIのシングルインスタンスが最も効率的なのだろうか? 答えは否である。 先の「デーモンへの接続がボトルネックになる」といったのは、「デーモンへの同時接続数は1なので、僅かな時間とはいえログ要求の応答があるまでみんなが待つことになる」からだ。 だが、FastCGIのシングルインスタンスだと、「アプリケーションの同時接続数は1なので、アプリケーションを利用する全てのリクエストがアプリケーションの処理が終わるまで待つことになる」になる。 アプリケーション全体について、オンメモリで処理されるようになるため大幅に速くなる。この速くなった時間がデーモンの応答を待つまでの時間より速ければ良いが、それはさすがに難しい。 ただ、それ以外の処理がだいぶ少ないため、実ははっきりとFastCGI不利とも言えない。

さらにいえば、FastCGIを使うことは、FastCGIがインターフェイスになる、というだけの話である。 素のFastCGIで書くのであれば、そこからマルチスレッドで動作させるというのもアリではあるのだ。 ここまで軽いプログラムだとメリットはないが、実はforkを駆使するテクニックがある。つまり、エラーハンドリングなどは無視すれば

while req = FCGI.accept
  msg = JSON.load(req.in)
  if msg["get"]
    fork do
      out = req.out
      out.puts "Content-Type: application/json;"
      out.puts
      out.puts JSON.dump(LOG.last(30))
      req.finish
    end
  else
    Mlock.syncronize do
      LOG.push msg["msg"]
    end
  end
end

みたいなことである。 この方法だと、CoW方式のforkをするLinuxでは基本的にメモリのコピーは発生せず、forkをかなり高速に行える。

もっとも、単純にこんなことをするとプロセスが溢れてしまうかもしれないし、こんな簡単な実装ではまずいのだが。

いずれにせよ、パフォーマンスのことまで考え出すと難しいのだが、ここでの主旨は「アプリケーションサーバーの特性を理解していないと、同じRackで書いていても動作したりしなかったりする」という話なので、まぁそこまではいいだろう。

ちなみに、パフォーマンスを追求する場合、各インスタンスに永続的なデータを用意してオンメモリで動作するようにした上で、各インスタンスがログを共有するようにする、というのが最善だと思われる。

これをするためにはそれなりにCPUとメモリがあることを前提としているため、貧弱で小規模な環境から適用できるわけではない。 ちなみに、実装としては、まず書き込み側インスタンスと読み出し側インスタンスを完全に分けてしまい、書き込み側はシングルスレッドにする。そして、読み出し側はUnicornなどで並列処理できるようにしておく。 そして、ZeroMQを用いて各インスタンスに配信する、という方式が考えられる。 ただし、この方法の問題は新たに生み出されたインスタンスは、そのインスタンスが生成される前のログを取得する方法がない。このあたりを手動でやろうとすると、実は結構大変である。

総括

OneChatとは何か、なぜ作られたのかといえば、チャットソフトウェアについては研究を重ねている割に公開しているものがない。

研究に関して面白いところを詰め込んだのが0Chatだが、現代においては明らかに問題があるしねそれが最善のソフトウェアだと思っているわけでもない。 私もいくらか実力をアピールする必要もあるし、0Chatで言っていることが(つまり、0Chatは現代においては問題があるということが)なにを意味しているのかということも、実際に提示しなければ伝わらないという人も多いようだった。

だから、私が考える理想的なチャット構成の、エッセンシャルな部分を提示してみせた、というわけだ。

現代のチャットアプリケーションの実装はメッセンジャーソフトウェアに限りなく近く、現実的にはウェブでないほうがより良い。 だが、突き詰めていくと結局「チャット」ではなく、「メッセンジャーアプリ」と「メッセージングソフトウェア」(要はスループットソフトウェア)になってしまうため、現実的な領域で、簡単に作れて負担が少ない、みたいなものを作ってみたのだ。

随所に問題を簡略化するためのテクニックがあり、「現実的なバランス感覚」で作っている。 テスト版とはいえ、400行ほどどそこそこ短く書けているのもポイントだろう。

かなり面白いソフトウェアだと思うのだが、どうだろうか。