Pierwsze kroki z Expressem i stosem JavaScript ES6+
Opublikowany: 2022-03-10Ten artykuł jest drugą częścią serii, z częścią pierwszą znajdującą się tutaj, która zapewnia podstawowy i (miejmy nadzieję) intuicyjny wgląd w Node.js, JavaScript ES6+, funkcje wywołania zwrotnego, funkcje strzałek, interfejsy API, protokół HTTP, JSON, MongoDB i jeszcze.
W tym artykule będziemy opierać się na umiejętnościach, które zdobyliśmy w poprzednim, nauczymy się, jak zaimplementować i wdrożyć bazę danych MongoDB do przechowywania informacji o listach użytkowników, zbudować interfejs API z Node.js i framework Express Web Application w celu udostępnienia tej bazy danych i wykonuj na nim operacje CRUD i nie tylko. Po drodze omówimy ES6 Object Destructuring, ES6 Object Shorthand, Async/Await Operator, Spread Operator, a także przyjrzymy się pokrótce CORS, Same Origin Policy i nie tylko.
W kolejnym artykule dokonamy refaktoryzacji naszej bazy kodu, aby oddzielić problemy, wykorzystując architekturę trójwarstwową i osiągając odwrócenie kontroli poprzez wstrzykiwanie zależności, wykonamy zabezpieczenia i kontrolę dostępu w oparciu o JSON Web Token i Firebase Authentication, dowiedz się, jak bezpiecznie przechowuj hasła i korzystaj z usługi AWS Simple Storage Service do przechowywania awatarów użytkowników za pomocą buforów i strumieni Node.js — przez cały czas wykorzystując PostgreSQL do utrwalania danych. Po drodze przepiszemy naszą bazę kodu od podstaw w TypeScript, aby zbadać klasyczne koncepcje OOP (takie jak polimorfizm, dziedziczenie, kompozycja itd.), a nawet wzorce projektowe, takie jak fabryki i adaptery.
Słowo ostrzeżenia
Istnieje problem z większością artykułów omawiających obecnie Node.js. Większość z nich, nie wszystkie, nie wykracza poza przedstawienie sposobu konfiguracji Express Routing, integracji Mongoose i być może wykorzystania uwierzytelniania JSON Web Token Authentication. Problem polega na tym, że nie mówią o architekturze, najlepszych praktykach bezpieczeństwa, zasadach czystego kodowania, zgodności z ACID, relacyjnych bazach danych, piątej postaci normalnej, twierdzeniu CAP czy transakcjach. Zakłada się, że wiesz o tym wszystkim, co się pojawi, albo że nie będziesz budować projektów na tyle dużych lub popularnych, aby uzasadnić wspomnianą wiedzę.
Wydaje się, że istnieje kilka różnych typów programistów węzłów — między innymi niektórzy są nowicjuszami w programowaniu w ogóle, a inni wywodzą się z długiej historii rozwoju przedsiębiorstw z C# i .NET Framework lub Java Spring Framework. Większość artykułów dotyczy pierwszej grupy.
W tym artykule zamierzam zrobić dokładnie to, co właśnie powiedziałem, że zbyt wiele artykułów robi, ale w kolejnym artykule całkowicie zrefaktoryzujemy naszą bazę kodu, co pozwoli mi wyjaśnić zasady, takie jak Dependency Injection, Three- Architektura warstw (kontroler/usługa/repozytorium), mapowanie danych i rekord aktywny, wzorce projektowe, testowanie jednostek, integracji i mutacji, zasady SOLID, jednostka pracy, kodowanie względem interfejsów, najlepsze praktyki bezpieczeństwa, takie jak HSTS, CSRF, NoSQL i SQL Injection Zapobieganie i tak dalej. Przeprowadzimy również migrację z MongoDB do PostgreSQL, używając prostego kreatora zapytań Knex zamiast ORM – co pozwoli nam zbudować własną infrastrukturę dostępu do danych oraz zbliżyć się i osobiście za pomocą Structured Query Language, różnych typów relacji (jeden- do jednego, wiele do wielu itd.) i nie tylko. Ten artykuł powinien zatem przemawiać do początkujących, ale kilka następnych powinno zadowolić bardziej średniozaawansowanych programistów, którzy chcą ulepszyć swoją architekturę.
W tym przypadku będziemy się martwić tylko o utrwalenie danych księgowych. Nie zajmiemy się uwierzytelnianiem użytkowników, haszowaniem haseł, architekturą ani niczym skomplikowanym. Wszystko to pojawi się w następnych i przyszłych artykułach. Na razie, iw zasadzie, po prostu zbudujemy metodę, która pozwoli klientowi komunikować się z naszym serwerem sieciowym za pośrednictwem protokołu HTTP w celu zapisania informacji o książkach w bazie danych.
Uwaga : celowo zachowałem to niezwykle prosto i być może nie aż tak praktyczne, ponieważ ten artykuł sam w sobie jest bardzo długi, ponieważ pozwoliłem sobie na odejście, aby omówić dodatkowe tematy. W związku z tym będziemy stopniowo poprawiać jakość i złożoność interfejsu API w tej serii, ale znowu, ponieważ uważam to za jedno z twoich pierwszych wprowadzeń do Express, celowo staram się, aby wszystko było niezwykle proste.
- ES6 Destrukturyzacja obiektów
- Skrót obiektu ES6
- Operator rozrzutu ES6 (...)
- Zbliża się...
ES6 Destrukturyzacja obiektów
ES6 Object Destructuring lub destructuring Assignment Syntax to metoda wyodrębniania lub rozpakowywania wartości z tablic lub obiektów do ich własnych zmiennych. Zaczniemy od właściwości obiektu, a następnie omówimy elementy tablicy.
const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; // Log properties: console.log('Name:', person.name); console.log('Occupation:', person.occupation);
Taka operacja jest dość prymitywna, ale może być trochę kłopotliwa, biorąc pod uwagę, że musimy wszędzie person.something
się do osoby. Załóżmy, że w naszym kodzie było 10 innych miejsc, w których musielibyśmy to zrobić — byłoby to dość uciążliwe i dość szybkie. Metodą zwięzłości byłoby przypisanie tych wartości do ich własnych zmiennych.
const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; const personName = person.name; const personOccupation = person.occupation; // Log properties: console.log('Name:', personName); console.log('Occupation:', personOccupation);
Być może wygląda to rozsądnie, ale co by było, gdybyśmy mieli 10 innych właściwości zagnieżdżonych również w obiekcie person
? Byłoby wiele niepotrzebnych wierszy tylko po to, aby przypisać wartości do zmiennych — w tym momencie jesteśmy w niebezpieczeństwie, ponieważ jeśli właściwości obiektu zostaną zmutowane, nasze zmienne nie będą odzwierciedlać tej zmiany (pamiętaj, że tylko odniesienia do obiektu są niezmienne w przypadku const
przypisania, nie właściwości obiektu), więc w zasadzie nie możemy dłużej utrzymywać „stanu” (a używam tego słowa luźno) w synchronizacji. Przekazywanie przez odniesienie vs przekazywanie przez wartość może mieć tutaj znaczenie, ale nie chcę odchodzić zbyt daleko od zakresu tej sekcji.
ES6 Object Destructing w zasadzie pozwala nam to zrobić:
const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; // This is new. It's called Object Destructuring. const { name, occupation } = person; // Log properties: console.log('Name:', name); console.log('Occupation:', occupation);
Nie tworzymy nowego literału obiektu/obiektu, rozpakowujemy właściwości name
i occupation
z oryginalnego obiektu i umieszczamy je w ich własnych zmiennych o tej samej nazwie. Nazwy, których używamy, muszą odpowiadać nazwom właściwości, które chcemy wyodrębnić.
Ponownie, składnia const { a, b } = someObject;
mówi konkretnie, że oczekujemy istnienia pewnej właściwości a
i pewnej właściwości b
w ramach someObject
(tj. someObject
może być na przykład { a: 'dataA', b: 'dataB' }
) i że chcemy umieścić dowolne wartości tych kluczy/właściwości w ramach const
zmiennych o tej samej nazwie. Dlatego powyższa składnia dostarczyłaby nam dwóch zmiennych const a = someObject.a
i const b = someObject.b
.
Oznacza to, że destrukturyzacja obiektów ma dwie strony. Strona „Szablon” i „Źródło”, gdzie strona const { a, b }
(lewa strona) to szablon, a strona someObject
(prawa strona) to strona źródłowa — co ma sens — definiujemy strukturę lub „szablon” po lewej stronie, który odzwierciedla dane po stronie „źródła”.
Ponownie, aby to wyjaśnić, oto kilka przykładów:
// ----- Destructure from Object Variable with const ----- // const objOne = { a: 'dataA', b: 'dataB' }; // Destructure const { a, b } = objOne; console.log(a); // dataA console.log(b); // dataB // ----- Destructure from Object Variable with let ----- // let objTwo = { c: 'dataC', d: 'dataD' }; // Destructure let { c, d } = objTwo; console.log(c); // dataC console.log(d); // dataD // Destructure from Object Literal with const ----- // const { e, f } = { e: 'dataE', f: 'dataF' }; // <-- Destructure console.log(e); // dataE console.log(f); // dataF // Destructure from Object Literal with let ----- // let { g, h } = { g: 'dataG', h: 'dataH' }; // <-- Destructure console.log(g); // dataG console.log(h); // dataH
W przypadku właściwości zagnieżdżonych, odwzoruj tę samą strukturę w swoim przypisaniu destrukcyjnym:
const person = { name: 'Richard P. Feynman', occupation: { type: 'Theoretical Physicist', location: { lat: 1, lng: 2 } } }; // Attempt one: const { name, occupation } = person; console.log(name); // Richard P. Feynman console.log(occupation); // The entire `occupation` object. // Attempt two: const { occupation: { type, location } } = person; console.log(type); // Theoretical Physicist console.log(location) // The entire `location` object. // Attempt three: const { occupation: { location: { lat, lng } } } = person; console.log(lat); // 1 console.log(lng); // 2
Jak widać, właściwości, które zdecydujesz się pobrać, są opcjonalne, a aby rozpakować właściwości zagnieżdżone, po prostu odzwierciedlaj strukturę oryginalnego obiektu (źródła) po stronie szablonu swojej składni destrukturyzacyjnej. Jeśli spróbujesz zdestrukturyzować właściwość, która nie istnieje w oryginalnym obiekcie, ta wartość będzie niezdefiniowana.
Możemy dodatkowo zdestrukturyzować zmienną bez wcześniejszej deklaracji — przypisanie bez deklaracji — używając następującej składni:
let name, occupation; const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; ;({ name, occupation } = person); console.log(name); // Richard P. Feynman console.log(occupation); // Theoretical Physicist
Wyrażenie poprzedzamy średnikiem, aby upewnić się, że przypadkowo nie utworzymy IIFE (Immediately Invoked Function Expression) z funkcją w poprzednim wierszu (jeśli taka funkcja istnieje), a nawiasy wokół instrukcji przypisania są wymagane, aby powstrzymaj JavaScript przed traktowaniem lewej strony (szablonu) jako bloku.
Bardzo powszechny przypadek użycia destrukturyzacji występuje w argumentach funkcji:
const config = { baseUrl: '<baseURL>', awsBucket: '<bucket>', secret: '<secret-key>' // <- Make this an env var. }; // Destructures `baseUrl` and `awsBucket` off `config`. const performOperation = ({ baseUrl, awsBucket }) => { fetch(baseUrl).then(() => console.log('Done')); console.log(awsBucket); // <bucket> }; performOperation(config);
Jak widać, mogliśmy po prostu użyć normalnej składni destrukturyzacyjnej, do której jesteśmy teraz przyzwyczajeni, wewnątrz funkcji, na przykład:
const config = { baseUrl: '<baseURL>', awsBucket: '<bucket>', secret: '<secret-key>' // <- Make this an env var. }; const performOperation = someConfig => { const { baseUrl, awsBucket } = someConfig; fetch(baseUrl).then(() => console.log('Done')); console.log(awsBucket); // <bucket> }; performOperation(config);
Ale umieszczenie wspomnianej składni w sygnaturze funkcji powoduje automatyczną destrukturyzację i oszczędza nam linię.
Rzeczywisty przypadek użycia tego znajduje się w React Functional Components dla props
:
import React from 'react'; // Destructure `titleText` and `secondaryText` from `props`. export default ({ titleText, secondaryText }) => ( <div> <h1>{titleText}</h1> <h3>{secondaryText}</h3> </div> );
W przeciwieństwie do:
import React from 'react'; export default props => ( <div> <h1>{props.titleText}</h1> <h3>{props.secondaryText}</h3> </div> );
W obu przypadkach możemy również ustawić domyślne wartości właściwości:
const personOne = { name: 'User One', password: 'BCrypt Hash' }; const personTwo = { password: 'BCrypt Hash' }; const createUser = ({ name = 'Anonymous', password }) => { if (!password) throw new Error('InvalidArgumentException'); console.log(name); console.log(password); return { id: Math.random().toString(36) // <--- Should follow RFC 4122 Spec in real app. .substring(2, 15) + Math.random() .toString(36).substring(2, 15), name: name, // <-- We'll discuss this next. password: password // <-- We'll discuss this next. }; } createUser(personOne); // User One, BCrypt Hash createUser(personTwo); // Anonymous, BCrypt Hash
Jak widać, w przypadku, gdy ta name
nie jest obecna podczas destrukturyzacji, podajemy jej wartość domyślną. Możemy to zrobić również z poprzednią składnią:
const { a, b, c = 'Default' } = { a: 'dataA', b: 'dataB' }; console.log(a); // dataA console.log(b); // dataB console.log(c); // Default
Tablice można również destrukturyzować:
const myArr = [4, 3]; // Destructuring happens here. const [valOne, valTwo] = myArr; console.log(valOne); // 4 console.log(valTwo); // 3 // ----- Destructuring without assignment: ----- // let a, b; // Destructuring happens here. ;([a, b] = [10, 2]); console.log(a + b); // 12
Praktyczny powód destrukturyzacji tablicy występuje w przypadku haków reakcyjnych. (I jest wiele innych powodów, po prostu używam Reacta jako przykładu).
import React, { useState } from "react"; export default () => { const [buttonText, setButtonText] = useState("Default"); return ( <button onClick={() => setButtonText("Toggled")}> {buttonText} </button> ); }
Zauważ, że useState
jest destrukturyzowana przez eksport, a funkcje/wartości tablicowe są destrukturyzowane przez hak useState
. Ponownie, nie martw się, jeśli powyższe nie ma sensu — musisz zrozumieć React — a ja używam go tylko jako przykładu.
Chociaż destrukturyzacja obiektów w ES6 to coś więcej, omówię tutaj jeszcze jeden temat: Destrukturyzacja Zmiana nazwy, która jest przydatna, aby zapobiec kolizjom zakresów lub zmiennym cieniom itp. Załóżmy, że chcemy zdestrukturyzować właściwość o nazwie name
z obiektu o nazwie person
, ale istnieje już zmienna o nazwie name
w zakresie. Możemy zmienić nazwę w locie za pomocą dwukropka:
// JS Destructuring Naming Collision Example: const name = 'Jamie Corkhill'; const person = { name: 'Alan Turing' }; // Rename `name` from `person` to `personName` after destructuring. const { name: personName } = person; console.log(name); // Jamie Corkhill <-- As expected. console.log(personName); // Alan Turing <-- Variable was renamed.
Na koniec możemy ustawić wartości domyślne ze zmianą nazwy:
const name = 'Jamie Corkhill'; const person = { location: 'New York City, United States' }; const { name: personName = 'Anonymous', location } = person; console.log(name); // Jamie Corkhill console.log(personName); // Anonymous console.log(location); // New York City, United States
Jak widać, w tym przypadku name
person
( person.name
) zostanie zmieniona na personName
i ustawiona na domyślną wartość Anonymous
, jeśli nie istnieje.
I oczywiście to samo można wykonać w sygnaturach funkcji:
const personOne = { name: 'User One', password: 'BCrypt Hash' }; const personTwo = { password: 'BCrypt Hash' }; const createUser = ({ name: personName = 'Anonymous', password }) => { if (!password) throw new Error('InvalidArgumentException'); console.log(personName); console.log(password); return { id: Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15), name: personName, password: password // <-- We'll discuss this next. }; } createUser(personOne); // User One, BCrypt Hash createUser(personTwo); // Anonymous, BCrypt Hash
Skrót obiektu ES6
Załóżmy, że masz następującą fabrykę: (fabryki omówimy później)
const createPersonFactory = (name, location, position) => ({ name: name, location: location, position: position });
Można użyć tej fabryki do stworzenia obiektu person
w następujący sposób. Należy również zauważyć, że fabryka niejawnie zwraca obiekt, co widać po nawiasach wokół nawiasów funkcji strzałek.
const person = createPersonFactory('Jamie', 'Texas', 'Developer'); console.log(person); // { ... }
To już wiemy ze składni literowej obiektu ES5. Zwróć jednak uwagę, że w funkcji fabryki wartość każdej właściwości jest taka sama jak identyfikator właściwości (klucz). To znaczy — location: location
lub name: name
. Okazało się, że było to dość częste zjawisko wśród programistów JS.
Używając skróconej składni z ES6, możemy osiągnąć ten sam wynik, przepisując fabrykę w następujący sposób:
const createPersonFactory = (name, location, position) => ({ name, location, position }); const person = createPersonFactory('Jamie', 'Texas', 'Developer'); console.log(person);
Wytwarzanie wyjścia:
{ name: 'Jamie', location: 'Texas', position: 'Developer' }
Ważne jest, aby zdać sobie sprawę, że możemy użyć tego skrótu tylko wtedy, gdy obiekt, który chcemy utworzyć, jest tworzony dynamicznie na podstawie zmiennych, gdzie nazwy zmiennych są takie same, jak nazwy właściwości, do których chcemy przypisać zmienne.
Ta sama składnia działa z wartościami obiektów:
const createPersonFactory = (name, location, position, extra) => ({ name, location, position, extra // <- right here. }); const extra = { interests: [ 'Mathematics', 'Quantum Mechanics', 'Spacecraft Launch Systems' ], favoriteLanguages: [ 'JavaScript', 'C#' ] }; const person = createPersonFactory('Jamie', 'Texas', 'Developer', extra); console.log(person);
Wytwarzanie wyjścia:
{ name: 'Jamie', location: 'Texas', position: 'Developer', extra: { interests: [ 'Mathematics', 'Quantum Mechanics', 'Spacecraft Launch Systems' ], favoriteLanguages: [ 'JavaScript', 'C#' ] } }
Jako ostatni przykład działa to również z literałami obiektowymi:
const id = '314159265358979'; const name = 'Archimedes of Syracuse'; const location = 'Syracuse'; const greatMathematician = { id, name, location };
Operator rozprzestrzeniania się ES6 (…)
Operator rozproszenia pozwala nam robić różne rzeczy, niektóre z nich omówimy tutaj.
Po pierwsze, możemy rozłożyć właściwości z jednego obiektu na inny:
const myObjOne = { a: 'a', b: 'b' }; const myObjTwo = { ...myObjOne }:
Powoduje to umieszczenie wszystkich właściwości myObjOne
na myObjTwo
, tak że myObjTwo
to teraz { a: 'a', b: 'b' }
. Możemy użyć tej metody do zastąpienia poprzednich właściwości. Załóżmy, że użytkownik chce zaktualizować swoje konto:
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
const user = { name: 'John Doe', email: '[email protected]', password: ' ', bio: 'Lorem ipsum' }; const updates = { password: ' ', bio: 'Ipsum lorem', email: '[email protected]' }; const updatedUser = { ...user, // <- original ...updates // <- updates }; console.log(updatedUser); /* { name: 'John Doe', email: '[email protected]', // Updated password: ' ', // Updated bio: 'Ipsum lorem' } */
To samo można wykonać z tablicami:
const apollo13Astronauts = ['Jim', 'Jack', 'Fred']; const apollo11Astronauts = ['Neil', 'Buz', 'Michael']; const unionOfAstronauts = [...apollo13Astronauts, ...apollo11Astronauts]; console.log(unionOfAstronauts); // ['Jim', 'Jack', 'Fred', 'Neil', 'Buz, 'Michael'];
Zauważ, że utworzyliśmy połączenie obu zestawów (tablic), rozkładając tablice na nową tablicę.
Operator odpoczynku/rozkładania ma o wiele więcej, ale wykracza to poza zakres tego artykułu. Można go na przykład użyć do uzyskania wielu argumentów funkcji. Jeśli chcesz dowiedzieć się więcej, przejrzyj dokumentację MDN tutaj.
ES6 Async/Oczekiwanie
Async/Await to składnia łagodząca ból związany z łączeniem obietnic.
Zastrzeżone słowo kluczowe await
pozwala na „oczekiwanie” na rozliczenie obietnicy, ale może być używane tylko w funkcjach oznaczonych słowem kluczowym async
. Załóżmy, że mam funkcję, która zwraca obietnicę. W nowej funkcji async
mogę await
na wynik tej obietnicy zamiast używać .then
i .catch
.
// Returns a promise. const myFunctionThatReturnsAPromise = () => { return new Promise((resolve, reject) => { setTimeout(() => resolve('Hello'), 3000); }); } const myAsyncFunction = async () => { const promiseResolutionResult = await myFunctionThatReturnsAPromise(); console.log(promiseResolutionResult); }; // Writes the log statement after three seconds. myAsyncFunction();
Należy tutaj zwrócić uwagę na kilka rzeczy. Kiedy używamy await
w funkcji async
, tylko rozwiązana wartość trafia do zmiennej po lewej stronie. Jeśli funkcja odrzuca, jest to błąd, który musimy wyłapać, jak zobaczymy za chwilę. Ponadto każda funkcja oznaczona jako async
domyślnie zwróci obietnicę.
Załóżmy, że musiałem wykonać dwa wywołania API, jedno z odpowiedzią z pierwszego. Używając obietnic i łańcuchów obietnic, możesz to zrobić w ten sposób:
const makeAPICall = route => new Promise((resolve, reject) => { console.log(route) resolve(route); }); const main = () => { makeAPICall('/whatever') .then(response => makeAPICall(response + ' second call')) .then(response => console.log(response + ' logged')) .catch(err => console.error(err)) }; main(); // Result: /* /whatever /whatever second call /whatever second call logged */
To, co się tutaj dzieje, polega na tym, że najpierw wywołujemy makeAPICall
przekazując do niego /whatever
, który jest rejestrowany za pierwszym razem. Obietnica rozwiązuje się z tą wartością. Następnie ponownie wywołujemy makeAPICall
, przekazując do niego /whatever second call
, które jest rejestrowane, i ponownie obietnica zostaje rozwiązana z tą nową wartością. Na koniec bierzemy tę nową wartość /whatever second call
, za pomocą którego właśnie rozwiązano obietnicę, i logujemy ją sami w końcowym dzienniku, dopisując na końcu logged
. Jeśli to nie ma sensu, powinieneś przyjrzeć się wiązaniu obietnic.
Używając async
/ await
, możemy dokonać refaktoryzacji do następującego:
const main = async () => { const resultOne = await makeAPICall('/whatever'); const resultTwo = await makeAPICall(resultOne + ' second call'); console.log(resultTwo + ' logged'); };
Oto, co się stanie. Cała funkcja przestanie wykonywać przy pierwszej instrukcji await
, dopóki nie zostanie rozwiązana obietnica z pierwszego wywołania makeAPICall
, po rozwiązaniu rozwiązana wartość zostanie umieszczona w resultOne
. Kiedy tak się stanie, funkcja przejdzie do drugiej instrukcji await
, ponownie zatrzymując się tam na czas realizacji obietnicy. Gdy obietnica zostanie rozwiązana, wynik rozstrzygnięcia zostanie umieszczony w resultTwo
. Jeśli pomysł na wykonanie funkcji brzmi blokująco, nie obawiaj się, nadal jest asynchroniczny, a za chwilę omówię dlaczego.
To tylko przedstawia „szczęśliwą” ścieżkę. W przypadku odrzucenia jednej z obietnic, możemy to złapać za pomocą try/catch, ponieważ jeśli obietnica zostanie odrzucona, zostanie zgłoszony błąd — który będzie dowolnym błędem, z którym odrzucono obietnicę.
const main = async () => { try { const resultOne = await makeAPICall('/whatever'); const resultTwo = await makeAPICall(resultOne + ' second call'); console.log(resultTwo + ' logged'); } catch (e) { console.log(e) } };
Jak powiedziałem wcześniej, każda funkcja zadeklarowana jako async
zwróci obietnicę. Tak więc, jeśli chcesz wywołać funkcję asynchroniczną z innej funkcji, możesz użyć normalnych obietnic lub await
, jeśli zadeklarujesz funkcję wywołującą async
. Jeśli jednak chcesz wywołać funkcję async
z kodu najwyższego poziomu i czekać na jej wynik, musisz użyć .then
i .catch
.
Na przykład:
const returnNumberOne = async () => 1; returnNumberOne().then(value => console.log(value)); // 1
Lub możesz użyć natychmiastowo wywołanego wyrażenia funkcji (IIFE):
(async () => { const value = await returnNumberOne(); console.log(value); // 1 })();
Gdy używasz await
w funkcji async
, wykonywanie funkcji zostanie zatrzymane na tej instrukcji await do momentu spełnienia obietnicy. Jednak wszystkie inne funkcje mogą kontynuować wykonywanie, dlatego nie są przydzielane żadne dodatkowe zasoby procesora ani wątek nigdy nie jest blokowany. Powiem to jeszcze raz — operacje w tej konkretnej funkcji w tym konkretnym czasie zostaną zatrzymane do czasu spełnienia obietnicy, ale wszystkie inne funkcje można uruchomić za darmo. Rozważmy serwer WWW HTTP — na podstawie żądania wszystkie funkcje mogą być uruchamiane za darmo dla wszystkich użytkowników jednocześnie w miarę składania żądań, po prostu składnia async/await zapewni iluzję , że operacja jest synchroniczna i blokuje obiecuje, że łatwiej się z tym pracuje, ale znowu wszystko pozostanie ładne i asynchroniczne.
To nie wszystko, co trzeba zrobić, aby async
/ await
, ale powinno to pomóc w zrozumieniu podstawowych zasad.
Klasyczne fabryki OOP
Teraz opuścimy świat JavaScript i wejdziemy do świata Javy . Może nadejść czas, kiedy proces tworzenia obiektu (w tym przypadku instancji klasy — znowu Java) jest dość złożony lub gdy chcemy stworzyć różne obiekty na podstawie szeregu parametrów. Przykładem może być funkcja, która tworzy różne obiekty błędów. Fabryka jest powszechnym wzorcem projektowym w programowaniu zorientowanym obiektowo i jest w zasadzie funkcją, która tworzy obiekty. Aby to zbadać, odejdźmy od JavaScriptu do świata Javy. Będzie to miało sens dla programistów, którzy wywodzą się z klasycznego OOP (tj. nie prototypowego), statycznie wpisanego języka. Jeśli nie jesteś jednym z takich programistów, możesz pominąć tę sekcję. Jest to małe odchylenie, więc jeśli podążanie w tym miejscu zakłóci przepływ JavaScript, to ponownie pomiń tę sekcję.
Powszechny wzorzec twórczy, wzorzec fabryki, pozwala nam tworzyć obiekty bez ujawniania wymaganej logiki biznesowej do wykonania wspomnianego tworzenia.
Załóżmy, że piszemy program, który pozwala nam wizualizować kształty pierwotne w n-wymiarach. Jeśli dostarczymy sześcian, na przykład, zobaczymy sześcian 2D (kwadrat), sześcian 3D (sześcian) i sześcian 4D (Tesseract lub Hypercube). Oto, jak można to zrobić, trywialnie i z wyłączeniem rzeczywistej części rysunkowej, w Javie.
// Main.java // Defining an interface for the shape (can be used as a base type) interface IShape { void draw(); } // Implementing the interface for 2-dimensions: class TwoDimensions implements IShape { @Override public void draw() { System.out.println("Drawing a shape in 2D."); } } // Implementing the interface for 3-dimensions: class ThreeDimensions implements IShape { @Override public void draw() { System.out.println("Drawing a shape in 3D."); } } // Implementing the interface for 4-dimensions: class FourDimensions implements IShape { @Override public void draw() { System.out.println("Drawing a shape in 4D."); } } // Handles object creation class ShapeFactory { // Factory method (notice return type is the base interface) public IShape createShape(int dimensions) { switch(dimensions) { case 2: return new TwoDimensions(); case 3: return new ThreeDimensions(); case 4: return new FourDimensions(); default: throw new IllegalArgumentException("Invalid dimension."); } } } // Main class and entry point. public class Main { public static void main(String[] args) throws Exception { ShapeFactory shapeFactory = new ShapeFactory(); IShape fourDimensions = shapeFactory.createShape(4); fourDimensions.draw(); // Drawing a shape in 4D. } }
Jak widać, definiujemy interfejs, który określa sposób rysowania kształtu. Mając różne klasy implementujące interfejs, możemy zagwarantować, że wszystkie kształty mogą zostać narysowane (ponieważ wszystkie muszą mieć nadpisaną metodę draw
zgodnie z definicją interfejsu). Biorąc pod uwagę, że ten kształt jest rysowany w różny sposób w zależności od wymiarów, w których jest oglądany, definiujemy klasy pomocnicze, które implementują interfejs, aby wykonywać intensywną pracę GPU polegającą na symulowaniu renderowania n-wymiarowego. ShapeFactory
wykonuje pracę polegającą na tworzeniu instancji właściwej klasy — metoda createShape
jest fabryką i podobnie jak powyższa definicja jest metodą zwracającą obiekt klasy. Typem zwracanym przez createShape
jest interfejs IShape
, ponieważ interfejs IShape
jest typem podstawowym wszystkich kształtów (ponieważ mają metodę draw
).
Ten przykład Javy jest dość trywialny, ale możesz łatwo zobaczyć, jak przydatny staje się w większych aplikacjach, w których konfiguracja tworzenia obiektu może nie być taka prosta. Przykładem może być gra wideo. Załóżmy, że użytkownik musi przetrwać różnych wrogów. Klasy abstrakcyjne i interfejsy mogą być używane do definiowania podstawowych funkcji dostępnych dla wszystkich wrogów (i metod, które mogą być nadpisane), być może wykorzystując wzorzec delegowania (przedstawiaj kompozycję nad dziedziczeniem, jak sugerowała Gang of Four, aby nie zostać zablokowanym w rozszerzaniu pojedynczą klasę bazową oraz w celu ułatwienia testowania/mocowania/DI). W przypadku obiektów wroga utworzonych na różne sposoby interfejs umożliwiałby tworzenie obiektów fabrycznych, korzystając z ogólnego typu interfejsu. Byłoby to bardzo istotne, gdyby wróg był tworzony dynamicznie.
Innym przykładem jest funkcja konstruktora. Załóżmy, że używamy wzorca delegacji, aby delegat klasy działał z innymi klasami, które honorują interfejs. Moglibyśmy umieścić statyczną metodę build
w klasie, aby skonstruowała ona własną instancję (zakładając, że nie używasz kontenera/struktury wstrzykiwania zależności). Zamiast dzwonić do każdego rozgrywającego, możesz to zrobić:
public class User { private IMessagingService msgService; private String name; private int age; public User(String name, int age, IMessagingService msgService) { this.name = name; this.age = age; this.msgService = msgService; } public static User build(String name, int age) { return new User(name, age, new SomeMessageService()); } }
Wzorzec delegowania wyjaśnię w późniejszym artykule, jeśli nie jesteś z nim zaznajomiony — zasadniczo, poprzez kompozycję i pod względem modelowania obiektów, tworzy on relację „ma-a” zamiast „jest-a” związek jak w przypadku dziedziczenia. Jeśli masz klasy Mammal
i Dog
, a Dog
rozszerza Mammal
, wtedy Dog
is-a Mammal
. Natomiast jeśli masz klasę Bark
i właśnie przekazałeś instancje Bark
do konstruktora Dog
, wtedy Dog
has-a Bark
. Jak możesz sobie wyobrazić, szczególnie ułatwia to testowanie jednostkowe, ponieważ możesz wstrzykiwać mocki i potwierdzać fakty na temat makiety, o ile makieta przestrzega kontraktu interfejsu w środowisku testowym.
Powyższa static
metoda fabryki „build” po prostu tworzy nowy obiekt User
i przekazuje konkretną MessageService
. Zauważ, że wynika to z powyższej definicji — nie ujawniając logiki biznesowej w celu utworzenia obiektu klasy lub, w tym przypadku, nieujawnianie tworzenia usługi przesyłania wiadomości dzwoniącemu w fabryce.
Ponownie, niekoniecznie jest to sposób, w jaki robiłbyś rzeczy w prawdziwym świecie, ale całkiem dobrze przedstawia ideę funkcji/metody fabryki. Zamiast tego możemy użyć kontenera Dependency Injection. Wróćmy teraz do JavaScriptu.
Zaczynając od Express
Express to Web Application Framework for Node (dostępny za pośrednictwem modułu NPM), który pozwala na utworzenie serwera WWW HTTP. Należy zauważyć, że Express nie jest jedynym frameworkiem, który to umożliwia (istnieje Koa, Fastify itp.) i że, jak pokazano w poprzednim artykule, Node może działać bez Express jako samodzielna jednostka. (Express jest jedynie modułem zaprojektowanym dla Node — Node może bez niego robić wiele rzeczy, chociaż Express jest popularny wśród serwerów WWW).
Jeszcze raz chciałbym dokonać bardzo ważnego rozróżnienia. Istnieje dychotomia między Node/JavaScript i Express. Węzeł, środowisko uruchomieniowe/środowisko, w którym uruchamiasz JavaScript, może robić wiele rzeczy — na przykład umożliwiać tworzenie aplikacji React Native, aplikacji desktopowych, narzędzi wiersza poleceń itp. — Express to nic innego jak lekka platforma, która pozwala na używanie Node/JS do budowania serwerów internetowych w przeciwieństwie do obsługi niskopoziomowej sieci Node i interfejsów API HTTP. Nie potrzebujesz Express, aby zbudować serwer WWW.
Przed rozpoczęciem tej sekcji, jeśli nie jesteś zaznajomiony z żądaniami HTTP i HTTP (GET, POST itp.), zachęcam do przeczytania odpowiedniej sekcji mojego poprzedniego artykułu, do którego link znajduje się powyżej.
Korzystając z Express, skonfigurujemy różne trasy, do których mogą być wysyłane żądania HTTP, a także powiązane punkty końcowe (które są funkcjami zwrotnymi), które zostaną uruchomione, gdy zostanie wysłane żądanie do tej trasy. Nie martw się, jeśli trasy i punkty końcowe są obecnie bezsensowne — wyjaśnię je później.
W przeciwieństwie do innych artykułów, przyjmę podejście polegające na pisaniu kodu źródłowego na bieżąco, wiersz po wierszu, zamiast wrzucania całej bazy kodu do jednego fragmentu, a następnie wyjaśniania później. Zacznijmy od otwarcia terminala (używam Terminusa nad Git Bash w systemie Windows — co jest fajną opcją dla użytkowników systemu Windows, którzy chcą powłoki Bash bez konfigurowania podsystemu Linux), skonfigurowania szablonu naszego projektu i otwarcia go w kodzie programu Visual Studio.
mkdir server && cd server touch server.js npm init -y npm install express code .
Wewnątrz pliku server.js
zacznę od wymagania express
przy użyciu funkcji require()
.
const express = require('express');
require('express')
mówi Node'owi, aby wyszedł i pobrał moduł Express, który zainstalowaliśmy wcześniej, który znajduje się obecnie w folderze node_modules
(bo to właśnie robi npm install
— utwórz folder node_modules
i umieść tam moduły i ich zależności). Zgodnie z konwencją, i mając do czynienia z Express, zmienną przechowującą wynik zwracamy wywołujemy z require('express')
express
, chociaż można ją nazwać jakakolwiek.
This returned result, which we have called express
, is actually a function — a function we'll have to invoke to create our Express app and set up our routes. Again, by convention, we call this app
— app
being the return result of express()
— that is, the return result of calling the function that has the name express
as express()
.
const express = require('express'); const app = express(); // Note that the above variable names are the convention, but not required. // An example such as that below could also be used. const foo = require('express'); const bar = foo(); // Note also that the node module we installed is called express.
The line const app = express();
simply puts a new Express Application inside of the app
variable. It calls a function named express
(the return result of require('express')
) and stores its return result in a constant named app
. If you come from an object-oriented programming background, consider this equivalent to instantiating a new object of a class, where app
would be the object and where express()
would call the constructor function of the express
class. Remember, JavaScript allows us to store functions in variables — functions are first-class citizens. The express
variable, then, is nothing more than a mere function. It's provided to us by the developers of Express.
I apologize in advance if I'm taking a very long time to discuss what is actually very basic, but the above, although primitive, confused me quite a lot when I was first learning back-end development with Node.
Inside the Express source code, which is open-source on GitHub, the variable we called express
is a function entitled createApplication
, which, when invoked, performs the work necessary to create an Express Application:
A snippet of Express source code:
exports = module.exports = createApplication; /* * Create an express application */ // This is the function we are storing in the express variable. (- Jamie) function createApplication() { // This is what I mean by "Express App" (- Jamie) var app = function(req, res, next) { app.handle(req, res, next); }; mixin(app, EventEmitter.prototype, false); mixin(app, proto, false); // expose the prototype that will get set on requests app.request = Object.create(req, { app: { configurable: true, enumerable: true, writable: true, value: app } }) // expose the prototype that will get set on responses app.response = Object.create(res, { app: { configurable: true, enumerable: true, writable: true, value: app } }) app.init(); // See - `app` gets returned. (- Jamie) return app; }
GitHub: https://github.com/expressjs/express/blob/master/lib/express.js
With that short deviation complete, let's continue setting up Express. Thus far, we have required the module and set up our app
variable.
const express = require('express'); const app = express();
From here, we have to tell Express to listen on a port. Any HTTP Requests made to the URL and Port upon which our application is listening will be handled by Express. We do that by calling app.listen(...)
, passing to it the port and a callback function which gets called when the server starts running:
const PORT = 3000; app.listen(PORT, () => console.log(`Server is up on port {PORT}.`));
We notate the PORT
variable in capital by convention, for it is a constant variable that will never change. You could do that with all variables that you declare const
, but that would look messy. It's up to the developer or development team to decide on notation, so we'll use the above sparsely. I use const
everywhere as a method of “defensive coding” — that is, if I know that a variable is never going to change then I might as well just declare it const
. Since I define everything const
, I make the distinction between what variables should remain the same on a per-request basis and what variables are true actual global constants.
Here is what we have thus far:
const express = require('express'); const app = express(); const PORT = 3000; // We will build our API here. // ... // Binding our application to port 3000. app.listen(PORT, () => { console.log(`Server is up on port ${PORT}.`); });
Let's test this to see if the server starts running on port 3000.
I'll open a terminal and navigate to our project's root directory. I'll then run node server/server.js
. Note that this assumes you have Node already installed on your system (You can check with node -v
).
If everything works, you should see the following in the terminal:
Server is up on port 3000.
Go ahead and hit Ctrl + C
to bring the server back down.
If this doesn't work for you, or if you see an error such as EADDRINUSE
, then it means you may have a service already running on port 3000. Pick another port number, like 3001, 3002, 5000, 8000, etc. Be aware, lower number ports are reserved and there is an upper bound of 65535.
At this point, it's worth taking another small deviation as to understand servers and ports in the context of computer networking. We'll return to Express in a moment. I take this approach, rather than introducing servers and ports first, for the purpose of relevance. That is, it is difficult to learn a concept if you fail to see its applicability. In this way, you are already aware of the use case for ports and servers with Express, so the learning experience will be more pleasurable.
A Brief Look At Servers And Ports
A server is simply a computer or computer program that provides some sort of “functionality” to the clients that talk to it. More generally, it's a device, usually connected to the Internet, that handles connections in a pre-defined manner. In our case, that “pre-defined manner” will be HTTP or the HyperText Transfer Protocol. Servers that use the HTTP Protocol are called Web Servers.
When building an application, the server is a critical component of the “client-server model”, for it permits the sharing and syncing of data (generally via databases or file systems) across devices. It's a cross-platform approach, in a way, for the SDKs of platforms against which you may want to code — be they web, mobile, or desktop — all provide methods (APIs) to interact with a server over HTTP or TCP/UDP Sockets. It's important to make a distinction here — by APIs, I mean programming language constructs to talk to a server, like XMLHttpRequest
or the Fetch
API in JavaScript, or HttpUrlConnection
in Java, or even HttpClient
in C#/.NET. This is different from the kind of REST API we'll be building in this article to perform CRUD Operations on a database.
To talk about ports, it's important to understand how clients connect to a server. A client requires the IP Address of the server and the Port Number of our specific service on that server. An IP Address, or Internet Protocol Address, is just an address that uniquely identifies a device on a network. Public and private IPs exist, with private addresses commonly used behind a router or Network Address Translator on a local network. You might see private IP Addresses of the form 192.168.XXX.XXX
or 10.0.XXX.XXX
. When articulating an IP Address, decimals are called “dots”. So 192.168.0.1
(a common router IP Addr.) might be pronounced, “one nine two dot one six eight dot zero dot one”. (By the way, if you're ever in a hotel and your phone/laptop won't direct you to the AP captive portal, try typing 192.168.0.1 or 192.168.1.1 or similar directly into Chrome).
For simplicity, and since this is not an article about the complexities of computer networking, assume that an IP Address is equivalent to a house address, allowing you to uniquely identify a house (where a house is analogous to a server, client, or network device) in a neighborhood. One neighborhood is one network. Put together all of the neighborhoods in the United States, and you have the public Internet. (This is a basic view, and there are many more complexities — firewalls, NATs, ISP Tiers (Tier One, Tier Two, and Tier Three), fiber optics and fiber optic backbones, packet switches, hops, hubs, etc., subnet masks, etc., to name just a few — in the real networking world.) The traceroute
Unix command can provide more insight into the above, displaying the path (and associated latency) that packets take through a network as a series of “hops”.
Numer portu identyfikuje określoną usługę działającą na serwerze. SSH lub Secure Shell, który umożliwia zdalny dostęp do powłoki do urządzenia, zwykle działa na porcie 22. FTP lub protokół przesyłania plików (który może być używany na przykład z klientem FTP do przesyłania statycznych zasobów na serwer) zwykle działa na Port 21. Możemy zatem powiedzieć, że porty są specyficznymi pokojami wewnątrz każdego domu w naszej analogii powyżej, ponieważ pokoje w domach są stworzone do różnych rzeczy – sypialnia do spania, kuchnia do przygotowywania posiłków, jadalnia do konsumpcji wspomnianego żywność itp., podobnie jak porty odpowiadają programom realizującym określone usługi. Dla nas serwery internetowe zwykle działają na porcie 80, chociaż możesz określić dowolny numer portu, o ile nie są one używane przez inne usługi (nie mogą kolidować).
Aby uzyskać dostęp do strony internetowej, potrzebujesz adresu IP strony. Mimo to zwykle uzyskujemy dostęp do stron internetowych za pośrednictwem adresu URL. Za kulisami serwer DNS lub serwer nazw domen konwertuje ten adres URL na adres IP, umożliwiając przeglądarce wykonanie żądania GET do serwera, pobranie kodu HTML i renderowanie go na ekranie. 8.8.8.8
to adres jednego z publicznych serwerów DNS firmy Google. Możesz sobie wyobrazić, że wymaganie rozwiązania nazwy hosta na adres IP za pośrednictwem zdalnego serwera DNS zajmie trochę czasu i masz rację. Aby zmniejszyć opóźnienia, systemy operacyjne mają pamięć podręczną DNS — tymczasową bazę danych, która przechowuje informacje o wyszukiwaniu DNS, zmniejszając w ten sposób częstotliwość, z jaką te wyszukiwania muszą występować. Pamięć podręczną DNS Resolver można wyświetlić w systemie Windows za pomocą polecenia ipconfig /displaydns
CMD i wyczyścić za pomocą polecenia ipconfig /flushdns
.
Na serwerze Unix, bardziej popularne porty o niższej liczbie, jak 80, wymagają uprawnień na poziomie administratora ( zwiększonych , jeśli pracujesz w tle Windows). Z tego powodu będziemy używać portu 3000 do naszych prac programistycznych, ale pozwolimy serwerowi wybrać numer portu (niezależnie od tego, jaki jest dostępny) podczas wdrażania w naszym środowisku produkcyjnym.
Na koniec zwróć uwagę, że możemy wpisać adresy IP bezpośrednio w pasku wyszukiwania Google Chrome, omijając w ten sposób mechanizm rozpoznawania DNS. Na przykład 216.58.194.36
spowoduje przejście do Google.com. W naszym środowisku programistycznym, używając własnego komputera jako serwera deweloperskiego, użyjemy localhost
i portu 3000. Adres jest sformatowany jako hostname:port
, więc nasz serwer będzie działał na localhost:3000
. Localhost lub 127.0.0.1
to adres pętli zwrotnej i oznacza adres „tego komputera”. Jest to nazwa hosta, a jej adres IPv4 jest tłumaczony na 127.0.0.1
. Spróbuj pingować localhost na swoim komputerze już teraz. Możesz otrzymać wstecz ::1
— co jest adresem sprzężenia zwrotnego IPv6 lub z powrotem 127.0.0.1
— co jest adresem sprzężenia zwrotnego IPv4. IPv4 i IPv6 to dwa różne formaty adresów IP powiązane z różnymi standardami — niektóre adresy IPv6 można przekonwertować na IPv4, ale nie wszystkie.
Powrót do ekspresu
Wspomniałem o żądaniach HTTP, czasownikach i kodach stanu w moim poprzednim artykule, Pierwsze kroki z węzłem: wprowadzenie do API, HTTP i ES6+ JavaScript. Jeśli nie masz ogólnej wiedzy na temat protokołu, możesz przejść do sekcji „Żądania HTTP i HTTP” tego fragmentu.
Aby poznać Express, po prostu skonfigurujemy nasze punkty końcowe dla czterech podstawowych operacji, które będziemy wykonywać na bazie danych — Create, Read, Update i Delete, znanych pod wspólną nazwą CRUD.
Pamiętaj, że uzyskujemy dostęp do punktów końcowych za pomocą tras w adresie URL. Oznacza to, że chociaż słowa „trasa” i „punkt końcowy” są powszechnie używane zamiennie, punkt końcowy jest technicznie funkcją języka programowania (jak funkcje strzałek ES6), która wykonuje pewne operacje po stronie serwera, podczas gdy trasa jest tym, za czym znajduje się punkt końcowy z . Określamy te punkty końcowe jako funkcje zwrotne, które Express uruchomi, gdy odpowiednie żądanie zostanie wysłane od klienta do trasy , za którą znajduje się punkt końcowy. Możesz zapamiętać powyższe, zdając sobie sprawę, że to punkty końcowe pełnią funkcję, a trasa jest nazwą używaną do uzyskania dostępu do punktów końcowych. Jak zobaczymy, ta sama trasa może być powiązana z wieloma punktami końcowymi przy użyciu różnych czasowników HTTP (podobnie do przeciążania metod, jeśli pochodzisz z klasycznego środowiska OOP z polimorfizmem).
Należy pamiętać, że postępujemy zgodnie z architekturą REST (Representational State Transfer), umożliwiając klientom przesyłanie żądań do naszego serwera. W końcu jest to REST lub RESTful API. Konkretne żądania skierowane do określonych tras uruchomią określone punkty końcowe , które wykonają określone czynności . Przykładem takiej „rzeczy”, jaką może zrobić punkt końcowy, jest dodawanie nowych danych do bazy danych, usuwanie danych, aktualizowanie danych itp.
Express wie, jaki punkt końcowy uruchomić, ponieważ mówimy mu wprost o metodzie żądania (GET, POST itp.) i trasie — definiujemy, jakie funkcje mają być uruchamiane dla konkretnych kombinacji powyższych, a klient wykonuje żądanie, podając trasa i metoda. Mówiąc prościej, w Node powiemy Expressowi — „Hej, jeśli ktoś wysyła żądanie GET do tej trasy, uruchom tę funkcję (użyj tego punktu końcowego)”. Sprawy mogą być bardziej skomplikowane: „Ekspresowo, jeśli ktoś wysyła żądanie GET do tej trasy, ale nie wyśle prawidłowego tokena na okaziciela autoryzacji w nagłówku żądania, odpowiedz za pomocą HTTP 401 Unauthorized
. Jeśli posiadają ważny token okaziciela, wyślij dowolny chroniony zasób, którego szukali, uruchamiając punkt końcowy. Bardzo dziękuję i życzę miłego dnia.” Rzeczywiście, byłoby fajnie, gdyby języki programowania były na tak wysokim poziomie bez wyciekania niejednoznaczności, ale mimo to demonstruje podstawowe koncepcje.
Pamiętaj, że punkt końcowy w pewnym sensie znajduje się za trasą. Dlatego konieczne jest, aby klient podał w nagłówku żądania, jakiej metody chce użyć, aby Express mógł dowiedzieć się, co zrobić. Żądanie zostanie skierowane na konkretną trasę, którą klient określi (wraz z typem żądania) kontaktując się z serwerem, co pozwoli Expressowi zrobić to, co musi, a nam zrobić to, co musimy zrobić, gdy Express uruchomi nasze wywołania zwrotne . Do tego to wszystko się sprowadza.
We wcześniejszych przykładach kodu wywołaliśmy funkcję listen
, która była dostępna w app
, przekazując jej port i callback. sama app
, jeśli pamiętasz, jest wynikiem powrotu wywołania zmiennej express
jako funkcji (czyli express()
), a zmienna express
jest tym, co nazwaliśmy wynikiem zwrotu z wymagania 'express'
z naszego folderu node_modules
. Podobnie jak listen
jest wywoływane w app
, określamy punkty końcowe żądania HTTP wywołując je w app
. Spójrzmy na GET:
app.get('/my-test-route', () => { // ... });
Pierwszym parametrem jest string
i jest to trasa, za którą będzie żył punkt końcowy. Funkcja zwrotna jest punktem końcowym. Powiem to jeszcze raz: funkcja wywołania zwrotnego — drugi parametr — jest punktem końcowym , który zostanie uruchomiony, gdy żądanie HTTP GET zostanie wysłane do dowolnej trasy określonej jako pierwszy argument (w tym przypadku /my-test-route
).
Teraz, zanim zaczniemy dalszą pracę z Express, musimy wiedzieć, jak działają trasy. Trasa określona jako ciąg zostanie wywołana przez wysłanie żądania do www.domain.com/the-route-we-chose-earlier-as-a-string
. W naszym przypadku domeną jest localhost:3000
, co oznacza, że aby uruchomić powyższą funkcję zwrotną, musimy wykonać żądanie GET do localhost:3000/my-test-route
. Gdybyśmy użyli innego ciągu jako pierwszego argumentu powyżej, URL musiałby być inny, aby pasował do tego, co określiliśmy w JavaScript.
Mówiąc o takich rzeczach, prawdopodobnie usłyszysz o Glob Patterns. Moglibyśmy powiedzieć, że wszystkie nasze trasy API znajdują się na localhost:3000/**
Wzorzec Glob, gdzie **
to symbol wieloznaczny oznaczający dowolny katalog lub podkatalog (zwróć uwagę, że trasy nie są katalogami), których root jest nadrzędnym — to znaczy wszystko.
Przejdźmy dalej i dodajmy instrukcję log do tej funkcji zwrotnej, abyśmy w sumie mieli:
// Getting the module from node_modules. const express = require('express'); // Creating our Express Application. const app = express(); // Defining the port we'll bind to. const PORT = 3000; // Defining a new endpoint behind the "/my-test-route" route. app.get('/my-test-route', () => { console.log('A GET Request was made to /my-test-route.'); }); // Binding the server to port 3000. app.listen(PORT, () => { console.log(`Server is up on port ${PORT}.`) });
Uruchomimy nasz serwer, wykonując node server/server.js
(z Node zainstalowanym w naszym systemie i dostępnym globalnie ze zmiennych środowiskowych systemu) w katalogu głównym projektu. Tak jak wcześniej, powinieneś zobaczyć komunikat, że serwer jest włączony w konsoli. Teraz, gdy serwer działa, otwórz przeglądarkę i odwiedź localhost:3000
w pasku adresu URL.
Powinieneś zostać powitany komunikatem o błędzie z informacją, że Cannot GET /
. Naciśnij Ctrl + Shift + I w systemie Windows w Chrome, aby wyświetlić konsolę programisty. Tam powinieneś zobaczyć, że mamy 404
(Nie znaleziono zasobu). To ma sens — powiedzieliśmy serwerowi tylko, co ma zrobić, gdy ktoś odwiedzi localhost:3000/my-test-route
. Przeglądarka nie ma nic do renderowania na localhost:3000
(co jest odpowiednikiem localhost:3000/
z ukośnikiem).
Jeśli spojrzysz na okno terminala, w którym działa serwer, nie powinno być żadnych nowych danych. Teraz odwiedź localhost:3000/my-test-route
w pasku adresu przeglądarki. Możesz zobaczyć ten sam błąd w konsoli Chrome (ponieważ przeglądarka buforuje zawartość i nadal nie ma kodu HTML do renderowania), ale jeśli spojrzysz na terminal, na którym działa proces serwera, zobaczysz, że funkcja wywołania zwrotnego rzeczywiście się uruchomiła i komunikat dziennika rzeczywiście został zarejestrowany.
Wyłącz serwer za pomocą Ctrl + C.
Teraz dajmy przeglądarce coś do renderowania, gdy do tej trasy zostanie wysłane żądanie GET, abyśmy mogli stracić komunikat Cannot GET /
. Wezmę naszą app.get()
z wcześniejszej wersji, aw funkcji wywołania zwrotnego dodam dwa argumenty. Pamiętaj, że funkcja wywołania zwrotnego, którą przekazujemy, jest wywoływana przez Express za kulisami, a Express może dodać dowolne argumenty. Właściwie dodaje dwa (cóż, technicznie trzy, ale zobaczymy to później) i chociaż oba są niezwykle ważne, na razie nie obchodzi nas pierwszy. Drugi argument nazywa się res
, skrót od response
, i uzyskam do niego dostęp, ustawiając undefined
jako pierwszy parametr:
app.get('/my-test-route', (undefined, res) => { console.log('A GET Request was made to /my-test-route.'); });
Ponownie możemy wywołać argument res
, jak tylko chcemy, ale res
jest konwencją w przypadku Express. res
jest w rzeczywistości obiektem, a na jego podstawie istnieją różne metody wysyłania danych z powrotem do klienta. W tym przypadku zamierzam uzyskać dostęp do funkcji send(...)
dostępnej w res
, aby odesłać HTML, który wyrenderuje przeglądarka. Nie ograniczamy się jednak do odsyłania HTML i możemy zdecydować się na odesłanie tekstu, obiektu JavaScript, strumienia (strumienie są szczególnie piękne) lub cokolwiek innego.
app.get('/my-test-route', (undefined, res) => { console.log('A GET Request was made to /my-test-route.'); res.send('<h1>Hello, World!</h1>'); });
Jeśli zamkniesz serwer, a następnie przywrócisz go z powrotem, a następnie odświeżysz przeglądarkę na trasie /my-test-route
, zobaczysz renderowany kod HTML.
Karta Sieć w narzędziach dla programistów Chrome pozwoli Ci zobaczyć to żądanie GET z bardziej szczegółowymi informacjami dotyczącymi nagłówków.
W tym momencie dobrze nam przysłuży się rozpoczęcie nauki o Express Middleware — funkcjach, które mogą być uruchamiane globalnie po złożeniu żądania przez klienta.
Ekspresowe oprogramowanie pośredniczące
Express udostępnia metody, za pomocą których można zdefiniować niestandardowe oprogramowanie pośredniczące dla aplikacji. Rzeczywiście, znaczenie Express Middleware jest najlepiej zdefiniowane w Express Docs, tutaj)
Funkcje oprogramowania pośredniego to funkcje, które mają dostęp do obiektu żądania (
req
), obiektu odpowiedzi (res
) i następnej funkcji oprogramowania pośredniego w cyklu żądanie-odpowiedź aplikacji. Następna funkcja oprogramowania pośredniego jest zwykle oznaczana przez zmienną o nazwienext
.
Funkcje oprogramowania pośredniego mogą wykonywać następujące zadania:
- Wykonaj dowolny kod.
- Wprowadź zmiany w obiektach żądania i odpowiedzi.
- Zakończ cykl żądanie-odpowiedź.
- Wywołaj następną funkcję oprogramowania pośredniego na stosie.
Innymi słowy, funkcja oprogramowania pośredniego to funkcja niestandardowa, którą możemy (programista) zdefiniować i która będzie działać jako pośrednik między momentem odebrania żądania przez Express, a uruchomieniem naszej odpowiedniej funkcji zwrotnej. Możemy na przykład stworzyć funkcję log
, która będzie rejestrować każde żądanie. Zwróć uwagę, że możemy również wybrać, aby te funkcje oprogramowania pośredniego były uruchamiane po uruchomieniu naszego punktu końcowego, w zależności od tego, gdzie umieścisz je na stosie — coś, co zobaczymy później.
Aby określić niestandardowe oprogramowanie pośredniczące, musimy zdefiniować je jako funkcję i przekazać do app.use(...)
.
const myMiddleware = (req, res, next) => { console.log(`Middleware has fired at time ${Date().now}`); next(); } app.use(myMiddleware); // This is the app variable returned from express().
Wszyscy razem mamy teraz:
// Getting the module from node_modules. const express = require('express'); // Creating our Express Application. const app = express(); // Our middleware function. const myMiddleware = (req, res, next) => { console.log(`Middleware has fired at time ${Date().now}`); next(); } // Tell Express to use the middleware. app.use(myMiddleware); // Defining the port we'll bind to. const PORT = 3000; // Defining a new endpoint behind the "/my-test-route" route. app.get('/my-test-route', () => { console.log('A GET Request was made to /my-test-route.'); }); // Binding the server to port 3000. app.listen(PORT, () => { console.log(`Server is up on port ${PORT}.`) });
Jeśli ponownie wyślesz żądania za pośrednictwem przeglądarki, powinieneś teraz zobaczyć, że funkcja oprogramowania pośredniego uruchamia się i rejestruje sygnatury czasowe. Aby wesprzeć eksperymentowanie, spróbuj usunąć wywołanie next
funkcji i zobacz, co się stanie.
Funkcja wywołania zwrotnego oprogramowania pośredniego jest wywoływana z trzema argumentami: req
, res
i next
. req
jest parametrem, który pominęliśmy podczas budowania GET Handler wcześniej, i jest to obiekt zawierający informacje dotyczące żądania, takie jak nagłówki, niestandardowe nagłówki, parametry i wszelkie treści, które mogły zostać wysłane przez klienta (takie jak robisz z żądaniem POST). Wiem, że mówimy tutaj o oprogramowaniu pośredniczącym, ale zarówno punkty końcowe, jak i funkcja oprogramowania pośredniczącego są wywoływane z req
i res
. req
i res
będą takie same (chyba że jedno lub drugie je zmutuje) zarówno w oprogramowaniu pośredniczącym, jak i punkcie końcowym w zakresie pojedynczego żądania od klienta. Oznacza to na przykład, że możesz użyć funkcji oprogramowania pośredniczącego do oczyszczenia danych przez usunięcie wszelkich znaków, które mogą mieć na celu wykonanie wstrzyknięć SQL lub NoSQL, a następnie przekazanie bezpiecznego req
do punktu końcowego.
res
, jak widzieliśmy wcześniej, umożliwia wysyłanie danych z powrotem do klienta na kilka różnych sposobów.
next
to funkcja wywołania zwrotnego, którą musisz wykonać, gdy oprogramowanie pośredniczące zakończy wykonywanie swojej pracy, aby wywołać następną funkcję oprogramowania pośredniego na stosie lub w punkcie końcowym. Pamiętaj, aby pamiętać, że będziesz musiał wywołać to w bloku then
wszystkich funkcji asynchronicznych, które uruchamiasz w oprogramowaniu pośredniczącym. W zależności od operacji asynchronicznej możesz lub nie chcesz wywoływać jej w bloku catch
. Oznacza to, że funkcja myMiddleware
jest uruchamiana po wysłaniu żądania od klienta, ale przed uruchomieniem funkcji punktu końcowego żądania. Gdy wykonamy ten kod i złożymy żądanie, powinien zostać wyświetlony komunikat Middleware has fired...
przed komunikatem A GET Request was made to...
w konsoli. Jeśli nie wywołasz next()
, druga część nigdy nie zostanie uruchomiona — twoja funkcja punktu końcowego do żądania nie zostanie uruchomiona.
Zauważ też, że mogłem zdefiniować tę funkcję anonimowo, jako taką (konwencja, której będę się trzymać):
app.use((req, res, next) => { console.log(`Middleware has fired at time ${Date().now}`); next(); });
Dla każdego, kto jest nowicjuszem w JavaScript i ES6, jeśli sposób, w jaki powyższe działa, nie ma od razu sensu, poniższy przykład powinien pomóc. Po prostu definiujemy funkcję zwrotną (funkcja anonimowa), która jako argument przyjmuje inną funkcję zwrotną ( next
). Nazywamy funkcję, która przyjmuje argument funkcji, funkcją wyższego rzędu. Spójrz na to w następujący sposób — przedstawia podstawowy przykład tego, jak ekspresowy kod źródłowy może działać za kulisami:
console.log('Suppose a request has just been made from the client.\n'); // This is what (it's not exactly) the code behind app.use() might look like. const use = callback => { // Simple log statement to see where we are. console.log('Inside use() - the "use" function has been called.'); // This depicts the termination of the middleware. const next = () => console.log('Terminating Middleware!\n'); // Suppose req and res are defined above (Express provides them). const req = res = null; // "callback" is the "middleware" function that is passed into "use". // "next" is the above function that pretends to stop the middleware. callback(req, res, next); }; // This is analogous to the middleware function we defined earlier. // It gets passed in as "callback" in the "use" function above. const myMiddleware = (req, res, next) => { console.log('Inside the myMiddleware function!'); next(); } // Here, we are actually calling "use()" to see everything work. use(myMiddleware); console.log('Moving on to actually handle the HTTP Request or the next middleware function.');
Najpierw wywołujemy use
, które przyjmuje myMiddleware
jako argument. myMiddleware
samo w sobie jest funkcją, która przyjmuje trzy argumenty - req
, res
i next
. Wewnątrz use
, wywoływane jest myMiddlware
i te trzy argumenty są przekazywane. next
jest funkcja zdefiniowana w use
. myMiddleware
jest zdefiniowane jako callback
w metodzie use
. Gdybym umieścił use
, w tym przykładzie, na obiekcie o nazwie app
, moglibyśmy całkowicie naśladować konfigurację Express, aczkolwiek bez żadnych gniazd ani łączności sieciowej.
W tym przypadku zarówno myMiddleware
, jak i callback
są funkcjami wyższego rzędu, ponieważ oba przyjmują funkcje jako argumenty.
Jeśli wykonasz ten kod, zobaczysz następującą odpowiedź:
Suppose a request has just been made from the client. Inside use() - the "use" function has been called. Inside the middleware function! Terminating Middleware! Moving on to actually handle the HTTP Request or the next middleware function.
Zauważ, że mogłem również użyć funkcji anonimowych, aby osiągnąć ten sam wynik:
console.log('Suppose a request has just been made from the client.'); // This is what (it's not exactly) the code behind app.use() might look like. const use = callback => { // Simple log statement to see where we are. console.log('Inside use() - the "use" function has been called.'); // This depicts the termination of the middlewear. const next = () => console.log('Terminating Middlewear!'); // Suppose req and res are defined above (Express provides them). const req = res = null; // "callback" is the function which is passed into "use". // "next" is the above function that pretends to stop the middlewear. callback(req, res, () => { console.log('Terminating Middlewear!'); }); }; // Here, we are actually calling "use()" to see everything work. use((req, res, next) => { console.log('Inside the middlewear function!'); next(); }); console.log('Moving on to actually handle the HTTP Request.');
Mając to, miejmy nadzieję, załatwione, możemy teraz wrócić do aktualnego zadania — konfiguracji naszego oprogramowania pośredniczącego.
Faktem jest, że zazwyczaj będziesz musiał przesłać dane za pomocą żądania HTTP. Możesz to zrobić na kilka różnych sposobów — wysyłanie parametrów zapytania URL, wysyłanie danych, które będą dostępne dla obiektu req
, o którym dowiedzieliśmy się wcześniej itp. Ten obiekt jest dostępny nie tylko w wywołaniu zwrotnym do wywołania app.use()
, ale także do dowolnego punktu końcowego. Wcześniej użyliśmy undefined
jako wypełniacza, aby móc skupić się na res
, aby wysłać HTML z powrotem do klienta, ale teraz potrzebujemy do niego dostępu.
app.use('/my-test-route', (req, res) => { // The req object contains client-defined data that is sent up. // The res object allows the server to send data back down. });
Żądania HTTP POST mogą wymagać wysłania obiektu body na serwer. Jeśli masz formularz na kliencie i weźmiesz nazwę użytkownika i adres e-mail, prawdopodobnie wyślesz te dane na serwer w treści żądania.
Przyjrzyjmy się, jak to może wyglądać po stronie klienta:
<!DOCTYPE html> <html> <body> <form action="https://localhost:3000/email-list" method="POST" > <input type="text" name="nameInput"> <input type="email" name="emailInput"> <input type="submit"> </form> </body> </html>
Po stronie serwera:
app.post('/email-list', (req, res) => { // What do we now? // How do we access the values for the user's name and email? });
Aby uzyskać dostęp do nazwy użytkownika i adresu e-mail, będziemy musieli użyć określonego typu oprogramowania pośredniczącego. Spowoduje to umieszczenie danych w obiekcie o nazwie body
dostępnym na req
. Body Parser był popularną metodą robienia tego, dostępną przez programistów Express jako samodzielny moduł NPM. Teraz Express jest dostarczany z własnym oprogramowaniem pośredniczącym, które to umożliwia, i nazwiemy to w ten sposób:
app.use(express.urlencoded({ extended: true }));
Teraz możemy zrobić:
app.post('/email-list', (req, res) => { console.log('User Name: ', req.body.nameInput); console.log('User Email: ', req.body.emailInput); });
Wszystko, co robi, to pobieranie dowolnych danych wejściowych zdefiniowanych przez użytkownika, które są wysyłane od klienta, i udostępnianie ich w obiekcie body
polecenia req
. Zauważ, że w req.body
mamy teraz nameInput
i emailInput
, które są nazwami tagów input
w kodzie HTML. Teraz te dane zdefiniowane przez klienta powinny być uważane za niebezpieczne (nigdy, przenigdy nie ufaj klientowi) i muszą zostać oczyszczone, ale omówimy to później.
Innym typem oprogramowania pośredniczącego dostarczanego przez express jest express.json()
. express.json
służy do pakowania dowolnych ładunków JSON wysyłanych w żądaniu od klienta do req.body
, podczas gdy express.urlencoded
pakuje wszystkie przychodzące żądania z ciągami, tablicami lub innymi danymi zakodowanymi w adresie URL na req.body
. Krótko mówiąc, oba manipulują req.body
, ale .json()
służy do obsługi ładunków JSON, a .urlencoded()
służy między innymi do parametrów zapytania POST.
Innym sposobem powiedzenia tego jest to, że przychodzące żądania z nagłówkiem Content-Type: application/json
(takie jak określenie treści POST za pomocą interfejsu API fetch
) będą obsługiwane przez express.json()
, podczas gdy żądania z nagłówkiem Content-Type: application/x-www-form-urlencoded
(na przykład formularze HTML) będzie obsługiwany przez express.urlencoded()
. Mam nadzieję, że teraz ma to sens.
Uruchamianie naszych tras CRUD dla MongoDB
Uwaga : podczas wykonywania żądań PATCH w tym artykule nie będziemy postępować zgodnie ze specyfikacją JSONPatch RFC — problem, który naprawimy w następnym artykule z tej serii.
Biorąc pod uwagę, że rozumiemy, że określamy każdy punkt końcowy, wywołując odpowiednią funkcję w app
, przekazując do niej trasę i funkcję wywołania zwrotnego zawierającą obiekty żądania i odpowiedzi, możemy rozpocząć definiowanie naszych tras CRUD dla interfejsu Bookshelf API. Rzeczywiście, i biorąc pod uwagę, że jest to artykuł wprowadzający, nie będę dbał o to, aby całkowicie przestrzegać specyfikacji HTTP i REST, ani nie będę próbował używać najczystszej możliwej architektury. To pojawi się w przyszłym artykule.
Otworzę plik server.js
, którego używaliśmy do tej pory i opróżnię wszystko, aby zacząć od poniższej czystej karty:
// Getting the module from node_modules. const express = require('express'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true )); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // ... // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Rozważ cały poniższy kod, aby zająć część // ...
powyższego pliku.
Aby zdefiniować nasze punkty końcowe i ponieważ budujemy REST API, powinniśmy omówić właściwy sposób nazywania tras. Ponownie, powinieneś zajrzeć do sekcji HTTP mojego poprzedniego artykułu, aby uzyskać więcej informacji. Mamy do czynienia z książkami, więc wszystkie trasy będą znajdować się za /books
(standardowa jest konwencja nazewnictwa w liczbie mnogiej).
Prośba | Trasa |
---|---|
POCZTA | /books |
DOSTWAĆ | /books/id |
SKRAWEK | /books/id |
USUNĄĆ | /books/id |
Jak widać, identyfikatora nie trzeba podawać podczas POST-owania książki, ponieważ wygenerujemy ją (a raczej MongoDB), automatycznie, po stronie serwera. Pobieranie, łatanie i usuwanie książek będą wymagały przekazania tego identyfikatora do naszego punktu końcowego, co omówimy później. Na razie stwórzmy po prostu punkty końcowe:
// HTTP POST /books app.post('/books', (req, res) => { // ... console.log('A POST Request was made!'); }); // HTTP GET /books/:id app.get('/books/:id', (req, res) => { // ... console.log(`A GET Request was made! Getting book ${req.params.id}`); }); // HTTP PATCH /books/:id app.patch('/books/:id', (req, res) => { // ... console.log(`A PATCH Request was made! Updating book ${req.params.id}`); }); // HTTP DELETE /books/:id app.delete('/books/:id', (req, res) => { // ... console.log(`A DELETE Request was made! Deleting book ${req.params.id}`); });
Składnia :id
mówi Expressowi, że id
jest parametrem dynamicznym, który będzie przekazywany w adresie URL. Mamy do niego dostęp w obiekcie params
dostępnym na req
. Wiem, że „mamy do niego dostęp na req
” brzmi jak magia, a magia (która nie istnieje) jest niebezpieczna w programowaniu, ale trzeba pamiętać, że Express nie jest czarną skrzynką. Jest to projekt open-source dostępny na GitHub na licencji MIT. Możesz łatwo wyświetlić jego kod źródłowy, jeśli chcesz zobaczyć, jak dynamiczne parametry zapytania są umieszczane w obiekcie req
.
W sumie mamy teraz w naszym pliku server.js
:
// Getting the module from node_modules. const express = require('express'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // HTTP POST /books app.post('/books', (req, res) => { // ... console.log('A POST Request was made!'); }); // HTTP GET /books/:id app.get('/books/:id', (req, res) => { // ... console.log(`A GET Request was made! Getting book ${req.params.id}`); }); // HTTP PATCH /books/:id app.patch('/books/:id', (req, res) => { // ... console.log(`A PATCH Request was made! Updating book ${req.params.id}`); }); // HTTP DELETE /books/:id app.delete('/books/:id', (req, res) => { // ... console.log(`A DELETE Request was made! Deleting book ${req.params.id}`); }); // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Śmiało i uruchom serwer, uruchamiając node server.js
z terminala lub wiersza poleceń i odwiedź przeglądarkę. Otwórz konsolę programistyczną Chrome i na pasku adresu URL (Uniform Resource Locator) odwiedź localhost:3000/books
. Powinieneś już zobaczyć wskaźnik w terminalu twojego systemu operacyjnego, że serwer działa, a także instrukcję log dla GET.
Do tej pory używaliśmy przeglądarki internetowej do wykonywania żądań GET. To jest dobre na początek, ale szybko przekonamy się, że istnieją lepsze narzędzia do testowania tras API. Rzeczywiście, możemy wkleić wywołania fetch
bezpośrednio do konsoli lub skorzystać z jakiejś usługi online. W naszym przypadku, aby zaoszczędzić czas, użyjemy cURL
i Postmana. Używam obu w tym artykule (chociaż możesz użyć jednego lub), abym mógł je przedstawić, jeśli ich nie używałeś. cURL
to biblioteka (bardzo, bardzo ważna biblioteka) i narzędzie wiersza poleceń przeznaczone do przesyłania danych przy użyciu różnych protokołów. Postman to oparte na GUI narzędzie do testowania interfejsów API. Po wykonaniu odpowiednich instrukcji instalacji obu narzędzi w systemie operacyjnym upewnij się, że serwer nadal działa, a następnie wykonaj następujące polecenia (pojedynczo) w nowym terminalu. Ważne jest, aby je wpisać i wykonać pojedynczo, a następnie obejrzeć komunikat dziennika w terminalu oddzielnym od serwera. Należy również zauważyć, że symbol komentarza standardowego języka programowania //
nie jest prawidłowym symbolem w Bash lub MS-DOS. Będziesz musiał pominąć te wiersze, a używam ich tutaj tylko do opisania każdego bloku poleceń cURL
.
// HTTP POST Request (Localhost, IPv4, IPv6) curl -X POST https://localhost:3000/books curl -X POST https://127.0.0.1:3000/books curl -X POST https://[::1]:3000/books // HTTP GET Request (Localhost, IPv4, IPv6) curl -X GET https://localhost:3000/books/123abc curl -X GET https://127.0.0.1:3000/books/book-id-123 curl -X GET https://[::1]:3000/books/book-abc123 // HTTP PATCH Request (Localhost, IPv4, IPv6) curl -X PATCH https://localhost:3000/books/456 curl -X PATCH https://127.0.0.1:3000/books/218 curl -X PATCH https://[::1]:3000/books/some-id // HTTP DELETE Request (Localhost, IPv4, IPv6) curl -X DELETE https://localhost:3000/books/abc curl -X DELETE https://127.0.0.1:3000/books/314 curl -X DELETE https://[::1]:3000/books/217
Jak widać, identyfikator przekazywany jako parametr adresu URL może mieć dowolną wartość. Flaga -X
określa typ żądania HTTP (można ją pominąć w przypadku GET), a my podajemy adres URL, na który następnie zostanie wykonane żądanie. Powielałem każde żądanie trzy razy, dzięki czemu możesz zobaczyć, że wszystko nadal działa, niezależnie od tego, czy używasz nazwy localhost
, adresu IPv4 ( 127.0.0.1
), na który rozwiązuje localhost
lokalny, czy adresu IPv6 ( ::1
), na który rozwiązuje localhost
lokalny . Zauważ, że cURL
wymaga umieszczania adresów IPv6 w nawiasach kwadratowych.
Jesteśmy teraz w przyzwoitym miejscu — mamy prostą strukturę naszych tras i punktów końcowych. Serwer działa poprawnie i akceptuje żądania HTTP tak, jak tego oczekujemy. W przeciwieństwie do tego, czego można się spodziewać, w tym momencie nie ma dużo czasu — musimy tylko skonfigurować naszą bazę danych, hostować ją (przy użyciu bazy danych jako usługi — Atlas MongoDB) i zachować w niej dane (i wykonać walidację i utworzyć odpowiedzi na błędy).
Konfigurowanie produkcyjnej bazy danych MongoDB
Aby skonfigurować produkcyjną bazę danych, przejdziemy do strony głównej MongoDB Atlas i założymy bezpłatne konto. Następnie utwórz nowy klaster. Możesz zachować ustawienia domyślne, wybierając poziom opłat odpowiedni dla regionu. Następnie naciśnij przycisk „Utwórz klaster”. Utworzenie klastra zajmie trochę czasu, po czym będziesz mógł uzyskać adres URL bazy danych i hasło. Zanotuj je, gdy je zobaczysz. Na razie zakodujemy je na sztywno, a później przechowujemy je w zmiennych środowiskowych ze względów bezpieczeństwa. Aby uzyskać pomoc w tworzeniu i łączeniu się z klastrem, odsyłam Cię do dokumentacji MongoDB, szczególnie tej strony i tej strony, lub możesz zostawić komentarz poniżej, a postaram się pomóc.
Tworzenie modelu mangusty
Zaleca się zrozumienie znaczenia dokumentów i zbiorów w kontekście NoSQL (nie tylko SQL — ustrukturyzowany język zapytań). Dla odniesienia możesz przeczytać zarówno Mongoose Quick Start Guide, jak i sekcję MongoDB w moim poprzednim artykule.
Mamy teraz bazę danych, która jest gotowa do przyjęcia operacji CRUD. Mongoose to moduł Node (lub ODM — Object Document Mapper), który pozwoli nam wykonać te operacje (abstrahując od niektórych złożoności), a także skonfigurować schemat lub strukturę kolekcji bazy danych.
Jako ważne zastrzeżenie, istnieje wiele kontrowersji wokół ORM-ów i takich wzorców, jak Active Record czy Data Mapper. Niektórzy programiści przysięgają na ORM-y, a inni przeklinają przeciwko nim (wierząc, że przeszkadzają). Ważne jest również, aby pamiętać, że ORM są bardzo abstrakcyjne, takie jak pulowanie połączeń, połączenia gniazd i obsługa itp. Można łatwo użyć sterownika natywnego MongoDB (kolejny moduł NPM), ale wymagałoby to dużo więcej pracy. Chociaż zaleca się, aby grać z Native Driver przed użyciem ORM, pomijam Native Driver dla zwięzłości. W przypadku złożonych operacji SQL na relacyjnej bazie danych nie wszystkie ORM zostaną zoptymalizowane pod kątem szybkości zapytań i możesz napisać swój własny surowy SQL. ORMs can come into play a lot with Domain-Driven Design and CQRS, among others. They are an established concept in the .NET world, and the Node.js community has not completely caught up yet — TypeORM is better, but it's not NHibernate or Entity Framework.
To create our Model, I'll create a new folder in the server
directory entitled models
, within which I'll create a single file with the name book.js
. Thus far, our project's directory structure is as follows:
- server - node_modules - models - book.js - package.json - server.js
Indeed, this directory structure is not required, but I use it here because it's simple. Allow me to note that this is not at all the kind of architecture you want to use for larger applications (and you might not even want to use JavaScript — TypeScript could be a better option), which I discuss in this article's closing. The next step will be to install mongoose
, which is performed via, as you might expect, npm i mongoose
.
The meaning of a Model is best ascertained from the Mongoose documentation:
Models are fancy constructors compiled from
Schema
definitions. An instance of a model is called a document. Models are responsible for creating and reading documents from the underlying MongoDB database.
Before creating the Model, we'll define its Schema. A Schema will, among others, make certain expectations about the value of the properties provided. MongoDB is schemaless, and thus this functionality is provided by the Mongoose ODM. Let's start with a simple example. Suppose I want my database to store a user's name, email address, and password. Traditionally, as a plain old JavaScript Object (POJO), such a structure might look like this:
const userDocument = { name: 'Jamie Corkhill', email: '[email protected]', password: 'Bcrypt Hash' };
If that above object was how we expected our user's object to look, then we would need to define a schema for it, like this:
const schema = { name: { type: String, trim: true, required: true }, email: { type: String, trim: true, required: true }, password: { type: String, required: true } };
Notice that when creating our schema, we define what properties will be available on each document in the collection as an object in the schema. In our case, that's name
, email
, and password
. The fields type
, trim
, required
tell Mongoose what data to expect. If we try to set the name
field to a number, for example, or if we don't provide a field, Mongoose will throw an error (because we are expecting a type of String
), and we can send back a 400 Bad Request
to the client. This might not make sense right now because we have defined an arbitrary schema
object. However, the fields of type
, trim
, and required
(among others) are special validators that Mongoose understands. trim
, for example, will remove any whitespace from the beginning and end of the string. We'll pass the above schema to mongoose.Schema()
in the future and that function will know what to do with the validators.
Understanding how Schemas work, we'll create the model for our Books Collection of the Bookshelf API. Let's define what data we require:
Tytuł
ISBN Number
Autor
Imię
Nazwisko
Publishing Date
Finished Reading (Boolean)
I'm going to create this in the book.js
file we created earlier in /models
. Like the example above, we'll be performing validation:
const mongoose = require('mongoose'); // Define the schema: const mySchema = { title: { type: String, required: true, trim: true, }, isbn: { type: String, required: true, trim: true, }, author: { firstName:{ type: String, required: true, trim: true }, lastName: { type: String, required: true, trim: true } }, publishingDate: { type: String }, finishedReading: { type: Boolean, required: true, default: false } }
default
will set a default value for the property if none is provided — finishedReading
for example, although a required field, will be set automatically to false
if the client does not send one up.
Mongoose also provides the ability to perform custom validation on our fields, which is done by supplying the validate()
method, which attains the value that was attempted to be set as its one and only parameter. In this function, we can throw an error if the validation fails. Oto przykład:
// ... isbn: { type: String, required: true, trim: true, validate(value) { if (!validator.isISBN(value)) { throw new Error('ISBN is invalid.'); } } } // ...
Now, if anyone supplies an invalid ISBN to our model, Mongoose will throw an error when trying to save that document to the collection. I've already installed the NPM module validator
via npm i validator
and required it. validator
contains a bunch of helper functions for common validation requirements, and I use it here instead of RegEx because ISBNs can't be validated with RegEx alone due to a tailing checksum. Remember, users will be sending a JSON body to one of our POST routes. That endpoint will catch any errors (such as an invalid ISBN) when attempting to save, and if one is thrown, it'll return a blank response with an HTTP 400 Bad Request
status — we haven't yet added that functionality.
Finally, we have to define our schema of earlier as the schema for our model, so I'll make a call to mongoose.Schema()
passing in that schema:
const bookSchema = mongoose.Schema(mySchema);
To make things more precise and clean, I'll replace the mySchema
variable with the actual object all on one line:
const bookSchema = mongoose.Schema({ title:{ type: String, required: true, trim: true, }, isbn:{ type: String, required: true, trim: true, validate(value) { if (!validator.isISBN(value)) { throw new Error('ISBN is invalid.'); } } }, author:{ firstName: { type: String required: true, trim: true }, lastName:{ type: String, required: true, trim: true } }, publishingDate:{ type: String }, finishedReading:{ type: Boolean, required: true, default: false } });
Let's take a final moment to discuss this schema. We are saying that each of our documents will consist of a title, an ISBN, an author with a first and last name, a publishing date, and a finishedReading boolean.
-
title
will be of typeString
, it's a required field, and we'll trim any whitespace. -
isbn
will be of typeString
, it's a required field, it must match the validator, and we'll trim any whitespace. -
author
is of typeobject
containing a required, trimmed,string
firstName and a required, trimmed,string
lastName. -
publishingDate
is of type String (although we could make it of typeDate
orNumber
for a Unix timestamp. -
finishedReading
is a requiredboolean
that will default tofalse
if not provided.
With our bookSchema
defined, Mongoose knows what data and what fields to expect within each document to the collection that stores books. However, how do we tell it what collection that specific schema defines? We could have hundreds of collections, so how do we correlate, or tie, bookSchema
to the Book
collection?
The answer, as seen earlier, is with the use of models. We'll use bookSchema
to create a model, and that model will model the data to be stored in the Book collection, which will be created by Mongoose automatically.
Append the following lines to the end of the file:
const Book = mongoose.model('Book', bookSchema); module.exports = Book;
As you can see, we have created a model, the name of which is Book
(— the first parameter to mongoose.model()
), and also provided the ruleset, or schema, to which all data is saved in the Book collection will have to abide. We export this model as a default export, allowing us to require
the file for our endpoints to access. Book
is the object upon which we'll call all of the required functions to Create, Read, Update, and Delete data which are provided by Mongoose.
Altogether, our book.js
file should look as follows:
const mongoose = require('mongoose'); const validator = require('validator'); // Define the schema. const bookSchema = mongoose.Schema({ title:{ type: String, required: true, trim: true, }, isbn:{ type: String, required: true, trim: true, validate(value) { if (!validator.isISBN(value)) { throw new Error('ISBN is invalid.'); } } }, author:{ firstName: { type: String, required: true, trim: true }, lastName:{ type: String, required: true, trim: true } }, publishingDate:{ type: String }, finishedReading:{ type: Boolean, required: true, default: false } }); // Create the "Book" model of name Book with schema bookSchema. const Book = mongoose.model('Book', bookSchema); // Provide the model as a default export. module.exports = Book;
Connecting To MongoDB (Basics)
Don't worry about copying down this code. I'll provide a better version in the next section. To connect to our database, we'll have to provide the database URL and password. We'll call the connect
method available on mongoose
to do so, passing to it the required data. For now, we are going hardcode the URL and password — an extremely frowned upon technique for many reasons: namely the accidental committing of sensitive data to a public (or private made public) GitHub Repository. Realize also that commit history is saved, and that if you accidentally commit a piece of sensitive data, removing it in a future commit will not prevent people from seeing it (or bots from harvesting it), because it's still available in the commit history. CLI tools exist to mitigate this issue and remove history.
As stated, for now, we'll hard code the URL and password, and then save them to environment variables later. At this point, let's look at simply how to do this, and then I'll mention a way to optimize it.
const mongoose = require('mongoose'); const MONGODB_URL = 'Your MongoDB URL'; mongoose.connect(MONGODB_URL, { useNewUrlParser: true, useCreateIndex: true, useFindAndModify: false, useUnifiedTopology: true });
To połączy się z bazą danych. Podajemy adres URL, który uzyskaliśmy z pulpitu MongoDB Atlas, a obiekt przekazany jako drugi parametr określa funkcje, których należy użyć, aby między innymi zapobiegać ostrzeżeniom o deprecjacji.
Mongoose, który za kulisami korzysta z rdzenia MongoDB Native Driver, musi starać się nadążyć za przełomowymi zmianami wprowadzonymi w sterowniku. W nowej wersji sterownika zmieniono mechanizm służący do parsowania adresów URL połączeń, dlatego przekazujemy useNewUrlParser: true
, aby określić, że chcemy korzystać z najnowszej wersji dostępnej z oficjalnego sterownika.
Domyślnie, jeśli ustawisz indeksy (które są nazywane „indeksami”, a nie „indeksami”) (czego nie omówimy w tym artykule) dla danych w Twojej bazie danych, Mongoose użyje funkcji ensureIndex()
dostępnej w sterowniku natywnym. MongoDB zdeprecjonowała tę funkcję na rzecz metody createIndex()
, dlatego ustawienie flagi useCreateIndex
na wartość true spowoduje, że Mongoose użyje metody createIndex()
ze sterownika, która jest funkcją, która nie jest przestarzała.
Oryginalna wersja findOneAndUpdate
firmy Mongoose (która jest metodą wyszukiwania dokumentu w bazie danych i jego aktualizacji) poprzedza wersję sterownika natywnego. Oznacza to, findOneAndUpdate()
nie był pierwotnie funkcją sterownika natywnego, ale raczej funkcją dostarczoną przez Mongoose, więc Mongoose musiał użyć findAndModify
dostarczonej za kulisami przez sterownik, aby utworzyć funkcję findOneAndUpdate
. Po zaktualizowaniu sterownika zawiera on własną taką funkcję, więc nie musimy używać findAndModify
. To może nie mieć sensu i to jest w porządku — nie jest to ważna informacja o skali rzeczy.
Wreszcie MongoDB wycofał swój stary system monitorowania serwerów i silników. Używamy nowej metody z useUnifiedTopology: true
.
To, co mamy do tej pory, to sposób na połączenie się z bazą danych. Ale o to chodzi — nie jest skalowalny ani wydajny. Kiedy piszemy testy jednostkowe dla tego interfejsu API, testy jednostkowe będą używać własnych danych testowych (lub urządzeń) we własnych testowych bazach danych. Dlatego chcemy mieć możliwość tworzenia połączeń do różnych celów — niektóre dla środowisk testowych (które możemy dowolnie rozkręcać i rozbierać), inne dla środowisk programistycznych, a inne dla środowisk produkcyjnych. W tym celu zbudujemy fabrykę. (Pamiętasz to wcześniej?)
Podłączanie do Mongo — budowanie wdrożenia fabryki JS
Rzeczywiście, obiekty Java wcale nie są analogiczne do obiektów JavaScript, a zatem to, co wiemy powyżej z wzorca projektowania fabrycznego, nie będzie miało zastosowania. Podałem to jedynie jako przykład, aby pokazać tradycyjny wzór. Aby uzyskać obiekt w Javie, C#, C++ itp., musimy utworzyć instancję klasy. Odbywa się to za pomocą słowa kluczowego new
, które instruuje kompilator, aby przydzielił pamięć dla obiektu na stercie. W C++ daje nam to wskaźnik do obiektu, który musimy sami oczyścić, dzięki czemu nie mamy zawieszonych wskaźników ani wycieków pamięci (C++ nie ma odśmiecacza pamięci, w przeciwieństwie do Node/V8, który jest zbudowany na C++). powyższego nie trzeba robić — nie musimy tworzyć instancji klasy, aby uzyskać obiekt — obiekt to po prostu {}
. Niektórzy powiedzą, że wszystko w JavaScript jest obiektem, chociaż technicznie nie jest to prawdą, ponieważ typy pierwotne nie są obiektami.
Z powyższych powodów nasza Fabryka JS będzie prostsza, pozostając przy luźnej definicji fabryki będącej funkcją zwracającą obiekt (obiekt JS). Ponieważ funkcja jest obiektem (dla function
dziedziczącej z object
poprzez dziedziczenie prototypowe), nasz poniższy przykład spełni to kryterium. Aby zaimplementować fabrykę, stworzę nowy folder wewnątrz server
o nazwie db
. W ramach db
nowy plik o nazwie mongoose.js
. Ten plik będzie nawiązywał połączenia z bazą danych. Wewnątrz mongoose.js
funkcję o nazwie connectionFactory
i domyślnie ją wyeksportuję:
// Directory - server/db/mongoose.js const mongoose = require('mongoose'); const MONGODB_URL = 'Your MongoDB URL'; const connectionFactory = () => { return mongoose.connect(MONGODB_URL, { useNewUrlParser: true, useCreateIndex: true, useFindAndModify: false }); }; module.exports = connectionFactory;
Używając skrótu dostarczonego przez ES6 dla funkcji strzałek, które zwracają jedną instrukcję w tym samym wierszu, co podpis metody, uprości ten plik, pozbywając się definicji connectionFactory
i po prostu eksportując fabrykę domyślnie:
// server/db/mongoose.js const mongoose = require('mongoose'); const MONGODB_URL = 'Your MongoDB URL'; module.exports = () => mongoose.connect(MONGODB_URL, { useNewUrlParser: true, useCreateIndex: true, useFindAndModify: true });
Teraz wystarczy tylko zażądać pliku i wywołać metodę, która zostanie wyeksportowana, na przykład:
const connectionFactory = require('./db/mongoose'); connectionFactory(); // OR require('./db/mongoose')();
Możesz odwrócić kontrolę, podając adres URL MongoDB jako parametr funkcji fabrycznej, ale zamierzamy dynamicznie zmienić adres URL jako zmienną środowiskową na podstawie środowiska.
Zaletą nawiązania naszego połączenia jako funkcji jest to, że możemy wywołać tę funkcję później w kodzie, aby połączyć się z bazą danych z plików przeznaczonych do produkcji oraz tych przeznaczonych do lokalnego i zdalnego testowania integracji zarówno na urządzeniu, jak i za pomocą zdalnego potoku CI/CD /buduj serwer.
Budowanie naszych punktów końcowych
Teraz zaczynamy dodawać bardzo prostą logikę związaną z CRUD do naszych punktów końcowych. Jak wspomniano wcześniej, krótkie zastrzeżenie jest w porządku. Metody, za pomocą których wdrażamy tutaj naszą logikę biznesową, nie są tymi, które powinieneś odzwierciedlać w przypadku innych niż proste projekty. Łączenie się z bazami danych i wykonywanie logiki bezpośrednio w punktach końcowych jest (i powinno być) niemile widziane, ponieważ tracisz możliwość zamiany usług lub DBMS bez konieczności przeprowadzania refaktoryzacji całej aplikacji. Niemniej jednak, biorąc pod uwagę, że jest to artykuł dla początkujących, stosuję tutaj te złe praktyki. W przyszłym artykule z tej serii omówimy, w jaki sposób możemy zwiększyć zarówno złożoność, jak i jakość naszej architektury.
Na razie wróćmy do naszego pliku server.js
i upewnijmy się, że obaj mamy ten sam punkt wyjścia. Zauważ, że dodałem oświadczenie require
dla naszej fabryki połączeń z bazą danych i zaimportowałem model, który wyeksportowaliśmy z ./models/book.js
.
const express = require('express'); // Database connection and model. require('./db/mongoose.js'); const Book = require('./models/book.js'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // HTTP POST /books app.post('/books', (req, res) => { // ... console.log('A POST Request was made!'); }); // HTTP GET /books/:id app.get('/books/:id', (req, res) => { // ... console.log(`A GET Request was made! Getting book ${req.params.id}`); }); // HTTP PATCH /books/:id app.patch('/books/:id', (req, res) => { // ... console.log(`A PATCH Request was made! Updating book ${req.params.id}`); }); // HTTP DELETE /books/:id app.delete('/books/:id', (req, res) => { // ... console.log(`A DELETE Request was made! Deleting book ${req.params.id}`); }); // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Zacznę od app.post()
. Mamy dostęp do modelu Book
, ponieważ wyeksportowaliśmy go z pliku, w którym go utworzyliśmy. Jak stwierdzono w dokumentach Mongoose, Book
można zbudować. Aby utworzyć nową książkę, wywołujemy konstruktora i przekazujemy dane książki w następujący sposób:
const book = new Book(bookData);
W naszym przypadku jako obiekt przesłany w żądaniu będziemy mieli bookData
, który będzie dostępny na req.body.book
. Pamiętaj, że oprogramowanie pośredniczące express.json()
umieści wszystkie wysyłane przez nas dane JSON w req.body
. Mamy wysłać JSON w następującym formacie:
{ "book": { "title": "The Art of Computer Programming", "isbn": "ISBN-13: 978-0-201-89683-1", "author": { "firstName": "Donald", "lastName": "Knuth" }, "publishingDate": "July 17, 1997", "finishedReading": true } }
Oznacza to zatem, że przekazany przez nas JSON zostanie przeanalizowany, a cały obiekt JSON (pierwsza para nawiasów klamrowych) zostanie umieszczony na req.body
przez oprogramowanie pośredniczące express.json express.json()
. Jedyną właściwością naszego obiektu JSON jest book
, a zatem obiekt book
będzie dostępny na req.body.book
.
W tym momencie możemy wywołać funkcję konstruktora modelu i przekazać nasze dane:
app.post('/books', async (req, res) => { // <- Notice 'async' const book = new Book(req.body.book); await book.save(); // <- Notice 'await' });
Zwróć uwagę na kilka rzeczy. Wywołanie metody save
na instancji, którą wrócimy z wywołania funkcji konstruktora, spowoduje utrwalenie obiektu req.body.book
w bazie danych wtedy i tylko wtedy, gdy jest on zgodny ze schematem, który zdefiniowaliśmy w modelu Mongoose. Czynność zapisywania danych do bazy danych jest operacją asynchroniczną, a ta metoda save()
zwraca obietnicę — na której rozstrzygnięcie bardzo czekamy. Zamiast łączyć łańcuch w .then()
, używam składni ES6 Async/Await, co oznacza, że muszę wykonać funkcję wywołania zwrotnego do app.post
async
.
book.save()
odrzuci z ValidationError
, jeśli obiekt wysłany przez klienta nie jest zgodny ze zdefiniowanym przez nas schematem. Nasza obecna konfiguracja powoduje, że kod jest bardzo niestabilny i źle napisany, ponieważ nie chcemy, aby nasza aplikacja zawieszała się w przypadku awarii dotyczącej walidacji. Aby to naprawić, omówię niebezpieczną operację w klauzuli try/catch
. W przypadku błędu zwrócę złe żądanie HTTP 400 lub niemożliwą do przetworzenia jednostkę HTTP 422. Istnieje pewna debata na temat tego, którego użyć, więc będę trzymać się 400 w tym artykule, ponieważ jest bardziej ogólny.
app.post('/books', async (req, res) => { try { const book = new Book(req.body.book); await book.save(); return res.status(201).send({ book }); } catch (e) { return res.status(400).send({ error: 'ValidationError' }); } });
Zauważ, że używam skrótu obiektów ES6, aby po prostu zwrócić obiekt book
z powrotem do klienta w przypadku powodzenia za pomocą res.send({ book })
— to byłoby równoważne res.send({ book: book })
. Zwracam również wyrażenie, aby upewnić się, że moja funkcja zakończy działanie. W bloku catch
ustawiam jawnie status na 400 i zwracam ciąg „ValidationError” we właściwości error
obiektu, który jest odsyłany. 201 to kod statusu ścieżki sukcesu oznaczający „UTWORZONY”.
Rzeczywiście, nie jest to również najlepsze rozwiązanie, ponieważ nie możemy być pewni, że przyczyną niepowodzenia było złe żądanie po stronie klienta. Być może straciliśmy połączenie (zakładając, że zerwaliśmy połączenie z gniazdem, a więc przejściowy wyjątek) z bazą danych, w takim przypadku prawdopodobnie powinniśmy zwrócić błąd 500 Internal Server. Sposobem na sprawdzenie tego byłoby odczytanie obiektu błędu e
i selektywne zwrócenie odpowiedzi. Zróbmy to teraz, ale jak już wielokrotnie mówiłem, kolejny artykuł omówi właściwą architekturę pod względem routerów, kontrolerów, usług, repozytoriów, niestandardowych klas błędów, niestandardowego oprogramowania pośredniczącego dla błędów, niestandardowych odpowiedzi na błędy, danych modelu bazy danych/jednostki domeny mapowanie i rozdzielanie zapytań poleceń (CQS).
app.post('/books', async (req, res) => { try { const book = new Book(req.body.book); await book.save(); return res.send({ book }); } catch (e) { if (e instanceof mongoose.Error.ValidationError) { return res.status(400).send({ error: 'ValidationError' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
Śmiało i otwórz Postman (zakładając, że go masz, w przeciwnym razie pobierz i zainstaluj) i utwórz nowe żądanie. Wyślemy żądanie POST do localhost:3000/books
. Na karcie „Body” w sekcji Postman Request wybiorę przycisk radiowy „surowe” i wybierz „JSON” z menu rozwijanego po prawej stronie. Spowoduje to automatyczne dodanie nagłówka Content-Type: application/json
do żądania. Następnie skopiuję i wkleję wcześniejszy obiekt Book JSON do obszaru tekstu treści. Oto co mamy:
Następnie nacisnę przycisk wysyłania i powinieneś zobaczyć odpowiedź 201 Utworzono w sekcji „Odpowiedź” programu Postman (dolny rząd). Widzimy to, ponieważ specjalnie poprosiliśmy Express o odpowiedź 201 i obiektem Book — gdybyśmy właśnie zrobili res.send()
bez kodu statusu, express
automatycznie odpowiedziałby 200 OK. Jak widać, obiekt Book jest teraz zapisany w bazie danych i zwrócony klientowi jako odpowiedź na żądanie POST.
Jeśli przejrzysz bazę danych Kolekcja książek za pośrednictwem MongoDB Atlas, zobaczysz, że książka została rzeczywiście zapisana.
Możesz również stwierdzić, że MongoDB wstawił pola __v
i _id
. Pierwsza reprezentuje wersję dokumentu, w tym przypadku 0, a druga to ObjectID dokumentu — który jest automatycznie generowany przez MongoDB i gwarantuje niskie prawdopodobieństwo kolizji.
Podsumowanie tego, co omówiliśmy do tej pory
Do tej pory w artykule omówiliśmy bardzo wiele. Zróbmy krótkie wytchnienie, przeglądając krótkie podsumowanie, zanim wrócimy, aby zakończyć interfejs Express API.
Dowiedzieliśmy się o ES6 Object Destructuring, ES6 Object Shorthand Syntax, a także o operatorze ES6 Rest/Spread. Wszystkie trzy z nich pozwalają nam wykonać następujące czynności (i więcej, jak omówiono powyżej):
// Destructuring Object Properties: const { a: newNameA = 'Default', b } = { a: 'someData', b: 'info' }; console.log(`newNameA: ${newNameA}, b: ${b}`); // newNameA: someData, b: info // Destructuring Array Elements const [elemOne, elemTwo] = [() => console.log('hi'), 'data']; console.log(`elemOne(): ${elemOne()}, elemTwo: ${elemTwo}`); // elemOne(): hi, elemTwo: data // Object Shorthand const makeObj = (name) => ({ name }); console.log(`makeObj('Tim'): ${JSON.stringify(makeObj('Tim'))}`); // makeObj('Tim'): { "name": "Tim" } // Rest, Spread const [c, d, ...rest] = [0, 1, 2, 3, 4]; console.log(`c: ${c}, d: ${d}, rest: ${rest}`) // c: 0, d: 1, rest: 2, 3, 4
Omówiliśmy również Express, Expess Middleware, Servers, Ports, IP Addressing, itp. Sprawa stała się ciekawa, gdy dowiedzieliśmy się, że istnieją metody dostępne dla wyniku zwracanego z require('express')();
z nazwami czasowników HTTP, takimi jak app.get
i app.post
.
Jeśli ta część require('express')()
nie miała dla ciebie sensu, to był punkt, o którym mówiłem:
const express = require('express'); const app = express(); app.someHTTPVerb
Powinno to mieć sens w taki sam sposób, w jaki wcześniej uruchomiliśmy fabrykę połączeń dla Mongoose.
Każda procedura obsługi trasy, która jest funkcją punktu końcowego (lub funkcją wywołania zwrotnego), jest przekazywana w obiekcie req
i obiekcie res
z Express za kulisami. (Technicznie też dostają next
, jak zobaczymy za minutę). req
zawiera dane specyficzne dla przychodzącego żądania od klienta, takie jak nagłówki lub dowolny wysłany JSON. res
jest tym, co pozwala nam zwracać odpowiedzi do klienta. next
funkcja jest również przekazywana do handlerów.
W Mongoose widzieliśmy, jak możemy połączyć się z bazą danych za pomocą dwóch metod — prymitywnego i bardziej zaawansowanego/praktycznego sposobu, który zapożycza ze wzorca Factory. Skończymy z tym, gdy będziemy omawiać testowanie jednostkowe i integracyjne za pomocą Jest (i testowanie mutacji), ponieważ pozwoli nam to na rozkręcenie testowej instancji bazy danych wypełnionej danymi źródłowymi, względem których możemy uruchamiać asercje.
Następnie utworzyliśmy obiekt schematu Mongoose i użyliśmy go do stworzenia modelu, a następnie dowiedzieliśmy się, jak możemy wywołać konstruktora tego modelu, aby utworzyć jego nową instancję. Na instancji dostępna jest m.in. metoda save
, która ma charakter asynchroniczny i która sprawdza, czy przekazana struktura obiektu jest zgodna ze schematem, rozwiązuje obietnicę, jeśli tak, i odrzuca obietnicę z ValidationError
, jeśli to nie. W przypadku rozwiązania, nowy dokument jest zapisywany do bazy danych i odpowiadamy HTTP 200 OK/201 CREATED, w przeciwnym razie przechwytujemy rzucony błąd w naszym punkcie końcowym i zwracamy klientowi HTTP 400 Bad Request.
Kontynuując budowanie naszych punktów końcowych, dowiesz się więcej o niektórych metodach dostępnych w modelu i instancji modelu.
Wykańczanie naszych punktów końcowych
Po ukończeniu POST Endpoint zajmijmy się GET. Jak wspomniałem wcześniej, składnia :id
wewnątrz trasy informuje Express, że id
jest parametrem trasy, dostępnym z req.params
. Widziałeś już, że kiedy dopasujesz jakiś identyfikator dla param „wildcard” na trasie, został on wyświetlony na ekranie we wczesnych przykładach. Na przykład, jeśli wyślesz żądanie GET do „/books/test-id-123”, to req.params.id
będzie ciągiem test-id-123
ponieważ nazwa parametru to id
, mając trasę jako HTTP GET /books/:id
.
Tak więc wszystko, co musimy zrobić, to pobrać ten identyfikator z obiektu req
i sprawdzić, czy jakikolwiek dokument w naszej bazie danych ma ten sam identyfikator — coś, co bardzo ułatwiło Mongoose (i Native Driver).
app.get('/books/:id', async (req, res) => { const book = await Book.findById(req.params.id); console.log(book); res.send({ book }); });
Widać, że dostępna w naszym modelu jest funkcja, którą możemy wywołać, która znajdzie dokument po jego identyfikatorze. Za kulisami Mongoose rzuci każdy identyfikator, który przekażemy do findById
, na typ pola _id
w dokumencie lub w tym przypadku ObjectId
. Jeśli zostanie znaleziony pasujący identyfikator (i tylko jeden zostanie znaleziony, ponieważ ObjectId
ma wyjątkowo niskie prawdopodobieństwo kolizji), ten dokument zostanie umieszczony w naszej zmiennej stałej book
. Jeśli nie, book
będzie zerowa — fakt, który wykorzystamy w najbliższej przyszłości.
Na razie zrestartujmy serwer (musisz zrestartować serwer, chyba że używasz nodemon
) i upewnijmy się, że nadal mamy jeden dokument księgi z poprzedniej kolekcji w Books
Collection. Śmiało skopiuj identyfikator tego dokumentu, podświetloną część obrazu poniżej:
I użyj go, aby wysłać żądanie GET do /books/:id
z listonoszem w następujący sposób (zauważ, że dane ciała zostały po prostu pozostawione z mojego wcześniejszego żądania POST. W rzeczywistości nie jest używany, mimo że jest przedstawiony na poniższym obrazku) :
Po wykonaniu tej czynności powinieneś otrzymać dokument książki z określonym identyfikatorem z powrotem w sekcji odpowiedzi listonosza. Zauważ, że wcześniej, przy POST Route, która ma na celu „POST” lub „przesłanie” nowych zasobów na serwer, odpowiedzieliśmy 201 Created — ponieważ nowy zasób (lub dokument) został utworzony. W przypadku GET nie utworzono nic nowego — po prostu poprosiliśmy o zasób o określonym identyfikatorze, więc otrzymaliśmy kod statusu 200 OK zamiast 201 Utworzono.
Jak to często bywa w dziedzinie tworzenia oprogramowania, przypadki brzegowe muszą być brane pod uwagę — dane wprowadzane przez użytkownika są z natury niebezpieczne i błędne, a naszym zadaniem, jako programistów, jest być elastycznym w stosunku do rodzajów danych wejściowych, które możemy otrzymać i na nie odpowiadać odpowiednio. Co zrobimy, jeśli użytkownik (lub API Caller) przekaże nam identyfikator, którego nie można rzutować na obiekt MongoDB ObjectID, lub identyfikator, który można rzutować, ale który nie istnieje?
W pierwszym przypadku Mongoose wyrzuci CastError
— co jest zrozumiałe, ponieważ jeśli dostarczymy identyfikator taki jak math-is-fun
, to oczywiście nie jest to coś, co można rzutować na ObjectID, a rzutowanie na ObjectID jest właśnie tym, Mongoose robi pod maską.
W tym drugim przypadku możemy łatwo rozwiązać problem za pomocą kontroli zerowej lub klauzuli ochronnej. Tak czy inaczej, zamierzam odesłać odpowiedź i HTTP 404 Not Found Response. Pokażę ci kilka sposobów, w jakie możemy to zrobić, zły sposób, a potem lepszy.
Po pierwsze, możemy wykonać następujące czynności:
app.get('/books/:id', async (req, res) => { try { const book = await Book.findById(req.params.id); if (!book) throw new Error(); return res.send({ book }); } catch (e) { return res.status(404).send({ error: 'Not Found' }); } });
To działa i możemy go dobrze wykorzystać. Oczekuję, że instrukcja await Book.findById()
zgłosi Mongoose CastError
, jeśli ciąg identyfikatora nie może być rzutowany na ObjectID, powodując wykonanie bloku catch
. Jeśli można go rzutować, ale odpowiadający mu identyfikator obiektu nie istnieje, book
będzie miał null
, a funkcja sprawdzania wartości null wygeneruje błąd, ponownie uruchamiając blok catch
. Wewnątrz catch
, po prostu zwracamy 404. Pojawiają się tutaj dwa problemy. Po pierwsze, nawet jeśli książka zostanie znaleziona, ale wystąpi jakiś inny nieznany błąd, odsyłamy 404, kiedy prawdopodobnie powinniśmy dać klientowi ogólne 500. Po drugie, tak naprawdę nie rozróżniamy, czy wysłany identyfikator jest ważny, ale nie istnieje, czy to tylko zły identyfikator.
Oto inny sposób:
const mongoose = require('mongoose'); app.get('/books/:id', async (req, res) => { try { const book = await Book.findById(req.params.id); if (!book) return res.status(404).send({ error: 'Not Found' }); return res.send({ book }); } catch (e) { if (e instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
Zaletą tego jest to, że możemy obsłużyć wszystkie trzy przypadki 400, 404 i generyczną 500. Zauważ, że po wykonaniu Null Check on book
używam słowa kluczowego return
w mojej odpowiedzi. Jest to bardzo ważne, ponieważ chcemy mieć pewność, że wyjdziemy tam z obsługi trasy.
Niektóre inne opcje mogą polegać na tym, że możemy sprawdzić, czy id
z req.params
może być rzutowany na ObjectID jawnie, w przeciwieństwie do zezwalania Mongoose na rzutowanie niejawnie za pomocą mongoose.Types.ObjectId.isValid('id);
, ale istnieje przypadek brzegowy z 12-bajtowymi ciągami, który powoduje, że czasami działa to nieoczekiwanie.
Możemy sprawić, że wspomniane powtórzenie będzie mniej bolesne dzięki Boom
, bibliotece odpowiedzi HTTP, na przykład, lub możemy zastosować oprogramowanie pośredniczące do obsługi błędów. Moglibyśmy również przekształcić Mongoose Errors w coś bardziej czytelnego dzięki Mongoose Hooks/Middleware, jak opisano tutaj. Dodatkową opcją byłoby zdefiniowanie niestandardowych obiektów błędów i użycie globalnego oprogramowania pośredniczącego Express Error Handling, jednak zachowam to na nadchodzący artykuł, w którym omawiamy lepsze metody architektoniczne.
W punkcie końcowym PATCH /books/:id
spodziewamy się przekazania obiektu aktualizacji zawierającego aktualizacje dla danej książki. W tym artykule zezwolimy na aktualizację wszystkich pól, ale w przyszłości pokażę, jak możemy zabronić aktualizacji poszczególnych pól. Dodatkowo zobaczysz, że logika obsługi błędów w naszym punkcie końcowym PATCH będzie taka sama jak w punkcie końcowym GET. To znak, że łamiemy zasady DRY, ale znowu poruszymy o tym później.
Oczekuję, że wszystkie aktualizacje będą dostępne w obiekcie updates
z req.body
(co oznacza, że klient wyśle JSON zawierający obiekt updates
) i użyje funkcji Book.findByAndUpdate
ze specjalną flagą do wykonania aktualizacji.
app.patch('/books/:id', async (req, res) => { const { id } = req.params; const { updates } = req.body; try { const updatedBook = await Book.findByIdAndUpdate(id, updates, { runValidators: true, new: true }); if (!updatedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: updatedBook }); } catch (e) { if (e instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
Zwróć uwagę na kilka rzeczy. Najpierw zdestrukturyzujemy id
z req.params
i updates
z req.body
.
W modelu Book
dostępna jest funkcja o nazwie findByIdAndUpdate
, która pobiera identyfikator danego dokumentu, aktualizacje do wykonania oraz opcjonalny obiekt opcji. Normalnie Mongoose nie przeprowadza ponownie walidacji operacji aktualizacji, więc flaga runValidators: true
przekazujemy, gdy obiekt options
wymusza to. Ponadto, począwszy od Mongoose 4, Model.findByIdAndUpdate
nie zwraca już zmodyfikowanego dokumentu, ale zamiast tego zwraca oryginalny dokument. new: true
(domyślnie false) zastępuje to zachowanie.
Na koniec możemy zbudować nasz punkt końcowy DELETE, który jest dość podobny do wszystkich innych:
app.delete('/books/:id', async (req, res) => { try { const deletedBook = await Book.findByIdAndDelete(req.params.id); if (!deletedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: deletedBook }); } catch (e) { if (e instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { return res.status(500).send({ error: 'Internal Error' }); } } });
Dzięki temu nasz prymitywny interfejs API jest kompletny i możesz go przetestować, wysyłając żądania HTTP do wszystkich punktów końcowych.
Krótkie zastrzeżenie dotyczące architektury i tego, jak ją naprawimy
Z architektonicznego punktu widzenia kod, który tu mamy, jest dość zły, niechlujny, nie jest SUCHY, nie jest SOLIDNY, w rzeczywistości można go nawet nazwać odrażającym. Te tak zwane „obsługujące trasy” robią znacznie więcej niż tylko „obsługują trasy” — są bezpośrednio połączone z naszą bazą danych. Oznacza to, że nie ma absolutnie żadnej abstrakcji.
Spójrzmy prawdzie w oczy, większość aplikacji nigdy nie będzie tak mała, w przeciwnym razie prawdopodobnie ujdzie ci na sucho architektury bezserwerowe z bazą danych Firebase. Może, jak zobaczymy później, użytkownicy chcą mieć możliwość przesyłania awatarów, cytatów i urywków ze swoich książek itp. Może chcemy dodać funkcję czatu na żywo między użytkownikami za pomocą WebSockets, a nawet powiedzmy, że otworzymy naszą aplikację, aby umożliwić użytkownikom wypożyczanie książek między sobą za niewielką opłatą — w tym momencie musimy rozważyć integrację płatności z API Stripe i logistykę wysyłki z API Shippo.
Załóżmy, że kontynuujemy naszą obecną architekturę i dodajemy wszystkie te funkcje. Te procedury obsługi tras, znane również jako akcje kontrolera, staną się bardzo, bardzo duże i będą miały dużą złożoność cyklomatyczną . Taki styl kodowania może nam odpowiadać na początku, ale co, jeśli uznamy, że nasze dane są referencyjne, a zatem PostgreSQL jest lepszym wyborem bazy danych niż MongoDB? Teraz musimy dokonać refaktoryzacji całej naszej aplikacji, usuwając Mongoose, zmieniając nasze kontrolery itp., co może prowadzić do potencjalnych błędów w pozostałej części logiki biznesowej. Innym przykładem może być decyzja, że AWS S3 jest zbyt drogi i chcemy przejść na GCP. Ponownie wymaga to refaktoryzacji całej aplikacji.
Chociaż istnieje wiele opinii na temat architektury, od projektowania opartego na domenie, segregacji odpowiedzialności za zapytania poleceń i pozyskiwania zdarzeń, po programowanie sterowane testami, SOILD, architekturę warstwową, architekturę cebulową i inne, skupimy się na implementacji prostej architektury warstwowej w przyszłe artykuły, składające się z kontrolerów, usług i repozytoriów oraz wykorzystujące wzorce projektowe, takie jak kompozycja, adaptery/opakowania i odwrócenie kontroli przez wstrzykiwanie zależności. Chociaż do pewnego stopnia można to wykonać w JavaScript, przyjrzymy się również opcjom TypeScript, aby osiągnąć tę architekturę, co pozwoli nam zastosować funkcjonalne paradygmaty programowania, takie jak Either Monads, oprócz koncepcji OOP, takich jak Generics.
Na razie możemy wprowadzić dwie małe zmiany. Ponieważ nasza logika obsługi błędów jest dość podobna w bloku catch
wszystkich punktów końcowych, możemy wyodrębnić ją do niestandardowej funkcji Express Error Handling Middleware na samym końcu stosu.
Sprzątanie naszej architektury
Obecnie powtarzamy bardzo dużą ilość logiki obsługi błędów we wszystkich naszych punktach końcowych. Zamiast tego możemy zbudować funkcję Express Error Handling Middleware, która jest funkcją Express Middleware, która jest wywoływana z błędem, obiektami req i res oraz następną funkcją.
Na razie zbudujmy tę funkcję oprogramowania pośredniczącego. Jedyne, co zamierzam zrobić, to powtórzyć tę samą logikę obsługi błędów, do której jesteśmy przyzwyczajeni:
app.use((err, req, res, next) => { if (err instanceof mongoose.Error.ValidationError) { return res.status(400).send({ error: 'Validation Error' }); } else if (err instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { console.log(err); // Unexpected, so worth logging. return res.status(500).send({ error: 'Internal error' }); } });
Wydaje się, że to nie działa z błędami Mongoose, ale ogólnie, zamiast używać if/else if/else
do określania wystąpień błędów, możesz przełączyć konstruktor błędu. Zostawię jednak to, co mamy.
W przypadku obsługi synchronicznego punktu końcowego/trasy, jeśli zgłosisz błąd, Express przechwyci go i przetworzy bez dodatkowej pracy z Twojej strony. Niestety tak nie jest w naszym przypadku. Mamy do czynienia z kodem asynchronicznym . Aby delegować obsługę błędów do programu Express za pomocą programów obsługi tras asynchronicznych, sami wyłapujemy błąd i przekazujemy go do next()
.
Tak więc pozwolę, aby next
był trzecim argumentem w punkcie końcowym, i usunę logikę obsługi błędów w blokach catch
na rzecz przekazania wystąpienia błędu do next
, jako takiego:
app.post('/books', async (req, res, next) => { try { const book = new Book(req.body.book); await book.save(); return res.send({ book }); } catch (e) { next(e) } });
Jeśli zrobisz to dla wszystkich programów obsługi tras, powinieneś otrzymać następujący kod:
const express = require('express'); const mongoose = require('mongoose'); // Database connection and model. require('./db/mongoose.js')(); const Book = require('./models/book.js'); // This creates our Express App. const app = express(); // Define middleware. app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Listening on port 3000 (arbitrary). // Not a TCP or UDP well-known port. // Does not require superuser privileges. const PORT = 3000; // We will build our API here. // HTTP POST /books app.post('/books', async (req, res, next) => { try { const book = new Book(req.body.book); await book.save(); return res.status(201).send({ book }); } catch (e) { next(e) } }); // HTTP GET /books/:id app.get('/books/:id', async (req, res) => { try { const book = await Book.findById(req.params.id); if (!book) return res.status(404).send({ error: 'Not Found' }); return res.send({ book }); } catch (e) { next(e); } }); // HTTP PATCH /books/:id app.patch('/books/:id', async (req, res, next) => { const { id } = req.params; const { updates } = req.body; try { const updatedBook = await Book.findByIdAndUpdate(id, updates, { runValidators: true, new: true }); if (!updatedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: updatedBook }); } catch (e) { next(e); } }); // HTTP DELETE /books/:id app.delete('/books/:id', async (req, res, next) => { try { const deletedBook = await Book.findByIdAndDelete(req.params.id); if (!deletedBook) return res.status(404).send({ error: 'Not Found' }); return res.send({ book: deletedBook }); } catch (e) { next(e); } }); // Notice - bottom of stack. app.use((err, req, res, next) => { if (err instanceof mongoose.Error.ValidationError) { return res.status(400).send({ error: 'Validation Error' }); } else if (err instanceof mongoose.Error.CastError) { return res.status(400).send({ error: 'Not a valid ID' }); } else { console.log(err); // Unexpected, so worth logging. return res.status(500).send({ error: 'Internal error' }); } }); // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
Idąc dalej, warto byłoby oddzielić nasze oprogramowanie pośredniczące do obsługi błędów do innego pliku, ale to trywialne i zobaczymy to w kolejnych artykułach z tej serii. Dodatkowo moglibyśmy użyć modułu NPM o nazwie express-async-errors
, aby nie trzeba było wywoływać następnego w bloku catch, ale znowu próbuję pokazać, jak to się robi oficjalnie.
Słowo o CORS i polityce tego samego pochodzenia
Załóżmy, że Twoja witryna jest obsługiwana z domeny myWebsite.com
, ale Twój serwer znajduje się pod adresem myOtherDomain.com/api
. CORS oznacza współużytkowanie zasobów między źródłami i jest mechanizmem, za pomocą którego można wykonywać żądania międzydomenowe. W powyższym przypadku, ponieważ serwer i kod frontonu JS znajdują się w różnych domenach, wysyłasz żądanie z dwóch różnych źródeł, co jest często ograniczane przez przeglądarkę ze względów bezpieczeństwa i łagodzone przez dostarczanie określonych nagłówków HTTP.
Zasady tego samego pochodzenia są tym, co wykonuje wyżej wymienione ograniczenia — przeglądarka internetowa zezwala tylko na to, aby była wykonana w tym samym miejscu.
CORS i SOP poruszymy później, gdy zbudujemy w pakiecie Webpack front-end dla naszego Book API z React.
Wniosek i co dalej
Dużo dyskutowaliśmy w tym artykule. Być może nie było to w pełni praktyczne, ale miejmy nadzieję, że ułatwiło ci pracę z funkcjami JavaScript Express i ES6. Jeśli jesteś nowy w programowaniu, a Node jest pierwszą ścieżką, którą podążasz, mam nadzieję, że odniesienia do języków statycznych typów, takich jak Java, C++ i C#, pomogły uwydatnić niektóre różnice między JavaScriptem a jego statycznymi odpowiednikami.
Next time, we'll finish building out our Book API by making some fixes to our current setup with regards to the Book Routes, as well as adding in User Authentication so that users can own books. We'll do all of this with a similar architecture to what I described here and with MongoDB for data persistence. Finally, we'll permit users to upload avatar images to AWS S3 via Buffers.
In the article thereafter, we'll be rebuilding our application from the ground up in TypeScript, still with Express. We'll also move to PostgreSQL with Knex instead of MongoDB with Mongoose as to depict better architectural practices. Finally, we'll update our avatar image uploading process to use Node Streams (we'll discuss Writable, Readable, Duplex, and Transform Streams). Along the way, we'll cover a great amount of design and architectural patterns and functional paradigms, including:
- Controllers/Controller Actions
- Usługi
- Repositories
- Data Mapping
- The Adapter Pattern
- The Factory Pattern
- The Delegation Pattern
- OOP Principles and Composition vs Inheritance
- Inversion of Control via Dependency Injection
- SOLID Principles
- Coding against interfaces
- Data Transfer Objects
- Domain Models and Domain Entities
- Either Monads
- Walidacja
- Decorators
- Logging and Logging Levels
- Unit Tests, Integration Tests (E2E), and Mutation Tests
- The Structured Query Language
- Relations
- HTTP/Express Security Best Practices
- Node Best Practices
- OWASP Security Best Practices
- I więcej.
Using that new architecture, in the article after that, we'll write Unit, Integration, and Mutation tests, aiming for close to 100 percent testing coverage, and we'll finally discuss setting up a remote CI/CD pipeline with CircleCI, as well as Message Busses, Job/Task Scheduling, and load balancing/reverse proxying.
Hopefully, this article has been helpful, and if you have any queries or concerns, let me know in the comments below.