開始使用 Express 和 ES6+ JavaScript 堆棧
已發表: 2022-03-10本文是系列文章的第二部分,第一部分位於此處,它提供了對 Node.js、ES6+ JavaScript、回調函數、箭頭函數、API、HTTP 協議、JSON、MongoDB 和更多的。
在本文中,我們將在前一篇文章中獲得的技能的基礎上,學習如何實現和部署用於存儲用戶書單信息的 MongoDB 數據庫,使用 Node.js 和 Express Web 應用程序框架構建 API 以公開該數據庫並對其執行 CRUD 操作等。 在此過程中,我們將討論 ES6 對象解構、ES6 對像簡寫、Async/Await 語法、擴展運算符,我們將簡要介紹 CORS、同源策略等。
在後面的文章中,我們將重構我們的代碼庫,通過利用三層架構和通過依賴注入實現控制反轉來分離關注點,我們將執行基於 JSON Web Token 和 Firebase 身份驗證的安全性和訪問控制,學習如何安全地存儲密碼,並使用 AWS Simple Storage Service 使用 Node.js 緩衝區和流存儲用戶頭像 — 同時使用 PostgreSQL 進行數據持久性。 在此過程中,我們將在 TypeScript 中從頭開始重寫我們的代碼庫,以檢查經典的 OOP 概念(例如多態、繼承、組合等),甚至像工廠和適配器這樣的設計模式。
一個警告
今天大多數討論 Node.js 的文章都存在問題。 他們中的大多數,而不是全部,只是描述瞭如何設置快速路由、集成 Mongoose 以及可能利用 JSON Web 令牌身份驗證。 問題是他們不談論架構,或安全最佳實踐,或乾淨的編碼原則,或 ACID 合規性、關係數據庫、第五範式、CAP 定理或事務。 要么假設你知道所有這些進來,要么你不會構建足夠大或受歡迎的項目來保證上述知識。
似乎有幾種不同類型的 Node 開發人員——其中一些是一般編程的新手,而另一些則來自使用 C# 和 .NET Framework 或 Java Spring Framework 進行企業開發的悠久歷史。 大多數文章迎合前一組。
在本文中,我將完全按照我剛才所說的那樣做,但在後續文章中,我們將完全重構我們的代碼庫,允許我解釋諸如依賴注入、三-層架構(控制器/服務/存儲庫)、數據映射和活動記錄、設計模式、單元、集成和變異測試、SOLID 原則、工作單元、針對接口的編碼、HSTS、CSRF、NoSQL 和 SQL 注入等安全最佳實踐預防等等。 我們還將從 MongoDB 遷移到 PostgreSQL,使用簡單的查詢構建器 Knex 而不是 ORM——允許我們構建自己的數據訪問基礎設施,並近距離接觸結構化查詢語言,不同類型的關係(One-一對一、多對多等)等等。 那麼,這篇文章應該會吸引初學者,但接下來的幾篇應該會迎合更多希望改進其架構的中級開發人員。
在這一節中,我們只需要擔心持久化書籍數據。 我們不會處理用戶身份驗證、密碼散列、架構或任何類似的複雜事物。 所有這些都將出現在下一篇和未來的文章中。 現在,基本上,我們將構建一種方法,允許客戶端通過 HTTP 協議與我們的 Web 服務器通信,以便將書籍信息保存在數據庫中。
注意:我故意讓它變得非常簡單,也許在這裡並不那麼實用,因為這篇文章本身非常長,因為我冒昧地偏離討論補充主題。 因此,我們將在本系列中逐步提高 API 的質量和復雜性,但同樣,因為我認為這是您對 Express 的第一次介紹,所以我有意讓事情變得非常簡單。
- ES6 對象解構
- ES6 對象速記
- ES6 擴展運算符 (...)
- 接下來...
ES6 對象解構
ES6 對象解構或解構賦值語法是一種從數組或對像中提取或解壓縮值到它們自己的變量中的方法。 我們將從對象屬性開始,然後討論數組元素。
const person = { name: 'Richard P. Feynman', occupation: 'Theoretical Physicist' }; // Log properties: console.log('Name:', person.name); console.log('Occupation:', person.occupation);
這樣的操作非常原始,但考慮到我們必須在任何地方繼續引用person.something
,它可能有點麻煩。 假設在我們的代碼中還有 10 個其他地方我們必須這樣做——很快就會變得相當艱鉅。 一種簡潔的方法是將這些值分配給它們自己的變量。
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);
也許這看起來很合理,但是如果我們在person
對像上也嵌套了 10 個其他屬性呢? 這將是許多不必要的行,只是為了給變量賦值——此時我們處於危險之中,因為如果對象屬性發生了變異,我們的變量將不會反映這種變化(請記住,只有對對象的引用是通過const
賦值不可變的,不是對象的屬性),所以基本上,我們不能再保持“狀態”(我用的是鬆散的詞)同步。 按引用傳遞與按值傳遞可能會在這裡發揮作用,但我不想偏離本節的範圍太遠。
ES6 Object Destructing 基本上讓我們這樣做:
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);
我們不是在創建新的對象/對象字面量,而是從原始對像中解壓縮name
和occupation
屬性,並將它們放入它們自己的同名變量中。 我們使用的名稱必須與我們希望提取的屬性名稱相匹配。
同樣,語法const { a, b } = someObject;
特別是說我們希望某些屬性a
和某些屬性b
存在於someObject
中(例如, someObject
可能是{ a: 'dataA', b: 'dataB' }
)並且我們希望放置任何值這些鍵/屬性在同名的const
變量中。 這就是為什麼上面的語法會為我們提供兩個變量const a = someObject.a
和const b = someObject.b
。
這意味著對象解構有兩個方面。 “模板”端和“源”端,其中const { a, b }
端(左側)是模板,而someObject
端(右側)是源端——這是有道理的— 我們在左側定義了一個結構或“模板”,以反映“源”端的數據。
同樣,為了清楚起見,這裡有幾個例子:
// ----- 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
在嵌套屬性的情況下,在破壞賦值中鏡像相同的結構:
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
如您所見,您決定提取的屬性是可選的,要解壓縮嵌套屬性,只需在解構語法的模板端鏡像原始對象(源)的結構。 如果您嘗試解構原始對像上不存在的屬性,則該值將是未定義的。
我們還可以在不首先聲明變量的情況下解構一個變量——不聲明的賦值——使用以下語法:
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
我們在表達式前面加上一個分號,以確保我們不會意外地在前一行創建一個帶有函數的 IIFE(立即調用函數表達式)(如果存在這樣的函數),並且賦值語句周圍的括號是必需的阻止 JavaScript 將您的左側(模板)視為一個塊。
函數參數中存在一個非常常見的解構用例:
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);
如您所見,我們可以在函數內部使用我們現在習慣的正常解構語法,如下所示:
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);
但是將所述語法放在函數簽名中會自動執行解構並為我們節省一行。
一個真實的用例是在 React Functional Components for props
中:
import React from 'react'; // Destructure `titleText` and `secondaryText` from `props`. export default ({ titleText, secondaryText }) => ( <div> <h1>{titleText}</h1> <h3>{secondaryText}</h3> </div> );
相對於:
import React from 'react'; export default props => ( <div> <h1>{props.titleText}</h1> <h3>{props.secondaryText}</h3> </div> );
在這兩種情況下,我們也可以為屬性設置默認值:
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
如您所見,如果name
在解構時不存在,我們會為其提供默認值。 我們也可以使用前面的語法來做到這一點:
const { a, b, c = 'Default' } = { a: 'dataA', b: 'dataB' }; console.log(a); // dataA console.log(b); // dataB console.log(c); // Default
數組也可以被解構:
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
數組解構的一個實際原因是 React Hooks。 (還有很多其他原因,我只是以 React 為例)。
import React, { useState } from "react"; export default () => { const [buttonText, setButtonText] = useState("Default"); return ( <button onClick={() => setButtonText("Toggled")}> {buttonText} </button> ); }
注意useState
正在從導出中解構,並且數組函數/值正在從useState
掛鉤中解構。 同樣,如果以上內容沒有意義,請不要擔心——你必須了解 React——我只是將其用作示例。
雖然 ES6 對象解構還有更多內容,但我將在這裡再討論一個主題:解構重命名,這對於防止範圍衝突或變量陰影等很有用。假設我們想從一個名為person
的對像中解構一個名為name
的屬性,但是範圍內已經有一個名為name
的變量。 我們可以用冒號即時重命名:
// 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.
最後,我們也可以通過重命名來設置默認值:
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
如您所見,在這種情況下,來自person
( person.name
) 的name
將重命名為personName
,如果不存在則設置為Anonymous
的默認值。
當然,同樣可以在函數簽名中執行:
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
ES6 對象速記
假設您有以下工廠:(我們稍後會介紹工廠)
const createPersonFactory = (name, location, position) => ({ name: name, location: location, position: position });
可以使用這個工廠來創建一個person
對象,如下所示。 另外,請注意,工廠隱式返回一個對象,箭頭函數括號周圍的括號很明顯。
const person = createPersonFactory('Jamie', 'Texas', 'Developer'); console.log(person); // { ... }
這就是我們從 ES5 Object Literal Syntax 中已經知道的。 但是請注意,在工廠函數中,每個屬性的值與屬性標識符(鍵)本身同名。 即 — location: location
或name: name
。 事實證明,這在 JS 開發人員中很常見。
使用 ES6 的簡寫語法,我們可以通過如下方式重寫工廠來獲得相同的結果:
const createPersonFactory = (name, location, position) => ({ name, location, position }); const person = createPersonFactory('Jamie', 'Texas', 'Developer'); console.log(person);
產生輸出:
{ name: 'Jamie', location: 'Texas', position: 'Developer' }
重要的是要意識到我們只能在我們希望創建的對像是基於變量動態創建時使用這個速記,其中變量名稱與我們希望分配變量的屬性的名稱相同。
同樣的語法適用於對象值:
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);
產生輸出:
{ name: 'Jamie', location: 'Texas', position: 'Developer', extra: { interests: [ 'Mathematics', 'Quantum Mechanics', 'Spacecraft Launch Systems' ], favoriteLanguages: [ 'JavaScript', 'C#' ] } }
作為最後一個示例,這也適用於對象文字:
const id = '314159265358979'; const name = 'Archimedes of Syracuse'; const location = 'Syracuse'; const greatMathematician = { id, name, location };
ES6 擴展運算符 (…)
擴展運算符允許我們做各種各樣的事情,我們將在這裡討論其中的一些。
首先,我們可以將屬性從一個對象分散到另一個對象:
const myObjOne = { a: 'a', b: 'b' }; const myObjTwo = { ...myObjOne }:
這具有將 myObjOne 上的所有屬性放到myObjOne
上的myObjTwo
,這樣myObjTwo
現在是{ a: 'a', b: 'b' }
。 我們可以使用此方法覆蓋以前的屬性。 假設用戶想要更新他們的帳戶:
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' } */
可以對數組執行相同的操作:
const apollo13Astronauts = ['Jim', 'Jack', 'Fred']; const apollo11Astronauts = ['Neil', 'Buz', 'Michael']; const unionOfAstronauts = [...apollo13Astronauts, ...apollo11Astronauts]; console.log(unionOfAstronauts); // ['Jim', 'Jack', 'Fred', 'Neil', 'Buz, 'Michael'];
請注意,我們通過將數組分散到一個新數組中來創建兩個集合(數組)的並集。
Rest/Spread 運算符還有很多其他內容,但超出了本文的範圍。 例如,它可用於獲得函數的多個參數。 如果您想了解更多信息,請在此處查看 MDN 文檔。
ES6 異步/等待
Async/Await 是一種用於減輕 Promise 鏈接痛苦的語法。
await
保留關鍵字允許您“等待”promise 的結算,但它只能用於標有async
關鍵字的函數中。 假設我有一個返回承諾的函數。 在一個新的async
函數中,我可以await
該承諾的結果,而不是使用.then
和.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();
這裡有幾點需要注意。 當我們在async
函數中使用await
時,只有解析的值進入左側的變量。 如果函數拒絕,那是我們必須捕獲的錯誤,稍後我們會看到。 此外,默認情況下,任何標記為async
的函數都將返回一個 Promise。
假設我需要進行兩次 API 調用,一次調用來自前者的響應。 使用 Promise 和 Promise 鏈,你可以這樣做:
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 */
這裡發生的是我們首先調用makeAPICall
傳遞給它/whatever
,它第一次被記錄。 承諾以該值解決。 然後我們再次調用makeAPICall
,將/whatever second call
傳遞給它,它會被記錄下來,再次,promise 會用那個新值解析。 最後,我們將新值/whatever second call
與 promise 解決,並將其自己logged
在最終日誌中,並在最後附加 log。 如果這沒有意義,您應該研究 Promise 鏈接。
使用async
/ await
,我們可以重構如下:
const main = async () => { const resultOne = await makeAPICall('/whatever'); const resultTwo = await makeAPICall(resultOne + ' second call'); console.log(resultTwo + ' logged'); };
這是將會發生的事情。 整個函數將在第一個await
語句處停止執行,直到第一次調用makeAPICall
的 Promise 解析完成,解析後,解析的值將放置在resultOne
中。 發生這種情況時,該函數將移動到第二個await
語句,在 Promise 結算期間再次暫停。 當 Promise 解析時,解析結果將放在resultTwo
中。 如果關於函數執行的想法聽起來是阻塞的,不要害怕,它仍然是異步的,我將在一分鐘內討論原因。
這只描繪了“幸福”的道路。 如果其中一個 Promise 被拒絕,我們可以使用 try/catch 來捕獲它,因為如果 Promise 被拒絕,就會拋出一個錯誤——這將是 Promise 被拒絕的任何錯誤。
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) } };
正如我之前所說,任何聲明為async
的函數都會返回一個 Promise。 因此,如果你想從另一個函數調用異步函數,你可以使用普通的 Promise,或者如果你聲明調用函數async
則await
。 但是,如果您想從頂級代碼調用async
函數並等待其結果,那麼您必須使用.then
和.catch
。
例如:
const returnNumberOne = async () => 1; returnNumberOne().then(value => console.log(value)); // 1
或者,您可以使用立即調用函數表達式 (IIFE):
(async () => { const value = await returnNumberOne(); console.log(value); // 1 })();
當您在async
函數中使用await
時,函數的執行將在該 await 語句處停止,直到 promise 完成。 但是,所有其他函數都可以自由執行,因此不會分配額外的 CPU 資源,也不會阻塞線程。 我再說一遍——那個特定時間的特定函數中的操作將停止,直到承諾解決,但所有其他函數都可以自由觸發。 考慮一個 HTTP Web 服務器——在每個請求的基礎上,所有函數都可以在發出請求時同時為所有用戶自由觸發,只是 async/await 語法會提供一個操作是同步和阻塞的錯覺。承諾更容易使用,但同樣,一切都將保持良好和異步。
這不是async
/ await
的全部內容,但它應該可以幫助您掌握基本原則。
經典的 OOP 工廠
我們現在要離開JavaScript世界,進入Java世界。 有時,對象(在這種情況下,是類的實例——同樣是 Java)的創建過程相當複雜,或者我們希望根據一系列參數生成不同的對象。 一個示例可能是創建不同錯誤對象的函數。 工廠是面向對象編程中的一種常見設計模式,基本上是一個創建對象的函數。 為了探索這一點,讓我們從 JavaScript 轉移到 Java 世界。 這對於來自經典 OOP(即非原型)、靜態類型語言背景的開發人員來說是有意義的。 如果您不是這樣的開發人員,請隨意跳過本節。 這是一個小的偏差,因此如果按照此處操作會中斷您的 JavaScript 流程,那麼請再次跳過本節。
一種常見的創建模式,工廠模式允許我們創建對象而不暴露執行所述創建所需的業務邏輯。
假設我們正在編寫一個程序,允許我們在 n 維中可視化原始形狀。 例如,如果我們提供一個立方體,我們會看到一個 2D 立方體(正方形)、一個 3D 立方體(一個立方體)和一個 4D 立方體(一個 Tesseract,或 Hypercube)。 以下是在 Java 中可以做到這一點的簡單方法,並且除了實際的繪圖部分之外。
// 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. } }
如您所見,我們定義了一個接口,該接口指定了繪製形狀的方法。 通過讓不同的類實現接口,我們可以保證可以繪製所有形狀(因為它們都必須具有根據接口定義的可覆蓋的draw
方法)。 考慮到這個形狀的繪製取決於它被查看的維度,我們定義了實現接口的幫助類,以執行模擬 n 維渲染的 GPU 密集型工作。 ShapeFactory
負責實例化正確的類createShape
方法是一個工廠,和上面的定義一樣,它是一個返回類對象的方法。 IShape
的返回類型是createShape
接口,因為IShape
接口是所有形狀的基本類型(因為它們具有draw
方法)。
這個 Java 示例相當簡單,但您可以很容易地看到它在創建對象的設置可能不那麼簡單的大型應用程序中變得多麼有用。 這方面的一個例子是視頻遊戲。 假設用戶必須在不同的敵人中生存。 抽像類和接口可用於定義可供所有敵人使用的核心功能(以及可以被覆蓋的方法),可能使用委託模式(正如四人組所建議的那樣,優先組合而不是繼承,這樣您就不會陷入擴展單個基類並使測試/模擬/DI更容易)。 對於以不同方式實例化的敵人對象,該接口將允許創建工廠對象,同時依賴於通用接口類型。 如果敵人是動態創建的,這將非常重要。
另一個例子是構建器函數。 假設我們利用委託模式讓一個類將工作委託給其他尊重接口的類。 我們可以在類上放置一個靜態build
方法,讓它構建自己的實例(假設您沒有使用依賴注入容器/框架)。 不必調用每個 setter,您可以這樣做:
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()); } }
如果您不熟悉委託模式,我將在後面的文章中解釋它——基本上,通過組合和對象建模,它創建了一個“has-a”關係而不是“is-a”與繼承的關係。 如果你有一個Mammal
類和一個Dog
類,並且Dog
擴展Mammal
,那麼Dog
is-a Mammal
。 然而,如果你有一個Bark
類,並且你只是將Bark
的實例傳遞給Dog
的構造函數,那麼Dog
就有一個Bark
。 正如您可能想像的那樣,這尤其使單元測試更容易,因為您可以注入模擬並斷言有關模擬的事實,只要模擬在測試環境中遵守接口契約。
上面的static
“構建”工廠方法只是創建了一個新的User
對象並傳入一個具體的MessageService
。注意這是如何從上面的定義中得出的——沒有暴露業務邏輯來創建一個類的對象,或者,在這種情況下,不將消息服務的創建暴露給工廠的調用者。
同樣,這不一定是您在現實世界中做事的方式,但它很好地展示了工廠函數/方法的想法。 例如,我們可能會改用依賴注入容器。 現在回到 JavaScript。
從快遞開始
Express 是一個用於 Node 的 Web 應用程序框架(可通過 NPM 模塊獲得),它允許創建 HTTP Web 服務器。 需要注意的是,Express 並不是唯一可以做到這一點的框架(還有 Koa、Fastify 等),並且如上一篇文章中所見,Node 可以在沒有 Express 的情況下作為獨立實體運行。 (Express 只是為 Node 設計的一個模塊——Node 可以在沒有它的情況下做很多事情,儘管 Express 在 Web 服務器中很流行)。
再次,讓我做一個非常重要的區分。 Node/JavaScript 和 Express 之間存在二分法。 Node,你運行 JavaScript 的運行時/環境,可以做很多事情——比如允許你構建 React Native 應用程序、桌面應用程序、命令行工具等——Express 只不過是一個允許你使用的輕量級框架Node/JS 用於構建 Web 服務器,而不是處理 Node 的低級網絡和 HTTP API。 您不需要 Express 來構建 Web 服務器。
在開始本節之前,如果您不熟悉 HTTP 和 HTTP 請求(GET、POST 等),那麼我鼓勵您閱讀我之前文章的相應部分,該部分鏈接在上面。
使用 Express,我們將設置可以向其發出 HTTP 請求的不同路由,以及在對該路由發出請求時將觸發的相關端點(它們是回調函數)。 如果路由和端點當前沒有意義,請不要擔心——我稍後會解釋它們。
與其他文章不同的是,我將採用逐行編寫源代碼的方法,而不是將整個代碼庫轉儲到一個片段中,然後再進行解釋。 讓我們從打開一個終端開始(我在 Windows 上的 Git Bash 上使用 Terminus——對於想要一個 Bash Shell 而無需設置 Linux 子系統的 Windows 用戶來說,這是一個不錯的選擇),設置我們項目的樣板,然後打開它在 Visual Studio 代碼中。
mkdir server && cd server touch server.js npm init -y npm install express code .
在server.js
文件中,我將首先使用require()
函數請求express
。
const express = require('express');
require('express')
告訴 Node 去獲取我們之前安裝的 Express 模塊,該模塊當前位於node_modules
文件夾中(這就是npm install
所做的——創建一個node_modules
文件夾並將模塊及其依賴項放入其中)。 按照慣例,在處理 Express 時,我們將保存require('express')
返回結果的變量稱為express
,儘管它可以被稱為任何東西。
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”.
端口號標識服務器上運行的特定服務。 SSH 或 Secure Shell,它允許遠程 shell 訪問設備,通常在端口 22 上運行。FTP 或文件傳輸協議(例如,可能與 FTP 客戶端一起使用以將靜態資產傳輸到服務器)通常運行在端口 21。那麼,我們可以說,在我們上面的類比中,端口是每個房子內的特定房間,因為房子裡的房間是為不同的東西而設計的——臥室用來睡覺,廚房用來準備食物,餐廳用來吃東西。食物等,就像端口對應於執行特定服務的程序一樣。 對我們來說,Web 服務器通常在端口 80 上運行,儘管您可以自由指定您希望的任何端口號,只要它們不被其他服務使用(它們不會衝突)。
為了訪問網站,您需要該網站的 IP 地址。 儘管如此,我們通常通過 URL 訪問網站。 在幕後,DNS 或域名服務器將該 URL 轉換為 IP 地址,允許瀏覽器向服務器發出 GET 請求,獲取HTML,並將其呈現到屏幕上。 8.8.8.8
是 Google 的公共 DNS 服務器之一的地址。 您可能會想像需要通過遠程 DNS 服務器將主機名解析為 IP 地址需要時間,您是對的。 為了減少延遲,操作系統有一個 DNS 緩存——一個存儲 DNS 查找信息的臨時數據庫,從而降低了必鬚髮生所述查找的頻率。 可以在 Windows 上使用ipconfig /displaydns
CMD 命令查看 DNS 解析器緩存,並通過ipconfig /flushdns
命令清除。
在 Unix 服務器上,更常見的較低編號端口,如 80,需要root級別(如果您來自 Windows 背景,則升級)權限。 出於這個原因,我們將使用端口 3000 進行開發工作,但在我們部署到生產環境時將允許服務器選擇端口號(無論可用)。
最後請注意,我們可以直接在谷歌瀏覽器的搜索欄中輸入 IP 地址,從而繞過 DNS 解析機制。 例如,鍵入216.58.194.36
會將您帶到 Google.com。 在我們的開發環境中,當使用我們自己的計算機作為開發服務器時,我們將使用localhost
和端口 3000。地址格式為hostname:port
,因此我們的服務器將在localhost:3000
上運行。 本地主機或127.0.0.1
是環回地址,表示“這台計算機”的地址。 它是一個主機名,其 IPv4 地址解析為127.0.0.1
。 立即嘗試在您的機器上 ping localhost。 您可能會得到::1
- 這是 IPv6 環回地址,或127.0.0.1
- 這是 IPv4 環回地址。 IPv4 和 IPv6 是與不同標準相關的兩種不同的 IP 地址格式——一些 IPv6 地址可以轉換為 IPv4,但不是全部。
返回快遞
我在之前的文章《Node 入門:API、HTTP 和 ES6+ JavaScript 簡介》中提到了 HTTP 請求、動詞和狀態碼。 如果您對協議沒有大致的了解,請隨意跳到該文章的“HTTP 和 HTTP 請求”部分。
為了了解 Express,我們將簡單地為我們將在數據庫上執行的四個基本操作設置端點——創建、讀取、更新和刪除,統稱為 CRUD。
請記住,我們通過 URL 中的路由訪問端點。 也就是說,雖然“路由”和“端點”這兩個詞通常可以互換使用,但端點在技術上是一種執行某些服務器端操作的編程語言函數(如 ES6 箭頭函數),而路由是端點位於後面的。 我們將這些端點指定為回調函數,當客戶端向端點所在的路由發出適當的請求時,Express 將觸發這些函數。 您可以通過意識到執行功能的是端點並且路由是用於訪問端點的名稱來記住上述內容。 正如我們將看到的,相同的路由可以通過使用不同的 HTTP 動詞與多個端點相關聯(如果您來自具有多態性的經典 OOP 背景,則類似於方法重載)。
請記住,我們通過允許客戶端向我們的服務器發出請求來遵循 REST(表示狀態傳輸)架構。 畢竟,這是一個 REST 或 RESTful API。 對特定路由的特定請求將觸發特定端點,這些端點將執行特定操作。 端點可能做的這種“事情”的一個例子是向數據庫添加新數據、刪除數據、更新數據等。
Express 知道要觸發哪個端點,因為我們明確地告訴它請求方法(GET、POST 等)和路由——我們定義了針對上述特定組合觸發的函數,客戶端發出請求,指定路線和方法。 更簡單地說,使用 Node,我們會告訴 Express——“嘿,如果有人向這個路由發出 GET 請求,那麼繼續並觸發這個函數(使用這個端點)”。 事情可能會變得更複雜:“表達,如果有人向這條路由發出 GET 請求,但他們沒有在請求的標頭中發送有效的 Authorization Bearer Token,那麼請用HTTP 401 Unauthorized
響應。 如果他們確實擁有一個有效的承載令牌,那麼請通過觸發端點發送他們正在尋找的任何受保護資源。 非常感謝,祝您有美好的一天。” 確實,如果編程語言能夠達到如此高的水平而不會洩露歧義,那就太好了,但它仍然展示了基本概念。
請記住,在某種程度上,端點存在於路線後面。 因此,客戶端必須在請求的標頭中提供它想要使用的方法,以便 Express 能夠弄清楚要做什麼。 請求將發送到特定路由,客戶端將在聯繫服務器時指定(連同請求類型),允許 Express 做它需要做的事情,當 Express 觸發我們的回調時我們做我們需要做的事情. 這就是一切。
在前面的代碼示例中,我們調用了app
上可用的listen
函數,向它傳遞了一個端口和回調。 app
本身,如果你還記得的話,就是將express
變量作為函數調用的返回結果(即express()
),而express
變量就是我們從node_modules
文件夾中調用'express'
的返回結果。 就像在app
上調用listen
一樣,我們通過在app
上調用它們來指定 HTTP 請求端點。 讓我們看一下GET:
app.get('/my-test-route', () => { // ... });
第一個參數是一個string
,它是端點所在的路由。 回調函數是端點。 我再說一遍:回調函數——第二個參數——是當對我們指定為第一個參數的任何路由(在本例中為/my-test-route
)發出 HTTP GET 請求時將觸發的端點。
現在,在我們對 Express 做更多工作之前,我們需要知道路由是如何工作的。 我們指定為字符串的路由將通過向www.domain.com/the-route-we-chose-earlier-as-a-string
發出請求來調用。 在我們的例子中,域是localhost:3000
,這意味著,為了觸發上面的回調函數,我們必須向localhost:3000/my-test-route
發出 GET 請求。 如果我們使用不同的字符串作為上面的第一個參數,則 URL 必須不同才能匹配我們在 JavaScript 中指定的內容。
在談論這些事情時,您可能會聽說 Glob Patterns。 我們可以說我們所有 API 的路由都位於localhost:3000/**
Glob 模式,其中**
是通配符,表示 root 是父目錄的任何目錄或子目錄(注意路由不是目錄)——也就是說,一切。
讓我們繼續在該回調函數中添加一條日誌語句,這樣我們就擁有了:
// 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}.`) });
我們將通過在項目的根目錄中執行node server/server.js
(在我們的系統上安裝 Node 並且可以從系統環境變量全局訪問)來啟動並運行我們的服務器。 和之前一樣,您應該在控制台中看到服務器已啟動的消息。 現在服務器正在運行,打開瀏覽器,在 URL 欄中訪問localhost:3000
。
您應該會看到一條錯誤消息,指出Cannot GET /
。 在 Chrome 中的 Windows 上按 Ctrl + Shift + I 以查看開發者控制台。 在那裡,您應該看到我們有一個404
(找不到資源)。 這是有道理的——我們只告訴服務器當有人訪問localhost:3000/my-test-route
時該做什麼。 瀏覽器在localhost:3000
(相當於localhost:3000/
帶有斜線)處沒有要呈現的內容。
如果您查看運行服務器的終端窗口,應該沒有新數據。 現在,在瀏覽器的 URL 欄中訪問localhost:3000/my-test-route
。 您可能會在 Chrome 的控制台中看到相同的錯誤(因為瀏覽器正在緩存內容並且仍然沒有要呈現的 HTML),但是如果您查看正在運行服務器進程的終端,您會看到回調函數確實觸發了並且確實記錄了日誌消息。
使用 Ctrl + C 關閉服務器。
現在,讓我們在向該路由發出 GET 請求時為瀏覽器提供一些要呈現的內容,這樣我們就可以丟失Cannot GET /
消息。 我將使用之前的app.get()
,在回調函數中,我將添加兩個參數。 請記住,我們傳入的回調函數在幕後被 Express 調用,Express 可以添加它想要的任何參數。 它實際上增加了兩個(嗯,技術上是三個,但我們稍後會看到),雖然它們都非常重要,但我們現在不關心第一個。 第二個參數稱為res
,是response
的縮寫,我將通過將undefined
設置為第一個參數來訪問它:
app.get('/my-test-route', (undefined, res) => { console.log('A GET Request was made to /my-test-route.'); });
同樣,我們可以隨意調用res
參數,但res
是處理 Express 時的約定。 res
實際上是一個對象,在它上面存在不同的方法將數據發送回客戶端。 在這種情況下,我將訪問res
上可用的send(...)
函數以發送回瀏覽器將呈現的 HTML。 但是,我們不僅限於發回 HTML,還可以選擇發回文本、JavaScript 對象、流(流特別漂亮)或其他任何東西。
app.get('/my-test-route', (undefined, res) => { console.log('A GET Request was made to /my-test-route.'); res.send('<h1>Hello, World!</h1>'); });
如果您關閉服務器然後將其重新啟動,然後在/my-test-route
路由處刷新您的瀏覽器,您將看到 HTML 被渲染。
Chrome 開發人員工具的網絡選項卡將允許您更詳細地查看此 GET 請求,因為它與標頭有關。
在這一點上,開始學習 Express Middleware 對我們很有幫助——這些函數可以在客戶端發出請求後全局觸發。
快速中間件
Express 提供了為您的應用程序定義自定義中間件的方法。 確實,Express Middleware 的含義最好在 Express Docs 中定義,這裡)
中間件函數是可以訪問請求對象 (
req
)、響應對象 (res
) 和應用程序請求-響應週期中的下一個中間件函數的函數。 next 中間件函數通常由名為next
的變量表示。
中間件函數可以執行以下任務:
- 執行任何代碼。
- 更改請求和響應對象。
- 結束請求-響應週期。
- 調用堆棧中的下一個中間件函數。
換句話說,中間件函數是我們(開發人員)可以定義的自定義函數,它將充當 Express 接收請求和触發適當的回調函數之間的中介。 例如,我們可能會創建一個log
函數,它會在每次發出請求時進行記錄。 請注意,我們還可以選擇在端點觸發後觸發這些中間件函數,具體取決於您將其放置在堆棧中的位置——我們稍後會看到。
為了指定自定義中間件,我們必須將其定義為一個函數並將其傳遞給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().
總之,我們現在有:
// 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}.`) });
如果您再次通過瀏覽器發出請求,您現在應該看到您的中間件函數正在觸發並記錄時間戳。 為了促進實驗,嘗試刪除對next
函數的調用,看看會發生什麼。
使用三個參數req
、 res
和next
調用中間件回調函數。 req
是我們之前構建 GET 處理程序時跳過的參數,它是一個包含有關請求的信息的對象,例如標頭、自定義標頭、參數以及可能從客戶端發送的任何主體(例如您使用 POST 請求)。 我知道我們在這裡談論的是中間件,但是端點和中間件函數都被req
和res
調用。 在來自客戶端的單個請求的範圍內,中間件和端點中的req
和res
將是相同的(除非其中一個或另一個對其進行了變異)。 這意味著,例如,您可以使用中間件函數通過剝離任何可能旨在執行 SQL 或 NoSQL 注入的字符來清理數據,然後將安全req
交給端點。
res
,如前所述,允許您以幾種不同的方式將數據發送回客戶端。
next
是一個回調函數,您必須在中間件完成其工作時執行該函數,以便調用堆棧或端點中的下一個中間件函數。 請務必注意,您必須在中間件中觸發的任何異步函數的then
塊中調用它。 根據您的異步操作,您可能希望也可能不想在catch
塊中調用它。 也就是說, myMiddleware
函數在客戶端發出請求之後但在請求的端點函數被觸發之前觸發。 當我們執行此代碼並發出請求時,您應該會在控制台中看到Middleware has fired...
消息之前A GET Request was made to...
消息。 如果您不調用next()
,則後一部分將永遠不會運行 - 您的請求端點函數將不會觸發。
另請注意,我可以匿名定義此函數(我將堅持的約定):
app.use((req, res, next) => { console.log(`Middleware has fired at time ${Date().now}`); next(); });
對於 JavaScript 和 ES6 的新手來說,如果上述工作方式沒有立即意義,下面的示例應該會有所幫助。 我們只是定義了一個回調函數(匿名函數),它接受另一個回調函數( next
)作為參數。 我們將接受函數參數的函數稱為高階函數。 請看下面的方式——它描述了 Express 源代碼如何在幕後工作的基本示例:
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.');
我們首先調用use
myMiddleware
作為參數的 use。 myMiddleware
本身就是一個函數,它接受三個參數—— req
、 res
和next
。 在use
中, myMiddlware
,並傳入了這三個參數。 next
是在use
中定義的函數。 myMiddleware
在use
方法中定義為callback
。 如果我在本例中將use
放在名為app
的對像上,我們可以完全模仿 Express 的設置,儘管沒有任何套接字或網絡連接。
在這種情況下, myMiddleware
和callback
都是高階函數,因為它們都將函數作為參數。
如果執行此代碼,您將看到以下響應:
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.
請注意,我也可以使用匿名函數來實現相同的結果:
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.');
有了希望解決的問題,我們現在可以回到手頭的實際任務——設置我們的中間件。
事實是,您通常必須通過 HTTP 請求向上發送數據。 這樣做有幾個不同的選項——發送 URL 查詢參數,發送可以在我們之前了解的req
對像上訪問的數據,等等。該對像不僅在調用app.use()
,但也可以到任何端點。 我們之前使用undefined
作為填充符,因此我們可以專注於res
將 HTML 發送回客戶端,但現在,我們需要訪問它。
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. });
HTTP POST 請求可能要求我們向服務器發送一個主體對象。 如果您在客戶端上有一個表單,並且您獲取了用戶的姓名和電子郵件,您可能會將該數據發送到請求正文中的服務器。
讓我們看一下客戶端的情況:
<!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>
在服務器端:
app.post('/email-list', (req, res) => { // What do we now? // How do we access the values for the user's name and email? });
要訪問用戶的姓名和電子郵件,我們必須使用特定類型的中間件。 這會將數據放在req
上一個名為body
的對像上。 Body Parser 是一種流行的方法,Express 開發人員可以將其作為獨立的 NPM 模塊使用。 現在,Express 預先打包了自己的中間件來執行此操作,我們將這樣稱呼它:
app.use(express.urlencoded({ extended: true }));
現在我們可以這樣做:
app.post('/email-list', (req, res) => { console.log('User Name: ', req.body.nameInput); console.log('User Email: ', req.body.emailInput); });
所有這一切都是從客戶端發送的任何用戶定義的輸入,並使它們在req
的body
對像上可用。 請注意,在req.body
上,我們現在有nameInput
和emailInput
,它們是 HTML 中input
標籤的名稱。 現在,這個客戶端定義的數據應該被認為是危險的(永遠,永遠不要相信客戶端),並且需要進行清理,但我們稍後會介紹。
express 提供的另一種中間件是express.json()
。 express.json
用於將來自客戶端的請求中發送的任何 JSON 有效負載打包到req.body
上,而express.urlencoded
將使用字符串、數組或其他 URL 編碼數據的任何傳入請求打包到req.body
上。 簡而言之,兩者都操作req.body
,但 .json .json()
用於 JSON 有效負載, .urlencoded()
用於 POST 查詢參數等。
另一種說法是,帶有Content-Type: application/json
標頭的傳入請求(例如使用fetch
API 指定 POST 正文)將由express.json()
處理,而帶有標頭Content-Type: application/x-www-form-urlencoded
請求Content-Type: application/x-www-form-urlencoded
(例如 HTML 表單)將使用express.urlencoded()
處理。 希望這現在是有道理的。
為 MongoDB 啟動我們的 CRUD 路由
注意:在本文中執行 PATCH 請求時,我們不會遵循 JSONPatch RFC 規範——我們將在本系列的下一篇文章中糾正這個問題。
考慮到我們知道我們通過調用app
上的相關函數來指定每個端點,將路由和包含請求和響應對象的回調函數傳遞給它,我們可以開始為 Bookshelf API 定義我們的 CRUD 路由。 事實上,考慮到這是一篇介紹性文章,我不會注意完全遵循 HTTP 和 REST 規範,也不會嘗試使用最簡潔的架構。 這將在以後的文章中介紹。
我將打開迄今為止我們一直在使用的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. // ... // Binding our application to port 3000. app.listen(PORT, () => console.log(`Server is up on port ${PORT}.`));
考慮以下所有代碼來佔用上面文件的// ...
部分。
為了定義我們的端點,並且因為我們正在構建一個 REST API,我們應該討論命名路由的正確方法。 同樣,您應該查看我之前文章的 HTTP 部分以獲取更多信息。 我們正在處理書籍,因此所有路由都將位於/books
後面(複數命名約定是標準的)。
要求 | 路線 |
---|---|
郵政 | /books |
得到 | /books/id |
修補 | /books/id |
刪除 | /books/id |
如您所見,發布書籍時不需要指定 ID,因為我們(或者更確切地說,MongoDB)將在服務器端自動為我們生成它。 GETting、PATCHing 和 DELETing 書籍都需要我們將該 ID 傳遞到我們的端點,我們將在稍後討論。 現在,讓我們簡單地創建端點:
// 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}`); });
:id
語法告訴 Express id
是一個動態參數,將在 URL 中向上傳遞。 我們可以在req
上可用的params
對像上訪問它。 我知道“我們可以在req
上訪問它”聽起來像是魔術,而且魔術(不存在)在編程中是危險的,但你必須記住 Express 不是黑匣子。 這是一個開源項目,可在 GitHub 上獲得 MIT 許可證。 如果您想了解如何將動態查詢參數放到req
對像上,您可以輕鬆查看它的源代碼。
總之,我們現在在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}.`));
繼續並啟動服務器,從終端或命令行運行node server.js
,然後訪問您的瀏覽器。 打開 Chrome 開發控制台,在 URL(統一資源定位器)欄中,訪問localhost:3000/books
。 您應該已經在操作系統終端中看到服務器已啟動的指示器以及 GET 的日誌語句。
到目前為止,我們一直在使用 Web 瀏覽器來執行 GET 請求。 這對剛開始很有用,但我們很快就會發現存在更好的工具來測試 API 路由。 事實上,我們可以將fetch
調用直接粘貼到控制台或使用一些在線服務。 在我們的例子中,為了節省時間,我們將使用cURL
和 Postman。 我在本文中使用了這兩種方法(儘管您可以使用其中一種或),因此如果您還沒有使用它們,我可以介紹它們。 cURL
是一個庫(一個非常非常重要的庫)和命令行工具,旨在使用各種協議傳輸數據。 Postman 是一個基於 GUI 的 API 測試工具。 在您的操作系統上按照這兩個工具的相關安裝說明進行操作後,確保您的服務器仍在運行,然後在新終端中執行以下命令(一個接一個)。 鍵入它們並單獨執行它們很重要,然後在與服務器不同的終端中查看日誌消息。 另外,請注意標準編程語言註釋符號//
在 Bash 或 MS-DOS 中不是有效符號。 您必須省略這些行,我在這裡只使用它們來描述每個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
如您所見,作為 URL 參數傳入的 ID 可以是任何值。 -X
標誌指定 HTTP 請求的類型(對於 GET 可以省略),我們提供此後將向其發出請求的 URL。 我將每個請求重複了 3 次,讓您可以看到無論您使用localhost
主機名、 localhost
解析到的 IPv4 地址 ( 127.0.0.1
) 還是localhost
解析到的 IPv6 地址 ( ::1
),一切仍然有效. 請注意, cURL
需要將 IPv6 地址包裝在方括號中。
我們現在處於一個不錯的位置——我們設置了簡單的路由結構和端點。 服務器正常運行並按照我們的預期接受 HTTP 請求。 與您可能期望的相反,此時距離不遠了——我們只需要設置我們的數據庫,託管它(使用數據庫即服務——MongoDB Atlas),並將數據持久保存到它(以及執行驗證並創建錯誤響應)。
設置生產 MongoDB 數據庫
要設置生產數據庫,我們將前往 MongoDB Atlas 主頁並註冊一個免費帳戶。 此後,創建一個新集群。 您可以保持默認設置,選擇適用區域的費用等級。 然後點擊“創建集群”按鈕。 集群將需要一些時間來創建,然後您將能夠獲得您的數據庫 URL 和密碼。 當你看到它們時,請注意它們。 我們現在將對它們進行硬編碼,然後出於安全目的將它們存儲在環境變量中。 有關創建和連接集群的幫助,我將向您推薦 MongoDB 文檔,特別是此頁面和此頁面,或者您可以在下面留下評論,我會盡力提供幫助。
創建 Mongoose 模型
建議您了解 NoSQL(Not Only SQL — Structured Query Language)上下文中 Documents 和 Collections 的含義。 作為參考,您可能需要同時閱讀 Mongoose 快速入門指南和我之前文章的 MongoDB 部分。
我們現在有一個準備好接受 CRUD 操作的數據庫。 Mongoose 是一個 Node 模塊(或 ODM — 對象文檔映射器),它允許我們執行這些操作(抽像出一些複雜性)以及設置數據庫集合的模式或結構。
作為一個重要的免責聲明,圍繞 ORM 和 Active Record 或 Data Mapper 等模式存在很多爭議。 一些開發人員對 ORM 發誓,而另一些開發人員則對他們發誓(認為他們妨礙了他們)。 同樣重要的是要注意,ORM 抽象了很多,例如連接池、套接字連接和處理等。您可以輕鬆地使用 MongoDB Native Driver(另一個 NPM 模塊),但它會涉及更多工作。 雖然建議您在使用 ORM 之前先使用 Native Driver,但為了簡潔起見,我在這裡省略了 Native Driver。 對於關係數據庫上的複雜 SQL 操作,並非所有 ORM 都會針對查詢速度進行優化,您最終可能會編寫自己的原始 SQL。 ORM 可以在領域驅動設計和 CQRS 等方面發揮很大作用。 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. 讓我們從一個簡單的例子開始。 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:
標題
國際標準書號
作者
名
姓
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. 這是一個例子:
// ... 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 });
這將連接到數據庫。 我們提供了從 MongoDB Atlas 儀表板獲得的 URL,並且作為第二個參數傳入的對象指定了要使用的功能,其中包括防止棄用警告。
Mongoose 在幕後使用核心 MongoDB Native Driver,必須嘗試跟上對驅動程序所做的重大更改。 在新版本的驅動程序中,用於解析連接 URL 的機制發生了變化,因此我們通過useNewUrlParser: true
標誌來指定我們要使用官方驅動程序提供的最新版本。
默認情況下,如果您為數據庫中的數據設置索引(它們被稱為“索引”而不是“索引”)(我們不會在本文中介紹),Mongoose 會使用 Native Driver 提供的ensureIndex()
函數。 MongoDB 棄用了該函數以支持createIndex()
,因此將標誌useCreateIndex
設置為 true 將告訴 Mongoose 使用驅動程序中的createIndex()
方法,這是未棄用的函數。
Mongoose 的findOneAndUpdate
原始版本(這是一種在數據庫中查找文檔並更新它的方法)早於 Native Driver 版本。 也就是說, findOneAndUpdate()
原本並不是 Native Driver 的函數,而是 Mongoose 提供的函數,所以 Mongoose 不得不使用驅動在後台提供的findAndModify
來創建findOneAndUpdate
功能。 現在更新了驅動程序,它包含自己的此類功能,因此我們不必使用findAndModify
。 這可能沒有意義,但這沒關係——它不是關於事物規模的重要信息。
最後,MongoDB 棄用了舊的服務器和引擎監控系統。 我們將新方法與useUnifiedTopology: true
一起使用。
到目前為止,我們擁有的是一種連接數據庫的方法。 但事情就是這樣——它不是可擴展的或高效的。 當我們為此 API 編寫單元測試時,單元測試將在他們自己的測試數據庫上使用他們自己的測試數據(或夾具)。 因此,我們想要一種能夠為不同目的創建連接的方法——一些用於測試環境(我們可以隨意啟動和拆除),另一些用於開發環境,還有一些用於生產環境。 為此,我們將建立一個工廠。 (還記得之前的那個嗎?)
連接到 Mongo — 構建一個 JS 工廠的實現
事實上,Java 對象與 JavaScript 對象完全不同,因此,我們在上面從工廠設計模式中了解到的內容將不再適用。 我只是提供了一個例子來展示傳統的模式。 要在 Java、C# 或 C++ 等中獲得一個對象,我們必須實例化一個類。 這是通過new
關鍵字完成的,它指示編譯器為堆上的對象分配內存。 在 C++ 中,這為我們提供了一個指向我們必須自己清理的對象的指針,這樣我們就沒有懸掛指針或內存洩漏(C++ 沒有垃圾收集器,這與基於 C++ 構建的 Node/V8 不同)在 JavaScript 中,上面不需要做——我們不需要實例化一個類來獲得一個對象——一個對像只是{}
。 有人會說 JavaScript 中的一切都是對象,儘管這在技術上並不正確,因為原始類型不是對象。
由於上述原因,我們的 JS 工廠會更簡單,堅持工廠的鬆散定義,即返回對象(JS 對象)的函數。 由於函數是一個對象( function
通過原型繼承從object
繼承),我們下面的示例將滿足這個標準。 為了實現工廠,我將在server
內部創建一個名為db
的新文件夾。 在db
中,我將創建一個名為mongoose.js
的新文件。 該文件將連接到數據庫。 在mongoose.js
內部,我將創建一個名為connectionFactory
的函數並默認導出它:
// 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;
使用 ES6 為箭頭函數提供的簡寫,它在方法簽名的同一行返回一個語句,我將通過刪除connectionFactory
定義並默認導出工廠來簡化此文件:
// 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 });
現在,所要做的就是獲取文件並調用導出的方法,如下所示:
const connectionFactory = require('./db/mongoose'); connectionFactory(); // OR require('./db/mongoose')();
您可以通過將 MongoDB URL 作為參數提供給工廠函數來反轉控制,但我們將根據環境將 URL 作為環境變量動態更改。
將連接作為函數的好處是,我們可以稍後在代碼中調用該函數,以從針對生產的文件以及針對本地和遠程集成測試的文件連接到數據庫,包括設備上和遠程 CI/CD 管道/建立服務器。
建立我們的端點
我們現在開始向端點添加非常簡單的 CRUD 相關邏輯。 如前所述,需要簡短的免責聲明。 我們在這裡實現業務邏輯的方法不是您應該為簡單項目以外的任何東西鏡像的方法。 連接到數據庫並直接在端點內執行邏輯是(並且應該)不贊成的,因為您失去了交換服務或 DBMS 的能力,而無需執行應用程序範圍的重構。 儘管如此,考慮到這是一篇初學者的文章,我在這裡採用了這些不好的做法。 本系列未來的一篇文章將討論我們如何提高架構的複雜性和質量。
現在,讓我們回到我們的server.js
文件並確保我們都有相同的起點。 請注意,我為我們的數據庫連接工廠添加了require
語句,並導入了我們從./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}.`));
我將從app.post()
開始。 我們可以訪問Book
模型,因為我們從創建它的文件中導出了它。 正如 Mongoose 文檔中所述, Book
是可構造的。 要創建一本新書,我們調用構造函數並傳入書籍數據,如下所示:
const book = new Book(bookData);
在我們的例子中,我們將bookData
作為請求中發送的對象,這將在req.body.book
上可用。 請記住, express.json()
中間件會將我們發送的任何 JSON 數據放到req.body
上。 我們將以以下格式發送 JSON:
{ "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 } }
那麼,這意味著我們傳遞的 JSON 將被解析,整個 JSON 對象(第一對大括號)將由express.json()
中間件放在req.body
上。 我們的 JSON 對象的唯一屬性是book
,因此book
對象將在req.body.book
上可用。
此時,我們可以調用模型構造函數並傳入我們的數據:
app.post('/books', async (req, res) => { // <- Notice 'async' const book = new Book(req.body.book); await book.save(); // <- Notice 'await' });
注意這裡的幾件事。 當且僅當它符合我們在 Mongoose 模型中定義的模式時,在我們從調用構造函數返回的實例上調用save
方法會將req.body.book
對象持久保存到數據庫中。 將數據保存到數據庫的行為是一個異步操作,並且這個save()
方法返回一個承諾——我們非常期待它的解決。 我沒有鏈接.then()
調用,而是使用 ES6 Async/Await 語法,這意味著我必須將回調函數設置為app.post
async
。
如果客戶端發送的對像不符合我們定義的模式,則book.save()
將拒絕並返回ValidationError
。 我們當前的設置導致了一些非常不穩定和編寫糟糕的代碼,因為我們不希望我們的應用程序在驗證失敗的情況下崩潰。 為了解決這個問題,我將在try/catch
子句中包含危險操作。 如果發生錯誤,我將返回 HTTP 400 錯誤請求或 HTTP 422 無法處理的實體。 關於使用哪個存在一些爭論,所以我將在本文中堅持使用 400,因為它更通用。
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' }); } });
請注意,我使用 ES6 Object Shorthand 在成功的情況下使用res.send({ book })
將book
對象直接返回給客戶端——這相當於res.send({ book: book })
。 我還返回表達式只是為了確保我的函數退出。 在catch
塊中,我將狀態顯式設置為 400,並在返回的對象的error
屬性上返回字符串“ValidationError”。 201 是成功路徑狀態代碼,意思是“已創建”。
事實上,這也不是最好的解決方案,因為我們不能確定失敗的原因是客戶端的錯誤請求。 也許我們失去了與數據庫的連接(假設是一個斷開的套接字連接,因此是一個暫時的異常),在這種情況下,我們可能應該返回一個 500 Internal Server 錯誤。 檢查這一點的一種方法是讀取e
錯誤對象並有選擇地返迴響應。 現在讓我們這樣做,但正如我多次說過的,後續文章將討論路由器、控制器、服務、存儲庫、自定義錯誤類、自定義錯誤中間件、自定義錯誤響應、數據庫模型/域實體數據方面的適當架構映射和命令查詢分離 (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' }); } } });
繼續打開 Postman(假設你有它,否則,下載並安裝它)並創建一個新請求。 我們將向localhost:3000/books
發出 POST 請求。 在 Postman Request 部分的“Body”選項卡下,我將選擇“raw”單選按鈕並在最右側的下拉按鈕中選擇“JSON”。 這將繼續並自動將Content-Type: application/json
標頭添加到請求中。 然後,我會將之前的 Book JSON 對象複製並粘貼到正文文本區域中。 這就是我們所擁有的:
此後,我將點擊發送按鈕,您應該會在 Postman 的“響應”部分(底行)中看到 201 Created 響應。 我們看到這一點是因為我們特別要求 Express 響應 201 和 Book 對象——如果我們剛剛完成res.send()
沒有狀態碼, express
會自動響應 200 OK。 如您所見,Book 對象現在已保存到數據庫中,並已作為對 POST 請求的響應返回給客戶端。
如果您通過 MongoDB Atlas 查看數據庫 Book 集合,您會看到該書確實已保存。
您還可以看出 MongoDB 已插入__v
和_id
字段。 前者代表文檔的版本,在本例中為 0,後者是文檔的 ObjectID——由 MongoDB 自動生成,保證碰撞概率低。
迄今為止我們所涵蓋內容的摘要
到目前為止,我們已經在文章中介紹了很多內容。 在返回完成 Express API 之前,讓我們先回顧一下簡短的摘要。
我們了解了 ES6 對象解構、ES6 對像簡寫語法以及 ES6 Rest/Spread 運算符。 所有這三個讓我們執行以下操作(以及更多,如上所述):
// 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
我們還介紹了 Express、Expess 中間件、服務器、端口、IP 地址等。當我們了解到require('express')();
的返回結果存在可用的方法時,事情變得有趣起來。 使用 HTTP 動詞的名稱,例如app.get
和app.post
。
如果這require('express')()
部分對您沒有意義,這就是我要提出的觀點:
const express = require('express'); const app = express(); app.someHTTPVerb
它應該與我們之前為 Mongoose 啟動連接工廠的方式相同。
每個路由處理程序,即端點函數(或回調函數),在幕後從 Express 傳入一個req
對象和一個res
對象。 (他們在技術上也得到next
,我們馬上就會看到)。 req
包含特定於來自客戶端的傳入請求的數據,例如標頭或發送的任何 JSON。 res
允許我們向客戶端返迴響應。 next
函數也傳遞給處理程序。
在 Mongoose 中,我們看到瞭如何使用兩種方法連接到數據庫——一種原始方式和一種借鑒工廠模式的更高級/實用的方式。 當我們討論 Jest 的單元和集成測試(和突變測試)時,我們最終會使用它,因為它允許我們啟動一個數據庫的測試實例,其中填充了我們可以運行斷言的種子數據。
之後,我們創建了一個 Mongoose 模式對象並使用它來創建模型,然後學習瞭如何調用該模型的構造函數來創建它的新實例。 實例上可用的是save
方法(以及其他方法),它本質上是異步的,它將檢查我們傳入的對象結構是否符合模式,如果符合則解析 promise,如果符合則使用ValidationError
拒絕 promise它不是。 在解決的情況下,新文檔被保存到數據庫中,我們以 HTTP 200 OK/201 CREATED 響應,否則,我們在端點中捕獲拋出的錯誤,並向客戶端返回 HTTP 400 錯誤請求。
隨著我們繼續構建我們的端點,您將了解有關模型和模型實例上可用的一些方法的更多信息。
完成我們的端點
完成 POST Endpoint 後,讓我們處理 GET。 正如我之前提到的,路由中的:id
語法讓 Express 知道id
是一個路由參數,可以從req.params
訪問。 您已經看到,當您為路徑中的參數“通配符”匹配某個 ID 時,它會在早期示例中打印到屏幕上。 例如,如果您向“/books/test-id-123”發出 GET 請求,那麼req.params.id
將是字符串test-id-123
,因為參數名稱是通過將路由設置為HTTP GET /books/:id
的id
HTTP GET /books/:id
。
因此,我們需要做的就是從req
對像中檢索該 ID,並檢查我們數據庫中的任何文檔是否具有相同的 ID——Mongoose(和本機驅動程序)使這變得非常容易。
app.get('/books/:id', async (req, res) => { const book = await Book.findById(req.params.id); console.log(book); res.send({ book }); });
您可以看到,在我們的模型上可訪問的是一個我們可以調用的函數,它將通過其 ID 查找文檔。 在幕後,Mongoose 會將我們傳遞給findById
的任何 ID 轉換為文檔上_id
字段的類型,或者在本例中為ObjectId
。 如果找到一個匹配的 ID(並且對於ObjectId
的衝突概率極低,只會找到一個),該文檔將被放置在我們的book
常量變量中。 如果不是,則book
將為空——我們將在不久的將來使用這一事實。
現在,讓我們重新啟動服務器(除非您使用nodemon
,否則您必須重新啟動服務器)並確保我們在Books
Collection 中仍然擁有之前的一本書文檔。 繼續複製該文檔的 ID,即下圖中突出顯示的部分:
並使用它通過 Postman 向/books/:id
發出 GET 請求,如下所示(請注意,正文數據只是我之前的 POST 請求中遺留下來的。儘管它如下圖所示,但實際上並沒有被使用) :
這樣做後,您應該在 Postman 響應部分中獲取具有指定 ID 的圖書文檔。 請注意,之前的 POST 路由旨在“POST”或“推送”新資源到服務器,我們以 201 Created 響應——因為創建了新資源(或文檔)。 在 GET 的情況下,沒有創建任何新內容——我們只是請求了一個具有特定 ID 的資源,因此我們得到了 200 OK 狀態碼,而不是 201 Created。
在軟件開發領域很常見,必須考慮邊緣情況——用戶輸入本質上是不安全和錯誤的,作為開發人員,我們的工作是靈活地處理我們可以提供的輸入類型並做出響應因此。 如果用戶(或 API 調用者)向我們傳遞了一些無法轉換為 MongoDB ObjectID 的 ID,或者可以轉換但不存在的 ID,我們該怎麼辦?
對於前一種情況,Mongoose 會拋出一個CastError
——這是可以理解的,因為如果我們提供一個類似math-is-fun
的 ID,那麼這顯然不是可以轉換為 ObjectID 的東西,而轉換為 ObjectID 是具體的Mongoose 在幕後工作。
對於後一種情況,我們可以通過 Null Check 或 Guard Clause 輕鬆糾正問題。 無論哪種方式,我都會發回 HTTP 404 Not Found 響應。 我將向您展示我們可以做到這一點的幾種方法,一種不好的方法,然後是一種更好的方法。
首先,我們可以做到以下幾點:
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' }); } });
這很有效,我們可以很好地使用它。 如果 ID 字符串無法轉換為 ObjectID,我希望語句await Book.findById()
將引發 Mongoose CastError
,從而導致執行catch
塊。 如果可以強制轉換但對應的 ObjectID 不存在,那麼book
將為null
並且 Null Check 將拋出錯誤,再次觸發catch
塊。 在catch
中,我們只返回一個 404。這裡有兩個問題。 首先,即使找到了 Book 但發生了一些其他未知錯誤,當我們可能應該給客戶端一個通用的 catch-all 500 時,我們發送回一個 404。其次,我們並沒有真正區分發送的 ID 是否有效,而是不存在,或者它是否只是一個錯誤的 ID。
所以,這裡有另一種方式:
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' }); } } });
這樣做的好處是我們可以處理 400、404 和通用 500 這三種情況。請注意,在book
上的 Null Check 之後,我在響應中使用了return
關鍵字。 這非常重要,因為我們要確保我們在那裡退出路由處理程序。
其他一些選項可能是我們檢查req.params
上的id
是否可以顯式轉換為 ObjectID,而不是允許 Mongoose 使用mongoose.Types.ObjectId.isValid('id);
,但是有一個 12 字節字符串的邊緣情況會導致它有時會意外地工作。
例如,我們可以使用 HTTP 響應庫Boom
來減輕重複的痛苦,或者我們可以使用錯誤處理中間件。 我們還可以使用 Mongoose Hooks/Middleware 將 Mongoose 錯誤轉換為更具可讀性的內容,如此處所述。 另一個選項是定義自定義錯誤對象並使用全局 Express 錯誤處理中間件,但是,我將把它留到下一篇討論更好的架構方法的文章中。
在PATCH /books/:id
的端點中,我們希望傳遞一個更新對象,其中包含相關書籍的更新。 對於本文,我們將允許更新所有字段,但在未來,我將展示如何禁止更新特定字段。 此外,您會看到我們的 PATCH 端點中的錯誤處理邏輯將與我們的 GET 端點相同。 這表明我們違反了 DRY 原則,但我們稍後再談。
我希望req.body
的updates
對像上的所有更新都可用(這意味著客戶端將發送包含updates
對象的 JSON)並將使用帶有特殊標誌的Book.findByAndUpdate
函數來執行更新。
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' }); } } });
注意這裡的幾件事。 我們首先從req.body
中解構id
並從req.params
中updates
。
Book
模型上可用的是一個名為findByIdAndUpdate
的函數,它獲取相關文檔的 ID、要執行的更新和一個可選的選項對象。 通常,Mongoose 不會為更新操作重新執行驗證,因此我們作為options
對像傳入的runValidators: true
標誌會強制它這樣做。 此外,從 Mongoose 4 開始, Model.findByIdAndUpdate
不再返回修改後的文檔,而是返回原始文檔。 new: true
標誌(默認為 false)會覆蓋該行為。
最後,我們可以構建我們的 DELETE 端點,它與所有其他端點非常相似:
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' }); } } });
這樣,我們的原始 API 就完成了,您可以通過向所有端點發出 HTTP 請求來測試它。
關於架構的簡短免責聲明以及我們將如何糾正它
從架構的角度來看,我們這裡的代碼很糟糕,很亂,它不是 DRY,它不是 SOLID,事實上,你甚至可以稱之為可惡。 這些所謂的“路由處理程序”不僅僅是“處理路由”——它們直接與我們的數據庫交互。 這意味著絕對沒有抽象。
讓我們面對現實吧,大多數應用程序永遠不會這麼小,或者您可能會使用 Firebase 數據庫擺脫無服務器架構。 也許,正如我們稍後將看到的,用戶希望能夠從他們的書中上傳頭像、報價和片段等。也許我們希望使用 WebSockets 在用戶之間添加實時聊天功能,甚至可以說我們'將打開我們的應用程序,讓用戶以少量費用互相借書——此時我們需要考慮與 Stripe API 的支付集成以及與 Shippo API 的運輸物流。
假設我們繼續我們當前的架構並添加所有這些功能。 這些路由處理程序,也稱為控制器動作,最終將變得非常非常大,具有很高的圈複雜度。 這種編碼風格在早期可能很適合我們,但是如果我們認為我們的數據是參考的,因此 PostgreSQL 是比 MongoDB 更好的數據庫選擇怎麼辦? 我們現在必須重構整個應用程序,剝離 Mongoose,更改我們的控制器等,所有這些都可能導致其餘業務邏輯中的潛在錯誤。 另一個這樣的例子是決定 AWS S3 太貴,我們希望遷移到 GCP。 同樣,這需要應用程序範圍的重構。
雖然關於架構有很多意見,從領域驅動設計、命令查詢職責分離和事件溯源,到測試驅動開發、SOILD、分層架構、洋蔥架構等等,我們將專注於實現簡單的分層架構未來的文章,包括控制器、服務和存儲庫,並採用組合、適配器/包裝器和通過依賴注入實現控制反轉等設計模式。 雖然在某種程度上,這可以通過 JavaScript 執行,但我們也將研究 TypeScript 選項來實現這種架構,允許我們使用函數式編程範式,例如 Either Monads 以及諸如泛型之類的 OOP 概念。
目前,我們可以做兩個小改動。 因為我們的錯誤處理邏輯在所有端點的catch
塊中都非常相似,所以我們可以將其提取到堆棧最末端的自定義 Express 錯誤處理中間件函數中。
清理我們的架構
目前,我們正在所有端點上重複大量錯誤處理邏輯。 相反,我們可以構建一個 Express 錯誤處理中間件函數,它是一個被錯誤調用的 Express 中間件函數、req 和 res 對像以及下一個函數。
現在,讓我們構建該中間件功能。 我要做的就是重複我們習慣的相同錯誤處理邏輯:
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' }); } });
這似乎不適用於 Mongoose 錯誤,但通常,您可以切換錯誤的構造函數,而不是使用if/else if/else
來確定錯誤實例。 不過,我會留下我們所擁有的。
在同步端點/路由處理程序中,如果您拋出錯誤,Express 將捕獲並處理它,而無需您進行額外的工作。 不幸的是,我們並非如此。 我們正在處理異步代碼。 為了使用異步路由處理程序將錯誤處理委託給 Express,我們自己捕獲錯誤並將其傳遞給next()
。
因此,我只允許next
成為端點的第三個參數,並且我將刪除catch
塊中的錯誤處理邏輯,以便將錯誤實例傳遞給next
,如下所示:
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) } });
如果你對所有路由處理程序都這樣做,你應該得到以下代碼:
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}.`));
更進一步,將我們的錯誤處理中間件分離到另一個文件中是值得的,但這是微不足道的,我們將在本系列的未來文章中看到它。 此外,我們可以使用一個名為express-async-errors
的 NPM 模塊來允許我們不必在 catch 塊中調用 next,但我再次嘗試向您展示事情是如何正式完成的。
關於 CORS 和同源策略的一句話
假設您的網站是從域myWebsite.com
提供的,但您的服務器位於myOtherDomain.com/api
。 CORS 代表 Cross-Origin Resource Sharing,是一種可以執行跨域請求的機制。 在上述情況下,由於服務器和前端 JS 代碼位於不同的域中,因此您將跨兩個不同的來源發出請求,出於安全原因,這通常受到瀏覽器的限制,並通過提供特定的 HTTP 標頭來緩解。
同源策略是執行上述限制的原因——Web 瀏覽器只允許跨同源請求。
稍後當我們使用 React 為 Book API 構建 Webpack 捆綁前端時,我們將涉及 CORS 和 SOP。
結論和下一步是什麼
我們在這篇文章中討論了很多。 也許它並不完全實用,但希望它能讓您更輕鬆地使用 Express 和 ES6 JavaScript 特性。 如果您是編程新手,而 Node 是您開始的第一條路徑,希望對 Java、C++ 和 C# 等靜態類型語言的引用有助於突出 JavaScript 與其靜態對應語言之間的一些差異。
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
- 服務
- 存儲庫
- 數據映射
- The Adapter Pattern
- The Factory Pattern
- The Delegation Pattern
- OOP Principles and Composition vs Inheritance
- Inversion of Control via Dependency Injection
- 堅實的原則
- Coding against interfaces
- 數據傳輸對象
- Domain Models and Domain Entities
- Either Monads
- 驗證
- 裝飾器
- Logging and Logging Levels
- Unit Tests, Integration Tests (E2E), and Mutation Tests
- 結構化查詢語言
- 關係
- HTTP/Express Security Best Practices
- Node Best Practices
- OWASP Security Best Practices
- And more.
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.