Глава 20
Node.js

Един студент попитал: ”Старите програмисти използват само прости машини и никакви програмни езици, но въпреки това те правят красиви програми. Защо ние използваме сложни машини и езици за програмиране?”. Фу-Дзъ отговорил: ”Старите строители използвали само пръчки и глина, но те правели само красиви колиби.””

Master Yuan-Ma, Книга за програмиране

Досега учихме езика JavaScript и го използвахме в рамките на една среда: браузъра. В тази глава и следващата накратко ще ви запозная с Node.js, една програма, която позволява прилагане на вашите умения с JavaScritp извън браузъра. С него може да се изгради всичко от прости инструменти за командния ред до динамични HTTP сървъри.

Тези глави имат за цел да ви научат на важните идеи, които Node.js надгражда и да ви дадат достатъчно информация, за да напишете някои полезни програми на него. Но те не се опитват да бъдат пълни или по-задълбочени при разглеждането на Node.js.

Като имате това в предвид, можете да стартирате кода от предишните глави директно на тези страници, тъй като той е суров JavaScript или е писан за браузъра, примерните кодове в тази глава са писани за Node и няма да се показват на браузъра.

Ако искате да вървим заедно и да изпълните кода от тази глава, започнете като отидете на nodejs.org и следвайте инструкциите за инсталиране на вашата операционна система. Същото се отнася до този уеб сайт за допълнителната документация на Node и неговите вградени модули.

Заден план (Background)

Един от най-трудните проблеми с писането на системи, които комуникират по мрежата е управлението на входа и изхода, тоест четенето и писането на данни към и от мрежата, на твърдия диск, както и на други подобни устройства. Местенето на данни на около отнема време и умелото планиране може да направи голяма разлика в това, колко бързо системата реагира с потребителя или на исканията на мрежата.

Традиционият начин да се справите с входа и изхода е да има функция, като например readFile, която да чете файл и се връща само, когато файлът е напълно прочетен. Това се нарича синхронен synchronous I/O (I/O означава вход/изход).

Node първоначално е бил замислен с цел за правене на асинхронен I/O лесно и удобно. Видяхме асинхронни интерфейси преди, като например, обекта на браузъра XMLHttpRequest дискутиран в Chapter 17. Асинхронния интерфейс позволява на скрипта да продължи да се изпълнява, докато той си работи и призовава функция за обратно извикване, когато това е направено. Това е начинът, по който Node върши цялата работа по I/O.

JavaScript се поддава добре на система, като Node. Той е един от малкото езици за програмиране, които нямат вграден начин да направите I/O. За това, JavaScript може да бъде подходящ ексцентричен подход към Node I/O, без да се стига до два несъвместими интерфейса. През 2009г., когато е бил проектиран Node, хората вече правят комуникации в браузъра, базирани на I/O, така че насочват услията си за използване на асинхронен стил на програмиране.

Asynchronicity

Ще се опитам да сравня синхронни срещу асинхронни I/O с един малък пример, когато дадена програма се нуждае от извличане на два ресурса от Интернет и след това направи няколко прости обработки с резултата.

В синхронна среда, очевидният начин да се изпълни тази задачата е да се направят исканията едно след друго. Този метод има недостатък, че второто искане ще бъде стартирано, само когато първото е приключило. Общото време ще бъде най-малко сумата от двата отговора. Това не е ефективно използване на машината, което ще бъде най-вече празен ход, когато се предават или приемат данни през мрежата.

Решението на този проблем със синхронната система е да се подадат допълнителни нишки за контрол. (Вижте Chapter 14, където обсъждахме тези теми). С втората нишка може да започне второто искане, а след това двете заявки чакат резултатите от тях да се върнат, след което те се ресинхронизират за да комбинират своите резултати.

В следната диаграма, дебелите линии проследяват времето, през което програмата работи нормално, а тънките линии представят времето за изчакване на I/O. В синхронен модел, времето необходимо за I/O е част от времевата линия за дадена тема на контрол. В асинхронния модел, със самото започване на I/O действията, концептуално се предизвиква разцепване в линията на времето. Нишката, която инициализира I/O продължава да работи и самото I/O се извършва успоредно с него, накрая се извиква функцията за обратно извикване, когато тя е завършена.

Control flow for synchronous and asynchronous I/O

Друг начин за изразяване на тази разлика е, че очакването на I/O да завърши е заложено в синхронния модел, макар и да е ясно, че е директно под наш контрол в асинхронен такъв. Но асинхронния модел е нож с две остриета. Това прави изразяващите програми, които не се вместват в линейния модел на контрол по-лесни, но също така прави изразяването на програми, които следват правата линия, по-неудобни.

В Глава 17 засегнахме факта, че тези обратни извиквания добавят доста околен шум към програмата. Дали асинхронния стил е добра идея, като цяло може да се дискутира. Във всеки случай това отнема известно привикване.

Но за система базирана на JavaScript, бих казал, че обратно извикващия асинхронен стил е разумен избор. Една от силните страни на JavaScript е неговата простота и опита да добавим няколко нишки на контрол към него, ще добави много сложност. Въпреки, че обратните извиквания, нямат тенденцията да доведат до прост код, като концепция, те са приятно прости, но и достатъчно мощни за да напишем високо производителен уеб сървър.

Node команди

Когато Node.js се инсталира на системата, той осигурява програма наречена node, която позволява стартиране на JavaScript файлове. Да речем, че имаме файл hello.js съдържащ този код.

var message = "Hello world";
console.log(message);

После можем да стартираме node от командния ред, подобно на изпълнението на програма:

$ node hello.js
Hello world

Метода console.log в Node прави нещо подобно на това, което прави в браузъра. Той отпечатва част от текст. Но в Node текста ще отиде в процеса на стандартния изходен поток, а не в JavaScript конзолата на браузъра.

Ако стартираме node без да му дадем файл, той предоставя команден ред (prompt), в който можем да въведем директно код на JavaScript и веднага да видим резултата.

$ node
> 1 + 1
2
> [-1, -2, -3].map(Math.abs)
[1, 2, 3]
> process.exit(0)
$

Променливата process, точно както console променливата е на глобално разположение в Node. Тя предоставя различни начини за преглеждане и манипулиране на текущата програма. Метода exit завършва процеса и прилага код със статут на излизане, което казва на програмата стартирана в node (в този случай обвивката на командния ред) дали е приключила успешно (код 0) или е възникнала грешка (всеки друг код).

За да видите аргументите от командния ред дадени за вашия скрипт, можете да прочетете process.argv, което е масив от strings. Имайте в предвид, те това включва името на node командите и името на вашия скрипт, така че действителните аргументи започват с индекс 2. Ако showargv.js просто съдържа изявление console.log(process.argv) можете да го използвате така:

$ node showargv.js one --and two
["node", "/home/marijn/showargv.js", "one", "--and", "two"]

Всички стандартни JavaScript глобални променливи, като Array, Math и JSON също присъстват в заобикалящата среда на Node. Свързаните с функционалноста на браузъра, като например, document и alert отсъстват.

Глобалния обект на обхвата, който се нарича window в браузъра, има по-разумното име global в Node.

Модули

Извън променливите, които споменахме, като console и process Node предоставя малко функционалност в глобалния обхват. Ако искате да получите достъп до други вградени функции, трябва питате модулната система за тях.

Модул системата CommonJS се основава на require функцията описана в Глава 10. Тази система е вградена в Node и се използва за да зареди нещо от вградените модули на изтеглени библиотеки с файлове, като част от вашата собствена програма.

Когато се извиква require, Node трябва да определи от дадената поредица от strings, действителния файл и да го зареди. Пътища и файлове, които започват с "/", "./" или "../" са решени по отношение на пътя на текущия модул, където "./" означава текущата директория, "../" една директория нагоре и "/" корена на файловата система. Така че, ако попитаме за "./world/world" от файловете на /home/marijn/elife/run.js, Node ще се опита да зареди файловете на /home/marijn/elife/world/world.js. Разширението .js може да се пропусне.

Когато string, които не изглежда, като относителен или абсолютен път се подаде на require, се предполага, че се отнася към вграден модул или модул инсталиран в node_modules директорията. Така например, require("fs") ще ни даде вградената модулна файлова система на Node, а require("elife") ще опита да зареди библиотека, която се намира в node_modules/elife/. Един общ начин за инсталиране на библиотеки е с помощта на NPM, който ще обсъдим по-късно.

За да илюстрираме използването на require, нека създадем прост проект състоящ се от два файла. Първият се нарича main.js, който дефинира скрипт, който може да бъде извикан от командния ред подобно на string.

var garble = require("./garble");

// Index 2 holds the first actual command-line argument
var argument = process.argv[2];

console.log(garble(argument));

Файла garble.js определя библиотеката за grabling strings, които могат да се използват, както от инструмента за командния ред определен по-рано, така и от други скриптове, които се нуждаят от директен достъп до функцията grabling.

module.exports = function(string) {
  return string.split("").map(function(ch) {
    return String.fromCharCode(ch.charCodeAt(0) + 5);
  }).join("");
};

Не забравяйте, че замяната на module.exports, а не добавяне на свойства в него ни позволява да изнасяме конкретна стойност от един модул. В този случай, ние правим резултат от изискването на нашия garble файл на самата функция grabling.

Функцията разделя string на единични характери разделени от празен string и след това заменя всеки характер с характера чиито код е пет пункта по-висок. Накрая ги свързва в резултата обратно в string.

Сега можем да извикаме нашия инструмент, подобен на този:

$ node main.js JavaScript
Of{fXhwnuy

Инсталиране с NPM

NPM, който е споменат на кратко в Глава 10 е онлайн хранилище на модули за JavaScript, много от които са специално написани за Node. Когато инсталирате Node на вашия компютър, също получавате и една програма наречена npm, която представлява удобен интерфейс за това хранилище.

Например, един модул, който ще намерите на NPM е figlet, който може да превърне текст в ASCII apm-drawings направен от текстови характери. Следната транскрипция показва как да го инсталирате и ползвате.

$ npm install figlet
npm GET https://registry.npmjs.org/figlet
npm 200 https://registry.npmjs.org/figlet
npm GET https://registry.npmjs.org/figlet/-/figlet-1.0.9.tgz
npm 200 https://registry.npmjs.org/figlet/-/figlet-1.0.9.tgz
figlet@1.0.9 node_modules/figlet
$ node
> var figlet = require("figlet");
> figlet.text("Hello world!", function(error, data) {
    if (error)
      console.error(error);
    else
      console.log(data);
  });
  _   _      _ _                            _     _ _
 | | | | ___| | | ___   __      _____  _ __| | __| | |
 | |_| |/ _ \ | |/ _ \  \ \ /\ / / _ \| '__| |/ _` | |
 |  _  |  __/ | | (_) |  \ V  V / (_) | |  | | (_| |_|
 |_| |_|\___|_|_|\___/    \_/\_/ \___/|_|  |_|\__,_(_)

След стартирането на npm install, NPM ще създаде директория, наречена node_modules. Вътре в тази директория ще бъде figlet директорията, която съдържа библиотеката. Когато стартираме node и извикаме require("figlet") тази библиотека се зарежда и ние можем да използваме своя text метод, за да начертае няколко големи букви.

Донякъде неочаквано, вместо просто да върне string с направените големи букви, figlet.text взема функция с обратно извикване, на която подава своя резултат. Той също така подава аргумент за обратно извикване към error, който държи обекта за грешка, ако нещо се обърка или нула ако всичко е наред

Това е често срещан модел на код в Node. Представяне на нещо с figlet изисква библиотеката да чете даден файл, който съдържа писмо от фигури. Четенето на такъв файл от диска е асинхронна операция в Node, така че figlet.text не може веднага да върне своя резултат. Asynchronicity е инфекциозен по начин, по който всяка функция, която извиква асинхронна функция, трябва да превърне себе си в асинхронна.

Има много повече неща за NPM, отколкото за npm install. Той чете package.json файлове, които съдържат JSON-кодирана информация за дадена програма или библиотека, като например, от кои други библиотеки зависи. Правейки npm install в директорията, която съдържа такъв файл, автоматично ще инсталира всички зависимости, както и техните зависимости. Инструмента npm се използва също и за публикуване на библиотеки (онлайн хранилища) пакети NPM, така че други хора да ги намерят, изтеглят и използват.

Тази книга няма да рови по-нататък в подробности за използването на NPM. Обърнете се към npmjs.org за допълнителна документация и лесен начин за търсене на библиотеки.

Модул файлова система

Един от най-често използваните вградени модули, които идват с Node e модула "fs", което е съкращение на файлова система. Този модул осигурява функции за работа с файлове и директории.

Например, там е функцията наречена readFile, която чете файл и след това се извиква обратно с файловото съдържание.

var fs = require("fs");
fs.readFile("file.txt", "utf8", function(error, text) {
  if (error)
    throw error;
  console.log("The file contained:", text);
});

Вторият аргумент на readFile посочва характер-кодирането, използвано за декодиране на файл в string. Има няколко начина, по който текст може да бъде кодиран в бинарни данни, но повечето съвременни системи използват UTF-8 за да кодират текст, така че ако нямате причини да смятате, че се използва друго кодиране, подаването на "utf8" при четене на текстов файл е безопасен залог. Ако не се подава кодиране, Node ще приеме, че се интересувате от бинарни данни и ще ви даде Buffer обект вместо string. Това е масиво-подобен обект, който съдържа числа представящи байтове във файла.

var fs = require("fs");
fs.readFile("file.txt", function(error, buffer) {
  if (error)
    throw error;
  console.log("The file contained", buffer.length, "bytes.",
              "The first byte is:", buffer[0]);
});

Подобна функция writeFile се използва за записване на файл на диска.

var fs = require("fs");
fs.writeFile("graffiti.txt", "Node was here", function(err) {
  if (err)
    console.log("Failed to write file:", err);
  else
    console.log("File written.");
});

Тук не е необходимо да се уточни кодирането, тъй като writeFile ще предположи, че ако е даден string за запис, а не Buffer обект, той трябва да го запише, като текст, използвайки своето стандартно кодиране на характери, което е UTF-8.

Модулът "fs" съдъръжа много други полезни функции: readdir връща файловете в директорията, като масив от strings, stat извлича информация за даден файл, rename преименува файл, unlink премахва файл и т.н. Вижте документацията на nodejs.org за всеки конкретен случай.

Много от функциите в "fs"идват в двата варианта: синхронен и асинхронен. Например, това е синхронна версия на readFile наречена readFileSync.

var fs = require("fs");
console.log(fs.readFileSync("file.txt", "utf8"));

Синхронните функции изискват по-малко приготовления за употреба и могат да бъдат полезни в прости скриптове, където допълнителната скорост предоставена от асинхронен I/O е без значение. Но имайте в предвид, че въпреки малкото синхронна работа, която се извършва, вашата програма ще бъде изцяло спряна. Ако трябва да се отговори на потребител или на други машини в мрежата, задържането на синхронно I/O може да произведе досадни забавяния.

HTTP модул

Друг централен модул се нарича "http". Той предоставя функционалност за работа с HTTP сървъри и вземане на HTTP заявки.

Това е всичко необходимо, за да започнем с един прост HTTP сървър.

var http = require("http");
var server = http.createServer(function(request, response) {
  response.writeHead(200, {"Content-Type": "text/html"});
  response.write("<h1>Hello!</h1><p>You asked for <code>" +
                 request.url + "</code></p>");
  response.end();
});
server.listen(8000);

Ако пуснете този скрипт на собствената си машина, можете да го посочите на вашия уеб браузър с http://localhost:8000/hello за да направи искане към сървъра. Той ще отговори с малка HTML страница.

Функцията се подава, като аргумент на createServer и се извиква всеки път, когато клиента се опитва да се свърже със сървъра. Променливите request и response са обекти представляващи входящите и изходящите данни. Първият съдържа информация за искането, като негово url свойство, което ни казва каквo URL искане е направено.

За да изпратите нещо обратно, трябва да извикате методите на response обекта. Първият writeHead ще напише заглавието на отговора (виж Глава 17). Можем да му дадем кода за състоянието (200 за случай на “OK”) и обект който съдържа стойностите на заглавието. Тука трябва да кажем на клиента, че ще изпратим обратно HTML документ.

На следващо място, действителното тяло на отговора (самия документ) е изпратен с response.write. Вие имате право да извиквате този метод няколко пъти, ако искате да изпратите отговора парче по парче, вероятно докато потока с данни на клиента станат достъпни. И накрая response.end сигнализира за края на отговора.

Извикването на server.listen, казва на сървъра да започне да чака връзка на порт 8000. Това е причината, поради която трябва да се свържем с localhost:8000, а не само с localhost (което по подразбиране е порт 80), за да направим връзка с този сървър.

За да спрете използването на Node скрипта, подобно на този който не завършва автоматично в очакване на по нататъшни събития (в този случай мрежова връзка), натиснете Ctrl-C.

Един истински уеб сървър обикновено е по-голям от този в предишния пример, той разглежда метода на искането (method свойството), за да види, какви действия се опитва да изпълни клиента и искането на URL, за да разбере с кой ресурс се извършва това действие. Ще разгледаме по напреднал сървър по-късно в тази глава..

За да действа, като HTTP клиент можем да използваме request функцията в "http" модула.

var http = require("http");
var request = http.request({
  hostname: "eloquentjavascript.net",
  path: "/20_node.html",
  method: "GET",
  headers: {Accept: "text/html"}
}, function(response) {
  console.log("Server responded with status code",
              response.statusCode);
});
request.end();

Първият аргумент на request конфигурира искането, казва на Node с кой сървър да говори, какъв път да поиска от този сървър, кой метод да използва и т.н. Вторият аргумент е функция, която трябва да се извика, когато се получи отговор. На нея и е даден обект, който ни позволява да се запознаем с отговора, например, да разберем кода на статуса.

Точно, както в response обекта, който видяхме в сървъра, обекта върнат от require позволява поток данни в искането с write метод и завършва искането с end метод на края. Примерът не използва write, защото GET заявката не трябва да съдържа данни в тялото на искането.

За да направим искане за защитени HTTP (HTTPS) URL адреси, Node осигурява пакет, наречен https, който съдържа своя собствена request функция, подобно на http.request.

Потоци

Видяхме два примера на записващи потоци в HTTP-примерите, а именно обекта на отговор, в който сървъра може да пише и обекта за искане, който се връща от http.request.

Записващите потоци представляват широко използвана концепция в интерфейса на Node. Всички записващи потоци имат write метод, който може да бъде прехвърлен на string или Buffer обект. Техния end метод затваря потока и ако му се даде аргумент, той също ще запише част от данните преди да го направи. И двата метода могат да приемат callback, като първи аргумент, който ще се извика, когато писането или затварянето на потока приключи.

Възможно е да се създаде записваем поток, който сочи към файл с функцията fs.createWriteStream. След това можете да използвате write метода върху получения обект, за да напишете едно парче файл в даден момент, а не в един изстрел, както с fs.writeFile.

Четимите потоци са малко по-ангажиращи. И двете променливи: request, която се подава на функцията за обратно повикване на сървъра HTTP и response подадена към клиента на HTTP, са четими потоци. (Сървърът прочита заявката и след това пише отговорите, като има в предвид, че клиента първо пише искането и след това чете отговора.) Четене на поток се извършва с помощта на манипулатор на събитие, а не с методи.

Обектите, които излъчват събития в Node имат метод наречен on, който е подобен на addEventListener метода в браузъра. Можете да му дадете име на събитие и след това функция и той ще регистрира тази функция за да бъде призована винаги, когато настъпи даденото събитие.

Четимите потоци имат "data" и "end" събития. Ефекта на първото се изпълнява всеки път, когато идват някакви данни, а второто се извиква, когато потока е в своя край. Този модел е най-подходящ за streaming (потоци) данни, които могат да бъдат незабавно обработени, дори ако целия документ не е на разположение. Файлът може да се чете, като четим поток с помощта на fs.createReadStream функцията.

Следният код създава сървър, който чете тялото на заявката и го излъчва обратно на клиента, като текст от главни букви.

var http = require("http");
http.createServer(function(request, response) {
  response.writeHead(200, {"Content-Type": "text/plain"});
  request.on("data", function(chunk) {
    response.write(chunk.toString().toUpperCase());
  });
  request.on("end", function() {
    response.end();
  });
}).listen(8000);

Променливата chunk се подава към манипулатора на данни, който е бинарен Buffer, който можем да превърнем в string, като извикаме toString върху него, който ще декодираме с помощта на кодировката по подразбиране UTF-8.

Следващото парче от код, ако се пусне докато горния сървър работи, ще изпрати искане към този сървър и ще напише отговора, който получава.

var http = require("http");
var request = http.request({
  hostname: "localhost",
  port: 8000,
  method: "POST"
}, function(response) {
  response.on("data", function(chunk) {
    process.stdout.write(chunk.toString());
  });
});
request.end("Hello server");

Примерът пише process.stdout (стандартен изход на process, като записващ поток), вместо да използва console.log. ние не можем да използваме console.log, защото добавя допълнителен характер за нов ред след всяка част от текста, който пише, което не е подходящо тука.

Един прост файлов сървър

Нека да комбинираме нашите новооткрити знания за HTTP сървъри и поговорим с файловата система да създаде мост между тях: един HTTP сървър, който да позволява отдалечен достъп до файловата система. Такива сървъри имат много приложения. Те позволяват на уеб приложения да съхраняват и обменят данни или позволяват на една група от хора да споделят достъпа до един куп файлове.

Когато третираме файлове, като HTTP ресурси, методите на HTTP- GET, PUT и DELETE могат съответно да се използват за четене, писане и изтриване на файлове. Ние ще интерпретираме пътя в заявката, като път на файл, така че искането да се отнася до него.

Вероятно не искаме да споделим цялата си файлова система, така че ще интерпретираме тези пътеки, като изходна работна директория на сървъра /home/marijn/public/ (или C:\Users\marijn\public\ на Windows), после искането за /file.txt следва да се отнася до /home/marijn/public/file.txt (или C:\Users\marijn\public\file.txt).

Ние ще изградим парче по парче програма, с помощта на един обект, наречен methods за съхранение на функциите, които се занимават с различните методи на HTTP.

var http = require("http"), fs = require("fs");

var methods = Object.create(null);

http.createServer(function(request, response) {
  function respond(code, body, type) {
    if (!type) type = "text/plain";
    response.writeHead(code, {"Content-Type": type});
    if (body && body.pipe)
      body.pipe(response);
    else
      response.end(body);
  }
  if (request.method in methods)
    methods[request.method](urlToPath(request.url),
                            respond, request);
  else
    respond(405, "Method " + request.method +
            " not allowed.");
}).listen(8000);

Стартирането на този сървър, ще върне отговор за грешка 405, което е кодът, който се използва за указване, че даден метод не се обработва от сървъра.

Функцията respond се предава на функциите, които се занимават с различните методи и действия, като обратно извикване за завършване на искането. Това взема HTTP статус код, тялото и по желание типа на съдържанието, като аргумент. Ако стойността подадена, като тяло е четим поток, тя ще има pipe метод, който се използва за да предаде четимия поток към записващия поток. Ако не е, се предполага, че е null (не тяло) или string и се предава директно в отговора на end метода.

За да получим пътя от URL в искането, urlToPath функцията използва вградения в Node "url" модул, за да направи разбор на URL адреса. Тя взема името на пътя, което ще бъде нещо като /file.txt, декодира го, за да се отърве от %20 - ескейпването на кода и слага префикс - една точка, за да произведе път спрямо текущата директория.

function urlToPath(url) {
  var path = require("url").parse(url).pathname;
  return "." + decodeURIComponent(path);
}

Ако се притеснявате за сигурността на urlToPath функцията, прави сте. Ще се върнем към това в упражненията.

Ние ще създадем GET метод, за да върне списък от файловете, когато чете директорията и да върне съдържанието на файла, когато чете обикновен файл.

Един труден въпрос е, какъв вид Content-Type заглавие трябва да сложим, когато се връща съдържанието на даден файл. Тъй като тези файлове могат да бъдат всичко, нашият сървър не може просто да върне един и същи тип за всички тях. Но NPM може да ни помогне с това. Пакета mime (съдържа типове показатели, като text/plain, които също се наричат MIME types) знае правилният тип за огромен брой файлови разширения.

Ако изпълните следната npm команда в директорията, в която се намира вашия скрипт на сървъра, вие ще бъдете в състояние да използвате require("mime") за да получите достъп до библиотеката:

$ npm install mime
npm http GET https://registry.npmjs.org/mime
npm http 304 https://registry.npmjs.org/mime
mime@1.2.11 node_modules/mime

Когато търсеният файл не съществува, правилният код за HTTP грешка е да се върне 404. Ние ще използваме fs.stat, който търси по информацията за даден файл и ще разбере дали файлът съществува и дали е директория.

methods.GET = function(path, respond) {
  fs.stat(path, function(error, stats) {
    if (error && error.code == "ENOENT")
      respond(404, "File not found");
    else if (error)
      respond(500, error.toString());
    else if (stats.isDirectory())
      fs.readdir(path, function(error, files) {
        if (error)
          respond(500, error.toString());
        else
          respond(200, files.join("\n"));
      });
    else
      respond(200, fs.createReadStream(path),
              require("mime").lookup(path));
  });
};

Понеже има работа с диска и по този начин може да отнеме известно време, fs.stat е асинхронен. Ако файлът не съществува, fs.stat ще подаде error обект с code свойство "ENOENT", като свое обратно извикване. Би било хубаво, ако Node дефинираше различни подтипове на Error за различните видове грешки, но не го прави. Вместо това, поставя неясни Unix кодове там.

Ние ще докладваме всякакви неочаквани грешки с код на състоянието 500, което показва, че проблемът съществува на сървъра, за разлика от кодове започващи с 4 (като 404), които се отнасят за лоши заявки. Има някои ситуации, в които това не е съвсем точно, но за един малък пример като този, е достатъчно добре.

В stats обекта върнат от fs.stat се казва няколко неща за даден файл, като размерите му (size свойство) и неговата дата на промяна (mtime свойство). Тук се интересуваме от въпроса дали е директория или обикновен файл, което метода isDirectory ни казва.

Ние използваме fs.readdir да прочете списъка с файлове в дадена директория и с още едно обратно извикване, го връща на потребителя. За нормални файлове, ние създаваме четим поток с fs.createReadStream и го подаваме на respond заедно с типа на съдържание, което“"mime" модула ни дава за името на файла.

Кода за справяне с DELETE заявките е малко по-лесен.

methods.DELETE = function(path, respond) {
  fs.stat(path, function(error, stats) {
    if (error && error.code == "ENOENT")
      respond(204);
    else if (error)
      respond(500, error.toString());
    else if (stats.isDirectory())
      fs.rmdir(path, respondErrorOrNothing(respond));
    else
      fs.unlink(path, respondErrorOrNothing(respond));
  });
};

Може би се чудите, защо когато се опитва да изтрие не съществуващ файл връща статус 204, а не грешка. Когато файлът, който се заличава не е там, може да се каже, че целта на искането вече е изпълнена. Стандартът за HTTP насърчава хората да правят искания idempotent, което означава, че прилагането им няколко пъти не произвежда различен резултат.

function respondErrorOrNothing(respond) {
  return function(error) {
    if (error)
      respond(500, error.toString());
    else
      respond(204);
  };
}

Когато HTTP отговора не съдържа никакви данни, кодът за състоянието 204 (без съдържание) може да се използва за да покаже това. Тъй като трябва да предоставим обратно извикване за докладване на грешка или да върнем 204 отговор в рамките на няколко различни ситуации, написахме функцията respondErrorOrNothing, която създава такова обратно извикване.

Това е манипулатор за PUT заявки:

methods.PUT = function(path, respond, request) {
  var outStream = fs.createWriteStream(path);
  outStream.on("error", function(error) {
    respond(500, error.toString());
  });
  outStream.on("finish", function() {
    respond(204);
  });
  request.pipe(outStream);
};

Тука не трябва да проверяваме дали файла съществува, ако съществува просто го презаписваме. Ние отново използваме pipe за да пренесем данните от четимия поток към записващия, в този случай, искането до файла. Ако създаването на потока не успее, се повдига "error" събитие, което отчитаме в нашия отговор. Когато данните се прехвърлят успешно, pipe ще затвори двата потока, което ще доведе до ефекта на "finish" събитие на записващия поток. Когато това се случи, можем да докладваме успех на клиента с отговор 204.

Пълният скрипт за сървъра е на разположение на eloquentjavascript.net/code/file_server.js. Можете да го изтеглите и ползвате с Node, като свой собствен файлов сървър. И разбира се, можете да го променяте и удължавате за решаване на упражненията в тази глава или просто да експериментирате.

Инструмента на командния ред curl, е на разположение за Unix-подобни системи и може да се използва за направата на HTTP заявки. Следващата сесия тества накратко нашия сървър. Имайте в предвид, че -X се използва за задаване на метода на искането и -d за включване на заявката.

$ curl http://localhost:8000/file.txt
File not found
$ curl -X PUT -d hello http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
hello
$ curl -X DELETE http://localhost:8000/file.txt
$ curl http://localhost:8000/file.txt
File not found

Първото искане за file.txt се провали, тъй като преписката все още не съществува. PUT искането създава файла и на следващата заявка успешно го извлича. След изтриването с DELETE заявката, файлът отново липсва.

Боравене с Error

В кода на файловия сървър има шест места, където изрично определяме маршрута на изключенията, но не знаем как се справят в отговор на грешки. Тъй като, изключенията не се размножават автоматично с обратните извиквания, а по-скоро преминават към тях като аргументи и за това те трябва да бъдат изрично обработени всеки път. Това напълно обезсмисля предимството на обработката на изключения, а именно способността да се централизира манипулирането на условията за отказ.

Какво се случва, когато нещо наистина хвърля изключение в тази система? Тъй като ние не използваме никакви try блокове, изключенията ще се разпространяват до върха на стека за извикване. Node прекъсва програмата и пише информация за изключение (включително и следата в стека) към стандартния поток за грешка в програмата.

Това означава, че нашия сървър ще се срине, когато се сблъскаме с проблем вътре в кода на сървъра, за разлика от асинхронни проблеми, които ще бъдат предадени като аргументи към обратните извиквания. Ако искаме да се справим с всички изключения, повдигнати по време на обработката на искането, за да сме сигурни, че е изпратен отговор, ние трябва да добавим try/catch блок на всяко обратно извикване.

Това не работи. Много Node програми са писани да направят малката употреба на изключения възможна с предположението, че ако изключението е било повдигнато, това не е нещо, с което програмата може да справи и счупва правилния отговор.

Друг подход е използването на обещания, които бяха въведени в Глава 17. Тези прихванати изключения, са повдигнати от функцията за обратно извикване и се разпространяват, като провал. Възможно е да заредите обещание от библиотека в Node и да го използвате за управление на вашия асинхронен контрол. Малко библиотеки на Node интегрират обещания, но често е тривиално да ги увиете. Отличният модул "promise" от NPM съдържа функция наречена denodeify, която взема асинхронна функция, като fs.readFile и я превръща във функция връщаща обещание.

var Promise = require("promise");
var fs = require("fs");

var readFile = Promise.denodeify(fs.readFile);
readFile("file.txt", "utf8").then(function(content) {
  console.log("The file contained: " + content);
}, function(error) {
  console.log("Failed to read file: " + error);
});

За сравнение, съм написал друга версия на файловия сървър на базата на обещания, която може да намерите на eloquentjavascript.net/code/file_server_promises.js. Тя е малко по-чиста, защото сега функциите могат да връщат техните резултати, а не да се налага извикването на обратното извикване и определя маршрута на изключенията по подразбиране, а не изрично.

Ще изброим няколко реда от базирания на обещания файлов сървър, за да илюстрираме разликата в стила на програмиране.

Обекта fsp, който се използва в този код съдържа обещание - варианти на редица fs функции, обвити в Promise.denodeify. Обекта се връща от метод манипулатор с код и тяло свойства, които ще се превърнат в крайния резултат от веригата от обещания и ще бъде използван за да се определи, какъв вид отговор да бъде изпратен на клиента.

methods.GET = function(path) {
  return inspectPath(path).then(function(stats) {
    if (!stats) // Does not exist
      return {code: 404, body: "File not found"};
    else if (stats.isDirectory())
      return fsp.readdir(path).then(function(files) {
        return {code: 200, body: files.join("\n")};
      });
    else
      return {code: 200,
              type: require("mime").lookup(path),
              body: fs.createReadStream(path)};
  });
};

function inspectPath(path) {
  return fsp.stat(path).then(null, function(error) {
    if (error.code == "ENOENT") return null;
    else throw error;
  });
}

Функцията inspectPath е просто обвивка около fs.stat, която обработва случаите, когато не е намерен файла. В този случай, ние заменяме провала с успех, като добавим null. На всички други грешки им е позволено да се разпространяват. Когато обещанието, което се връща от тези манипулатори се провали, HTTP сървърът отговаря с код на състоянието 500.

Резюме

Node хубава, разбираема система, която ни позволява да стартираме JavaScript без браузър. Тя първоначално е била предназначена за мрежови задачи да играе ролята на разклонение в мрежата. Но тя се поддава на всички видове скриптови задачи и ако писането на JavaScript е нещо, което обичате, автоматизирането на ежедневните задачи с Node работи чудесно.

NPM осигурява библиотеки за всичко, което можете да се сетите (и доста неща, за които най-вероятно никога няма да се сетите) и ви позволява да извлечете и инсталирате тези библиотеки със стартирането на една проста команда. Node също така идва с редица вградени модули, включително "fs" модул, за работа с файловата система и "http" модул за управление на HTTP сървъри и вземане на HTTP заявки.

Целият вход и изход в Node се извършва асинхронно, освен ако изрично не използвате синхронен вариант на функцията, като например fs.readFileSync. Можете да предоставите функции за обратно извикване и Node ще ги извика в подходящо време, когато I/O иска да приключи.

Упражнения

Преговори content отново

В Глава 17 първото упражнение е да се направят няколко искания за eloquentjavascript.net/author, които питат за различните типове съдържание чрез подаване на различни Accept заглавия.

Направете това отново, използвайки http.request функцията на Node. Питайте за поне text/plain, text/html и application/json видове медии. Не забравяйте, че заглавието на искането може да се даде, като обект в свойството на headers на http.request, като първи аргумент.

Напишете съдържанието на отговорите за всяка заявка.

Не забравяйте да извикате end метода на обекта върнат от http.request за действително изстрелване на искането.

Обекта на отговора подава към обратното извикване на http.request четим поток. Това означава, че не е съвсем тривиално да получите цялото тяло на отговора от него. Следната програма функция чете целия поток и извиква функцията за обратно извикване с резултата използвайки обичайния модел за подаване на error, ако срещне някава грешка, като първи аргумент за обратното извикване:

function readStreamAsString(stream, callback) {
  var data = "";
  stream.on("data", function(chunk) {
    data += chunk.toString();
  });
  stream.on("end", function() {
    callback(null, data);
  });
  stream.on("error", function(error) {
    callback(error);
  });
}

Определяне на течове

За лесен отдалечен достъп до някои файлове, можех да ги получа имайки файловия сървър (дефиниран в настоящата глава) работещ на моята машина в /home/marijn/public. Тогава един ден разбирам, че някой е получил достъп до всички пароли, които съхранявах на браузъра си.

Как се случи това?

Ако още не ви е ясно, се върнете обратно на urlToPath функцията, дефинирана, като това:

function urlToPath(url) {
  var path = require("url").parse(url).pathname;
  return "." + decodeURIComponent(path);
}

Сега да разгледаме факта, че пътеките подадени към "fs" функцията, могат да бъдат относителни - те могат да съдържат "../", за да се изкачат нагоре по директорията. Какво ще се случи, ако един клиент изпрати искане за URL адреси, като тези показани тук?

http://myhostname:8000/../.config/config/google-chrome/Default/Web%20Data
http://myhostname:8000/../.ssh/id_dsa
http://myhostname:8000/../../../etc/passwd

Променете urlToPath за да решите този проблем. Вземете под внимание факта, че Node на Windows позволява двете наклонени черти и обратно наклонените черти за отделяне на директории.

Също така, помислете върху факта, че веднага след като се изложат някои половинчати системи в Интернет, бъговете в тези системи могат да бъдат използвани, за правенето на лоши неща на вашата машина.

Достатъчно е да се оголят всички срещания на две точки, които имат наклонена черта, обратно наклонена черта или в края на string от двете страни. Използвайки replace метода с регулярен израз е най-лесният начин да се направи това. Не забравяте и g флага на израза или replace ще замени само един случай и хората все още могат да заобикалят тази мярка за безопасност чрез включване на допълнителни двойни точки в пътищата си! Също така се уврете, че правите замени след декодиране на string или е възможно да се саботира проверката чрез кодиране на точка или наклонена черта.

Друг потенциално тревожен случай е, когато пътеките започват с наклонена черта, което се тълкува, като абслулютни пътеки. Но тъй като urlToPath поставя характера за точка пред пътя, не е възможно да се създаде искане, което води по такъв път. Множество наклонени черти на един ред във вътрешноста на пътя са странни, но ще се третират, като една наклонена черта от файловата система.

Създаване на директории

Въпреки, че метода DELETE е свързан с изтриването на директории (използвайки fs.rmdir), файловият сървър в момента няма никакъв начин да създаде директория.

Добавете поддръжка към метода MKCOL, който трябва да създаде директория с извикването на fs.mkdir. MKCOL не е от основните методи на HTTP, но съществува с това също предназначение в стандарта на WebDAV, който определя набор от разширения за HTTP, правейки го подходящ за писане на ресурси, а не само да ги чете.

Можете да използвате функцията, която изпълнява DELETE метода, като модел за MKCOL метода. Когато няма намерен файл, опитайте се да създадете директория с fs.mkdir. Когато съществува директория на този път можете да върнете отговор 204, така че исканията за създаване на директория са idempotent. Ако няма такава директория на съществуващ файл, върнете код за грешка. Кодът 400 ("лошо искане") би бил подходящ за това.

Публично пространство в Интернет

Тъй като файловият сървър служи за всякакъв вид файлове и дори включва правило Content-Type хедър, можете да го използвате да послужи за уеб-сайт. Но тъй като това позволява на всеки да изтрие и замени файлове, ще бъде интересен вид уеб-сайт: който може да бъде модифициран, обезобразен и унищожен от всеки, който отдели време да създаде правилното искане за HTTP. Все пак това ще бъде един уеб-сайт.

Напишете основната HTML страница, която включва един прост JavaScript файл. Сложете файловете в директорията обслужвана от файловия сървър и го отворете в браузъра си.

На следващо място, като специално упражнение или дори проект за уикенд, комбинирайте всички знания, които сте придобили от тази книга за изграждане на по-лесен за употреба от потребителя интерфейс за промяна на уеб-сайта, отвътре на сайта.

Използвайте HTML форма (Глава 18), за да редактирате съдържанието на файловете, които правят уеб-сайта, което позволява на потребителя да ги актуализира в сървъра с помощта на HTTP заявки, както е описано в Глава 17.

Започнете само с един файл, който може да се редактира. След това го направете така, че потребителя да може да избере кой файл да редактира. Използвайте факта, че нашият файлов сървър връща списъци с файлове, когато чете директория.

Не работете директно в кода на файловия сървър, понеже ако допуснете грешка има вероятност да наредите на файловете там. Вместо това, дръжте своята работа извън публично достъпна директория и го копирайте там когато се изпробва.

Ако компютърът ви е директно вързан към Интернет, без защитна стена, рутер или друго устройство, което се намесва между тях, може да успеете да поканите приятел да използва вашия сайт. За проверка, отидете на whatismyip.com, копирайте IP адреса даден в адресния бар на браузъра ви и добавете :8000 след него, за да изберете правилния порт. Ако ви отведе на вашия сайт, то той е онлайн за всеки да го види.

Можете да създадете <textarea> елемент за поддържане на съдържанието на файла, който се редактира. Можете да използвате GET искането използвайни XMLHttpRequest, за да получите текущото съдържание на файла. Можете да използвате относителни URL адреси, като index.html вместо http://localhost:8000/index.html да се отнасят към файловете на същия сървър, като стартирате скрипта.

След това, когато потребителя кликне на бутон (може да използвате <form> елемент и "submit" събитие или просто "click" манипулатор) да направи PUT заявка до същия URL адрес със съдържанието на <textarea>, като тяло на заявката, за да запишете файла.

След това можете да добавите <select> елемент, който съдържа всички файлове в главната директория на сървъра чрез добавяне на <option> елементи съдържащи линиите върнати от GET заявката към URL адреса /. Когато потребителя избере друг файл (с "change" събитие на полето) скрипта трябва да донесе и покаже файл. Също така се уверете, че при запис на файл, който използвате в момента е избрано името на файла.

За съжаление, сървърът е твърде опростен, за да може надеждно да чете файлове от поддиректории, тъй като не ни казва дали нещо, което е донесено с GET искане е обикновен файл или директория. Сещате ли се за начин за разширяване на сървъра да се справи с това?