Hội thảo về API web không máy chủ

1. Tổng quan

Mục tiêu của lớp học lập trình này là giúp bạn có được trải nghiệm với các dịch vụ "không máy chủ" do Google Cloud Platform cung cấp:

  • Cloud Functions – để triển khai các đơn vị nhỏ của logic nghiệp vụ dưới dạng các hàm, phản ứng với nhiều sự kiện (tin nhắn Pub/Sub, tệp mới trong Cloud Storage, yêu cầu HTTP, v.v.),
  • App Engine – để triển khai và phân phát các ứng dụng web, API web, phần phụ trợ di động, tài sản tĩnh, với khả năng mở rộng và thu hẹp nhanh chóng,
  • Cloud Run – để triển khai và mở rộng quy mô vùng chứa, có thể chứa mọi ngôn ngữ, thời gian chạy hoặc thư viện.

Ngoài ra, bạn cũng sẽ khám phá cách tận dụng các dịch vụ không máy chủ đó để triển khai và mở rộng quy mô API Web và API REST, đồng thời tìm hiểu một số nguyên tắc thiết kế RESTful hiệu quả.

Trong hội thảo này, chúng ta sẽ tạo một trình khám phá giá sách bao gồm:

  • Một Hàm Cloud: để nhập tập dữ liệu ban đầu gồm các cuốn sách có trong thư viện của chúng tôi, trong cơ sở dữ liệu tài liệu Cloud Firestore,
  • Một vùng chứa Cloud Run: vùng chứa này sẽ hiển thị một API REST trên nội dung của cơ sở dữ liệu,
  • Giao diện người dùng web App Engine: để duyệt qua danh sách sách bằng cách gọi API REST của chúng tôi.

Sau đây là giao diện người dùng web sẽ có khi bạn hoàn tất lớp học lập trình này:

705e014da0ca5e90.png

Kiến thức bạn sẽ học được

  • Cloud Functions
  • Cloud Firestore
  • Cloud Run
  • App Engine

2. Thiết lập và yêu cầu

Thiết lập môi trường theo tốc độ của riêng bạn

  1. Đăng nhập vào Google Cloud Console rồi tạo một dự án mới hoặc sử dụng lại một dự án hiện có. Nếu chưa có tài khoản Gmail hoặc Google Workspace, bạn phải tạo một tài khoản.

295004821bab6a87.png

37d264871000675d.png

96d86d3d5655cdbe.png

  • Tên dự án là tên hiển thị của những người tham gia dự án này. Đây là một chuỗi ký tự mà các API của Google không sử dụng. Bạn luôn có thể cập nhật thông tin này.
  • Mã dự án là mã duy nhất trên tất cả các dự án trên Google Cloud và không thể thay đổi (bạn không thể thay đổi mã này sau khi đã đặt). Cloud Console sẽ tự động tạo một chuỗi duy nhất; thường thì bạn không cần quan tâm đến chuỗi này. Trong hầu hết các lớp học lập trình, bạn sẽ cần tham chiếu đến Mã dự án (thường được xác định là PROJECT_ID). Nếu không thích mã nhận dạng được tạo, bạn có thể tạo một mã nhận dạng ngẫu nhiên khác. Hoặc bạn có thể thử tên người dùng của riêng mình để xem tên đó có được chấp nhận hay không. Bạn không thể thay đổi tên này sau bước này và tên này sẽ tồn tại trong suốt thời gian của dự án.
  • Để bạn nắm được thông tin, có một giá trị thứ ba là Số dự án mà một số API sử dụng. Tìm hiểu thêm về cả 3 giá trị này trong tài liệu.
  1. Tiếp theo, bạn cần bật tính năng thanh toán trong Cloud Console để sử dụng các tài nguyên/API trên Cloud. Việc thực hiện lớp học lập trình này sẽ không tốn nhiều chi phí, nếu có. Để tắt các tài nguyên nhằm tránh bị tính phí ngoài phạm vi hướng dẫn này, bạn có thể xoá các tài nguyên đã tạo hoặc xoá dự án. Người dùng mới của Google Cloud đủ điều kiện tham gia chương trình Dùng thử miễn phí trị giá 300 USD.

Khởi động Cloud Shell

Mặc dù có thể vận hành Google Cloud từ xa trên máy tính xách tay, nhưng trong lớp học lập trình này, bạn sẽ sử dụng Google Cloud Shell, một môi trường dòng lệnh chạy trên Cloud.

Trên Bảng điều khiển Google Cloud, hãy nhấp vào biểu tượng Cloud Shell trên thanh công cụ ở trên cùng bên phải:

84688aa223b1c3a2.png

Quá trình này chỉ mất vài phút để cung cấp và kết nối với môi trường. Khi quá trình này kết thúc, bạn sẽ thấy như sau:

320e18fedb7fbe0.png

Máy ảo này được trang bị tất cả các công cụ phát triển mà bạn cần. Nó cung cấp một thư mục chính có dung lượng 5 GB và chạy trên Google Cloud, giúp tăng cường đáng kể hiệu suất mạng và hoạt động xác thực. Bạn có thể thực hiện mọi thao tác trong lớp học lập trình này trong trình duyệt. Bạn không cần cài đặt bất cứ thứ gì.

3. Chuẩn bị môi trường và bật các API đám mây

Để sử dụng các dịch vụ mà chúng ta sẽ cần trong suốt dự án này, chúng ta sẽ bật một số API. Chúng ta sẽ thực hiện việc này bằng cách chạy lệnh sau trong Cloud Shell:

$ gcloud services enable \
      appengine.googleapis.com \
      cloudbuild.googleapis.com \
      cloudfunctions.googleapis.com \
      compute.googleapis.com \
      firestore.googleapis.com \
      run.googleapis.com

Sau một thời gian, bạn sẽ thấy thao tác hoàn tất thành công:

Operation "operations/acf.5c5ef4f6-f734-455d-b2f0-ee70b5a17322" finished successfully.

Chúng ta cũng sẽ thiết lập một biến môi trường mà chúng ta sẽ cần trong quá trình này: khu vực đám mây nơi chúng ta sẽ triển khai hàm, ứng dụng và vùng chứa:

$ export REGION=europe-west3

Vì sẽ lưu trữ dữ liệu trong cơ sở dữ liệu Cloud Firestore, nên chúng ta cần tạo cơ sở dữ liệu:

$ gcloud app create --region=${REGION}
$ gcloud firestore databases create --location=${REGION}

Sau này trong lớp học lập trình này, khi triển khai REST API, chúng ta sẽ cần sắp xếp và lọc dữ liệu. Vì mục đích đó, chúng ta sẽ tạo 3 chỉ mục:

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=language,order=ascending \
      --field-config field-path=updated,order=descending 

$ gcloud firestore indexes composite create --collection-group=books \
      --field-config field-path=author,order=ascending \
      --field-config field-path=updated,order=descending 

3 chỉ mục đó tương ứng với các lượt tìm kiếm mà chúng ta sẽ thực hiện theo tác giả hoặc ngôn ngữ, đồng thời duy trì thứ tự trong bộ sưu tập thông qua một trường được cập nhật.

4. Lấy mã

Lấy mã từ kho lưu trữ Github sau đây:

$ git clone https://github.com/glaforge/serverless-web-apis

Mã xử lý ứng dụng được viết bằng Node.JS.

Bạn sẽ có cấu trúc thư mục sau đây có liên quan đến lớp học này:

serverless-web-apis
 |
 ├── data
 |   ├── books.json
 |
 ├── function-import
 |   ├── index.js
 |   ├── package.json
 |
 ├── run-crud
 |   ├── index.js
 |   ├── package.json
 |   ├── Dockerfile
 |
 ├── appengine-frontend
     ├── public
     |   ├── css/style.css
     |   ├── html/index.html
     |   ├── js/app.js
     ├── index.js
     ├── package.json
     ├── app.yaml

Sau đây là các thư mục có liên quan:

  • data – Thư mục này chứa dữ liệu mẫu của danh sách 100 cuốn sách.
  • function-import – Hàm này sẽ cung cấp một điểm cuối để nhập dữ liệu mẫu.
  • run-crud – Vùng chứa này sẽ hiển thị một Web API để truy cập vào dữ liệu sách được lưu trữ trong Cloud Firestore.
  • appengine-frontend – Ứng dụng web App Engine này sẽ hiển thị một giao diện người dùng chỉ đọc đơn giản để duyệt qua danh sách sách.

5. Dữ liệu mẫu của thư viện sách

Trong thư mục dữ liệu, chúng ta có một tệp books.json chứa danh sách 100 cuốn sách đáng đọc. Tài liệu JSON này là một mảng chứa các đối tượng JSON. Hãy xem hình dạng của dữ liệu mà chúng ta sẽ nhận qua Cloud Functions:

[
  {
    "isbn": "9780435272463",
    "author": "Chinua Achebe",
    "language": "English",
    "pages": 209,
    "title": "Things Fall Apart",
    "year": 1958
  },
  {
    "isbn": "9781414251196",
    "author": "Hans Christian Andersen",
    "language": "Danish",
    "pages": 784,
    "title": "Fairy tales",
    "year": 1836
  },
  ...
]

Tất cả mục nhập sách của chúng tôi trong mảng này đều chứa thông tin sau:

  • isbn – Mã ISBN-13 xác định cuốn sách.
  • author – Tên tác giả của cuốn sách.
  • language – Ngôn ngữ nói được dùng để viết sách.
  • pages – Số trang trong sách.
  • title – Tên sách.
  • year – Năm xuất bản sách.

6. Một điểm cuối hàm để nhập dữ liệu mẫu về sách

Trong phần đầu tiên này, chúng ta sẽ triển khai điểm cuối dùng để nhập dữ liệu mẫu về sách. Chúng ta sẽ sử dụng Cloud Functions cho mục đích này.

Khám phá mã

Hãy bắt đầu bằng cách xem xét tệp package.json:

{
    "name": "function-import",
    "description": "Import sample book data",
    "license": "Apache-2.0",
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9"
    },
    "devDependencies": {
        "@google-cloud/functions-framework": "^3.1.0"
    },
    "scripts": {
        "start": "npx @google-cloud/functions-framework --target=parseBooks"
    }
}

Trong các phần phụ thuộc thời gian chạy, chúng ta chỉ cần mô-đun @google-cloud/firestore NPM để truy cập cơ sở dữ liệu và lưu trữ dữ liệu sách. Về cơ bản, thời gian chạy Cloud Functions cũng cung cấp khung web Express, nên chúng ta không cần khai báo khung này dưới dạng một phần phụ thuộc.

Trong các phần phụ thuộc phát triển, chúng ta khai báo Functions Framework (@google-cloud/functions-framework). Đây là khung thời gian chạy dùng để gọi các hàm của bạn. Đây là một khung mã nguồn mở mà bạn cũng có thể sử dụng cục bộ trên máy của mình (trong trường hợp này là trong Cloud Shell) để chạy các hàm mà không cần triển khai mỗi khi thực hiện thay đổi, nhờ đó cải thiện vòng phản hồi phát triển.

Để cài đặt các phần phụ thuộc, hãy dùng lệnh install:

$ npm install

Tập lệnh start sử dụng Functions Framework để cung cấp cho bạn một lệnh mà bạn có thể dùng để chạy hàm cục bộ theo hướng dẫn sau:

$ npm start

Bạn có thể sử dụng curl hoặc có thể là bản xem trước web Cloud Shell cho các yêu cầu HTTP GET để tương tác với hàm.

Bây giờ, hãy xem tệp index.js chứa logic của hàm nhập dữ liệu sách:

const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');

Chúng ta sẽ tạo thực thể cho mô-đun Firestore và trỏ đến bộ sưu tập sách (tương tự như một bảng trong cơ sở dữ liệu quan hệ).

functions.http('parseBooks', async (req, resp) => {
    if (req.method !== "POST") {
        resp.status(405).send({error: "Only method POST allowed"});
        return;
    }
    if (req.headers['content-type'] !== "application/json") {
        resp.status(406).send({error: "Only application/json accepted"});
        return;
    }
    ... 
})

Chúng tôi đang xuất hàm JavaScript parseBooks. Đây là hàm mà chúng ta sẽ khai báo khi triển khai sau này.

Một vài hướng dẫn tiếp theo là để kiểm tra xem:

  • Chúng tôi chỉ chấp nhận các yêu cầu HTTP POST, nếu không, chúng tôi sẽ trả về mã trạng thái 405 để cho biết rằng các phương thức HTTP khác không được phép.
  • Chúng tôi chỉ chấp nhận tải trọng application/json, nếu không, chúng tôi sẽ gửi mã trạng thái 406 để cho biết đây không phải là định dạng tải trọng được chấp nhận.
    const books = req.body;

    const writeBatch = firestore.batch();

    for (const book of books) {
        const doc = bookStore.doc(book.isbn);
        writeBatch.set(doc, {
            title: book.title,
            author: book.author,
            language: book.language,
            pages: book.pages,
            year: book.year,
            updated: Firestore.Timestamp.now()
        });
    }

Sau đó, chúng ta có thể truy xuất tải trọng JSON thông qua body của yêu cầu. Chúng ta sẽ chuẩn bị một thao tác hàng loạt trên Firestore để lưu trữ tất cả sách theo lô. Chúng ta lặp lại mảng JSON bao gồm thông tin chi tiết về sách, đi qua các trường isbn, title, author, language, pagesyear. Mã ISBN của sách sẽ đóng vai trò là khoá chính hoặc giá trị nhận dạng.

    try {
        await writeBatch.commit();
        console.log("Saved books in Firestore");
    } catch (e) {
        console.error("Error saving books:", e);
        resp.status(400).send({error: "Error saving books"});
        return;
    };

    resp.status(202).send({status: "OK"});

Giờ đây, khi phần lớn dữ liệu đã sẵn sàng, chúng ta có thể thực hiện thao tác này. Nếu thao tác lưu trữ không thành công, chúng tôi sẽ trả về mã trạng thái 400 để cho biết thao tác đó không thành công. Nếu không, chúng ta có thể trả về phản hồi OK, với mã trạng thái 202 cho biết yêu cầu lưu hàng loạt đã được chấp nhận.

Chạy và kiểm thử hàm nhập

Trước khi chạy mã, chúng ta sẽ cài đặt các phần phụ thuộc bằng cách:

$ npm install

Để chạy hàm cục bộ, nhờ Functions Framework, chúng ta sẽ dùng lệnh tập lệnh start mà chúng ta đã xác định trong package.json:

$ npm start

> start
> npx @google-cloud/functions-framework --target=parseBooks

Serving function...
Function: parseBooks
URL: http://localhost:8080/

Để gửi yêu cầu HTTP POST đến hàm cục bộ, bạn có thể chạy:

$ curl -d "@../data/books.json" \
       -H "Content-Type: application/json" \
       http://localhost:8080/

Khi chạy lệnh này, bạn sẽ thấy kết quả sau, xác nhận rằng hàm đang chạy cục bộ:

{"status":"OK"}

Bạn cũng có thể truy cập vào giao diện người dùng Cloud Console để kiểm tra xem dữ liệu có thực sự được lưu trữ trong Firestore hay không:

409982568cebdbf8.png

Trong ảnh chụp màn hình ở trên, chúng ta có thể thấy bộ sưu tập books đã được tạo, danh sách các tài liệu về sách được xác định bằng mã ISBN của sách và thông tin chi tiết về mục nhập sách cụ thể đó ở bên phải.

Triển khai hàm trên đám mây

Để triển khai hàm trong Cloud Functions, chúng ta sẽ sử dụng lệnh sau trong thư mục function-import:

$ gcloud functions deploy bulk-import \
         --gen2 \
         --trigger-http \
         --runtime=nodejs20 \
         --allow-unauthenticated \
         --max-instances=30
         --region=${REGION} \
         --source=. \
         --entry-point=parseBooks

Chúng ta triển khai hàm bằng tên tượng trưng là bulk-import. Hàm này được kích hoạt thông qua các yêu cầu HTTP. Chúng tôi sử dụng thời gian chạy Node.JS 20. Chúng ta triển khai hàm này công khai (lý tưởng nhất là chúng ta nên bảo mật điểm cuối đó). Chúng ta chỉ định khu vực mà chúng ta muốn hàm cư trú. Chúng ta chỉ đến các nguồn trong thư mục cục bộ và sử dụng parseBooks (hàm JavaScript đã xuất) làm điểm truy cập.

Sau khoảng 2 phút hoặc ít hơn, hàm sẽ được triển khai trên đám mây. Trong giao diện người dùng Cloud Console, bạn sẽ thấy hàm xuất hiện:

c910875d4dc0aaa8.png

Trong đầu ra triển khai, bạn sẽ thấy URL của hàm, theo một quy ước đặt tên nhất định (https://${REGION}-${GOOGLE_CLOUD_PROJECT}.cloudfunctions.net/${FUNCTION_NAME}). Tất nhiên, bạn cũng có thể tìm thấy URL kích hoạt HTTP này trong giao diện người dùng bảng điều khiển Cloud, trong thẻ điều kiện kích hoạt:

380ffc46eb56441e.png

Bạn cũng có thể truy xuất URL thông qua dòng lệnh bằng gcloud:

$ export BULK_IMPORT_URL=$(gcloud functions describe bulk-import \
                                  --region=$REGION \
                                  --format 'value(httpsTrigger.url)')
$ echo $BULK_IMPORT_URL

Hãy lưu trữ khoá này trong biến môi trường BULK_IMPORT_URL để chúng ta có thể dùng lại khoá này để kiểm thử hàm đã triển khai.

Kiểm thử hàm đã triển khai

Với một lệnh curl tương tự mà chúng ta đã dùng trước đó để kiểm thử hàm đang chạy cục bộ, chúng ta sẽ kiểm thử hàm đã triển khai. Thay đổi duy nhất sẽ là URL:

$ curl -d "@../data/books.json" \
       -H "Content-Type: application/json" \
       $BULK_IMPORT_URL

Tương tự, nếu thành công, lệnh này sẽ trả về kết quả sau:

{"status":"OK"}

Bây giờ, khi hàm nhập của chúng ta đã được triển khai và sẵn sàng, chúng ta đã tải dữ liệu mẫu lên, đã đến lúc chúng ta phát triển API REST để hiển thị tập dữ liệu này.

7. Hợp đồng API REST

Mặc dù không xác định hợp đồng API bằng cách sử dụng, chẳng hạn như quy cách Open API, nhưng chúng ta sẽ xem xét các điểm cuối khác nhau của API REST.

API này trao đổi các đối tượng JSON về sách, bao gồm:

  • isbn (không bắt buộc) – String gồm 13 ký tự đại diện cho mã ISBN hợp lệ,
  • authorString không trống, biểu thị tên tác giả của cuốn sách,
  • language – một String không trống chứa ngôn ngữ mà cuốn sách được viết,
  • pages – một số dương Integer cho số trang của cuốn sách,
  • title – một String không trống có tên sách,
  • year – giá trị Integer cho năm xuất bản của cuốn sách.

Ví dụ về tải trọng sách:

{
    "isbn": "9780435272463",
    "author": "Chinua Achebe",
    "language": "English",
    "pages": 209,
    "title": "Things Fall Apart",
    "year": 1958
  }

GET /books

Nhận danh sách tất cả sách, có thể lọc theo tác giả và/hoặc ngôn ngữ, đồng thời phân trang theo các nhóm 10 kết quả tại một thời điểm.

Phần tải nội dung: không có.

Tham số truy vấn:

  • author (không bắt buộc) – lọc danh mục sách theo tác giả,
  • language (không bắt buộc) – lọc danh mục sách theo ngôn ngữ,
  • page (không bắt buộc, mặc định = 0) – cho biết thứ hạng của trang kết quả cần trả về.

Trả về: một mảng JSON gồm các đối tượng sách.

Mã trạng thái:

  • 200 – khi yêu cầu tìm nạp danh sách sách thành công,
  • 400 – nếu xảy ra lỗi.

POST /books và POST /books/{isbn}

Đăng tải trọng sách mới, có hoặc không có tham số đường dẫn isbn (trong trường hợp này, bạn không cần mã isbn trong tải trọng sách)isbn

Tải trọng nội dung: một đối tượng sách.

Tham số truy vấn: không có.

Trả về: không có gì.

Mã trạng thái:

  • 201 – khi sách được lưu trữ thành công,
  • 406 – nếu mã isbn không hợp lệ,
  • 400 – nếu xảy ra lỗi.

GET /books/{isbn}

Truy xuất một cuốn sách trong thư viện, được xác định bằng mã isbn, được truyền dưới dạng tham số đường dẫn.

Phần tải nội dung: không có.

Tham số truy vấn: không có.

Trả về: một đối tượng JSON của sách hoặc một đối tượng lỗi nếu sách không tồn tại.

Mã trạng thái:

  • 200 – nếu tìm thấy sách trong cơ sở dữ liệu,
  • 400 – nếu xảy ra lỗi,
  • 404 – nếu không tìm thấy sách,
  • 406 – nếu mã isbn không hợp lệ.

PUT /books/{isbn}

Cập nhật một cuốn sách hiện có, được xác định bằng isbn được truyền dưới dạng tham số đường dẫn.

Tải trọng nội dung: một đối tượng sách. Bạn chỉ có thể truyền những trường cần cập nhật, còn những trường khác là không bắt buộc.

Tham số truy vấn: không có.

Trả về: cuốn sách đã cập nhật.

Mã trạng thái:

  • 200 – khi sách được cập nhật thành công,
  • 400 – nếu xảy ra lỗi,
  • 406 – nếu mã isbn không hợp lệ.

DELETE /books/{isbn}

Xoá một cuốn sách hiện có, được xác định bằng isbn được truyền dưới dạng tham số đường dẫn.

Phần tải nội dung: không có.

Tham số truy vấn: không có.

Trả về: không có gì.

Mã trạng thái:

  • 204 – khi xoá thành công sách,
  • 400 – nếu xảy ra lỗi.

8. Triển khai và hiển thị một API REST trong vùng chứa

Khám phá mã

Dockerfile

Hãy bắt đầu bằng cách xem Dockerfile, đây là thành phần chịu trách nhiệm đóng gói mã xử lý ứng dụng của chúng ta:

FROM node:20-slim
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --only=production
COPY . ./
CMD [ "node", "index.js" ]

Chúng tôi đang sử dụng hình ảnh "slim" của Node.JS 20. Chúng ta đang làm việc trong thư mục /usr/src/app. Chúng ta đang sao chép tệp package.json (thông tin chi tiết bên dưới) để xác định các phần phụ thuộc, cùng với những nội dung khác. Chúng ta cài đặt các phần phụ thuộc bằng npm install, sao chép mã nguồn. Cuối cùng, chúng ta chỉ ra cách chạy ứng dụng này bằng lệnh node index.js.

package.json

Tiếp theo, chúng ta có thể xem xét tệp package.json:

{
    "name": "run-crud",
    "description": "CRUD operations over book data",
    "license": "Apache-2.0",
    "engines": {
        "node": ">= 20.0.0"
    },
    "dependencies": {
        "@google-cloud/firestore": "^4.9.9",
        "cors": "^2.8.5",
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "scripts": {
        "start": "node index.js"
    }
}

Chúng ta chỉ định rằng chúng ta muốn sử dụng Node.JS 14, như trường hợp với Dockerfile.

Ứng dụng API web của chúng tôi phụ thuộc vào:

  • Mô-đun Firestore NPM để truy cập vào dữ liệu sách trong cơ sở dữ liệu,
  • Thư viện cors để xử lý các yêu cầu CORS (Chia sẻ tài nguyên trên nhiều nguồn gốc), vì API REST của chúng ta sẽ được gọi từ mã ứng dụng khách của giao diện người dùng ứng dụng web App Engine,
  • Khung Express, sẽ là khung web của chúng ta để thiết kế API,
  • Sau đó là mô-đun isbn3 giúp xác thực mã ISBN của sách.

Chúng ta cũng chỉ định tập lệnh start. Tập lệnh này sẽ hữu ích khi bắt đầu ứng dụng cục bộ cho mục đích phát triển và kiểm thử.

index.js

Hãy chuyển sang phần chính của mã, với cái nhìn sâu sắc về index.js:

const Firestore = require('@google-cloud/firestore');
const firestore = new Firestore();
const bookStore = firestore.collection('books');

Chúng ta cần mô-đun Firestore và tham chiếu đến bộ sưu tập books, nơi lưu trữ dữ liệu sách.

const express = require('express');
const app = express();
const bodyParser = require('body-parser');
app.use(bodyParser.json());

const querystring = require('querystring');

const cors = require('cors');
app.use(cors({
    exposedHeaders: ['Content-Length', 'Content-Type', 'Link'],
}));

Chúng tôi đang sử dụng Express làm khung web để triển khai REST API. Chúng ta sẽ dùng mô-đun body-parser để phân tích cú pháp các tải trọng JSON được trao đổi với API của chúng ta.

Mô-đun querystring rất hữu ích khi thao tác với URL. Điều này sẽ xảy ra khi chúng ta tạo tiêu đề Link cho mục đích phân trang (sẽ nói thêm về vấn đề này sau).

Sau đó, chúng ta định cấu hình mô-đun cors. Chúng ta sẽ hiện rõ các tiêu đề mà chúng ta muốn truyền qua CORS, vì hầu hết các tiêu đề thường bị loại bỏ, nhưng ở đây, chúng ta muốn giữ lại độ dài và loại nội dung thông thường, cũng như tiêu đề Link mà chúng ta sẽ chỉ định cho việc phân trang.

const ISBN = require('isbn3');

function isbnOK(isbn, res) {
    const parsedIsbn = ISBN.parse(isbn);
    if (!parsedIsbn) {
        res.status(406)
            .send({error: `Invalid ISBN: ${isbn}`});
        return false;
    }
    return parsedIsbn;
}

Chúng ta sẽ dùng mô-đun isbn3 NPM để phân tích cú pháp và xác thực mã ISBN, đồng thời phát triển một hàm hiệu dụng nhỏ để phân tích cú pháp mã ISBN và phản hồi bằng mã trạng thái 406 trên phản hồi nếu mã ISBN không hợp lệ.

  • GET /books

Hãy xem xét từng phần của điểm cuối GET /books:

app.get('/books', async (req, res) => {
    try {
        var query = new Firestore().collection('books');

        if (!!req.query.author) {
            console.log(`Filtering by author: ${req.query.author}`);
            query = query.where("author", "==", req.query.author);
        }
        if (!!req.query.language) {
            console.log(`Filtering by language: ${req.query.language}`);
            query = query.where("language", "==", req.query.language);
        }

        const page = parseInt(req.query.page) || 0;

        // - -  - -  - -  - -  - -  - -

    } catch (e) {
        console.error('Failed to fetch books', e);
        res.status(400)
            .send({error: `Impossible to fetch books: ${e.message}`});
    }
});

Chúng ta sẽ chuẩn bị truy vấn để truy vấn cơ sở dữ liệu. Truy vấn này sẽ phụ thuộc vào các tham số truy vấn không bắt buộc, để lọc theo tác giả và/hoặc theo ngôn ngữ. Chúng ta cũng đang trả về danh mục sách theo từng nhóm 10 cuốn.

Nếu có lỗi xảy ra trong quá trình tìm nạp sách, chúng tôi sẽ trả về một lỗi có mã trạng thái 400.

Hãy phóng to phần bị cắt của điểm cuối đó:

        const snapshot = await query
            .orderBy('updated', 'desc')
            .limit(PAGE_SIZE)
            .offset(PAGE_SIZE * page)
            .get();

        const books = [];

        if (snapshot.empty) {
            console.log('No book found');
        } else {
            snapshot.forEach(doc => {
                const {title, author, pages, year, language, ...otherFields} = doc.data();
                const book = {isbn: doc.id, title, author, pages, year, language};
                books.push(book);
            });
        }

Ở phần trước, chúng ta đã lọc theo authorlanguage, nhưng trong phần này, chúng ta sẽ sắp xếp danh sách sách theo thứ tự ngày cập nhật gần đây nhất (ngày cập nhật gần đây nhất sẽ xuất hiện trước). Chúng ta cũng sẽ phân trang kết quả bằng cách xác định một giới hạn (số lượng phần tử cần trả về) và một độ lệch (điểm bắt đầu để trả về nhóm sách tiếp theo).

Chúng ta sẽ thực thi truy vấn, nhận ảnh chụp nhanh dữ liệu và đưa những kết quả đó vào một mảng JavaScript sẽ được trả về ở cuối hàm.

Hãy kết thúc phần giải thích về điểm cuối này bằng cách xem xét một phương pháp hay: sử dụng tiêu đề Link để xác định các đường liên kết URI đến trang đầu tiên, trang trước, trang tiếp theo hoặc trang cuối cùng của dữ liệu (trong trường hợp này, chúng ta sẽ chỉ cung cấp trang trước và trang tiếp theo).

        var links = {};
        if (page > 0) {
            const prevQuery = querystring.stringify({...req.query, page: page - 1});
            links.prev = `${req.path}${prevQuery != '' ? `?${prevQuery}` : ''}`;
        }
        if (snapshot.docs.length === PAGE_SIZE) {
            const nextQuery = querystring.stringify({...req.query, page: page + 1});
            links.next = `${req.path}${nextQuery != '' ? `?${nextQuery}` : ''}`;
        }
        if (Object.keys(links).length > 0) {
            res.links(links);
        }

        res.status(200).send(books);

Ban đầu, logic này có vẻ hơi phức tạp, nhưng những gì chúng ta đang làm là thêm một đường liên kết previous (trước đó) nếu chúng ta không ở trang dữ liệu đầu tiên. Chúng tôi thêm một đường liên kết next nếu trang dữ liệu đã đầy (tức là chứa số lượng sách tối đa theo hằng số PAGE_SIZE, giả sử có một trang khác sắp xuất hiện với nhiều dữ liệu hơn). Sau đó, chúng ta sử dụng hàm resource#links() của Express để tạo tiêu đề phù hợp với cú pháp phù hợp.

Để bạn biết, tiêu đề đường liên kết sẽ có dạng như sau:

link: </books?page=1>; rel="prev", </books?page=3>; rel="next"
  • POST /booksPOST /books/:isbn

Cả hai điểm cuối đều dùng để tạo một cuốn sách mới. Một phương thức truyền mã ISBN trong tải trọng của sách, còn phương thức kia truyền mã này dưới dạng tham số đường dẫn. Dù bằng cách nào, cả hai đều gọi hàm createBook() của chúng ta:

async function createBook(isbn, req, res) {
    const parsedIsbn = isbnOK(isbn, res);
    if (!parsedIsbn) return;

    const {title, author, pages, year, language} = req.body;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.set({
            title, author, pages, year, language,
            updated: Firestore.Timestamp.now()
        });
        console.log(`Saved book ${parsedIsbn.isbn13}`);

        res.status(201)
            .location(`/books/${parsedIsbn.isbn13}`)
            .send({status: `Book ${parsedIsbn.isbn13} created`});
    } catch (e) {
        console.error(`Failed to save book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to create book ${parsedIsbn.isbn13}: ${e.message}`});
    }    
}

Chúng ta kiểm tra xem mã isbn có hợp lệ hay không, nếu không thì trả về từ hàm (và đặt mã trạng thái 406). Chúng ta truy xuất các trường của cuốn sách từ tải trọng được truyền trong nội dung yêu cầu. Sau đó, chúng ta sẽ lưu trữ thông tin chi tiết về cuốn sách trong Firestore. Trả về 201 khi thành công và 400 khi không thành công.

Khi trả về thành công, chúng tôi cũng đặt tiêu đề vị trí để cung cấp chỉ dấu cho ứng dụng API về vị trí của tài nguyên mới tạo. Tiêu đề sẽ có dạng như sau:

Location: /books/9781234567898
  • GET /books/:isbn

Hãy tìm nạp một cuốn sách (được xác định thông qua ISBN) từ Firestore.

app.get('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        const docSnapshot = await docRef.get();

        if (!docSnapshot.exists) {
            console.log(`Book not found ${parsedIsbn.isbn13}`)
            res.status(404)
                .send({error: `Could not find book ${parsedIsbn.isbn13}`});
            return;
        }

        console.log(`Fetched book ${parsedIsbn.isbn13}`, docSnapshot.data());

        const {title, author, pages, year, language, ...otherFields} = docSnapshot.data();
        const book = {isbn: parsedIsbn.isbn13, title, author, pages, year, language};

        res.status(200).send(book);
    } catch (e) {
        console.error(`Failed to fetch book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to fetch book ${parsedIsbn.isbn13}: ${e.message}`});
    }
});

Như thường lệ, chúng tôi sẽ kiểm tra xem mã ISBN có hợp lệ hay không. Chúng ta sẽ truy vấn Firestore để truy xuất sách. Thuộc tính snapshot.exists rất hữu ích khi bạn muốn biết liệu có tìm thấy sách hay không. Nếu không, chúng tôi sẽ gửi lại một lỗi và mã trạng thái 404 Không tìm thấy. Chúng ta truy xuất dữ liệu về sách và tạo một đối tượng JSON đại diện cho cuốn sách để trả về.

  • PUT /books/:isbn

Chúng tôi đang sử dụng phương thức PUT để cập nhật một cuốn sách hiện có.

app.put('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.set({
            ...req.body,
            updated: Firestore.Timestamp.now()
        }, {merge: true});
        console.log(`Updated book ${parsedIsbn.isbn13}`);

        res.status(201)
            .location(`/books/${parsedIsbn.isbn13}`)
            .send({status: `Book ${parsedIsbn.isbn13} updated`});
    } catch (e) {
        console.error(`Failed to update book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to update book ${parsedIsbn.isbn13}: ${e.message}`});
    }    
});

Chúng tôi cập nhật trường ngày/giờ updated để ghi nhớ thời điểm chúng tôi cập nhật bản ghi đó gần đây nhất. Chúng tôi sử dụng chiến lược {merge:true} để thay thế các trường hiện có bằng giá trị mới (nếu không, tất cả các trường sẽ bị xoá và chỉ các trường mới trong tải trọng sẽ được lưu, xoá các trường hiện có khỏi nội dung cập nhật trước đó hoặc lần tạo ban đầu).

Chúng tôi cũng đặt tiêu đề Location để trỏ đến URI của cuốn sách.

  • DELETE /books/:isbn

Việc xoá sách khá đơn giản. Chúng ta chỉ cần gọi phương thức delete() trên tài liệu tham chiếu. Chúng tôi trả về mã trạng thái 204 vì không trả về nội dung nào.

app.delete('/books/:isbn', async (req, res) => {
    const parsedIsbn = isbnOK(req.params.isbn, res);
    if (!parsedIsbn) return;

    try {
        const docRef = bookStore.doc(parsedIsbn.isbn13);
        await docRef.delete();
        console.log(`Book ${parsedIsbn.isbn13} was deleted`);

        res.status(204).end();
    } catch (e) {
        console.error(`Failed to delete book ${parsedIsbn.isbn13}`, e);
        res.status(400)
            .send({error: `Impossible to delete book ${parsedIsbn.isbn13}: ${e.message}`});
    }
});

Khởi động máy chủ Express / Node

Cuối cùng nhưng không kém phần quan trọng, chúng ta sẽ khởi động máy chủ, theo mặc định là nghe trên cổng 8080:

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`Books Web API service: listening on port ${port}`);
    console.log(`Node ${process.version}`);
});

Chạy ứng dụng cục bộ

Để chạy ứng dụng cục bộ, trước tiên, chúng ta sẽ cài đặt các phần phụ thuộc bằng lệnh:

$ npm install

Sau đó, chúng ta có thể bắt đầu bằng:

$ npm start

Máy chủ sẽ khởi động vào localhost và theo mặc định sẽ lắng nghe trên cổng 8080.

Bạn cũng có thể tạo một vùng chứa Docker và chạy hình ảnh vùng chứa bằng các lệnh sau:

$ docker build -t crud-web-api .

$ docker run --rm -p 8080:8080 -it crud-web-api

Chạy trong Docker cũng là một cách tuyệt vời để kiểm tra kỹ xem quá trình tạo vùng chứa của ứng dụng có chạy tốt hay không khi chúng ta tạo ứng dụng trên đám mây bằng Cloud Build.

Kiểm thử API

Bất kể cách chúng ta chạy mã API REST (trực tiếp qua Node hoặc thông qua một hình ảnh vùng chứa Docker), giờ đây, chúng ta có thể chạy một vài truy vấn đối với mã đó.

  • Tạo một cuốn sách mới (ISBN trong phần nội dung hữu ích):
$ curl -XPOST -d '{"isbn":"9782070368228","title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books
  • Tạo một cuốn sách mới (ISBN trong tham số đường dẫn):
$ curl -XPOST -d '{"title":"Book","author":"me","pages":123,"year":2021,"language":"French"}' \
    -H "Content-Type: application/json" \
    http://localhost:8080/books/9782070368228
  • Xoá một cuốn sách (cuốn sách chúng ta đã tạo):
$ curl -XDELETE http://localhost:8080/books/9782070368228
  • Truy xuất một cuốn sách theo ISBN:
$ curl http://localhost:8080/books/9780140449136
$ curl http://localhost:8080/books/9782070360536
  • Cập nhật một cuốn sách hiện có bằng cách chỉ thay đổi tiêu đề:
$ curl -XPUT \
       -d '{"title":"Book"}' \
       -H "Content-Type: application/json" \      
       http://localhost:8080/books/9780003701203
  • Truy xuất danh sách sách (10 cuốn sách đầu tiên):
$ curl http://localhost:8080/books
  • Tìm sách của một tác giả cụ thể:
$ curl http://localhost:8080/books?author=Virginia+Woolf
  • Liệt kê những cuốn sách được viết bằng tiếng Anh:
$ curl http://localhost:8080/books?language=English
  • Tải trang sách thứ 4:
$ curl http://localhost:8080/books?page=3

Chúng ta cũng có thể kết hợp các tham số truy vấn author, languagebooks để tinh chỉnh nội dung tìm kiếm.

Tạo và triển khai API REST được chứa trong vùng chứa

Vì API REST hoạt động theo kế hoạch, nên đây là thời điểm thích hợp để triển khai API này trên Đám mây, cụ thể là trên Cloud Run!

Chúng ta sẽ thực hiện việc này theo 2 bước:

  • Trước tiên, bằng cách tạo hình ảnh vùng chứa bằng Cloud Build, với lệnh sau:
$ gcloud builds submit \
         --tag gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api
  • Sau đó, bằng cách triển khai dịch vụ bằng lệnh thứ hai này:
$ gcloud run deploy run-crud \
         --image gcr.io/${GOOGLE_CLOUD_PROJECT}/crud-web-api \
         --allow-unauthenticated \
         --region=${REGION} \
         --platform=managed

Với lệnh đầu tiên, Cloud Build sẽ tạo hình ảnh vùng chứa và lưu trữ hình ảnh đó trong Container Registry. Lệnh tiếp theo triển khai hình ảnh vùng chứa từ sổ đăng ký và triển khai hình ảnh đó trong khu vực đám mây.

Chúng ta có thể kiểm tra kỹ trong giao diện người dùng Cloud Console để đảm bảo dịch vụ Cloud Run của chúng ta hiện xuất hiện trong danh sách:

f62fbca02a8127c0.png

Bước cuối cùng mà chúng ta sẽ thực hiện ở đây là truy xuất URL của dịch vụ Cloud Run vừa triển khai, nhờ lệnh sau:

$ export RUN_CRUD_SERVICE_URL=$(gcloud run services describe run-crud \
                                       --region=${REGION} \
                                       --platform=managed \
                                       --format='value(status.url)')

Chúng ta sẽ cần URL của Cloud Run API REST trong phần tiếp theo, vì mã giao diện người dùng App Engine sẽ tương tác với API này.

9. Lưu trữ một ứng dụng web để duyệt xem thư viện

Mảnh ghép cuối cùng để thêm chút lấp lánh cho dự án này là cung cấp một giao diện người dùng web sẽ tương tác với API REST của chúng ta. Để làm việc đó, chúng ta sẽ sử dụng Google App Engine, với một số mã JavaScript phía máy khách sẽ gọi API thông qua các yêu cầu AJAX (bằng cách sử dụng API Tìm nạp phía máy khách).

Mặc dù được triển khai trên thời gian chạy App Engine của Node.JS, nhưng ứng dụng của chúng tôi chủ yếu được tạo từ các tài nguyên tĩnh! Không có nhiều mã phụ trợ, vì hầu hết lượt tương tác của người dùng sẽ diễn ra trong trình duyệt thông qua JavaScript phía máy khách. Chúng ta sẽ không sử dụng bất kỳ khung JavaScript giao diện người dùng nào phức tạp, mà chỉ sử dụng một số JavaScript "thuần", với một vài Thành phần web cho giao diện người dùng bằng cách sử dụng thư viện thành phần web Shoelace:

  • một hộp chọn để chọn ngôn ngữ của sách:

6fb9f741000a2dc1.png

  • một thành phần thẻ để hiển thị thông tin chi tiết về một cuốn sách cụ thể (bao gồm cả mã vạch để biểu thị ISBN của cuốn sách, bằng cách sử dụng thư viện JsBarcode):

3aa21a9e16e3244e.png

  • và một nút để tải thêm sách từ cơ sở dữ liệu:

3925ad81c91bbac9.png

Khi kết hợp tất cả các thành phần trực quan đó với nhau, trang web kết quả để duyệt qua thư viện của chúng ta sẽ có dạng như sau:

18a5117150977d6.png

Tệp cấu hình app.yaml

Hãy bắt đầu tìm hiểu cơ sở mã của ứng dụng App Engine này bằng cách xem tệp cấu hình app.yaml của ứng dụng. Đây là một tệp dành riêng cho App Engine, cho phép bạn định cấu hình những thứ như biến môi trường, nhiều "trình xử lý" của ứng dụng hoặc chỉ định rằng một số tài nguyên là tài sản tĩnh sẽ được CDN tích hợp của App Engine phân phát.

runtime: nodejs14

env_variables:
  RUN_CRUD_SERVICE_URL: CHANGE_ME

handlers:

- url: /js
  static_dir: public/js

- url: /css
  static_dir: public/css

- url: /img
  static_dir: public/img

- url: /(.+\.html)
  static_files: public/html/\1
  upload: public/(.+\.html)

- url: /
  static_files: public/html/index.html
  upload: public/html/index\.html

- url: /.*
  secure: always
  script: auto

Chúng ta chỉ định rằng ứng dụng của mình là một ứng dụng Node.JS và chúng ta muốn sử dụng phiên bản 14.

Sau đó, chúng ta xác định một biến môi trường trỏ đến URL dịch vụ Cloud Run. Bạn cần cập nhật phần giữ chỗ CHANGE_ME bằng URL chính xác (xem bên dưới để biết cách thay đổi phần giữ chỗ này).

Sau đó, chúng ta sẽ xác định nhiều trình xử lý. 3 mục đầu tiên trỏ đến vị trí mã phía máy khách HTML, CSS và JavaScript, trong thư mục public/ và các thư mục con của thư mục này. URL thứ tư cho biết URL gốc của ứng dụng App Engine phải trỏ đến trang index.html. Bằng cách đó, chúng tôi sẽ không thấy hậu tố index.html trong URL khi truy cập vào thư mục gốc của trang web. Và cuối cùng là URL mặc định sẽ định tuyến tất cả các URL khác (/.*) đến ứng dụng Node.JS của chúng ta (tức là phần "động" của ứng dụng, trái ngược với các thành phần tĩnh mà chúng ta đã mô tả).

Bây giờ, hãy cập nhật URL Web API của dịch vụ Cloud Run.

Trong thư mục appengine-frontend/, hãy chạy lệnh sau để cập nhật biến môi trường trỏ đến URL của API REST dựa trên Cloud Run:

$ sed -i -e "s|CHANGE_ME|${RUN_CRUD_SERVICE_URL}|" app.yaml

Hoặc thay đổi chuỗi CHANGE_ME trong app.yaml theo cách thủ công bằng URL chính xác:

env_variables:
    RUN_CRUD_SERVICE_URL: CHANGE_ME

Tệp package.json Node.JS

{
    "name": "appengine-frontend",
    "description": "Web frontend",
    "license": "Apache-2.0",
    "main": "index.js",
    "engines": {
        "node": "^14.0.0"
    },
    "dependencies": {
        "express": "^4.17.1",
        "isbn3": "^1.1.10"
    },
    "devDependencies": {
        "nodemon": "^2.0.7"
    },
    "scripts": {
        "start": "node index.js",
        "dev": "nodemon --watch server --inspect index.js"
    }
}

Chúng tôi xin nhấn mạnh lại rằng chúng tôi muốn chạy ứng dụng này bằng Node.JS 14. Chúng tôi phụ thuộc vào khung Express, cũng như mô-đun isbn3 NPM để xác thực mã ISBN của sách.

Trong các phần phụ thuộc phát triển, chúng ta sẽ sử dụng mô-đun nodemon để theo dõi các thay đổi của tệp. Mặc dù có thể chạy ứng dụng cục bộ bằng npm start, thực hiện một số thay đổi đối với mã, dừng ứng dụng bằng ^C rồi khởi chạy lại, nhưng việc này hơi tẻ nhạt. Thay vào đó, chúng ta có thể sử dụng lệnh sau để ứng dụng tự động tải lại / khởi động lại khi có thay đổi:

$ npm run dev

index.js Đoạn mã Node.JS

const express = require('express');
const app = express();

app.use(express.static('public'));

const bodyParser = require('body-parser');
app.use(bodyParser.json());

Chúng tôi yêu cầu khung web Express. Chúng tôi chỉ định rằng thư mục công khai chứa các thành phần tĩnh mà static middleware có thể phân phát (ít nhất là khi chạy cục bộ ở chế độ phát triển). Cuối cùng, chúng ta cần body-parser để phân tích cú pháp tải trọng JSON.

Hãy xem xét một vài tuyến đường mà chúng ta đã xác định:

app.get('/', async (req, res) => {
    res.redirect('/html/index.html');
});

app.get('/webapi', async (req, res) => {
    res.send(process.env.RUN_CRUD_SERVICE_URL);
});

Đường liên kết đầu tiên khớp với / sẽ chuyển hướng đến index.html trong thư mục public/html. Vì không chạy trong thời gian chạy App Engine ở chế độ phát triển, nên chúng ta không nhận được hoạt động định tuyến URL của App Engine. Vì vậy, thay vào đó, ở đây, chúng ta chỉ cần chuyển hướng URL gốc đến tệp HTML.

Điểm cuối thứ hai mà chúng ta xác định /webapi sẽ trả về URL của Cloud Run API REST. Bằng cách đó, mã JavaScript phía máy khách sẽ biết nơi cần gọi để lấy danh sách sách.

const port = process.env.PORT || 8080;
app.listen(port, () => {
    console.log(`Book library web frontend: listening on port ${port}`);
    console.log(`Node ${process.version}`);
    console.log(`Web API endpoint ${process.env.RUN_CRUD_SERVICE_URL}`);
});

Để hoàn tất, chúng ta sẽ chạy ứng dụng web Express và theo mặc định, ứng dụng này sẽ lắng nghe trên cổng 8080.

index.html trang

Chúng ta sẽ không xem xét từng dòng của trang HTML dài này. Thay vào đó, hãy cùng xem xét một số dòng chính.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.37/dist/themes/base.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.37/dist/shoelace.js"></script>

<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.0/dist/barcodes/JsBarcode.ean-upc.min.js"></script>

<script src="/js/app.js"></script>
<link rel="stylesheet" type="text/css" href="/css/style.css">

Hai dòng đầu tiên nhập thư viện thành phần web Shoelace (một tập lệnh và một biểu định kiểu).

Dòng tiếp theo nhập thư viện JsBarcode để tạo mã vạch cho mã ISBN của sách.

Các dòng cuối cùng đang nhập mã JavaScript và biểu định kiểu CSS của riêng chúng tôi, nằm trong các thư mục con public/.

Trong body của trang HTML, chúng ta sử dụng các thành phần Shoelace với thẻ phần tử tuỳ chỉnh, chẳng hạn như:

<sl-icon name="book-half"></sl-icon>
...

<sl-select id="language-select" placeholder="Select a language..." clearable>
    <sl-menu-item value="English">English</sl-menu-item>
    <sl-menu-item value="French">French</sl-menu-item>
    ...
</sl-select>
...

<sl-button id="more-button" type="primary" size="large">
    More books...
</sl-button>
...

Chúng tôi cũng sử dụng các mẫu HTML và khả năng điền vào vị trí của các mẫu này để biểu thị một cuốn sách. Chúng ta sẽ tạo bản sao của mẫu đó để điền sẵn danh sách sách và thay thế các giá trị trong các vị trí bằng thông tin chi tiết về sách:

    <template id="book-card">
        <sl-card class="card-overview">
        ...
            <slot name="author">Author</slot>
            ... 
        </sl-card>
    </template>

Đủ rồi, chúng ta sắp xem xong mã HTML. Còn một phần quan trọng cuối cùng: mã JavaScript phía máy khách app.js tương tác với API REST của chúng ta.

Mã JavaScript phía máy khách app.js

Chúng ta bắt đầu bằng một trình nghe sự kiện cấp cao nhất, chờ nội dung DOM được tải:

document.addEventListener("DOMContentLoaded", async function(event) {
    ...
}

Sau khi chuẩn bị xong, chúng ta có thể thiết lập một số hằng số và biến chính:

    const serverUrlResponse = await fetch('/webapi');
    const serverUrl = await serverUrlResponse.text();
    console.log('Web API endpoint:', serverUrl);
    
    const server = serverUrl + '/books';
    var page = 0;
    var language = '';

Trước tiên, chúng ta sẽ tìm nạp URL của API REST, nhờ mã nút App Engine trả về biến môi trường mà chúng ta đã đặt ban đầu trong app.yaml. Nhờ biến môi trường, điểm cuối /webapi (được gọi từ mã phía máy khách JavaScript), chúng tôi không phải mã hoá cứng URL API REST trong mã giao diện người dùng.

Chúng ta cũng xác định các biến pagelanguage mà chúng ta sẽ dùng để theo dõi việc phân trang và lọc ngôn ngữ.

    const moreButton = document.getElementById('more-button');
    moreButton.addEventListener('sl-focus', event => {
        console.log('Button clicked');
        moreButton.blur();

        appendMoreBooks(server, page++, language);
    });

Chúng ta thêm một trình xử lý sự kiện vào nút để tải sách. Khi người dùng nhấp vào nút này, hàm appendMoreBooks() sẽ được gọi.

    const langSelect = document.getElementById('language-select');
    langSelect.addEventListener('sl-change', event => {
        page = 0;
        language = event.srcElement.value;
        document.getElementById('library').replaceChildren();
        console.log(`Language selected: "${language}"`);

        appendMoreBooks(server, page++, language);
    });

Tương tự đối với hộp chọn, chúng ta sẽ thêm một trình xử lý sự kiện để nhận thông báo về các thay đổi trong lựa chọn ngôn ngữ. Tương tự như nút, chúng ta cũng gọi hàm appendMoreBooks(), truyền URL API REST, trang hiện tại và lựa chọn ngôn ngữ.

Hãy xem hàm tìm nạp và thêm sách:

async function appendMoreBooks(server, page, language) {
    const searchUrl = new URL(server);
    if (!!page) searchUrl.searchParams.append('page', page);
    if (!!language) searchUrl.searchParams.append('language', language);
        
    const response = await fetch(searchUrl.href);
    const books = await response.json();
    ... 
}

Ở trên, chúng ta đang tạo URL chính xác để dùng gọi API REST. Thông thường, chúng ta có thể chỉ định 3 tham số truy vấn, nhưng trong giao diện người dùng này, chúng ta chỉ chỉ định 2 tham số:

  • page – một số nguyên cho biết trang hiện tại để phân trang sách,
  • language – một chuỗi ngôn ngữ để lọc theo ngôn ngữ viết.

Sau đó, chúng ta sẽ dùng Fetch API để truy xuất mảng JSON chứa thông tin chi tiết về sách.

    const linkHeader = response.headers.get('Link')
    console.log('Link', linkHeader);
    if (!!linkHeader && linkHeader.indexOf('rel="next"') > -1) {
        console.log('Show more button');
        document.getElementById('buttons').style.display = 'block';
    } else {
        console.log('Hide more button');
        document.getElementById('buttons').style.display = 'none';
    }

Tuỳ thuộc vào việc tiêu đề Link có xuất hiện trong phản hồi hay không, chúng tôi sẽ hiện hoặc ẩn nút [More books...], vì tiêu đề Link là một gợi ý cho chúng tôi biết liệu có còn sách nào cần tải hay không (sẽ có một URL next trong tiêu đề Link).

    const library = document.getElementById('library');
    const template = document.getElementById('book-card');
    for (let book of books) {
        const bookCard = template.content.cloneNode(true);

        bookCard.querySelector('slot[name=title]').innerText = book.title;
        bookCard.querySelector('slot[name=language]').innerText = book.language;
        bookCard.querySelector('slot[name=author]').innerText = book.author;
        bookCard.querySelector('slot[name=year]').innerText = book.year;
        bookCard.querySelector('slot[name=pages]').innerText = book.pages;
        
        const img = document.createElement('img');
        img.setAttribute('id', book.isbn);
        img.setAttribute('class', 'img-barcode-' + book.isbn)
        bookCard.querySelector('slot[name=barcode]').appendChild(img);

        library.appendChild(bookCard);
        ... 
    }
}

Trong phần trên của hàm, đối với mỗi cuốn sách do API REST trả về, chúng ta sẽ nhân bản mẫu bằng một số thành phần web đại diện cho một cuốn sách và chúng ta sẽ điền sẵn các khung giờ của mẫu bằng thông tin chi tiết về cuốn sách.

JsBarcode('.img-barcode-' + book.isbn).EAN13(book.isbn, {fontSize: 18, textMargin: 0, height: 60}).render();

Để mã ISBN trông đẹp mắt hơn, chúng ta sẽ dùng thư viện JsBarcode để tạo một mã vạch đẹp mắt như trên bìa sau của sách thật!

Chạy và kiểm thử ứng dụng cục bộ

Đến đây là đủ mã rồi, giờ là lúc xem ứng dụng hoạt động. Trước tiên, chúng ta sẽ thực hiện việc này cục bộ trong Cloud Shell trước khi triển khai thực tế.

Chúng ta sẽ cài đặt các mô-đun NPM mà ứng dụng cần bằng cách:

$ npm install

Và chúng ta có thể chạy ứng dụng theo cách thông thường:

$ npm start

Hoặc với tính năng tự động tải lại các thay đổi nhờ nodemon, với:

$ npm run dev

Ứng dụng đang chạy cục bộ và chúng ta có thể truy cập vào ứng dụng này từ trình duyệt tại http://localhost:8080.

Triển khai ứng dụng App Engine

Giờ đây, khi đã chắc chắn rằng ứng dụng của chúng ta chạy tốt trên máy cục bộ, đã đến lúc triển khai ứng dụng đó trên App Engine.

Để triển khai ứng dụng, hãy chạy lệnh sau:

$ gcloud app deploy -q

Sau khoảng một phút, ứng dụng sẽ được triển khai.

Ứng dụng sẽ có sẵn tại một URL có dạng: https://${GOOGLE_CLOUD_PROJECT}.appspot.com.

Khám phá giao diện người dùng của ứng dụng web App Engine

Giờ đây, bạn có thể:

  • Nhấp vào nút [More books...] để tải thêm sách.
  • Chọn một ngôn ngữ cụ thể để chỉ xem sách bằng ngôn ngữ đó.
  • Bạn có thể xoá lựa chọn bằng dấu x nhỏ trong hộp chọn để quay lại danh sách tất cả sách.

10. Dọn dẹp (không bắt buộc)

Nếu không có ý định giữ lại ứng dụng này, bạn có thể dọn dẹp tài nguyên để tiết kiệm chi phí và trở thành một người dùng đám mây có trách nhiệm bằng cách xoá toàn bộ dự án:

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

11. Xin chúc mừng!

Chúng tôi đã tạo một bộ dịch vụ (nhờ Cloud Functions, App Engine và Cloud Run) để hiển thị nhiều điểm cuối Web API và giao diện người dùng web, nhằm lưu trữ, cập nhật và duyệt xem một thư viện sách, đồng thời tuân theo một số mẫu thiết kế hay để phát triển API REST.

Nội dung đã đề cập

  • Cloud Functions
  • Cloud Firestore
  • Cloud Run
  • App Engine

Vươn xa hơn

Nếu muốn tìm hiểu thêm về ví dụ cụ thể này và mở rộng nó, bạn có thể xem danh sách những điều bạn nên tìm hiểu:

  • Tận dụng API Gateway để cung cấp một giao diện API chung cho chức năng nhập dữ liệu và vùng chứa API REST, nhằm thêm các tính năng như xử lý khoá API để truy cập vào API hoặc xác định giới hạn về tốc độ cho người dùng API.
  • Triển khai mô-đun nút Swagger-UI trong ứng dụng App Engine để ghi lại và cung cấp một sân chơi kiểm thử cho API REST.
  • Ở giao diện người dùng, ngoài khả năng duyệt web hiện có, hãy thêm các màn hình bổ sung để chỉnh sửa dữ liệu, tạo mục nhập sách mới. Ngoài ra, vì chúng ta đang sử dụng cơ sở dữ liệu Cloud Firestore, hãy tận dụng tính năng theo thời gian thực của cơ sở dữ liệu này để cập nhật dữ liệu sách được hiển thị khi có thay đổi.