チャットアプリケーションの作成

著者: Mads Hartmann Jensen
コードの共著者: David Pollak

ここでは、David Pollak がいつも見せているデモを紹介します。例の Lift によるチャットアプリケーションです。

具体的には、Lift を使って Comet 対応のチャットアプリケーションを作成する方法を説明します。まず、アプリケーションを動作させるのに必要なすべてのコードを示し、実際にどのようなことを行っているのか理解しやすいように、コードの各行を追いながら解説を加えます。その後、いくつか機能や効果を追加してアプリケーションを拡張する方法を示します。

アプリケーションの作成に入る前に、Comet という言葉を初めて聞くユーザーのために、ひとこと Comet について説明しておきましょう。Comet は、クライアントがサーバーにリクエストを送信する際の 1 つのモデルです。クライアントから送信されたリクエストは、サーバー側でレスポンスを返すに値する何らかのできごとが発生するまで保留されます。サーバーがレスポンスを返すと、すぐにクライアントから別のリクエストが送信されます。要するに Comet とは、サーバー側で起こった変化をサーバーからクライアントに通知するような通信方法を実現するための手段です。

アプリケーションを作成するには、Simple Build Tool (sbt) をインストールし、デフォルト Lift プロジェクトの TAR または Zip をダウンロードします。ダウンロードしたファイルは展開してください。

展開後に作成された新しいディレクトリに移動して sbt update と入力し、依存関係を取得します。

次に、好みのエディタを起動し、src/main/scala/code/comet/Chat.scala ファイルを作成します。以下に示すコードを Chat.scala ファイルに入力します (マークアップとロジックがまぜこぜになっていると指摘される前にお断りしておくと、この手順を終えた後で、マークアップとロジックを分離する方法を示します)。

package code.comet

import net.liftweb._
import http._
import actor._

object ChatServer extends LiftActor with ListenerManager {
  private var messages = List("Welcome")

  def createUpdate = messages

  override def lowPriority = {
    case s: String => messages ::= s ; updateListeners()
 }
}

class Chat extends CometActor with CometListener { 

  private var msgs: List[String] = Nil 

  def registerWith = ChatServer 

  override def lowPriority = {
    case m: List[String] => msgs = m; reRender(false) 
  } 

  def render = { 
    <div>
    <ul>
    {
      msgs.reverse.map(m => <li>{m}</li>)
    }
    </ul>
    <lift:form>
    {
      SHtml.text("", s => ChatServer ! s)
    }
    <input type="submit" value="Chat"/>
    </lift:form>
    </div>
  }
}

src/main/webapp/index.html ファイルにタグ <lift:comet type="Chat"/> を入力し、コンソールで次のコマンドを入力してアプリケーションを実行します。

sbt ~jetty-run
http://localhost:8080 に複数のブラウザでアクセスすれば、チャットアプリケーションが動作しているのを確認できるはずです。どうですか? 簡単でしょう。では、コードを順に追ってどんな処理を行っているのか見ていきましょう。
object ChatServer extends LiftActor with ListenerManager {

  private var messages = List("Welcome")
  
  def createUpdate = messages
  
  override def lowPriority = {
    case s: String => messages ::= s ; updateListeners()
  }
}

この部分では、いくつかの処理をしています。最初の行では、LiftActor を継承し、ListenerManager トレイトを mixin することでリスナーを管理できるオブジェクト (シングルトン) として、チャットサーバーを定義しています。

ChatServer の実装では、プライベートな文字列のリストを作成しています。このリストは、クライアントからポストされたメッセージを格納するのに使います。createUpdate メソッドは、登録された Actor に送信するメッセージを updateListeners メソッドが必要とする場合に呼び出されます。ここでは、単純に、サーバーにポストされたすべてのメッセージを返しています。

最後に lowPriority メソッドをオーバーライドし、自身に送信されたメッセージに対してパターンマッチングを行っています。メッセージが文字列の場合は、単純にその文字列をメッセージのリストに追加し、updateListeners メソッドを呼び出して、イベントが発生したことをすべてのリスナーに知らせます。updateListeners メソッドは、ListenerManager トレイトを mixin したことで利用可能になっています。

lowPriority は、メッセージを処理するためにオーバーライドできる 3 つのメソッド (lowPriority, mediumPriority, hightPriority) のうちの 1 つです。名前から想像できるとおり、これら 3 つのメソッドを使い分けることでメッセージに優先度を付けることができます。

class Chat extends CometActor with CometListener {

ここでは、更新内容をブラウザにプッシュするとともに、ChatServer と対話する方法を知っている Chat コンポーネントを定義しています。

private var msgs: List[String] = Nil

ローカルな状態を格納する場所を用意します。

def registerWith = ChatServer

ここでは、Chat コンポーネントが自身を ChatServer に登録し、変更があったときに通知を受けられるようにしています。

override def lowPriority = {
  case m: List[String] => msgs = m; reRender(false)
}

ここでは、ChatServer からのメッセージの処理方法を実装しています。この例では、単にローカルな状態 (msgs) を更新し、reRender(false) を呼び出しています。false は Lift に対し、ページ全体をレンダリングし直す必要はなく、Comet コンポーネントだけをレンダリングし直せばよいことを知らせます。

def render = {
  <div>
  <ul>
  {
    msgs.reverse.map(m => <li>{m}</li>)
  }
  </ul>
  <lift:form>
  {
    // <lift:form> は Ajax フォームです.
    // メッセージをチャットサーバーに送信する入力ボックスを定義します.
    SHtml.text("", s => ChatServer ! s)
  }
  <input type="submit" value="Chat"/>
  </lift:form>
  </div>
}

ここでは、Chat コンポーネントに対して、自身をレンダリングする方法を指示しています。しかし、しかし…。そうでした、ビューと Scala のコードをまぜこぜにしていました。この例からわかるように、Lift ではビューをビジネスロジックに混ぜることができます。ただし、そうするかどうかは選択できます。ここではコードを分割して、ビューをロジックから分離してみましょう。注意してほしいのは、ロジックにはまったく手を加えることなく、こうした分離を行うことができることです。

まず、ビューの方 (src/main/webapp/index.html) を次のように修正します。

<lift:comet type="Chat">
  <ul>
    <chat:line>
      <li><chat:msg/></li>
    </chat:line>
  </ul>
  <lift:form>
    <chat:input/>
    <input type="submit" value="chat"/>
  </lift:form>
</lift:comet>

新しいビューに含まれるレイアウトの定義には「バインドポイント」があり、ここに動的コンテンツが挿入されることになります。

次に、Chat コンポーネントの render メソッドを修正します。def render = という行からファイルの終わりまで (ただし最後の右ブレース (}) は除く) までを、次の内容で置き換えます。

// NodeSeq をインポートします… ええと、Scala では任意のスコープ内で import 文を使用できます.
import scala.xml.NodeSeq

def render =
  bind("chat", // バインディングのための名前空間
  "line" -> lines _, // 関数 lines をバインド
  "input" -> SHtml.text("", s => ChatServer ! s))

private def lines(xml: NodeSeq): NodeSeq =
  msgs.reverse.flatMap(m => bind("chat", xml, "msg" -> m))

インライン xhtml を使う代わりに、bind メソッドを使っています。bind メソッドは BindHelpers トレイトにあります。ここでは、この bind メソッドを使って、実際のコンテンツをテンプレートのバインドポイントにバインドしています。

このコードでは、名前空間 chat のタグにコンテンツをバインドしています。bind の最後の引数は繰り返し可能で、BindParam を受け付けます。したがって、bind ステートメントの 2 行目と 3 行目は実際には BindParam のインスタンスで、それぞれ、「lines を呼び出した戻り値でタグ <chat:line> を置き換えよ」、「SHtml オブジェクトの text メソッドを呼び出した戻り値でタグ <chat:input> を置き換えよ」と読む必要があります。
以前のコードとは異なり、bind ステートメントでは、どの xml にバインドするかを指定していないことに気付いたかもしれません。これは、Comet コンポーネントがインスタンス化されるときに、このコンポーネントに渡されるマークアップがずっと (すなわち <lift:comet type="Chat"> のすべての子ノードで) 保持され、これがバインド先となるからです。

lines は、NodeSeq を取り、NodeSeq を返すだけのメソッドです。このメソッドは、一番新しいメッセージがリストの最後になるようにメッセージのリストを逆順にし、bind メソッドを使ってメッセージのリストを適切な xhtml に flatMap します。map の代わりに flatMap を使う必要があるのは、map では結果が Seq[NodeSeq] となり、期待している NodeSeq (Seq[Node]) という結果が得られないからです。

SHtml オブジェクトの text メソッドは、入力フィールドを返します。text メソッドは、通常の引数を 2 つと、繰り返し可能な引数を取ります。最初の引数は入力フィールドの初期値で、2 番目の引数は、文字列を引数に取って Any を返す関数 ((String) => Any) です。この関数は、フォームがサブミットされたときに呼び出されます。上のコードで指定している関数では、文字列 s を取り、この文字列を bang (!) メソッドを使って ChatServer に送信します。

以上の説明で、コードの中でどんな処理が行われているか、いくらか見当が付いたでしょうか。さて、David がすでに何度も見せているデモを繰り返すだけでは、わざわざこのガイドを読んでもらう価値はありませんから、デモアプリケーションを拡張して機能を追加してみましょう。

ここでは、次のような機能を追加します。

  • メッセージを削除できるようにする。
  • 削除または追加したメッセージが、フェードアウトまたはフェードインするようにする。

これらの機能を追加するために、まずビュー (src/main/webapp/index.html) を編集し、次のように書き換えます。

<lift:comet type="Chat">
  <ul id="ul_dude">
    <chat:line>
      <li><chat:msg/> <chat:btn/></li>
    </chat:line>
  </ul>
  <lift:form>
    <chat:input/>
    <input type="submit" value="chat"/>
  </lift:form>
</lift:comet>

変更箇所はそれほど多くありません。新しいタグ <chat:btn/> を追加し、<ul> タグに id 属性を追加しただけです[訳注:一番外側の <lift:surround> タグの部分は残しておきます]。では、肝心な部分に移りましょう。ChatServerChat に手を加えます。

まず、src/main/scala/code/comet/Chat.scala ファイルに次の import 文を追加してください。

import js._
import JsCmds._
import js.jquery.JqJsCmds.{AppendHtml, FadeOut, Hide, FadeIn}
import java.util.Date
import scala.xml._
import util.Helpers
import util.Helpers._

必要なクラスをインポートしたら、コードの本体に移りましょう。同じ src/main/scala/code/comet/Chat.scala ファイルに、次のコードを追加します。

sealed trait ChatCmd

object ChatCmd {
  implicit def strToMsg(msg: String): ChatCmd =
  new AddMessage(Helpers.nextFuncName, msg, new Date)
}

final case class AddMessage(guid: String, msg: String, date: Date) extends ChatCmd
final case class RemoveMessage(guid: String) extends ChatCmd

最後の 2 行では、 2 つの case クラスを作成しています (final キーワードは、これらのクラスをサブクラス化することはできないという意味です)。最初に作成したアプリケーションでは、クライアントとサーバーとの間でやり取りするのは文字列でしたが、今回はこれらのクラスのインスタンスを送信します。また、文字列から AddMessage への暗黙の変換を行う ChatCmd という名前のオブジェクトを作成し、Chat のコードをシンプルに記述できるようにしています。このような暗黙の変換を行うことで、ユーザーによって入力フィールドに入力された文字列はそのまま送信することができ、AddMessage をインスタンス化する作業は暗黙の変換にまかせることができます。

Helpers.nextFuncName は、現在の時刻とランダムに生成された文字列を基に、一意の文字列を作成します。作成された一意の文字列は、AddMessage と RemoveMessage の両方の一意の id として使います。

さて、今度は ChatServer です。次にように変更します。

object ChatServer extends LiftActor with ListenerManager {

  private var messages: List[ChatCmd] = List("Welcome")
  
  def createUpdate = messages
  
  override def lowPriority = {
    case s: String => messages ::= s ; updateListeners()
    case d: RemoveMessage => messages ::= d ; updateListeners()
  }
}

メッセージを格納するのに、文字列のリストを使う代わりに今度は ChatCmd を使っています (AddMessage も RemoveMessage も、どちらも ChatCmd のサブクラスです)。lowPriority メッセージも少し変更されています。メッセージに対してパターンマッチングを行い、メッセージが String だった場合には、そのメッセージを ChatCmd のリストに追加しています。おっと、ちょっと待ってください。String は ChatCmd のサブクラスではないので、これではコンパイルは通りませんね。ところが、実際にはコンパイルは通ります。ここで効いてくるのが、String から AddMessage への暗黙の変換なのです。コンパイラは、String が ChatCmd のサブクラスでないことに気が付きますが、そのことを指摘する前に、型の問題を解決できる暗黙の変換がスコープ内にないかどうかチェックします。そして、上の場合にはその変換が存在するので、コンパイルが通るわけです。

最後に、Chat のコードを示します。以前のコードの代わりに、次のようなコードで Chat を実装します。

class Chat extends CometActor with CometListener { 
  private var msgs: List[ChatCmd] = Nil 
  private var bindLine: NodeSeq = Nil
  
  def registerWith = ChatServer 
  
  override def lowPriority = {
    case m: List[ChatCmd] => {
      val delta = m diff msgs
      msgs = m
      updateDeltas(delta)
    }
  } 
  
  def updateDeltas(what: List[ChatCmd]) {
    partialUpdate(what.foldRight(Noop) {
      case (m: AddMessage , x) =>
        x & AppendHtml("ul_dude", doLine(m)) &
        Hide(m.guid) & FadeIn(m.guid, TimeSpan(0),TimeSpan(500))
      case (RemoveMessage(guid), x) =>
        x & FadeOut(guid,TimeSpan(0),TimeSpan(500)) &
        After(TimeSpan(500),Replace(guid, NodeSeq.Empty))
    })
  }
  
  def render =
    bind("chat", // バインディングのための名前空間
      "line" -> lines _, // 関数 lines をバインド
      "input" -> SHtml.text("", s => ChatServer ! s)) // 入力
  
  private def lines(xml: NodeSeq): NodeSeq = {
    bindLine = xml
    val deleted = Set((for {
      RemoveMessage(guid) <- msgs
    } yield guid) :_*)
  
    for {
      m @ AddMessage(guid, msg, date) <- msgs.reverse if !deleted.contains(guid)
      node <- doLine(m) 
    } yield node
  }
  
  private def doLine(m: AddMessage): NodeSeq =
    bind("chat", addId(bindLine, m.guid),
      "msg" -> m.msg,
      "btn" -> SHtml.ajaxButton("delete", 
        () => {
          ChatServer ! 
          RemoveMessage(m.guid)
          Noop}))
  
  
  private def addId(in: NodeSeq, id: String): NodeSeq = in map {
    case e: Elem => e % ("id" -> id)
    case x => x
  }
}

上のコードを貼り付けてから、jetty サーバーを再起動し、ブラウザで http://localhost:8080 にアクセスすると、メッセージがフェードイン/フェードアウトし、古いメッセージを削除できる新しいチャットアプリケーションが表示されるはずです。実際に操作してみると、新しい機能がどんなふうに実現されているのか、コードを読みたくなると思います。ではさっそく、順にみていきましょう。

class Chat extends CometActor with CometListener { 
  private var msgs: List[ChatCmd] = Nil 
  private var bindLine: NodeSeq = Nil
  
  def registerWith = ChatServer

ここでは、ローカルな状態として (以前の String の代わりに) 使う ChatCmd のリストと、bindLine という名前の NodeSeq を宣言しています。bindLine については、あとで実際に使うときに説明します。今回も、Chat コンポーネントの登録先は ChatServer です。

override def lowPriority = {
 case m: List[ChatCmd] => {
   val delta = m diff msgs
   msgs = m
   updateDeltas(delta)
 }
}

新しい Chat でも、ChatServer から送信されたメッセージを lowPriority メソッドで処理します。メッセージに対してパターンマッチングを行い、メッセージが ChatCmd のリストである場合には、List に対して diff メソッドを使って、新しいリストとローカルな状態との差異を計算し、結果を変数 delta に格納しています。次に、ローカルな状態を新しいリストで置き換え、最後に delta を引数に指定して updateDeltas を呼び出しています。では、updateDeltas で実際にどのような処理が行われているのか、見てみましょう。

def updateDeltas(what: List[ChatCmd]) {
  partialUpdate(what.foldRight(Noop) {
    case (m: AddMessage , x) =>
      x & AppendHtml("ul_dude", doLine(m)) &
      Hide(m.guid) & FadeIn(m.guid, TimeSpan(0),TimeSpan(500))
    case (RemoveMessage(guid), x) =>
      x & FadeOut(guid,TimeSpan(0),TimeSpan(500)) &
      After(TimeSpan(500),Replace(guid, NodeSeq.Empty))
  })
}

updateDelats では、partialUpdate を呼び出しています。partialUpdate は、CometActor で宣言されていて、JsCmd を唯一の引数に取ります。名前から想像できるとおり、partialUpdate は Comet コンポーネントの部分的更新を行います。updateDeltas で注目すべきところは、List[ChatCmd] から JsCmd への変換方法です。じっくり見ていきましょう。

what を updateDeltas の引数として宣言しています。この what に対して、List のメソッドである foldRight を呼び出しています。このメソッドのシグニチャーは、foldRight [B](z : B)(f : (A, B) => B) : B です。Scala のコードを読み慣れている場合は別として、このようなシグニチャーを示してもあまり助けにはならないでしょうから、Scala ライブラリのドキュメントから foldRight の説明を引用しておきます。二項関数 f を使い、値 z から始めて、このリストのすべての要素を右から左へと結合します。

上のコードの中では、JsCmd を継承し、基本的には空の JavaScript ステートメントである Noop によって、カリー化しています。二項関数の中では、引数に対してパターンマッチングを行っています。この引数は、一番右の要素を起点としたリスト内部の現在の要素と、これまでの foldRight の累積値です。

要素が AddMessage のインスタンスである場合には、いくつかの処理を行います。最初に、& メソッド (演算子と呼ぶこともできますが、これは記号名を持つメソッドです) を使って複数の JavaScript の呼び出しをチェインしています。一番左の引数が、一番右の引数より前に呼び出されます。また、JqJsCmds で定義されている AppendHtml オブジェクトを使っています。この AppendHtml オブジェクトには apply メソッド def apply(uid: String, content: NodeSeq): JsCmd があって、最初の引数には html の追加先のノードの id を取ります。上のコードの場合には、id が ul_dude の UL タグです。2 番目の引数は、追加する NodeSeq です。上のコードの場合には、AddMessage のインスタンスを指定して doLine メソッドを呼び出しています。doLine メソッドについては、あとで説明します。最初の CmdPair の 2 番目の引数では、もうひとつの CmdPair をチェインし、作成したばかりの html を Hide クラスを使って非表示にし、続いて FadeIn オブジェクトを使ってメッセージをフェードインさせています。もし上のコードがデモでなければ、おそらく新しく作成されたメッセージに css クラスを追加して diplay プロパティに none を設定し、追加されたメッセージをフェードインさせることになるでしょう。

2 番目の match ステートメントでは、Scala の Extractor を使って RemoveMessage の値 guid を取り出しています。Extractor の詳細については、ここを参照してください。RemoveMessage の unapply メソッドが成功した場合 (すなわち Some が返された場合) には、再度 CmdPair を使います。まず、FadeOut を使ってメッセージをフェードアウトさせ、次に After のインスタンスを作成します。After を使うと、After のインスタンス化中に指定された時間だけ待機してから、JsCmd を呼び出すことができます。After に渡している JsCmd は、Replace のインスタンスで、このインスタンスを使って、id が guid のノードを NodeSeq.Empty (すなわち空) で置き換えます。

いずれの場合も、& メソッドを使って新しい JsCmd とそれまでに累積された JsCmd を結合しているので、結果として foldRight メソッド全体が 1 つの JsCmd となり、すべての不要なメッセージの削除とすべての新しいメッセージの追加を JavaScript で一気に実行することになります。どうですか? なかなかうまいやり方ではないでしょうか。

render メソッドは以前のものと同じなので説明は省きます。lines メソッドはいくつか変更箇所があります。

private def lines(xml: NodeSeq): NodeSeq = {
  bindLine = xml
  val deleted = Set((for {
    RemoveMessage(guid) <- msgs
  } yield guid) :_*)

  for {
    m @ AddMessage(guid, msg, date) <- msgs.reverse if !deleted.contains(guid)
    node <- doLine(m) 
  } yield node
}

最初に行っているのは、(すでに上の説明の中でちょっと触れた) プライベート変数 bindLine に xml を格納することです。xml を格納しておくのは、あとで説明する doLine メソッドで使う必要があるからです。次に、削除する必要があるすべてのメッセージの guid を格納するために使うローカル変数 deleted を作成しています。すべての削除メッセージは for-comprehension で見つけます。for-comprehension では、RemoveMessage extractor (extractor についてはすでに説明しました) を使って、msgs (メッセージのローカルリスト) 内のすべてのオブジェクトの guid を取り出しています。for-comprehension は List を返すので、これを Set(..) に渡すと、得られるのは String を Set したものではなく、Lists を Set したものになります。これを避けるために、リストの各要素をそれぞれ別個の引数として Set に渡すよう指示する :_* を使っています。

次に、もうひとつ別の for-comprehension を使っています。今度は、削除されたメッセージの集合に含まれない、(逆順にされた) msgs 内のすべてのインスタンスを処理対象にします。2 行目では、メッセージを指定して doLine を呼び出した結果を格納しています。今回も、for-comprehension の結果が NodeSeq となるように、for-comprehension の結果を生成しています。

最後に doLine を取り上げます。

private def doLine(m: AddMessage): NodeSeq =
  bind("chat", addId(bindLine, m.guid),
    "msg" -> m.msg,
    "btn" -> SHtml.ajaxButton("delete", 
      () => {
        ChatServer ! 
        RemoveMessage(m.guid)
        Noop}))

ここでは、doLine に渡された AddMessage の guid と bindLine の 2 つを引数として addId を呼び出し、返される NodeSeq の中でプリフィックス chat を持つノードに対して、bind メソッドを使ってコンテンツをバインドしています。addId については、すぐこのあとで取り上げます。bind ステートメントの中で目新しいのは、SHtml.ajaxButton(...) の呼び出しでしょう。SHtml オブジェクトの AjaxButton は 2 つの引数を取ります。1 つはボタンのテキスト、もう 1 つは関数です。この関数は、引数を取らず、ボタンがクリックされたときに呼び出される JsCmd を返します。上のコードでは、ボタンの値に delete を設定し、関数では現在のメッセージの guid を指定した RemoveMessage を ChatServer に送信し、続いて Noop を指定しています。

では、コードの一番最後にある addId メソッドに移りましょう。

private def addId(in: NodeSeq, id: String): NodeSeq = in map {
  case e: Elem => e % ("id" -> id)
  case x => x
}

addId メソッドは NodeSeq と String を引数に取り、NodeSeq に対してパターンマッチングを行います。NodeSeq が Elem (scala.xml.Elem) である場合には、引数 id の値を持つ属性 id を追加します。それ以外の何かである場合 (NodeSeq か NodeSeq のサブクラスである必要があります。そうでなければ、コンパイラが問題を指摘するはずです) には、その何かをそのまま返します。

以上でこのガイドは終わりです。Lift でどんなことができるのか、感触をつかんでもらえたでしょうか。何かフィードバックがあれば、遠慮なくコミュニティに知らせてください。