Obsługa HTTP w Node.js: Ścieżki
W poprzedniej lekcji stworzyliśmy bardzo podstawową aplikację Node.js uruchamiającą prosty serwer HTTP. W tej lekcji nauczymy się podstaw obsługi ścieżek, czytania danych z pliku oraz poprawnego oznaczania typu zwracanej zawartości.
URI, ścieżki, statusy i złożoność
Przypomnienie: Części składowe URI
Na poprzedniej lekcji mówiliśmy o dokumentach Request For Comments (RFC), które standaryzują zasady działania różnych technologii internetowych. To co często nazywamy adresami internetowymi albo linkami, z techniczego punktu widzenia są to przykłady Uniform Resource Identifiers (URI), czyli jednorodnych identyfikatorów zasobów. Format URI definiowany jest przez RFC 3986. Poniższy rysunek pokazuje przykładowy adres z wyróżnieniem poszczególnych części URI:

Przekazywanie ścieżek i metod w Node.js
W samouczku zaczęliśmy pracować nad prostym serwerem. Korzystając z niego dalej możemy zmodyfikować funkcję obsługującą żądania i wypisać do terminala całe żądanie przekazywane nam przez serwer w parametrze req.
1import { createServer } from 'node:http';
2
3// Create a HTTP server
4const server = createServer((req, res) => {
5 console.log(req);
6 res.writeHead(200, { 'Content-Type': 'text/plain' });
7 res.end('hello world!');
8});
9
10const port = 8000;
11const host = "localhost";
12
13// Start the server
14server.listen(port, host, () => {
15 console.log(`Server listening on http://${host}:${port}`);
16});Następnie wykonajmy zapytanie albo poprzez otwarcie przeglądarki i podanie adresu naszego serwera, albo używając narzędzia curl:
> index.js
# w osobnym terminalu
> curl http://localhost:8000/Jednak szybko się przekonamy, że takie drukowanie daje nam stanowczo za dużo informacji. Osoby wytrwałe mogą przejść przez listę i przekonać się, że w obiekcie req znajduje się pole url, które wygląda jakby zawierało interesującą nas informację.
W programowaniu czasami chcemy ukryć przed użytkownikami informacje, które nie są im potrzebne. W językach programowania takich jak C++, C# czy Java, istnieje możliwość oznaczania części składowych obiektu jako prywatne, niedostępne dla zewnętrzych użytkowników. Taki zabieg jasno daje znać użytkownikom, że to nie jest część interfejsu, z której powinni korzystać, a programistom pozwala swobodnie dzielić funkcjonalność na mniejsze, spójniejsze funkcje i umieszczać dane pomocnicze w sensownie nazwanych zmiennych.
W języku JavaScript nie ma takiej możliwości, dlatego programiści często stosują konwencje nazewnicze do oznaczenia rzeczy jako “prywatne”, dając znać swoim użytkownikom, że informacje zawarte w tym polu nie powinny ich interesować, a już na pewno nie powinni opierać na nich swoich rozwiązań ani modyfikować tych pól. W przypadku obiektu req znajdziecie co najmniej kilka pól, których nazwa zaczyna się od znaku “_”. To są właśnie pola oznaczone przez developerów Node.js jako prywatne
Aby ograniczyć ilość drukowanych przez naszą aplikację logów, zamieńmy drukowanie całego obiektu żądania na wypisanie tylko interesująch nas informacji. W razie potrzeby możemy dodać ich więcej później:
1import { createServer } from 'node:http';
2
3// Create a HTTP server
4const server = createServer((req, res) => {
5 console.log(`Request: ${req.method} ${req.url}`);
6 res.writeHead(200, { 'Content-Type': 'text/plain' });
7 res.end('hello world!\n');
8});
9
10const port = 8000;
11const host = "localhost";
12
13// Start the server
14server.listen(port, host, () => {
15 console.log(`Server listening on http://${host}:${port}`);
16});Po dodaniu takiego logowania uruchom serwer i wykonaj następujące komendy w drugim terminalu:
> curl http://localhost:8000/
> curl http://localhost:8000/foobar/
> curl http://localhost:8000/ready/player/one
> curl "http://localhost:8000/items?order=price,ascending&filter=none"
> curl "http://localhost:8000/blog/post/123?clickid=21fdj9l0#chapter-3"I jak wyniki? Czy tego się spodziewaliśmy?
Ewidentnie korzystanie z pola url wciąż daje nam za dużo informacji, jeżeli interesuje nas tylko ścieżka. Twórcy Node.js w tej sytuacji radzą użyć pomocniczego narzędzia do wydobycia części składowych z url, konkretnie klasy URL z modułu node:url. Po więcej informacji odsyłam oczywiście do dokumentacji.
1import { createServer } from 'node:http';
2import { URL } from "node:url";
3
4// Create a HTTP server
5const server = createServer((req, res) => {
6 const request_url = new URL(`http://${host}${req.url}`);
7 console.log(`Request: ${req.method} ${request_url.pathname}`);
8
9 res.writeHead(200, { 'Content-Type': 'text/plain' });
10 res.end('hello world!\n');
11});
12
13const port = 8000;
14const host = "localhost";
15
16// Start the server
17server.listen(port, host, () => {
18 console.log(`Server listening on http://${host}:${port}`);
19});Dodając parsowanie URL w linii 6 możemy teraz wydobyć samą ścieżkę używając pola pathname w uzyskanym obiekcie, tak jak w linii 7.
Selektywna obsługa ścieżek
Nasz serwer na chwilę obecną jest zbyt chętny do współpracy. Niezależnie od wpisanej ścieżki, zawsze zwraca pozytywny i ten sam rezultat. Zmieńmy to w taki sposób, żeby serwer pozytywnie odpowiadał tylko na żądanie o główną ścieżkę “/”, a na wszystkie pozostałe odpowiadał kodem 404, czyli informując klienta, że nie posiada takiej podstrony.
1import { createServer } from 'node:http';
2import { URL } from "node:url";
3
4// Create a HTTP server
5const server = createServer((req, res) => {
6 const request_url = new URL(`http://${host}${req.url}`);
7 const path = request_url.pathname;
8 console.log(`Request: ${req.method} ${path}`);
9
10 if (path === "/" && req.method === 'GET') {
11 res.writeHead(200, { 'Content-Type': 'text/plain' });
12 res.end('hello world!\n');
13 }
14
15 if (!res.writableEnded) {
16 res.writeHead(404, { 'Content-Type': 'text/plain' });
17 res.end('Site not found!\n');
18 }
19});
20
21// ... dalej nie ma żadnych zmian
W linii 15 skorzystaliśmy z pola writableEnded obiektu res, o którym możemy się dowiedzieć więcej z dokumentacji Node.js. W celu przeczesywania dokumentacji bardzo polecam korzystanie z agregatrów dokumentacji takich jak np. DevDocs.
Możemy teraz spróbować wysłać do naszego serwera różne żądania i upewnić się, że funkcjonuje jak należy.
Jeżeli chcesz sprawdzić, jak serwer zareaguje na żądanie z inną metodą HTTP niż GET, polecam użyć narzędzia curl z parametrem --request <METODA>, np. w taki sposób:
> curl --request POST http://localhost:8000/W tej sytuacji nasza sposób obsługi żądania nie do końca ma sens, prawda? Ewidentnie apliacja obsługuje ścieżkę / dla metody GET, więc błąd NOT FOUND dla metody POST wydaje się nietrafiony.
Zadanie 1
Popraw obsługę ścieżki głównej “/” tak, żeby zwracała adekwatny kod błędu dla metody HTTP innej niż GET
Różne ścieżki, różne rezultaty
Dodajmy do naszej aplikacji obsługę dodatkowej ścieżki. Dokładnie rzecz biorąc proponuję:
- przenieść obecną odpowiedź
hello world!pod ścieżkę/hello - dla głównej ścieżki
/zwracać prosty dokument HTML
Dokument HTML moglibyśmy zdefiniować w kodzie, ale proponuję umieścić go w osobnym folderze w swoim własnym pliku. Proponuję taką strukturę:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My first site</title>
</head>
<body>
<h1>The best site there ever was</h1>
<p>There are many like it, but this site is mine</p>
<p>See <a href="/hello">here</a> for a different handler of this server</p>
</body>
</html>Natomiast do naszego kodu serwera wprowadzimy następujące zmiany:
1import { createServer } from "node:http";
2import { readFileSync } from "node:fs";
3import { URL } from "node:url";
4
5const index_html = readFileSync("static/index.html");
6
7// Create a HTTP server
8const server = createServer((req, res) => {
9 const request_url = new URL(`http://${host}${req.url}`);
10 const path = request_url.pathname;
11 console.log(`Request: ${req.method} ${path}`);
12
13 if (path === "/") {
14 if (req.method !== "GET") {
15 res.writeHead(405, { "Content-Type": "text/plain" });
16 res.end("Method not allowed\n");
17 } else {
18 res.writeHead(200, { "Content-Type": "text/html" });
19 res.end(index_html);
20 }
21 }
22
23 if (path === "/hello") {
24 if (req.method !== "GET") {
25 res.writeHead(405, { "Content-Type": "text/plain" });
26 res.end("Method not allowed\n");
27 } else {
28 res.writeHead(200, { "Content-Type": "text/plain" });
29 res.end("hello world!\n");
30 }
31 }
32
33 if (!res.writableEnded) {
34 res.writeHead(404, { "Content-Type": "text/plain" });
35 res.end("Site not found!\n");
36 }
37});
38
39// ... dalej nie ma żadnych zmian
Oprócz dodania obsługi ścieżek poprawiliśmy też zwracany kod błędu w przypadku użycia metody innej niż GET dla obsługiwanych ścieżek.
Zwróć uwagę na naglówek Content-Type w linii 18. To jest informacja, która pozwala przeglądarce poprawnie zinterpretować zwróconą zawartość.
Zadanie 2
Co się stanie, jeżeli zwrócimy text/plain jako wartość Content-Type dla dokumentu HTML?
Złożoność kontrolowana
Ostatnim krokiem na dziś będzie uporządkowanie naszego kodu. Proces zmiany albo reorganizacji kodu bez zmiany jego funkcjonalności nazwywamy refaktoryzacją. Kiedy doprowadziliśmy już nasz program do działania warto się zastanowić, czy nie da się naszego programu napisać w sposób bardziej logiczny i czytelny.
Patrząc na naszą funkcję po pierwsze widzimy, że znacząco się rozrosła, a po drugie pojawiła się duplikacja kodu związana z ograniczeniem dozwolonych metod. Sama duplikacja kodu nie jest z definicji zła, ale czasami świadczy o tym, że może dałoby się coś w kodzie uprościć.
Możemy przeredagować nasz kod na przykład w ten sposób:
1import { createServer } from "node:http";
2import { readFileSync } from "node:fs";
3import { URL } from "node:url";
4
5const index_html = readFileSync("static/index.html");
6
7// Create a HTTP server
8const server = createServer((req, res) => {
9 const request_url = new URL(`http://${host}${req.url}`);
10 const path = request_url.pathname;
11 console.log(`Request: ${req.method} ${path}`);
12
13 // Only GET requests are supported
14 if (req.method !== "GET") {
15 res.writeHead(405, { "Content-Type": "text/plain" });
16 res.end("Method not allowed\n");
17 }
18
19 if (!res.writableEnded && path === "/") {
20 res.writeHead(200, { "Content-Type": "text/html" });
21 res.end(index_html);
22 }
23
24 if (!res.writableEnded && path === "/hello") {
25 res.writeHead(200, { "Content-Type": "text/plain" });
26 res.end("hello world!\n");
27 }
28
29 if (!res.writableEnded) {
30 res.writeHead(404, { "Content-Type": "text/plain" });
31 res.end("Site not found!\n");
32 }
33});
34
35// ... dalej nie ma żadnych zmian
Co eliminuje problem duplikacji logiki, ale wprowadza inne problemy.
- Co jeżeli jedna ze ścieżek będzie obsługiwać w przyszłości inne metody HTTP? Możemy wtedy dodać jej obsługę przed warunek z linii 13, albo dopisać dodatkową logikę kiedy będzie potrzebna.
- Wszystkie warunki w funkcji wyglądają mniej więcej podobnie, a jednak mają trochę inne zadania. Pierwszy i ostatni obsługują błędy w żądaniach, podczas gdy wewnętrzne to prawidłowa obsługa ścieżek. A jednak na pierwszy rzut oka ciężko zauważyć tę różnicę.
Proponuję jeszcze jedną rundę refaktoryzacji, zobaczmy gdzie nas to zaprowadzi.
1import { createServer } from "node:http";
2import { readFileSync } from "node:fs";
3import { URL } from "node:url";
4
5const index_html = readFileSync("static/index.html");
6
7const pathConfigs = [
8 {
9 path: "/",
10 allowed_methods: ["GET"],
11 handler: (req, res) => {
12 res.writeHead(200, { "Content-Type": "text/html" });
13 res.end(index_html);
14 },
15 },
16 {
17 path: "/hello",
18 allowed_methods: ["GET"],
19 handler: (req, res) => {
20 res.writeHead(200, { "Content-Type": "text/plain" });
21 res.end("hello world!\n");
22 },
23 },
24];
25
26// Create a HTTP server
27const server = createServer((req, res) => {
28 const request_url = new URL(`http://${host}${req.url}`);
29 const path = request_url.pathname;
30 console.log(`Request: ${req.method} ${path}`);
31
32 for (let config of pathConfigs) {
33 if (path === config.path) {
34 if (config.allowed_methods.includes(req.method)) {
35 config.handler(req, res);
36 } else {
37 res.writeHead(405, { "Content-Type": "text/plain" });
38 res.end("Method not allowed\n");
39 }
40 break;
41 }
42 }
43
44 if (!res.writableEnded) {
45 res.writeHead(404, { "Content-Type": "text/plain" });
46 res.end("Site not found!\n");
47 }
48});
49
50// ... dalej nie ma żadnych zmian
Poświęć chwilę na analizę powyższego kodu.
Czy nasze zmiany sprawiły, że kod jest czytelniejszy? Może? A może niekoniecznie? Na pewno udało nam się pogrupować obsługiwane ścieżki w jednym miejscu. Może gdyby podzielić nasz kod na mniejsze pliki byłoby łatwiej go zrozumieć? Spróbujmy.
1import { createServer } from "node:http";
2import { URL } from "node:url";
3import { handlePath } from "./src/path_handlers.js";
4
5// Create a HTTP server
6const server = createServer((req, res) => {
7 const request_url = new URL(`http://${host}${req.url}`);
8 console.log(`Request: ${req.method} ${request_url.pathname}`);
9
10 handlePath(request_url.pathname, req, res);
11
12 if (!res.writableEnded) {
13 res.writeHead(404, { "Content-Type": "text/plain" });
14 res.end("Site not found!\n");
15 }
16});
17
18const port = 8000;
19const host = "localhost";
20
21// Start the server
22server.listen(port, host, () => {
23 console.log(`Server listening on http://${host}:${port}`);
24}); 1import { readFileSync } from "node:fs";
2
3const index_html = readFileSync("static/index.html");
4
5const pathConfigs = [
6 {
7 path: "/",
8 allowed_methods: ["GET"],
9 handler: (req, res) => {
10 res.writeHead(200, { "Content-Type": "text/html" });
11 res.end(index_html);
12 },
13 },
14 {
15 path: "/hello",
16 allowed_methods: ["GET"],
17 handler: (req, res) => {
18 res.writeHead(200, { "Content-Type": "text/plain" });
19 res.end("hello world!\n");
20 },
21 },
22];
23
24export function handlePath(path, req, res) {
25 for (let config of pathConfigs) {
26 if (path === config.path) {
27 if (config.allowed_methods.includes(req.method)) {
28 config.handler(req, res);
29 } else {
30 res.writeHead(405, { "Content-Type": "text/plain" });
31 res.end("Method not allowed\n");
32 }
33 break;
34 }
35 }
36}Przenieśliśmy kod obsługujący ścieżki do osobnego pliku i umieściliśmy go w funkcji handlePath. Aby ta funkcja była dostępna dla innych plików w naszym projekcie, żeby inne pliki mogły ją zaimportować, w pliku gdzie jest stworzona funkcja musi być wyeksportowana. Służy do tego słowo kluczowe export, które stawiamy przed definicją funkcji. W ten sposób w pliku index.js możemy ją zaimportować, a następnie użyć później w kodzie.
Być może taka restrukturyzacja kodu była nieco na wyrost dla tego projektu. Może kod wydawał się czytelniejszy w jednej z poprzednich wersji, ale w ten sposób nauczyliśmy się jak dzielić nasz projekt na mniejsze kawałki, co na pewno będzie nam potrzebne w przyszłości.
Natura projektów programistycznych jest taka, że zazwyczaj tylko rosną z czasem. Dobrze jest czasem poświęcić chwilę na uporządkowanie ich, zanim wymkną się nam spod kontroli i sami przestaniemy rozumieć, który kawałek kodu odpowiada za jakie funkcjonalności i jak elementy systemu się ze sobą łączą.
The purpose of software engineering is to control complexity, not to create it.
—Dr. Pamela Zave