Выразительный JavaScript.
Сервер.
Начнём с написания серверной части программы. Код работает на Node.js.
Маршрутизация.
Для запуска сервера будет использоваться http.createServer. В функции, обрабатывающей новый запрос, мы должны различать запросы (определяемые методом и путём), которые мы поддерживаем. Это можно сделать через длинную цепочку if / else, но можно и красивее.
Маршрутизатор – компонент, помогающий распределить запрос к функции, которая может его обработать. Можно сказать маршрутизатору, что запросы PUT с путём, совпадающим с регуляркой /^\/talks\/(\/+)$/ (что совпадает с /talks/, за которым идёт название темы), могут быть обработаны заданной функцией. Кроме того, он может помочь извлечь осмысленные части пути, в нашем случае – название темы, заключённое в кавычки, и передать их вспомогательной функции.
В NPM есть много хороших модулей маршрутизации, но тут мы сами себе такой напишем, чтобы продемонстрировать принцип его работы.
Вот файл router.js, который будет запрашиваться через require из модуля сервера:
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. Объект router позволяет регистрировать новые обработчики с методом add, и распределять запросы методом resolve.
Последний вернёт булевское значение, показывающее, был ли найден обработчик. Метод some массива путей будет пробовать их по очереди (в порядке, в каком они были заданы), и остановится с возвратом true, если путь найден.
Функции обработчиков вызываются с объектами request и response. Когда регулярка, проверяющая URL, возвращает группы, то представляющие их строки передаются в обработчик в качестве дополнительных аргументов. Эти строчки надо декодировать из URL-стиля %20.
Выдача файлов.
Когда тип запроса не совпадает ни с одним из типов, которые обрабатывает маршрутизатор, сервер должен интерпретировать его как запрос файла из общей директории. Можно было бы использовать файловый сервер из главы 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);
Функции respond и respondJSON используются в коде сервера, чтобы можно было отправлять ответы одним вызовом функции.
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, у которого именами свойств являются названия тем. Они будут выглядеть как ресурсы HTTP по адресу /talks/[title], поэтому нам нужно добавить в маршрутизатор обработчиков, реализующих различные методы, которые клиенты могут использовать для работы с ними.
Обработчик для запросов 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 – это обработчик 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. Если дать функции parse модуля “url” второй аргумент значения true, он также распарсит вторую часть URL – query, часть запроса. У возвращаемого объекта будет свойство 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 обработчик просто строит список всех тем и возвращает его.
Иначе, сперва надо проверить параметр changeSince на предмет того, что это число. Функция getChangedTalks, которую мы вскоре определим, возвращает массив изменённых тем с некоего заданного времени. Если она возвращает пустой массив, то серверу нечего возвращать клиенту, так что он сохраняет объект response (при помощи 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, их значения будут вставлены в массив на заданной позиции, и заместят удалённые элементы.
Когда объект response сохранён в массиве 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 = [];
}
Наконец, getChangedTalks использует массив changes, чтобы построить массив изменившихся тем, включая объекты со свойством deleted для тем, которых уже не существует. При построении массива getChangedTalks должна убедиться, что одна и та же тема не включается дважды, так как тема могла измениться несколько раз с заданного момента времени.
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, который выдаёт файлы из публичной поддиректории и управляет интерфейсом тем по адресу /talks.
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
- 46.
- 47.
- 48.
- 49.
- 50.
- 51.
- 52.
- 53.
- 54.
- 55.
- 56.
- 57.
- 58.
- 59.
- 60.
- 61.
- 62.
- 63.
- 64.
- 65.
- 66.
- 67.
- 68.
- 69.
- 70.
- 71.
- 72.
- 73.
- 74.
- 75.
- 76.
- 77.
- 78.
- 79.
- 80.
- 81.
- 82.
- 83.
- 84.
- 85.
- 86.
- 87.
- 88.
- 89.
- 90.
- 91.
- 92.
- 93.
- 94.
- 95.
- 96.
- 97.
- 98.
- 99.
- 100.
- 101.
- 102.
- 103.
- 104.
- 105.
- 106.
- 107.
- 108.
- 109.
- 110.
- 111.
- 112.
- 113.
- 114.
- 115.
- 116.
- 117.
- 118.
- 119.
- 120.
- 121.
- 122.
- 123.
- 124.
- 125.
- 126.
- 127.
- 128.
- 129.
- 130.
- 131.
- 132.
- 133.
- 134.
- 135.
- 136.
- 137.
- 138.
- 139.
- 140.
- 141.
- 142.
- 143.
- 144.
- 145.
- 146.
- 147.
- 148.
- 149.
- 150.
- 151.
- 152.
- 153.
- 154.
- 155.
- 156.
- 157.
- 158.
- 159.
- 160.
- 161.
- 162.
- 163.
- 164.
- 165.
- 166.
- 167.
- 168.
- 169.
- 170.
- 171.
- 172.
- 173.
- 174.
- 175.
- 176.
- 177.
- 178.
- 179.
- 180.
- 181.
- 182.
- 183.
- 184.
- 185.
- 186.
- 187.
- 188.
- 189.
- 190.
- 191.
- 192.
- 193.
- 194.
- 195.
- 196.
- 197.
- 198.
- 199.
- 200.
- 201.
- 202.
- 203.
- 204.
- 205.
- 206.
- 207.
- 208.
- 209.
- 210.
- 211.
- 212.
- 213.
- 214.
- 215.
- 216.
- 217.
- 218.
- 219.
- 220.
- 221.
- 222.
- 223.
- 224.
- 225.
- 226.
- 227.
- 228.
- 229.
- 230.
- 231.
- 232.
- 233.
- 234.
- 235.
- 236.
- 237.
- 238.
- 239.
- 240.
- 241.
- 242.
- 243.
- 244.
- 245.
- 246.
- 247.
- 248.
- 249.
- 250.
- 251.
- 252.
- 253.
- 254.
- 255.
- 256.
- 257.
- 258.
- 259.
- 260.
- 261.
- 262.
- 263.
- 264.
- 265.
- 266.
- 267.
- 268.
- 269.
- 270.
- 271.
- 272.
- 273.
- 274.
- 275.
- 276.
- 277.
- 278.
- 279.
- 280.
- 281.
- 282.
- 283.
- 284.
- 285.
- 286.
- 287.
- 288.
- 289.
- 290.
- 291.
- 292.
- 293.
- 294.
- 295.
- 296.
- 297.
- 298.
- 299.
- 300.
- 301.
- 302.
- 303.
- 304.
- 305.
- 306.
- 307.
- 308.
- 309.
- 310.
- 311.
- 312.
- 313.
- 314.
- 315.
- 316.
- 317.
- 318.
- 319.
- 320.
- 321.
- 322.
- 323.
- 324.
- 325.
- 326.
- 327.
- 328.
- 329.
- 330.
- 331.
- 332.
- 333.
- 334.
- 335.
- 336.
- 337.
- 338.
- 339.
- 340.
- 341.
- 342.
- 343.
- 344.
- 345.
- 346.
- 347.
- 348.
- 349.
- 350.
- 351.
- 352.
- 353.
- 354.
- 355.
- 356.
- 357.
- 358.
- 359.
- 360.
- 361.
- 362.
- 363.
- 364.
- 365.
- 366.
- 367.
- 368.
- 369.
- 370.
- 371.
- 372.
- 373.