第3版が利用可能です。ここで読む

第21章
プロジェクト:スキル共有ウェブサイト

スキル共有ミーティングとは、共通の関心を持つ人々が集まり、自分が知っていることについて小規模で非公式なプレゼンテーションを行うイベントです。ガーデニングのスキル共有ミーティングでは、セロリの栽培方法を説明する人がいるかもしれません。あるいは、プログラミングを中心としたスキル共有グループでは、立ち寄ってNode.jsについて皆に話すことができます。

このようなミートアップは、コンピュータに関する場合はユーザーグループとも呼ばれ、視野を広げ、新しい開発について学んだり、単に同じ興味を持つ人々と出会うための素晴らしい方法です。多くの大都市にはJavaScriptのミートアップがあります。通常は無料で参加でき、私が訪れたものはどれもフレンドリーで歓迎的でした。

この最後のプロジェクトの章では、スキル共有ミーティングで行われた講演を管理するためのウェブサイトを立ち上げることを目標としています。メンバーのオフィスで定期的に集まり、一輪車について話し合う少人数のグループを想像してみてください。問題は、以前のミーティングの主催者が別の町に引っ越したとき、誰もこの任務を引き継ぐ人がいなかったことです。参加者が中心的な主催者なしで、講演を提案し、議論できるシステムが必要です。

The unicycling meetup

前の章と同様に、この章のコードはNode.js用に書かれており、見ているHTMLページで直接実行しても動作する可能性は低いです。プロジェクトの完全なコードは、eloquentjavascript.net/2nd_edition/code/skillsharing.zipからダウンロードできます。

設計

このプロジェクトには、Node.js用に書かれたサーバー側と、ブラウザ用に書かれたクライアント側があります。サーバーはシステムのデータを保存し、クライアントに提供します。また、クライアント側システムを実装するHTMLおよびJavaScriptファイルも提供します。

サーバーは次回のミーティングに提案された講演のリストを保持し、クライアントはこのリストを表示します。各講演には、発表者の名前、タイトル、概要、および関連付けられたコメントのリストがあります。クライアントは、ユーザーが新しい講演を提案(リストに追加)、講演を削除、および既存の講演にコメントすることを許可します。ユーザーがこのような変更を行うたびに、クライアントはHTTPリクエストを行い、サーバーに変更を通知します。

Screenshot of the skill-sharing website

アプリケーションは、現在提案されている講演とそのコメントのライブビューを表示するように設定されます。誰かがどこかで新しい講演を送信したり、コメントを追加したりすると、ブラウザでページを開いているすべての人がすぐに変更を確認できるはずです。Webサーバーがクライアントへの接続を開く方法がなく、どのクライアントが現在特定のWebサイトを閲覧しているかを知るための適切な方法もないため、これは少し課題となります。

この問題の一般的な解決策は、ロングポーリングと呼ばれ、これはNodeの設計の動機の1つです。

ロングポーリング

クライアントに何かが変更されたことをすぐに通知するには、そのクライアントへの接続が必要です。Webブラウザは従来、接続を受け入れておらず、クライアントは通常、そのような接続をブロックするデバイスの背後にいるため、サーバーがこの接続を開始することは現実的ではありません。

クライアントが接続を開いて維持するようにすることで、サーバーが必要なときに情報を送信するために使用できるようにすることができます。

しかし、HTTPリクエストでは、クライアントがリクエストを送信し、サーバーが単一のレスポンスを返し、それで終わりという単純な情報の流れしか許可されません。最新のブラウザでサポートされているWebソケットと呼ばれるテクノロジーがあり、任意のデータ交換のために接続を開くことができます。しかし、それらを適切に使用するのはやや注意が必要です。

この章では、比較的単純な手法であるロングポーリングを使用します。クライアントは通常のHTTPリクエストを使用してサーバーに新しい情報を継続的に問い合わせ、サーバーは報告することがない場合は単に回答を停止します。

クライアントがポーリングリクエストを常に開いたままにしておく限り、サーバーからすぐに情報を受け取ります。たとえば、アリスがスキル共有アプリケーションをブラウザで開いている場合、そのブラウザは更新のリクエストを行い、そのリクエストへのレスポンスを待機しています。ボブがエクストリームダウンヒル一輪車に関する講演を送信すると、サーバーはアリスが更新を待機していることに気づき、保留中のリクエストへのレスポンスとして新しい講演に関する情報を送信します。アリスのブラウザはデータを受信し、画面を更新して講演を表示します。

接続がタイムアウトする(アクティビティの不足のために中止される)のを防ぐため、ロングポーリング技術は通常、各リクエストの最大時間を設定します。その後、サーバーは報告することがない場合でもとにかく応答し、クライアントは新しいリクエストを開始します。リクエストを定期的に再起動することで、技術の堅牢性が向上し、クライアントは一時的な接続障害やサーバーの問題から回復できます。

ロングポーリングを使用しているビジー状態のサーバーには、数千の待機中のリクエスト、つまりTCP接続が開かれている可能性があります。それぞれに個別の制御スレッドを作成することなく、多くの接続を簡単に管理できるNodeは、このようなシステムに適しています。

HTTPインターフェース

サーバーまたはクライアントのいずれかを具体化する前に、それらが接触するポイント、つまりそれらが通信するHTTPインターフェースについて考えてみましょう。

第20章のファイルサーバーと同様に、インターフェースはJSONに基づいており、HTTPメソッドを適切に使用しようとします。インターフェースは/talksパスを中心にしています。/talksで始まらないパスは、静的ファイル(クライアント側システムを実装するHTMLおよびJavaScriptコード)を提供するために使用されます。

/talksへのGETリクエストは、次のようなJSONドキュメントを返します

{"serverTime": 1405438911833,
 "talks": [{"title": "Unituning",
            "presenter": "Carlos",
            "summary": "Modifying your cycle for extra style",
            "comment": []}]}

serverTimeフィールドは、信頼性の高いロングポーリングを可能にするために使用されます。後ほど戻ります。

新しい講演を作成するには、/talks/UnituningのようなURLにPUTリクエストを行います。ここで、2番目のスラッシュの後の部分は講演のタイトルです。PUTリクエストの本文には、presenterおよびsummaryプロパティを持つJSONオブジェクトが含まれている必要があります。

講演のタイトルにはスペースやURLに通常表示されないその他の文字が含まれている可能性があるため、このようなURLを作成する場合は、encodeURIComponent関数を使用してタイトル文字列をエンコードする必要があります。

console.log("/talks/" + encodeURIComponent("How to Idle"));
// → /talks/How%20to%20Idle

アイドリングに関する講演を作成するリクエストは、次のようになります

PUT /talks/How%20to%20Idle HTTP/1.1
Content-Type: application/json
Content-Length: 92

{"presenter": "Dana",
 "summary": "Standing still on a unicycle"}

このようなURLは、講演のJSON表現を取得するためのGETリクエストと、講演を削除するためのDELETEリクエストもサポートしています。

講演にコメントを追加するには、/talks/Unituning/commentsのようなURLにPOSTリクエストを行い、リクエストの本文としてauthorおよびmessageプロパティを持つJSONオブジェクトを使用します。

POST /talks/Unituning/comments HTTP/1.1
Content-Type: application/json
Content-Length: 72

{"author": "Alice",
 "message": "Will you talk about raising a cycle?"}

ロングポーリングをサポートするために、/talksへのGETリクエストには、changesSinceと呼ばれるクエリパラメータを含めることができます。これは、クライアントが特定の時点以降に発生した更新に関心があることを示すために使用されます。そのような変更がある場合、それらはすぐに返されます。ない場合、何かが発生するか、特定の期間(90秒を使用します)が経過するまで、応答は遅延されます。

時間は、1970年の開始からの経過ミリ秒数として示す必要があります。これは、Date.now()によって返される数値と同じタイプです。すべての更新を受信し、同じ更新を複数回受信しないようにするために、クライアントはサーバーから最後に情報を受信した時刻を渡す必要があります。サーバーのクロックはクライアントのクロックと完全に同期していない可能性があり、たとえ同期していたとしても、ネットワーク経由でデータを送信するには時間がかかるため、クライアントがサーバーが応答を送信した正確な時刻を知ることは不可能です。

これが、/talksへのGETリクエストに送信されるレスポンスにserverTimeプロパティが存在する理由です。このプロパティは、クライアントに、受信したデータが作成されたサーバーの観点からの正確な時刻を伝えます。クライアントはこの時間を保存し、次のポーリングリクエストで渡すだけで、以前に見ていなかった更新を正確に受信できます。

GET /talks?changesSince=1405438911833 HTTP/1.1

(time passes)

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 95

{"serverTime": 1405438913401,
 "talks": [{"title": "Unituning",
            "deleted": true}]}

講演が変更された場合、新しく作成された場合、またはコメントが追加された場合、講演の完全な表現はクライアントの次のポーリングリクエストへのレスポンスに含まれます。講演が削除された場合、そのタイトルとプロパティdeletedのみが含まれます。クライアントは、まだ表示されていないタイトルの講演をディスプレイに追加し、すでに表示していた講演を更新し、削除された講演を削除できます。

本章で説明するプロトコルは、アクセス制御を行いません。誰でもコメントしたり、講演内容を変更したり、削除したりできます。インターネットにはフーリガン(迷惑行為をする人)がたくさんいるため、このようなシステムを保護なしにオンラインに置くと、災害につながる可能性があります。

簡単な解決策は、システムをリバースプロキシの背後に置くことです。リバースプロキシは、システム外部からの接続を受け入れて、ローカルで実行されているHTTPサーバーに転送するHTTPサーバーです。このようなプロキシは、ユーザー名とパスワードを要求するように設定でき、スキル共有グループの参加者だけがこのパスワードを持つようにすることができます。

サーバー

まずは、プログラムのサーバー側部分を作成することから始めましょう。このセクションのコードはNode.js上で実行されます。

ルーティング

サーバーは、http.createServerを使用してHTTPサーバーを起動します。新しいリクエストを処理する関数では、サポートするさまざまな種類のリクエスト(メソッドとパスによって決定される)を区別する必要があります。これは、長いif文の連鎖で行うことができますが、より良い方法があります。

ルーターは、リクエストを処理できる関数にディスパッチするのに役立つコンポーネントです。たとえば、正規表現/^\/talks\/([^\/]+)$//talks/の後に講演タイトルが続くものと一致)に一致するパスを持つPUTリクエストは、指定された関数で処理できることをルーターに指示できます。さらに、パスの意味のある部分(この場合は正規表現で括弧で囲まれた講演タイトル)を抽出して、ハンドラー関数に渡すことができます。

NPMには優れたルーターパッケージがいくつかありますが、ここでは原則を説明するために、独自に作成します。

これは、後でサーバーモジュールからrequireするrouter.jsです。

var Router = module.exports = function() {
  this.routes = [];
};

Router.prototype.add = function(method, url, handler) {
  this.routes.push({method: method,
                    url: url,
                    handler: handler});
};

Router.prototype.resolve = function(request, response) {
  var path = require("url").parse(request.url).pathname;

  return this.routes.some(function(route) {
    var match = route.url.exec(path);
    if (!match || route.method != request.method)
      return false;

    var urlParts = match.slice(1).map(decodeURIComponent);
    route.handler.apply(null, [request, response]
                                .concat(urlParts));
    return true;
  });
};

このモジュールは、Routerコンストラクターをエクスポートします。ルーターオブジェクトを使用すると、addメソッドで新しいハンドラーを登録し、resolveメソッドでリクエストを解決できます。

後者は、ハンドラーが見つかったかどうかを示すブール値を返します。ルートの配列に対するsomeメソッドは、ルートを1つずつ(定義された順序で)試行し、一致するものが見つかったら停止してtrueを返します。

ハンドラー関数は、requestオブジェクトとresponseオブジェクトを使用して呼び出されます。URLと一致する正規表現にグループが含まれている場合、それらが一致する文字列は、追加の引数としてハンドラーに渡されます。生のURLには%20スタイルのコードが含まれているため、これらの文字列はURLデコードする必要があります。

ファイルの提供

リクエストがルーターで定義されたリクエストタイプのいずれにも一致しない場合、サーバーはそれをpublicディレクトリ内のファイルに対するリクエストとして解釈する必要があります。第20章で定義されているファイルサーバーを使用して、このようなファイルを配信することもできますが、ファイルに対するPUTリクエストとDELETEリクエストをサポートする必要も望みもなく、キャッシュのサポートなどの高度な機能が必要です。そのため、代わりにNPMの堅牢で十分にテストされた静的ファイルサーバーを使用しましょう。

私はecstaticを選択しました。これはNPMで唯一のサーバーではありませんが、うまく機能し、私たちの目的に合っています。ecstaticモジュールは、設定オブジェクトを使用して呼び出すことで、リクエストハンドラー関数を生成できる関数をエクスポートします。rootオプションを使用して、サーバーがファイルを検索する場所を指示します。ハンドラー関数は、requestパラメーターとresponseパラメーターを受け取り、createServerに直接渡して、ファイル*のみ*を提供するサーバーを作成できます。ただし、最初に特別に処理するリクエストを確認したいので、別の関数でラップします。

var http = require("http");
var Router = require("./router");
var ecstatic = require("ecstatic");

var fileServer = ecstatic({root: "./public"});
var router = new Router();

http.createServer(function(request, response) {
  if (!router.resolve(request, response))
    fileServer(request, response);
}).listen(8000);

ヘルパー関数respondrespondJSONは、サーバーコード全体で使用され、単一の関数呼び出しでレスポンスを送信します。

function respond(response, status, data, type) {
  response.writeHead(status, {
    "Content-Type": type || "text/plain"
  });
  response.end(data);
}

function respondJSON(response, status, data) {
  respond(response, status, JSON.stringify(data),
          "application/json");
}

リソースとしての講演

サーバーは、提案された講演をtalksというオブジェクトに保持します。そのプロパティ名は講演のタイトルです。これらは/talks/[title]の下でHTTPリソースとして公開されるため、クライアントがそれらを操作するために使用できるさまざまなメソッドを実装するハンドラーをルーターに追加する必要があります。

単一の講演をGETするリクエストのハンドラーは、講演を検索し、講演のJSONデータまたは404エラーレスポンスのいずれかで応答する必要があります。

var talks = Object.create(null);

router.add("GET", /^\/talks\/([^\/]+)$/,
           function(request, response, title) {
  if (title in talks)
    respondJSON(response, 200, talks[title]);
  else
    respond(response, 404, "No talk '" + title + "' found");
});

講演の削除は、talksオブジェクトから削除することで行われます。

router.add("DELETE", /^\/talks\/([^\/]+)$/,
           function(request, response, title) {
  if (title in talks) {
    delete talks[title];
    registerChange(title);
  }
  respond(response, 204, null);
});

後ほど定義するregisterChange関数は、待機中のロングポーリングリクエストに変更を通知します。

JSONエンコードされたリクエスト本文の内容を取得するために、readStreamAsJSONという関数を定義します。この関数は、ストリームからすべてのコンテンツを読み取り、JSONとして解析し、コールバック関数を呼び出します。

function readStreamAsJSON(stream, callback) {
  var data = "";
  stream.on("data", function(chunk) {
    data += chunk;
  });
  stream.on("end", function() {
    var result, error;
    try { result = JSON.parse(data); }
    catch (e) { error = e; }
    callback(error, result);
  });
  stream.on("error", function(error) {
    callback(error);
  });
}

JSONレスポンスを読み取る必要のあるハンドラーの1つは、新しい講演を作成するために使用されるPUTハンドラーです。これは、指定されたデータに文字列であるpresenterプロパティとsummaryプロパティがあるかどうかを確認する必要があります。システム外部からのデータはナンセンスである可能性があり、不正なリクエストが発生した場合に、内部データモデルを破損させたり、クラッシュさせたりしたくありません。

データが有効に見える場合、ハンドラーは新しい講演を表すオブジェクトをtalksオブジェクトに格納し、場合によっては既存の講演をこのタイトルで上書きし、再びregisterChangeを呼び出します。

router.add("PUT", /^\/talks\/([^\/]+)$/,
           function(request, response, title) {
  readStreamAsJSON(request, function(error, talk) {
    if (error) {
      respond(response, 400, error.toString());
    } else if (!talk ||
               typeof talk.presenter != "string" ||
               typeof talk.summary != "string") {
      respond(response, 400, "Bad talk data");
    } else {
      talks[title] = {title: title,
                      presenter: talk.presenter,
                      summary: talk.summary,
                      comments: []};
      registerChange(title);
      respond(response, 204, null);
    }
  });
});

講演にコメントを追加するのも同様です。readStreamAsJSONを使用してリクエストのコンテンツを取得し、結果のデータを検証し、有効な場合はコメントとして保存します。

router.add("POST", /^\/talks\/([^\/]+)\/comments$/,
           function(request, response, title) {
  readStreamAsJSON(request, function(error, comment) {
    if (error) {
      respond(response, 400, error.toString());
    } else if (!comment ||
               typeof comment.author != "string" ||
               typeof comment.message != "string") {
      respond(response, 400, "Bad comment data");
    } else if (title in talks) {
      talks[title].comments.push(comment);
      registerChange(title);
      respond(response, 204, null);
    } else {
      respond(response, 404, "No talk '" + title + "' found");
    }
  });
});

存在しない講演にコメントを追加しようとすると、当然404エラーが返されます。

ロングポーリングのサポート

サーバーの最も興味深い側面は、ロングポーリングを処理する部分です。/talksに対するGETリクエストが到着すると、それはすべての講演に対する単純なリクエストか、changesSinceパラメーターを持つ更新のリクエストのいずれかになります。

講演のリストをクライアントに送信する必要がある状況はさまざまなので、最初に、そのようなレスポンスにserverTimeフィールドを添付する小さなヘルパー関数を定義します。

function sendTalks(talks, response) {
  respondJSON(response, 200, {
    serverTime: Date.now(),
    talks: talks
  });
}

ハンドラー自体は、リクエストのURLのクエリパラメーターを調べて、changesSinceパラメーターが指定されているかどうかを確認する必要があります。"url"モジュールのparse関数に2番目の引数としてtrueを指定すると、URLのクエリ部分も解析されます。返されるオブジェクトには、パラメーター名と値をマッピングする別のオブジェクトを保持するqueryプロパティがあります。

router.add("GET", /^\/talks$/, function(request, response) {
  var query = require("url").parse(request.url, true).query;
  if (query.changesSince == null) {
    var list = [];
    for (var title in talks)
      list.push(talks[title]);
    sendTalks(list, response);
  } else {
    var since = Number(query.changesSince);
    if (isNaN(since)) {
      respond(response, 400, "Invalid parameter");
    } else {
      var changed = getChangedTalks(since);
      if (changed.length > 0)
         sendTalks(changed, response);
      else
        waitForChanges(since, response);
    }
  }
});

changesSinceパラメーターがない場合、ハンドラーは単にすべての講演のリストを作成して返します。

それ以外の場合、changesSinceパラメーターを最初にチェックして、有効な数値であることを確認する必要があります。まもなく定義されるgetChangedTalks関数は、指定された時点以降に変更された講演の配列を返します。空の配列が返された場合、サーバーはまだクライアントに送り返すものが何もないため、レスポンスオブジェクトを(waitForChangesを使用して)格納し、後で応答します。

var waiting = [];

function waitForChanges(since, response) {
  var waiter = {since: since, response: response};
  waiting.push(waiter);
  setTimeout(function() {
    var found = waiting.indexOf(waiter);
    if (found > -1) {
      waiting.splice(found, 1);
      sendTalks([], response);
    }
  }, 90 * 1000);
}

spliceメソッドは、配列から一部分を切り取るために使用されます。インデックスと要素の数を指定すると、配列が*変更*され、指定されたインデックス以降のその数の要素が削除されます。この場合、indexOfを呼び出して見つけたインデックスである、待機中のレスポンスを追跡するオブジェクトである単一の要素を削除します。spliceに追加の引数を渡すと、それらの値が指定された位置に配列に挿入され、削除された要素が置き換えられます。

レスポンスオブジェクトがwaiting配列に格納されると、すぐにタイムアウトが設定されます。90秒後、このタイムアウトはリクエストがまだ待機しているかどうかを確認し、待機している場合は空のレスポンスを送信してwaiting配列から削除します。

指定された時点以降に変更された講演を正確に見つけるために、変更の履歴を追跡する必要があります。registerChangeで変更を登録すると、その変更と現在の時刻がchangesという配列に記憶されます。変更が発生すると、新しいデータがあることを意味するため、待機中のすべてのリクエストにすぐに応答できます。

var changes = [];

function registerChange(title) {
  changes.push({title: title, time: Date.now()});
  waiting.forEach(function(waiter) {
    sendTalks(getChangedTalks(waiter.since), waiter.response);
  });
  waiting = [];
}

最後に、getChangedTalkschanges配列を使用して、変更された講演の配列を作成します。これには、存在しなくなった講演のdeletedプロパティを持つオブジェクトが含まれます。その配列を作成するときに、指定された時間以降に講演に複数の変更が加えられている可能性があるため、getChangedTalksは同じ講演を2回含まないようにする必要があります。

function getChangedTalks(since) {
  var found = [];
  function alreadySeen(title) {
    return found.some(function(f) {return f.title == title;});
  }
  for (var i = changes.length - 1; i >= 0; i--) {
    var change = changes[i];
    if (change.time <= since)
      break;
    else if (alreadySeen(change.title))
      continue;
    else if (change.title in talks)
      found.push(talks[change.title]);
    else
      found.push({title: change.title, deleted: true});
  }
  return found;
}

これでサーバーコードは完了です。これまでに定義されたプログラムを実行すると、ポート8000で実行されているサーバーが起動します。これは、publicサブディレクトリからファイルを提供し、/talks URLの下で講演管理インターフェースを提供します。

クライアント

講演管理Webサイトのクライアント側部分は、HTMLページ、スタイルシート、JavaScriptファイルの3つのファイルで構成されています。

HTML

Webサーバーがディレクトリに対応するパスに直接リクエストが行われたときに、index.htmlという名前のファイルを配信しようとするのは、広く使用されている慣例です。私たちが使用しているファイルサーバーモジュールであるecstaticは、この慣例をサポートしています。パス/にリクエストが行われると、サーバーはファイル./public/index.html./publicは指定したルート)を検索し、見つかった場合はそのファイルを返します。

したがって、ブラウザがサーバーを指しているときにページを表示させたい場合は、public/index.htmlに配置する必要があります。これが、インデックスファイルの開始方法です。

<!doctype html>

<title>Skill Sharing</title>
<link rel="stylesheet" href="skillsharing.css">

<h1>Skill sharing</h1>

<p>Your name: <input type="text" id="name"></p>

<div id="talks"></div>

ドキュメントのタイトルを定義し、スタイルシートを含んでいます。スタイルシートでは、とりわけ、トークの周りに枠線を追加するなど、いくつかのスタイルを定義しています。そして、見出しと名前フィールドを追加します。ユーザーは後者に自分の名前を入力することで、送信するトークやコメントに名前を添付することができます。

IDが"talks"<div>要素には、現在のトークのリストが含まれます。スクリプトは、サーバーからトークを受信すると、リストにデータを入力します。

次に、新しいトークを作成するために使用されるフォームがあります。

<form id="newtalk">
  <h3>Submit a talk</h3>
  Title: <input type="text" style="width: 40em" name="title">
  <br>
  Summary: <input type="text" style="width: 40em" name="summary">
  <button type="submit">Send</button>
</form>

スクリプトはこのフォームに"submit"イベントハンドラーを追加し、そこからサーバーにトークを伝えるHTTPリクエストを作成できます。

次に、やや不可解なブロックがあります。これは、displayスタイルがnoneに設定されているため、実際にはページに表示されません。これは何のためか推測できますか?

<div id="template" style="display: none">
  <div class="talk">
    <h2>{{title}}</h2>
    <div>by <span class="name">{{presenter}}</span></div>
    <p>{{summary}}</p>
    <div class="comments"></div>
    <form>
      <input type="text" name="comment">
      <button type="submit">Add comment</button>
      <button type="button" class="del">Delete talk</button>
    </form>
  </div>
  <div class="comment">
    <span class="name">{{author}}</span>: {{message}}
  </div>
</div>

JavaScriptコードで複雑なDOM構造を作成すると、醜いコードになります。第13章elt関数のようなヘルパー関数を導入することで、コードを少し改善できますが、それでもDOM構造を表すためのドメイン固有言語と考えることができるHTMLよりも見劣りします。

トークのDOM構造を作成するために、プログラムは単純な*テンプレート*システムを定義します。これは、ドキュメントに含まれる非表示のDOM構造を使用して、二重中括弧の間のプレースホルダーを特定のトークの値に置き換えて、新しいDOM構造をインスタンス化します。

最後に、HTMLドキュメントには、クライアントサイドコードを含むスクリプトファイルが含まれています。

<script src="skillsharing_client.js"></script>

起動

ページが読み込まれたときにクライアントが行うべき最初のことは、サーバーに現在のトークのセットを要求することです。多くのHTTPリクエストを行うため、XMLHttpRequestの小さなラッパーを再び定義します。これは、リクエストを設定するためのオブジェクトと、リクエストが完了したときに呼び出すコールバックを受け入れます。

function request(options, callback) {
  var req = new XMLHttpRequest();
  req.open(options.method || "GET", options.pathname, true);
  req.addEventListener("load", function() {
    if (req.status < 400)
      callback(null, req.responseText);
    else
      callback(new Error("Request failed: " + req.statusText));
  });
  req.addEventListener("error", function() {
    callback(new Error("Network error"));
  });
  req.send(options.body || null);
}

初期リクエストは、受信したトークを画面に表示し、waitForChangesを呼び出すことでロングポーリングプロセスを開始します。

var lastServerTime = 0;

request({pathname: "talks"}, function(error, response) {
  if (error) {
    reportError(error);
  } else {
    response = JSON.parse(response);
    displayTalks(response.talks);
    lastServerTime = response.serverTime;
    waitForChanges();
  }
});

lastServerTime変数は、サーバーから受信した最後の更新の時刻を追跡するために使用されます。初期リクエストの後、クライアントのトークのビューは、サーバーがそのリクエストに応答したときのビューに対応します。したがって、レスポンスに含まれるserverTimeプロパティは、lastServerTimeの適切な初期値を提供します。

リクエストが失敗した場合、ページが何もせずにただそこに座っている状態にしたくありません。そこで、reportErrorという単純な関数を定義します。これは、少なくともユーザーに何か問題が発生したことを伝えるダイアログを表示します。

function reportError(error) {
  if (error)
    alert(error.toString());
}

この関数は、実際にエラーがあるかどうかを確認し、エラーがある場合にのみ警告します。そうすることで、レスポンスを無視できるリクエストに対して、この関数を直接requestに渡すこともできます。これにより、リクエストが失敗した場合、エラーがユーザーに報告されます。

トークの表示

変更が発生したときにトークのビューを更新できるようにするには、クライアントは現在表示しているトークを追跡する必要があります。そうすることで、画面に既に表示されているトークの新しいバージョンが来たときに、トークを(その場で)更新されたフォームに置き換えることができます。同様に、トークが削除されているという情報が来た場合は、ドキュメントから適切なDOM要素を削除できます。

displayTalks関数は、初期表示の構築と、何か変更があった場合の更新の両方に使用されます。トークのタイトルをDOMノードに関連付けるshownTalksオブジェクトを使用して、現在画面に表示されているトークを記憶します。

var talkDiv = document.querySelector("#talks");
var shownTalks = Object.create(null);

function displayTalks(talks) {
  talks.forEach(function(talk) {
    var shown = shownTalks[talk.title];
    if (talk.deleted) {
      if (shown) {
        talkDiv.removeChild(shown);
        delete shownTalks[talk.title];
      }
    } else {
      var node = drawTalk(talk);
      if (shown)
        talkDiv.replaceChild(node, shown);
      else
        talkDiv.appendChild(node);
      shownTalks[talk.title] = node;
    }
  });
}

トークのDOM構造の構築は、HTMLドキュメントに含まれているテンプレートを使用して行われます。最初に、テンプレートを検索して入力するinstantiateTemplateを定義する必要があります。

nameパラメータはテンプレートの名前です。テンプレート要素を検索するために、クラス名がテンプレート名と一致する要素を検索します。これは、IDが"template"の要素の子です。querySelectorメソッドを使用すると、これが簡単になります。HTMLページには、"talk""comment"という名前のテンプレートがありました。

function instantiateTemplate(name, values) {
  function instantiateText(text) {
    return text.replace(/\{\{(\w+)\}\}/g, function(_, name) {
      return values[name];
    });
  }
  function instantiate(node) {
    if (node.nodeType == document.ELEMENT_NODE) {
      var copy = node.cloneNode();
      for (var i = 0; i < node.childNodes.length; i++)
        copy.appendChild(instantiate(node.childNodes[i]));
      return copy;
    } else if (node.nodeType == document.TEXT_NODE) {
      return document.createTextNode(
               instantiateText(node.nodeValue));
    } else {
      return node;
    }
  }

  var template = document.querySelector("#template ." + name);
  return instantiate(template);
}

すべてのDOMノードが持つcloneNodeメソッドは、ノードのコピーを作成します。最初の引数としてtrueが指定されない限り、ノードの子ノードはコピーされません。instantiate関数は、テンプレートのコピーを再帰的に構築し、テンプレートに入力していきます.

instantiateTemplateの2番目の引数は、オブジェクトである必要があります。そのプロパティは、テンプレートに入力される文字列を保持します。{{title}}のようなプレースホルダーは、valuestitleプロパティの値に置き換えられます。

これはテンプレート化の粗雑な方法ですが、drawTalkを実装するには十分です.

function drawTalk(talk) {
  var node = instantiateTemplate("talk", talk);
  var comments = node.querySelector(".comments");
  talk.comments.forEach(function(comment) {
    comments.appendChild(
      instantiateTemplate("comment", comment));
  });

  node.querySelector("button.del").addEventListener(
    "click", deleteTalk.bind(null, talk.title));

  var form = node.querySelector("form");
  form.addEventListener("submit", function(event) {
    event.preventDefault();
    addComment(talk.title, form.elements.comment.value);
    form.reset();
  });
  return node;
}

"talk"テンプレートをインスタンス化した後、修正する必要があることがいくつかあります。まず、"comment"テンプレートを繰り返しインスタンス化し、結果をクラス"comments"のノードに追加することで、コメントを入力する必要があります。次に、タスクを削除するボタンと新しいコメントを追加するフォームにイベントハンドラーを添付する必要があります。

サーバーの更新

drawTalkによって登録されたイベントハンドラーは、deleteTalk関数とaddComment関数を呼び出して、トークの削除やコメントの追加に必要な実際のアクションを実行します。これらは、指定されたタイトルのトークを参照するURLを構築する必要があります。そのため、ヘルパー関数talkURLを定義します。

function talkURL(title) {
  return "talks/" + encodeURIComponent(title);
}

deleteTalk関数はDELETEリクエストを送信し、失敗した場合はエラーを報告します。

function deleteTalk(title) {
  request({pathname: talkURL(title), method: "DELETE"},
          reportError);
}

コメントを追加するには、コメントのJSON表現を作成し、それをPOSTリクエストの一部として送信する必要があります.

function addComment(title, comment) {
  var comment = {author: nameField.value, message: comment};
  request({pathname: talkURL(title) + "/comments",
           body: JSON.stringify(comment),
           method: "POST"},
          reportError);
}

コメントのauthorプロパティを設定するために使用されるnameField変数は、ページ上部にある、ユーザーが自分の名前を指定できる<input>フィールドへの参照です。また、ページがリロードされるたびに再入力する必要がないように、そのフィールドをlocalStorageに接続します.

var nameField = document.querySelector("#name");

nameField.value = localStorage.getItem("name") || "";

nameField.addEventListener("change", function() {
  localStorage.setItem("name", nameField.value);
});

新しいトークを提案するための、ページ下部にあるフォームには、"submit"イベントハンドラーがあります。このハンドラーは、イベントのデフォルトの効果(ページのリロードを引き起こす)を防ぎ、フォームをクリアし、トークを作成するためのPUTリクエストを送信します。

var talkForm = document.querySelector("#newtalk");

talkForm.addEventListener("submit", function(event) {
  event.preventDefault();
  request({pathname: talkURL(talkForm.elements.title.value),
           method: "PUT",
           body: JSON.stringify({
             presenter: nameField.value,
             summary: talkForm.elements.summary.value
           })}, reportError);
  talkForm.reset();
});

変更の検知

トークを作成または削除したり、コメントを追加したりすることでアプリケーションの状態を変更するさまざまな関数は、変更が画面に表示されることを保証するために何もしていないことを指摘しておく必要があります。それらは単にサーバーに指示し、ロングポーリングメカニズムに依存してページの適切な更新をトリガーします。

サーバーに実装したメカニズムと、既にページにあるトークの更新を処理するためにdisplayTalksを定義した方法を考えると、実際のロングポーリングは驚くほど単純です.

function waitForChanges() {
  request({pathname: "talks?changesSince=" + lastServerTime},
          function(error, response) {
    if (error) {
      setTimeout(waitForChanges, 2500);
      console.error(error.stack);
    } else {
      response = JSON.parse(response);
      displayTalks(response.talks);
      lastServerTime = response.serverTime;
      waitForChanges();
    }
  });
}

この関数は、プログラムの起動時に一度呼び出され、その後はポーリングリクエストが常にアクティブになるように自分自身を呼び出し続けます。リクエストが失敗した場合、サーバーに到達できないたびにダイアログが表示されると煩わしいため、reportErrorは呼び出しません。代わりに、エラーはコンソールに書き込まれ(デバッグを容易にするため)、2.5秒後に別の試行が行われます。

リクエストが成功すると、新しいデータが画面に表示され、この新しい時点に対応するデータを受信したという事実を反映するためにlastServerTimeが更新されます。リクエストは、次の更新を待つためにすぐに再開されます。

サーバーを実行し、*localhost:8000/*の2つのブラウザウィンドウを並べて開くと、一方のウィンドウで実行したアクションがもう一方のウィンドウにすぐに表示されることがわかります。

演習

以下の演習では、この章で定義されているシステムを変更します。作業するには、最初にコードをダウンロードし(_eloquentjavascript.net/2nd_edition/code/skillsharing.zip_)、Nodeがインストールされていることを確認してください(_nodejs.org_)。

ディスクへの永続化

スキル共有サーバーは、データをメモリ内にのみ保持します。つまり、クラッシュしたり、何らかの理由で再起動したりすると、すべてのトークとコメントが失われます。

サーバーがトークデータをディスクに保存し、再起動時に自動的にデータをリロードするようにサーバーを拡張します。効率については心配しないでください。機能する最も簡単な方法を実行してください。

私が思いつく最も簡単な解決策は、talksオブジェクト全体をJSONとしてエンコードし、fs.writeFileを使用してファイルにダンプすることです。サーバーのデータが変更されるたびに呼び出される関数(registerChange)が既にあります。新しいデータをディスクに書き込むように拡張できます。

ファイル名を選択します。たとえば、`./talks.json`です。サーバーの起動時に、`fs.readFile`でそのファイルを読み取ろうとすることができます。それが成功した場合、サーバーはファイルの内容を起動データとして使用できます。

ただし、注意してください。 talksオブジェクトは、`in`演算子を正しく使用できるように、プロトタイプのないオブジェクトとして開始されました。 `JSON.parse`は、`Object.prototype`をプロトタイプとする通常のオブジェクトを返します。JSONをファイル形式として使用する場合は、`JSON.parse`によって返されるオブジェクトのプロパティを新しいプロトタイプのないオブジェクトにコピーする必要があります。

コメントフィールドのリセット

トークの全体的な再描画は、DOMノードとその同一の置換との違いを通常は見分けられないため、非常にうまく機能します。ただし、例外があります。一方のブラウザウィンドウでトークのコメントフィールドに入力し始め、もう一方のウィンドウでそのトークにコメントを追加すると、最初のウィンドウのフィールドが再描画され、コンテンツとフォーカスの両方が削除されます。

複数の人が一つのトークにコメントを追加するような、活発な議論の中では、これは非常に煩わしいでしょう。これを回避する方法を考えられますか?

その場しのぎのアプローチとしては、トークを再描画する前に、トークのコメントフィールドの状態(内容とフォーカスされているかどうか)を単純に保存しておき、その後、フィールドを元の状態にリセットする方法があります。

別の解決策としては、古いDOM構造を新しいものに単純に置き換えるのではなく、ノードごとに再帰的に比較し、実際に変更された部分のみを更新する方法があります。これは実装がはるかに難しいですが、より汎用的であり、別のテキストフィールドを追加した場合でも動作し続けます。

より良いテンプレート

ほとんどのテンプレートシステムは、単に文字列を埋め込む以上のことを行います。少なくとも、`if`文に類似したテンプレートの一部の条件付き包含と、ループに類似したテンプレートの一部の繰り返しを許可します。

配列の各要素に対してテンプレートの一部を繰り返すことができれば、2番目のテンプレート(`"comment"`)は必要ありません。代わりに、`“talk”`テンプレートがトークの`comments`プロパティに保持されている配列をループし、配列のすべての要素に対してコメントを構成するノードをレンダリングするように指定できます。

これは次のようになります。

<div class="comments">
  <div class="comment" template-repeat="comments">
    <span class="name">{{author}}</span>: {{message}}
  </div>
</div>

`template-repeat`属性を持つノードがテンプレートのインスタンス化中に見つかるたびに、インスタンス化コードは、その属性によって名前が付けられたプロパティに保持されている配列をループするという考え方です。配列の各要素に対して、ノードのインスタンスを追加します。このループ中、テンプレートのコンテキスト(`instantiateTemplate`の`values`変数)は配列の現在の要素を指すため、`{{author}}`は元のコンテキスト(トーク)ではなく、コメントオブジェクトで検索されます。

これを実装するために`instantiateTemplate`を書き直し、この機能を使用するようにテンプレートを変更し、`drawTalk`関数からコメントの明示的なレンダリングを削除してください。

指定された値がtrueまたはfalseの場合にテンプレートの一部を省略できるように、ノードの条件付きインスタンス化を追加するにはどうすればよいですか?

内部関数がノードだけでなく現在のコンテキストも引数として取るように、`instantiateTemplate`を変更できます。その後、ノードの子ノードをループするときに、子が`template-repeat`属性を持っているかどうかを確認できます。持っている場合は、1回インスタンス化するのではなく、属性の値で示される配列をループし、配列のすべての要素に対して1回インスタンス化し、現在の配列要素をコンテキストとして渡します。

条件は、たとえば`template-when`および`template-unless`と呼ばれる属性を使用して同様の方法で実装できます。これらの属性は、指定されたプロパティがtrue(またはfalse)の場合にのみノードがインスタンス化されるようにします。

スクリプトなしの場合

JavaScriptが無効になっているブラウザ、または単にJavaScriptを表示できないブラウザで誰かがWebサイトにアクセスすると、完全に壊れて操作できないページが表示されます。これは良くありません。

一部の種類のWebアプリケーションは、JavaScriptなしでは本当に実現できません。その他の場合、スクリプトを実行できないクライアントについて気にする予算や忍耐力がないだけです。しかし、幅広いユーザーがいるページでは、スクリプトなしのユーザーをサポートするのが礼儀です。

JavaScriptなしで実行した場合に基本的な機能を維持するために、スキル共有Webサイトをどのように設定できるかを考えてみてください。自動更新はできなくなり、人々は昔ながらの方法でページを更新する必要があります。しかし、既存のトークを見たり、新しいトークを作成したり、コメントを送信したりできるのは良いことです。

実際にこれを実装する義務はありません。解決策の概要を示せば十分です。改訂されたアプローチは、最初に私たちが行ったことよりも、より洗練されていると感じますか、それともそれほど洗練されていないと感じますか?

この章で採用されたアプローチの2つの中心的な側面、つまりクリーンなHTTPインターフェースとクライアント側のテンプレートレンダリングは、JavaScriptなしでは機能しません。通常のHTMLフォームは`GET`および`POST`リクエストを送信できますが、`PUT`または`DELETE`リクエストは送信できず、データを固定URLにのみ送信できます。

したがって、サーバーは、本文がJSONではなく、HTMLフォームが使用するURLエンコード形式(第17章を参照)を使用する`POST`リクエストを介して、コメント、新しいトーク、および削除されたトークを受け入れるように改訂する必要があります。これらのリクエストは、ユーザーが変更を加えた後にサイトの新しい状態を確認できるように、完全な新しいページを返す必要があります。これは設計がそれほど難しくなく、「クリーンな」HTTPインターフェースと並行して実装できます。

トークをレンダリングするコードは、サーバー上で複製する必要があります。`index.html`ファイルは、静的ファイルではなく、ルーターにハンドラーを追加することによって動的に生成する必要があります。そうすれば、提供されたときに既に現在のトークとコメントが含まれています。