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à nâng cao trải nghiệm với mô hình "không máy chủ" các dịch vụ do Google Cloud Platform cung cấp:

  • Chức năng đám mây – triển khai các đơn vị logic nghiệp vụ nhỏ dưới dạng các hàm phản ứng với các sự kiện khác nhau (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ối các ứng dụng web, API web, phần phụ trợ trên thiết bị di động, tài sản tĩnh, với khả năng mở rộng quy mô nhanh chóng,
  • Cloud Run – để triển khai và mở rộng quy mô các vùng chứa có thể chứa bất kỳ ngôn ngữ, thời gian chạy hoặc thư viện nào.

Đồng thời, để 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 các API Web và REST, đồng thời tìm hiểu một số nguyên tắc thiết kế hiệu quả cho RESTful trong quá trình này.

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:

  • Chức năng đám mây: để nhập tập dữ liệu sách ban đầu có trong thư viện của chúng tôi, trong cơ sở dữ liệu tài liệu Cloud Firestore,
  • Vùng chứa Cloud Run: sẽ hiển thị API REST trên nội dung cơ sở dữ liệu của chúng tôi,
  • Giao diện người dùng web của App Engine: để duyệt qua danh sách các 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 khi kết thúc lớp học lập trình này:

705e014da0ca5e90.pngS

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 tiến độ riêng

  1. Đăng nhập vào Google Cloud Console rồi tạo dự án mới hoặc sử dụng lại 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.pngS

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ự không được API của Google sử dụng. Bạn luôn có thể cập nhật ứng dụng.
  • Mã dự án là duy nhất trong tất cả các dự án Google Cloud và không thể thay đổi (không thể thay đổi sau khi đã đặt). Cloud Console sẽ tự động tạo một chuỗi duy nhất; thường bạn không quan tâm đến sản phẩm đó là gì. Trong hầu hết các lớp học lập trình, bạn sẽ cần tham khảo Mã dự án của mình (thường được xác định là PROJECT_ID). Nếu không thích mã đã tạo, bạn có thể tạo một mã nhận dạng ngẫu nhiên khác. Ngoài ra, bạn có thể thử cách riêng của mình để xem có thể sử dụng hay không. Bạn không thể thay đổi mã này sau bước này và mã vẫn giữ nguyên trong thời gian dự án.
  • Đối với thông tin của bạn, có giá trị thứ ba, Project Number (Số dự án), mà một số API sử dụng. Tìm hiểu thêm về cả ba giá trị này trong tài liệu này.
  1. Tiếp theo, bạn sẽ phải bật tính năng thanh toán trong Cloud Console để sử dụng API/tài nguyên trên đám mây. Việc chạy qua lớp học lập trình này sẽ không tốn nhiều chi phí. Để tắt các tài nguyên nhằm tránh phát sinh việc thanh toán ngoài hướng dẫn này, bạn có thể xoá các tài nguyên bạ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í 300 USD.

Khởi động Cloud Shell

Mặc dù bạn 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 trong Đám mây.

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

84688aa223b1c3a2.pngS

Sẽ chỉ mất một chút thời gian để cấp phép và kết nối với môi trường. Sau khi hoàn tất, bạn sẽ thấy như sau:

320e18fb7fbe0.pngs

Máy ảo này chứa tất cả các công cụ phát triển mà bạn cần. Phiên bản này cung cấp thư mục gốc có dung lượng ổn định 5 GB và chạy trên Google Cloud, giúp nâng cao đáng kể hiệu suất và khả năng xác thực của mạng. Bạn có thể thực hiện mọi công việ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 gì cả.

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

Để sử dụng các dịch vụ khác nhau mà chúng tôi sẽ cần trong suốt dự án này, chúng tôi sẽ bật một số API. Chúng ta sẽ thực hiện việc này bằng cách khởi 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 lúc, 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 suốt 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ì 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}

Ở phần sau của lớp học lập trình này, khi triển khai API REST, chúng ta sẽ cần sắp xếp và lọc dữ liệu. Vì mục đích đó, chúng tôi 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 nội dung tìm kiếm mà chúng tôi sẽ thực hiện theo tác giả hoặc ngôn ngữ, trong khi vẫn duy trì thứ tự trong bộ sưu tập thông qua trường được cập nhật.

4. Lấy mã

Lấy mã từ kho lưu trữ GitHub sau:

$ 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 phù hợp với phòng thí nghiệm 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 liên quan:

  • data — Thư mục này chứa dữ liệu mẫu về 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 API Web để 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 thư viện sách mẫu

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

[
  {
    "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ả các mục sách của chúng tôi trong mảng này đều chứa các thông tin sau:

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

6. Điểm cuối của hàm để nhập dữ liệu sách mẫu

Trong phần đầu tiên này, chúng ta sẽ triển khai điểm cuối sẽ dùng để nhập dữ liệu sách mẫu. 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 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 trong thời gian chạy, chúng ta chỉ cần mô-đun GMS @google-cloud/firestore để truy cập cơ sở dữ liệu và lưu trữ dữ liệu sách. Về sau, thời gian chạy Cloud Functions cũng cung cấp khung web Express, vì vậy, chúng ta không cần khai báo đây là một phần phụ thuộc.

Trong các phần phụ thuộc trong quá trình phát triển, chúng ta khai báo Khung hàm (@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 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 của chúng ta là bên trong Cloud Shell) để chạy các hàm mà không cần triển khai mỗi khi bạn thực hiện thay đổi, nhờ đó cải thiện vòng lặp phản hồi trong quá trình 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 Khung hàm để 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ể sử dụng bản xem trước trên web của Cloud Shell cho các yêu cầu HTTP GET để tương tác với hàm này.

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 tạo thực thể cho mô-đun Firestore và trỏ vào 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 số hướng dẫn tiếp theo sẽ giúp bạn kiểm tra để đảm bảo rằng:

  • Chúng tôi chỉ chấp nhận các yêu cầu HTTP POST và trả về mã trạng thái 405 để cho biết các phương thức HTTP khác không được phép.
  • Chúng tôi chỉ chấp nhận các tải trọng application/json và gửi một mã trạng thái 406 để cho biết rằng đâ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 tôi đang chuẩn bị một thao tác xử lý theo lô trên Firestore để lưu trữ tất cả sách hàng loạt. Chúng ta lặp lại mảng JSON chứa thông tin chi tiết về sách, kiểm tra các trường isbn, title, author, language, pagesyear. Mã ISBN của sách sẽ đóng vai trò là khoá hoặc giá trị nhận dạng chính.

    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"});

Bây giờ, hàng loạt dữ liệu đã sẵn sàng nên chúng ta có thể cam kết hoạt động. Nếu thao tác lưu trữ không thành công, chúng ta 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, kèm theo 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 với:

$ npm install

Để chạy hàm này một cách cục bộ, nhờ Khung hàm, 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 đây, xác nhận rằng hàm đang chạy trên máy:

{"status":"OK"}

Bạn cũng có thể chuyển đến 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.pngs

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 mục tài liệu sách được xác định theo 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 trong đá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 này 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 dùng thời gian chạy Node.JS 20. Chúng ta triển khai hàm này một cách công khai (tốt nhất là chúng ta nên bảo mật điểm cuối đó). Chúng ta sẽ chỉ định khu vực nơi chúng ta muốn hàm này cư trú. Chúng ta trỏ vào 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 vài phút trở xuống, chức năng này 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 kết quả triển khai, bạn sẽ thấy được URL của hàm tuân 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 của điều kiện kích hoạt HTTP này trong giao diện người dùng của Cloud Console, 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ữ biến này trong biến môi trường BULK_IMPORT_URL để có thể sử dụng lại nhằm kiểm thử hàm đã triển khai.

Kiểm thử hàm được triển khai

Với lệnh curl tương tự mà chúng ta đã sử dụng trước đó để kiểm thử hàm 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

Xin nhắc lại, nếu thành công, hàm sẽ trả về kết quả sau:

{"status":"OK"}

Chức năng nhập của chúng ta hiện đã đượ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, chẳng hạn như dựa trên tài liệu đặc tả Open API, nhưng chúng ta sẽ xem xét các điểm cuối khác nhau của REST API.

API trao đổi các đối tượng JSON của sách, bao gồm:

  • isbn (không bắt buộc) — một String gồm 13 ký tự đại diện cho mã ISBN hợp lệ,
  • author – một String không trống thể hiện tên tác giả của cuốn sách,
  • language — một String không trống có chứa ngôn ngữ được viết trong sách,
  • pages – một Integer dương cho số trang của sách,
  • title — một String không trống có tên sách,
  • year – một giá trị Integer cho năm xuất bả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
  }

TẢI /books

Tải danh sách tất cả các sách, có thể được lọc theo tác giả và/hoặc ngôn ngữ, và được đánh số trang theo cửa sổ gồm 10 kết quả cùng một lúc.

Tải trọng 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 chứa các đối tượng sách.

Mã trạng thái:

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

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

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

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

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

Trả lại: không có gì.

Mã trạng thái:

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

TẢI /books/{isbn}

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

Tải trọng nội dung: không có.

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

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

Mã trạng thái:

  • 200 – nếu sách được tìm thấy trong cơ sở dữ liệu,
  • 400 – nếu có lỗi xảy ra,
  • 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ủa cuốn sách đó đượ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ể chuyển những trường cần cập nhật, các trường còn lại 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 cập nhật sách thành công,
  • 400 – nếu có lỗi xảy ra,
  • 406 – nếu mã isbn không hợp lệ.

XOÁ /books/{isbn}

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

Tải trọng nội dung: không có.

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

Trả lại: không có gì.

Mã trạng thái:

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

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

Khám phá mã

Tệp Docker

Hãy bắt đầu bằng cách xem Dockerfile, nó sẽ chịu trách nhiệm chứa 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 Node.JS 20 "slim". Chúng tôi đang xử lý 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 nhiều 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 sẽ cho biết 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 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 xác định rõ rằng chúng ta muốn sử dụng Node.JS 14, như trong 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 GMS Firestore để truy cập 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 nhiều nguồn gốc), vì API REST của chúng tôi 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 để thiết kế API của chúng tôi,
  • 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, rất hữu ích khi khởi động ứ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 mã nguồn, tìm hiểu sâu hơn về index.js:

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

Chúng ta cần có 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 dùng Express làm khung web để triển khai API REST. Chúng tôi đang 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.

Mô-đun querystring rất hữu ích khi chỉnh sửa URL. Trong trường hợp này, chúng ta sẽ tạo tiêu đề Link cho mục đích phân trang (sẽ tìm hiểu thêm ở phần sau).

Sau đó, chúng ta sẽ định cấu hình mô-đun cors. Chúng ta làm rõ các tiêu đề mà chúng ta muốn chuyển qua CORS, vì hầu hết các tiêu đề thường bị xoá, 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 tính năng 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 tôi sẽ sử dụng mô-đun TLD isbn3 để phân tích cú pháp và xác thực mã ISBN. Đồng thời, chúng tôi cũng phát triển một hàm tiện ích nhỏ có thể phân tích cú pháp các mã ISBN và phản hồi bằng mã trạng thái 406 trên phản hồi nếu các mã ISBN không hợp lệ.

  • GET /books

Hãy xem từng điểm cuối của 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ẵn sàng để truy vấn cơ sở dữ liệu bằng cách chuẩn bị một truy vấn. 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 tôi cũng đang trả lại danh mục sách theo từng phần gồm 10 cuốn sách.

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

Hãy phóng to phần đã 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);
            });
        }

Trong 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 các sách theo thứ tự ngày cập nhật gần nhất (lần cập nhật gần nhất sẽ xuất hiện trước). Và chúng ta cũng sẽ phân trang kết quả, bằng cách xác định giới hạn (số phần tử cần trả về) và giá trị bù trừ (điểm bắt đầu để trả về lô sách tiếp theo).

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

Hãy hoàn tất phần giải thích về điểm cuối này bằng cách tham khảo 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 dữ liệu đầu tiên, trước, tiếp theo hoặc cuối cùng (trong trường hợp này, chúng ta sẽ chỉ cung cấp dữ liệu trước và 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);

Logic ban đầu có vẻ hơi phức tạp ở đây, nhưng những gì chúng tôi đang làm là thêm liên kết trước nếu chúng tôi không ở trang dữ liệu đầu tiên. Và chúng ta sẽ thêm đường liên kết tiếp theo nếu trang dữ liệu đã đầy (tức là chứa số lượng sách tối đa như được xác định bằng hằng số PAGE_SIZE, giả sử có một số khác sắp tới 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 đề bên phải theo đúng cú pháp.

Để bạn biết thêm thông tin, tiêu đề của đườ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 có ở đây để 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 sách, trong khi phương thức còn lại truyền mã 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ì sẽ trả về từ hàm (và đặt mã trạng thái 406). Chúng tôi truy xuất các trường sách từ tải trọng được truyền trong phần nội dung của 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 ta cũng đặt tiêu đề vị trí để đưa ra tín hiệu cho ứng dụng khách của API nơi có 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 qua ISBN của 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 kiểm tra xem ISBN có hợp lệ không. Chúng ta gửi truy vấn đến Firestore để truy xuất sách này. Thuộc tính snapshot.exists rất hữu ích khi biết liệu có thực sự đã tìm thấy một cuốn sách hay không. Nếu không, chúng tôi sẽ gửi lại thông báo lỗi và mã trạng thái 404 Không tìm thấy. Chúng ta truy xuất dữ liệu sách và tạo đối tượng JSON đại diện cho sách để được 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 gần đây nhất chúng tôi cập nhật bản ghi đó. Chúng tôi sử dụng chiến lược {merge:true} để thay thế các trường hiện có bằng các 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 mới được lưu, đồng thời xoá các trường hiện có khỏi nội dung cập nhật trước đó hoặc nội dung tạo ban đầu).

Chúng ta cũng thiết lập tiêu đề Location để trỏ đến URI của sách.

  • DELETE /books/:isbn

Việc xoá sách khá đơn giản. Chúng ta chỉ gọi phương thức delete() trên tham chiếu tài liệu. Chúng tôi trả về mã trạng thái 204 vì chúng tôi không trả lại bất kỳ 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 khởi động máy chủ, nghe trên cổng 8080 theo mặc định:

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 trên máy

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

$ npm install

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

$ npm start

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

Bạn cũng có thể tạo 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 vùng chứa của ứng dụng có chạy tốt khi chúng ta xây dựng ứng dụng trên đám mây bằng Cloud Build.

Kiểm thử API

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

  • Tạo sách mới (ISBN trong phần tải trọng nội dung):
$ 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 sách mới (ISBN trong một 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á sách (sách chúng tôi đã tạo):
$ curl -XDELETE http://localhost:8080/books/9782070368228
  • Truy xuất sách theo ISBN:
$ curl http://localhost:8080/books/9780140449136
$ curl http://localhost:8080/books/9782070360536
  • Cập nhật sách hiện có bằng cách chỉ thay đổi tên sách:
$ 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 thứ 4 của sách:
$ 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 kết quả tìm kiếm.

Xây dựng và triển khai API REST trong vùng chứa

Vì chúng tôi rất vui vì API REST hoạt động theo kế hoạch, nên bây giờ là thời điểm thích hợp để triển khai API REST trong Cloud Run!

Chúng tôi sẽ thực hiện theo hai bước:

  • Trước tiên, bằng cách tạo hình ảnh vùng chứa bằng Cloud Build, qua 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 sẽ triển khai hình ảnh vùng chứa từ sổ đăng ký rồi 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 hiện đã xuất hiện trong danh sách:

f62fbca02a8127c0.png

Bước cuối cùng chúng ta sẽ thực hiện tại đây là truy xuất URL của dịch vụ Cloud Run mới triển khai bằng 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 API Cloud Run 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ữ ứng dụng web để duyệt qua thư viện

Phần cuối cùng của bài toán cần thêm vào 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 tôi. Vì mục đích đó, chúng tôi sẽ sử dụng Google App Engine, với một số mã JavaScript của ứng dụng khách sẽ gọi API qua các yêu cầu AJAX (sử dụng API Tìm nạp phía máy khách).

Mặc dù được triển khai trong thời gian chạy Node.JS App Engine, nhưng ứng dụng của chúng ta chủ yếu được tạo từ tài nguyên tĩnh! Không có nhiều mã phụ trợ, vì hầu hết hoạt động 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 tôi sẽ không sử dụng bất kỳ khung JavaScript giao diện người dùng ưa thích nào, chúng tôi sẽ chỉ sử dụng một số JavaScript "vanilla", cùng 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 Shoechain:

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

6fb9f741000a2dc1.png.

  • 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 mã vạch đại diện cho ISBN của sá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 tạo ra để duyệt qua thư viện của chúng ta sẽ có dạng như sau:

18a5117150977d6.pngS

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à tệp dành riêng cho App Engine và cho phép định cấu hình những thứ như biến môi trường, nhiều "handler" khác nhau 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 xác định rằng ứng dụng của mình là một 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. Chúng tôi cần phải 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 URL này).

Sau đó, chúng ta sẽ xác định các trình xử lý. 3 đoạn mã đầu tiên trỏ vào 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 rằng URL gốc của ứng dụng App Engine phải trỏ đến trang index.html. Bằng cách đó, chúng ta 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 (tức là phần "dynamic" 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ả).

Hãy cập nhật ngay 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 Node.JS package.json

{
    "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 nhấn mạnh một lần nữa 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 GMS isbn3 để xác thực sách Mã ISBN.

Trong các phần phụ thuộc trong quá trình phát triển, chúng ta sẽ sử dụng mô-đun nodemon để theo dõi những thay đổi đối với tệp. Mặc dù chúng ta có thể chạy ứng dụng cục bộ bằng npm start, nhưng hãy 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 ứng dụng này. Việc này sẽ 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ó các thay đổi:

$ npm run dev

Mã Node.JS index.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 nêu rõ rằng thư mục công khai chứa các tài sản tĩnh mà phần mềm trung gian static 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 tôi yêu cầu body-parser phân tích cú pháp các tải trọng JSON.

Hãy xem một số 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);
});

URL đầu tiên khớp với / sẽ chuyển hướng đến index.html trong thư mục public/html. Như ở chế độ phát triển, chúng tôi không chạy trong thời gian chạy App Engine, nên chúng tôi không thực hiện định tuyến URL của App Engine. Vì vậy, ở đây, chúng ta chỉ 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 API Cloud RUN REST. Bằng cách đó, mã JavaScript phía máy khách sẽ biết cần gọi ở đâu để lấy danh mục 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 tôi đang chạy ứng dụng web Express và nghe trên cổng 8080 theo mặc định.

Trang index.html

Chúng tôi sẽ không xem xét mọi dòng của trang HTML dài này. Thay vào đó, hãy cùng làm nổi bậ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 Chuỗi giày (tập lệnh và biểu định kiểu).

Dòng tiếp theo nhập thư viện JsBarcode để tạo mã vạch của các 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 riêng nằm trong các thư mục con public/ của chúng tôi.

Trong body của trang HTML, chúng ta sử dụng các thành phần Dây giày cùng với các thẻ phần tử tuỳ chỉnh của các thành phầ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>
...

Đồng thời, chúng tôi cũng sử dụng các mẫu HTML và khả năng lấp đầy chỗ của chúng để thể hiện một cuốn sách. Chúng tôi sẽ tạo bản sao của mẫu đó để điền danh mục sách và thay thế các giá trị trong chỗ trống 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>

Vậy là đủ HTML, chúng ta gần hoàn tất việc xem xét mã. Phần quan trọng cuối cùng còn lại: mã JavaScript phía máy khách app.js tương tác với API REST của chúng tôi.

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

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

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

Khi đã sẵn sàng, chúng ta có thể thiết lập một số biến và hằng số 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 của API REST trong mã giao diện người dùng của mình.

Chúng ta cũng xác định các biến pagelanguage mà chúng ta sẽ sử dụng để theo dõi quá trình 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, hệ thống sẽ gọi hàm appendMoreBooks().

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

Hãy cùng xem hàm tìm nạp và nối 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 để gọi API REST. Có 3 tham số truy vấn mà thông thường chúng ta có thể chỉ định, nhưng ở đây 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 API Tìm nạp để 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ó trong phản hồi hay không, chúng ta sẽ hiện hoặc ẩn nút [More books...], vì tiêu đề Link là gợi ý cho chúng ta biết liệu vẫn còn sách cần tải hay không (sẽ có 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 này, đối với mỗi cuốn sách do API REST trả về, chúng ta sẽ sao chép mẫu bằng một số thành phần web đại diện cho một cuốn sách, rồi điền thông tin chi tiết về cuốn sách vào các ô của mẫu.

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

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

Chạy và kiểm thử ứng dụng trên máy

Hiện tại, đã có đủ mã, đã đến lúc xem ứng dụng hoạt động. Trước tiên, chúng tôi sẽ triển khai cục bộ trong Cloud Shell trước khi triển khai trên thực tế.

Chúng tôi cài đặt các mô-đun TLD mà ứng dụng của chúng tôi cần với:

$ npm install

Và chúng ta chạy ứng dụng bằng cách làm như sau:

$ npm start

Hoặc bằng 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 ứng dụng từ trình duyệt tại http://localhost:8080.

Triển khai ứng dụng App Engine

Bây giờ, chúng tôi đã tự tin rằng ứng dụng của mình chạy tốt trên thiết 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 1 phút, ứng dụng sẽ được triển khai.

Ứng dụng sẽ được cung cấp tại URL có hình 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á bộ sách đã chọn bằng dấu chữ thập nhỏ trong hộp chọn để quay lại danh sách tất cả các cuốn 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 công dân tốt nói chung trên nền tảng đám mây bằng cách xoá toàn bộ dự án:

gcloud projects delete ${GOOGLE_CLOUD_PROJECT} 

11. Xin chúc mừng!

Nhờ có Cloud Functions, App Engine và Cloud Run, chúng tôi đã tạo ra một tập hợp các dịch vụ để hiển thị nhiều điểm cuối của 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, tuân theo một số mẫu thiết kế hiệu quả để phát triển API REST trong quá trình phát triển.

Nội dung đã đề cập

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

Phát triển hơn nữa

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

  • Tận dụng Cổng vào API để cung cấp giao diện API chung cho chức năng nhập dữ liệu và vùng chứa API REST, để thêm các tính năng như xử lý khoá API để truy cập API hoặc xác định giới hạn số lượng yêu cầu cho người sử 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 sân chơi kiểm thử cho API REST.
  • Trong 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 tôi đ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ập nhật dữ liệu sách được hiển thị khi có các thay đổi.