Zajmiemy się napisaniem prostej aplikacji internetowej - programu, który będzie wysyłał strony do przeglądarki. Skorzystamy z node.js i Express.
Podobna prosta aplikacja była już na pierwszych zajęciach. Wtedy koncentrowaliśmy się na przygotowaniu środowiska i uruchomieniu aplikacji, teraz spróbujemy zrozumieć co się tam właściwie działo.
Zaczniemy od założenia pliku z opisem projektu. Już to robiliśmy jakiś czas temu, ale teraz ręcznie wpiszemy informacje. W nowym katalogu napiszmy:
npm init
Dostaniemy wiele pytań, na które możemy udzielić mniej lub bardziej sensownych odpowiedzi. Znajdą się one w pliku package.json
, który możemy obejrzeć w VS Code. Ten plik można też stworzyć ręcznie, chociaż jest to mniej wygodne.
npm
z którego skorzystaliśmy to program służący do instalacji pakietów a także uruchamiania skryptów. Pozwala łatwo odtworzyć środowisko (zestaw pakietów używanych do uruchamiania programu) na różnych maszynach. Dostępnych jest wiele pakietów - na stronie npmjs.com jest wyszukiwarka, można znaleźć proste pakiety jak left-pad i skomplikowane biblioteki, np. express którego będziemy używać. Pakiety mogą wymagać innych do działania, a npm dba o to, żeby wszystkie niezbędne pakiety zostały zainstalowane.
Pakiety można instalować lokalnie i globalnie. Domyślne jest instalowanie lokalnie, czyli w katalogu node_modules
umieszczonym tam, gdzie plik package.json
. Niektóre pakiety, np. typescript, instalują jakieś programy, które możemy wywołać. Zwykle nie mamy w ścieżce katalogu node_modules/bin
, w którym można znaleźć te programy, ale możemy skorzystać z programu npx
, który służy do uruchamiania takich programów.
Globalnie zainstalowane programy są od razu w ścieżce, ale zwykle globalnie może być zainstalowana tylko jedna wersja danego pakietu. W zależności od systemu i używanych dodatkowych narzędzi do instalacji globalnej mogą być też potrzebne prawa administratora.
Zainstalujmy lokalnie pakiet express:
npm install express --save
W katalogu node_modules
pojawiło się wiele - ponad 40 - pakietów, w tym express, którego sobie życzyliśmy. Pozostałe pakiety są mu potrzebne do działania. Ponieważ przy wywołaniu npm użyliśmy parametru --save
, w package.json
znalazł się nowy wpis:
"dependencies": {
"express": "^4.17.1"
}
Oznacza on, że nasz program potrzebuje biblioteki express, w wersji 4.x.x (patrz dokumentacja). Gdy teraz usuniemy katalog z pakietami (rm -rf node_modules
), możemy łatwo go odtworzyć uruchamiając
npm install
Rzecz jasna w ten sam sposób możemy zainstalować potrzebne pakiety na nowym komputerze, więc o ile wierzymy, że repozytorium pakietów nie zniknie z internetu, nie ma potrzeby rozpowszechniać ich z naszym programem, wystarczy tylko ich lista.
Część pakietów, np. express, będzie potrzebna do działania naszego programu, ale inne, takie jak mocha czyli mechanizm uruchamiania testów, będą potrzebne tylko na etapie tworzenia programu, nie ma za to sensu instalować ich „na produkcji”. npm instaluje wszystkie pakiety w node_modules
, ale potrafi odróżnić te dwie sytuacje:
npm install mocha --save-dev
Potem aby zainstalować wyłącznie pakiety potrzebne do uruchamiania naszego programu możemy napisać
npm install --only=production
Jest jeszcze kategoria pakietów opcjonalnych i wiele innych przydatnych parametrów, warto zerknąć na stronę podręcznika.
Często podczas pisania programu potrzebujemy wielokrotnie powtarzać takie same czynności, np. kompilowanie programu. Przygotujmy skomplikowany program w TypeScripcie i zapiszmy go w pliku program.ts
:
function wypisz(napis: string) {
console.log(napis);
}
wypisz('napis');
Próba uruchomienia go za pomocą node.js
skończy się oczywiście niepowodzeniem, bo musimy go najpierw skompilować. Zainstalujmy pakiet typescript
i skompilujmy nasz plik. Po każdej zmianie musimy go kompilować ponownie, pamiętając jakie parametry przekazać. Możemy to zapisać w package.json
. W kluczu "scripts"
dopiszmy:
"build": "tsc --target es2015 program.ts",
Aby wywołać skrypt piszemy npm run build
. Skrypt to po prostu komenda shella. Możemy jeszcze doinstalować pakiet npm-watch
i dopisać do package.json
klucz watch
:
"watch": {
"build": {
"patterns": ["."],
"extensions": "ts"
}
},
oraz dodać skrypt watch
:
"watch": "npm-watch",
Teraz gdy w okienku terminala uruchomimy npm run watch
i zmienimy jakiś plik ts
w katalogu naszego programu, to skompiluje się on automatycznie.
Nasz skompilowany program uruchamiamy oczywiście za pomocą polecenia node
:
node program.js
node
pozwala nam uruchamiać programy napisane w Javascripcie z okienka terminala. Mają one większe możliwości niż rzeczy uruchamiane w przeglądarce, np. mogą korzystać z plików czy dowolnych połączeń sieciowych.
Wadą jest to, że piszemy w TypeScripcie, więc musimy za każdym razem kompilować program. watch
oczywiście pomaga, ale doinstalujmy kolejny pakiet, który umożliwi uruchamianie plików .ts
:
npm install ts-node --save-dev
Mamy teraz program ts-node
(który musimy uruchamiać za pomocą npx
), którego możemy używać jak zwykłego node
, ale także pisać lub podawać jako parametry kod w TypeScripcie.
npx ts-node program.ts
uruchomi nam nasz program i skompiluje go sobie po cichu. Bardzo wygodne podczas eksperymentowania, a także do uruchamiania testów. Niektórzy mogą nawet pamiętać, że już w jednym z wcześniejszych scenariuszy korzystaliśmy z ts-node
do uruchamiania napisanych w TypeScripcie testów.
Skoro możemy korzystać z różnych funkcji systemowych, to spróbujmy napisać serwer www. Dokładniej, spróbujmy napisać fragmenty, których nie ma w bibliotece node
.
Zacznijmy oczywiście od zainstalowania biblioteki opisującej typy dostępne w bibliotece standardowej node
:
npm install @types/node --save-dev
i napiszmy pierwszy serwer www, w pliku serwer.ts
:
import {createServer} from 'http';
let server = createServer(
(req, res) => {
res.write('Ale super!');
res.end();
}
);
server.listen(8080);
Gdy go uruchomimy (npx ts-node serwer.ts
) i podłączymy się przeglądarką do adresu localhost:8080
, to wyświetli się nam interesujący napis. Zobaczmy teraz co zrobiliśmy.
import {createServer} from 'http';
pozwala nam korzystać z funkcji createServer
(dokumentacja). Oczywiście po zaimportowaniu korzystamy z tej funkcji, podając tylko jeden parametr, funkcję która będzie obsługiwała zlecenia. Jeśli zajrzymy do źródeł, zobaczymy, że ta funkcja tworzy po prostu nowy obiekt klasy http.Server
. W node.js
programujemy podobnie jak w przeglądarce - kod jest sterowany zdarzeniami. Powyższe wywołanie createServer
odpowiada poniższemu kodowi:
let server = new Server();
server.on('request', (req, res) => {
res.write('Ale super!');
res.end();
});
Kolejne przychodzące do serwera wywołania będą generowały zdarzenie request
, które obsługujemy za pomocą podanej funkcji. Ta funkcja ma dwa parametry, req
i res
. To klasyczne podejście w programowaniu serwerowej części aplikacji www: dostajemy zlecenie (req
typu [[https://nodejs.org/api/http.html#http_class_http_incomingmessage][IncomingMessage]]
), a odpowiadamy działając na przekazanym obiekcie res
typu[[https://nodejs.org/api/http.html#http_class_http_serverresponse][ServerResponse]]
.
W req
znajdziemy np. nagłówki ale przede wszystkim url
, czyli adres strony którą mamy odesłać.
res
to odpowiedź, treść odpowiedzi możemy zapisać jak powyżej - funkcją write
, oczywiście możemy też ustawić różne parametry takie jak kod (statusCode
) czy nagłówki odpowiedzi (setHeader
).
listen(8080)
powoduje, że nasz nowo stworzony serwer zaczyna nasłuchiwać na porcie 8080, do którego już wcześniej podłączyliśmy się przeglądarką.
Zobaczmy teraz jak możemy otwierać i czytać/pisać pliki z programów w node. Potrzebujemy zaimportować moduł fs
:
import * as fs from 'fs';
Dalej możemy się posługiwać dość standardowymi funkcjami służącymi do dostępu do plików: fs.open
, fs.read
, fs.write
, ale z istotną różnicą: wszystkie te operacje są asynchroniczne i jednym z parametrów jest funkcja wywoływana po zakończeniu operacji (ang. callback). W związku z tym kod nieco się komplikuje. Przykładowo, aby zapisać tekst do pliku musimy napisać coś w rodzaju:
fs.open('plik.txt', 'a', (err, fd) => {
if (err) {
console.log('Nie udało się otworzyć pliku :(', err);
return;
}
fs.write(fd, 'Kolejny wpis do pliku!\n', (err, written,str) => {
if (err) {
console.log('Nie udało się zapisać', err);
}
fs.close(fd, () => {});
});
});
Callback musimy przekazać nawet do operacji close
, żeby dowiedzieć się o ewentualnym błędzie (który w powyższym kodzie nieelegancko pomijamy). Strasznie niewygodne, ale już wcześniej widzieliśmy jak można sobie z tym poradzić. Potrzebujemy promise-ów. Świetnie się składa, że node ma funkcję fs.promisify
, która potrafi przekształcić „normalną” funkcję z callbackiem na funkcję zwracającą Promise
. Możemy więc napisać:
import {promisify} from 'util';
let open = promisify(fs.open);
let write = promisify(fs.write);
let close = promisify(fs.close);
/* ... */
let fd;
open('plik.txt', 'a').then((_fd) => {
fd = _fd;
write(fd, 'A z promisami też się może zapisze?\n');
}).then(() => close(fd)).catch((reason) => {
console.log('Błąd był straszliwy!', reason);
});
co wygląda nieco bardziej normalnie. I tak jak poprzednio, możemy zrobić jeszcze jeden krok z async/await
:
async function zapiszCos() {
let fd = -1;
try {
fd = await open('plik3.txt', 'a');
await write(fd, 'To jeszcze z async/await');
await close(fd);
} catch (e) {
console.log('Jakiś błąd w trakcie zapisywania', e);
if (fd != -1) {
await close(fd);
}
}
}
Trzeba tylko pamiętać o tym, że wywołania await
oznaczają, że pomiędzy wywołaniami kolejnych funkcji - open
, write
, close
- mogą się dziać różne rzeczy, np. serwer może obsługiwać inne zlecenia. Czyli mimo tego, że node.js jest, w takim sensie jak przeglądarka, jednowątkowy, to używając takich funkcji musimy uważać na możliwe przeploty.
Skoro możemy czytać pliki i obsługiwać połączenia sieciowe, to nie powinno dziwić, że możemy też korzystać z bazy danych. Dostępne są sterowniki wielu baz, ale dla naszych prostych zastosowań wystarczy sqlite3. Zainstalujmy sterownik i opis typów:
npm install sqlite3 --save
npm install @types/sqlite3 --save-dev
W poniższym, krótkim przykładzie pominiemy obsługę błędów. Bardziej szczegółowy opis korzystania z bazy sqlite3, uwzględniający obsługę błędów, ale niestety dla Javascriptu, można znaleźć na stronie dokumentacji bazy.
Przygotujmy nowy program, zakladacz.ts
, który będzie zakładał nową bazę danych:
import * as sqlite3 from 'sqlite3';
function zalozBaze() {
sqlite3.verbose();
let db = new sqlite3.Database('baza.db');
db.run('CREATE TABLE wyswietlenia (sciezka VARCHAR(255), liczba INT);');
db.close();
}
zalozBaze();
verbose()
powoduje, że sqlite3 poinformuje nas o napotkanych problemach wypisując komunikaty. Baza danych przechowywana jest w pojedynczym pliku, aby się do niej dostać musimy podać jego nazwę. sqlite3.Database
to obiekt służący do wykonywania operacji na bazie. exec
wykonuje podany kod SQLowy, w naszym przypadku założenie tabeli. Wpiszmy do niej przykładowe dane:
function wpiszDane() {
sqlite3.verbose();
let db = new sqlite3.Database('baza.db');
db.run('INSERT INTO wyswietlenia (sciezka, liczba) VALUES ("a", 1), ("b",2);');
db.close();
}
Pobieranie informacji z bazy też nie jest trudne:
sqlite3.verbose();
let db = new sqlite3.Database('baza.db');
db.all('SELECT sciezka, liczba FROM wyswietlenia;', [], (err, rows) => {
if (err) throw(err);
for(let {sciezka, liczba} of rows) {
console.log(sciezka, '->', liczba);
}
db.close();
});
db.all
wczytuje dane z bazy do pamięci; pierwszy argument to SQLowe zapytanie, drugi - ewentualne parametry, a trzeci to funkcja, która zostanie wywołana po wczytaniu informacji. Jej pierwszy parametr to opis napotkanego błędu, a drugi - wiersze danych wczytane z bazy.
Biblioteka dostępu do bazy jest dużo bardziej obszerna, więcej informacji można znaleźć na wspomnianej stronie dokumentacji.
Napisz program, który uruchamia serwer www obsługujący wywołania następująco:
url
to /statystyki
, to wyświetlana jest strona z pobranymi z bazy danych informacjami o liczbie odwołań do poszczególnych plików. Strona powinna być poprawnym HTMLem.url
to nazwa pliku w obecnym katalogu, to zwracana jest zawartość pliku. W bazie danych należy odnotować kolejne odwołanie do pliku.Zwróć uwagę na obsługę błędów. Jeśli w katalogu jest plik statystyki
, to niestety nie będzie można się do niego odwołać.