Xây dựng hoàn thiện CRUD gRPC API với Node.js và Express (Phần 1)


Trong hành trình phát triển ứng dụng, việc tạo và quản lý API là một phần quan trọng giúp tương tác với cơ sở dữ liệu và các thành phần khác của hệ thống. Trong loạt bài viết này, chúng ta sẽ bước vào một chủ đề sâu hơn của việc xây dựng ứng dụng - sử dụng gRPC API để thực hiện các thao tác CRUD (Create, Read, Update, Delete) trên dữ liệu, và tất cả được thực hiện bằng sức mạnh của Node.js và framework Express.
Điều kiện tiên quyết
Software
  • Kiến thức cơ bản về Node.js và PostgreSQL
  • Hiểu biết  về Prisma và cách sử dụng ORM và pgAdmin. 
  • Đã cài đặt Node.js và Docker
VS Code Extensions
  • DotENV – Nhận đánh dấu cú pháp cho bất kỳ tệp biến môi trường nào có phần mở rộng .env. 
  • Proto3 – Để làm nổi bật cú pháp, xác thực cú pháp, nhận xét dòng và khối, biên dịch, đoạn mã, hoàn thành mã và định dạng mã cho tất cả các tệp kết thúc bằng phần mở rộng .proto. 
Tiện ích mở rộng Proto3 của VS Code sử dụng Protocol buffer compiler được cài đặt ở OS, vì vậy bạn cần cài đặt Protocol buffer compiler.

Bạn truy cập trang web gRPC chính thức để cài đặt Protocol buffer compiler cần thiết cho hệ điều hành của mình.

Theo mặc định, Protocol buffer compiler tìm kiếm các tệp proto trong thư mục gốc và chúng ta cần cho nó biết vị trí của các tệp proto theo cách thủ công. 

Để làm điều đó, hãy mở trang setting trong VS Code và tìm kiếm Proto3. Tiếp theo, nhấp vào liên kết Edit  trong settings.json và thêm tùy chọn này vào cấu hình Protoc.

{
"protoc": {
  "options": ["--proto_path=proto"]
  }
}
Thiết lập PostgreSQL và Redis Server
Mở terminal lên và tạo project `node-grpc-prisma`
$ mkdir node-grpc-prisma
Trong thư root của dự án, tạo tệp docker-compose.yml và thêm đoạn mã sau để định cấu hình image Redis và PostgreSQL.

Nếu các bạn đã đọc về series về "Xây dựng gRPC API với Node.js và Express" thì sẽ thấy project đã được tạo rồi, nếu thế các bạn có thể tạo nhánh mới và clean hết code cũ rồi cài đặt theo loạt bài viết này.

Ở trong bài viết này tôi sử dụng PostgressSQL database, tuy nhiên bạn cũng có thể sử dụng bất kỳ một database server nào được prisma hỗ trợ.

Tiếp theo chúng ta tạo file docker-composer.yml để setup docker cho dự án.

docker-compose.yml
version: '3'
services:
  postgres:
    image: postgres
    container_name: postgres-grpc
    ports:
      - '6500:5432'
    restart: always
    env_file:
      - ./.env
    volumes:
      - postgres-db:/var/lib/postgresql/data
  redis:
    image: redis:latest
    container_name: redis-grpc
    ports:
      - '7001:6379'
    volumes:
      - redis:/data
volumes:
  postgres-db:
  redis:
Tạo file .env để lưu thông tin cài đặt của postgres
.env
DATABASE_PORT=6500
POSTGRES_PASSWORD=password123
POSTGRES_USER=postgres
POSTGRES_DB=grpc-node-prisma
POSTGRES_HOST=postgres
POSTGRES_HOSTNAME=127.0.0.1
Tạo file .gitignore để không public các file nhạy cảm trước khi đẩy lên git
.gitignore
node_modules
.env
Bây giờ khởi động các container postgres và redis bằng lệnh sau
docker-compose up -d
Để stop các container postgres và redis bạn chạy lệnh sau
docker-compose down
Sau khi chạy thành công các bạn có thể thấy thông báo như sau

Tiếp theo chúng ta sẽ cài đặt các gói dependencies để có thể làm việc với TypeScript

yarn init -y && yarn add -D typescript && yarn tsc --init
Tạo các gRPC Protocol Buffer Messages

Trước khi có thể bắt đầu sử dụng gRPC, chúng ta cần tạo các tệp Protocol Buffer để liệt kê các dịch vụ và phương thức RPC bằng protocol buffer language.

Một phương pháp hay nhất là giữ tất cả các tệp Protocol buffer trong một thư mục duy nhất để làm cho project của bạn gọn gàng hơn và cho phép bạn dễ dàng tạo các lớp stub hoặc interface server và client gRPC.
  1. Lớp stub gRPC: Lớp stub là một thành phần trung gian được tạo tự động bởi trình biên dịch Protocol buffer từ tệp proto. Trong gRPC, lớp stub trên máy khách được sử dụng để gọi các phương thức từ máy khách đến máy chủ. Stub đóng vai trò như một giao diện dễ sử dụng giữa mã máy khách và máy chủ gRPC. Nó ẩn đi các chi tiết phức tạp của việc gửi và nhận dữ liệu qua mạng, cho phép người lập trình dễ dàng tương tác với máy chủ gRPC một cách trừu tượng.
  2. Giao diện (Interface) gRPC: Giao diện hoặc interface là một tập hợp các phương thức mà máy chủ gRPC cung cấp và máy khách gRPC có thể gọi. Giao diện gRPC được xác định trong tệp proto bằng cách định nghĩa một service (dịch vụ) và các phương thức trong service đó. Giao diện định nghĩa các phương thức gRPC mà máy khách có thể sử dụng để gửi yêu cầu và nhận phản hồi từ máy chủ. Sau khi đã xác định giao diện trong tệp proto, trình biên dịch Protocol buffer sẽ tạo ra lớp stub tương ứng trên máy khách và máy chủ để triển khai các phương thức trong giao diện.

Bây giờ hãy tạo một thư mục "proto" trong thư mục gốc. Thư mục này sẽ chứa tất cả các tệp Protocol buffer.

Định nghĩa gRPC Post Protocol Buffer Message

Để định nghĩa một Protocol buffer message, chúng ta chỉ định phiên bản của ngôn ngữ Protocol buffer ở đầu tệp proto, trong ví dụ của chúng ta là ngôn ngữ Protobuf phiên bản 3.

Tiếp theo, chúng ta xác định tên gói (package) bằng cách sử dụng từ khóa package được cung cấp bởi ngôn ngữ proto3.

Tiếp theo, tạo một file proto/post.proto và thêm Protobuf messages sau:
syntax = "proto3";

import "google/protobuf/timestamp.proto";

message Post {
  string id = 1;
  string title = 2;
  string content = 3;
  string category = 4;
  string image = 5;
  bool published = 6;
  google.protobuf.Timestamp created_at = 7;
  google.protobuf.Timestamp updated_at = 8;
}

message PostResponse { Post post = 1; }
Bây giờ để định nghĩa trường (cặp tên/giá trị) của một thông điệp giao thức buffer, chúng ta chỉ định loại trường, tiếp theo là tên trường và một định danh duy nhất sẽ được sử dụng bởi framework gRPC để dễ dàng xác định các trường trong định dạng nhị phân của thông điệp.

Các số trường nằm trong khoảng từ 1 đến 15 chỉ cần một byte để mã hóa, trong khi các số trong khoảng từ 16 đến 2047 cần hai byte để mã hóa.

Bạn có thể tìm hiểu thêm về cách các trường được mã hóa trên trang web Protocol Buffer Encoding.

Tiếp theo chúng ta tạo file rpc_create_post.proto

proto/rpc_create_post.proto
syntax = "proto3";

package post;

message CreatePostRequest {
  string title = 1;
  string content = 2;
  string category = 3;
  string image = 4;
  bool published = 5;
}
Tạo các gRPC Services

Bây giờ chúng ta đã định nghĩa được các Protobuf messages, hãy tạo một tệp proto/services.proto để chứa các định nghĩa RPC.

Vì chúng ta có một số Protobuf messages được định nghĩa trong các tệp khác nên chúng ta sẽ sử dụng từ khóa import để import các tệp đó.

Nói qua về cơ chế "stream" trong gRPC. Cơ chế "stream" trong gRPC liên quan đến việc truyền dữ liệu qua mạng trong một luồng liên tục, cho phép trao đổi dữ liệu theo thời gian thực giữa máy khách và máy chủ. Trong gRPC, cơ chế stream được sử dụng để xử lý các tình huống mà dữ liệu có thể được gửi và nhận một cách liên tục, không cần phải chờ đợi cho đến khi toàn bộ dữ liệu được gửi hoặc nhận.

Trong tình huống gRPC, có hai loại cơ chế stream:
  1. Server Streaming (Máy chủ gửi nhiều tin nhắn): Trong trường hợp này, máy khách gửi một yêu cầu đến máy chủ và máy chủ trả về một chuỗi các tin nhắn. Máy khách có thể đọc các tin nhắn này trong khi máy chủ tiếp tục gửi chúng. Điều này hữu ích khi máy chủ cần gửi nhiều kết quả liên quan đến một yêu cầu duy nhất.
  2. Client Streaming (Máy khách gửi nhiều tin nhắn): Trong trường hợp này, máy khách gửi một chuỗi các tin nhắn đến máy chủ. Máy chủ nhận và xử lý các tin nhắn này trong khi máy khách có thể tiếp tục gửi thêm. Điều này thường được sử dụng khi máy khách cần gửi dữ liệu lớn hoặc dữ liệu phát sinh theo thời gian.
proto/services.proto
syntax = "proto3";

import "post.proto";
import "rpc_create_post.proto";
import "rpc_update_post.proto";

// Post Service
service PostService {
  rpc CreatePost(CreatePostRequest) returns (PostResponse) {}
  rpc GetPost(PostRequest) returns (PostResponse) {}
  rpc GetPosts(GetPostsRequest) returns (stream Post) {}
  rpc UpdatePost(UpdatePostRequest) returns (PostResponse) {}
  rpc DeletePost(PostRequest) returns (DeletePostResponse) {}
}

message GetPostsRequest {
  int64 page = 1;
  int64 limit = 2;
}

message PostRequest { string id = 1; }

message DeletePostResponse { bool success = 1; }
  • CreatePost - Phương thức RPC một chiều này sẽ được gọi để thêm một bản ghi mới vào cơ sở dữ liệu.
  • GetPost - Service RPC một chiều này sẽ được gọi để trả về một bản ghi duy nhất từ cơ sở dữ liệu.
  • GetPosts - Service RPC streamming từ server này sẽ được gọi để trả về một luồng các bản ghi được tìm thấy.
  • UpdatePost - Service RPC một chiều này sẽ được gọi để cập nhật một bản ghi trong cơ sở dữ liệu.
  • DeletePost - Phương thức RPC một chiều này sẽ được gọi để xóa một bản ghi khỏi cơ sở dữ liệu.
Trước khi có thể sử dụng gRPC trong Node.js, chúng ta cần cài đặt hai dependencies này để giúp chúng ta tải các tệp Protobuf và thiết lập server và client gRPC.
yarn add @grpc/grpc-js @grpc/proto-loader
  • @grpc/grpc-js – Thư viện này chứa các gRPC implementation cho Nodejs.
  • @grpc/proto-loader – Thư viện này chứa các hàm hỗ trợ để tải các tệp .proto.
Tiếp theo, hãy tạo một tập lệnh bash để giúp chúng ta tạo các tệp TypeScript từ các file Protobuf đã định nghĩa.

proto-gen.sh
#!/bin/bash

rm -rf pb/
yarn proto-loader-gen-types --longs=String --enums=String --defaults --keepCase --oneofs --grpcLib=@grpc/grpc-js --outDir=pb/ proto/*.proto
Nếu có bất kỳ trường hợp nào, các file TypeScript không được tạo thì hãy chạy các lệnh riêng lẻ trực tiếp trong terminal.

Khi bạn chạy tập lệnh trên, gói @grpc/proto-loader sẽ tạo các tệp TypeScript và xuất kết quả vào thư mục pb/.

Bây giờ hãy chạy tập lệnh bash để tạo các tệp TypeScript bằng lệnh này:
./proto-gen.sh
Tạo model dữ liệu API với Prisma

Prisma về cơ bản là ORM cho TypeScript và Node.js. Code trong dự án này có thể được điều chỉnh để hoạt động với bất kỳ database server nào được Prisma hỗ trợ.

Trước tiên, hãy cài đặt Prisma CLI và Client:
yarn add -D prisma && yarn add @prisma/client
Bây giờ hãy chạy lệnh init Prisma với cờ --datasource-provider để tạo file lược đồ.

mkdir server && cd server && yarn prisma init --datasource-provider postgresql
Lệnh trên sẽ tạo các tệp prisma/schema.prisma , .gitignore và .env trong thư mục máy chủ.

Nếu thư mực Prisma không tự động xuất hiện trong thư mục server chúng ta có thể di chuyển thư mục Prisma vào trong thư mục server.

Tiếp theo, thêm URL kết nối PostgreSQL vào tệp .env trong thư mục gốc. DATABASE_URL sẽ được Prisma sử dụng để tạo kết nối giữa ứng dụng Node.js và máy chủ Postgres.
DATABASE_PORT=6500
POSTGRES_PASSWORD=password123
POSTGRES_USER=postgres
POSTGRES_DB=grpc-node-prisma
POSTGRES_HOST=postgres
POSTGRES_HOSTNAME=127.0.0.1

DATABASE_URL="postgresql://postgres:password123@localhost:6500/grpc-node-prisma?schema=public"
Bây giờ hãy xác định cấu trúc dữ liệu API với Prisma. Mở tệp server/prisma/schema.prisma và thêm định nghĩa schema sau:
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Post {
  id         String   @id @default(uuid())
  title      String   @unique @db.VarChar(255)
  content    String
  category   String
  image      String
  published  Boolean  @default(false)
  created_at DateTime @default(now())
  updated_at DateTime @updatedAt

  @@map(name: "posts")
}
Ở phần trên, chúng ta đã xác định một model Post sẽ được công cụ Prisma chuyển đổi thành bảng SQL.

Thêm các tập lệnh sau vào tệp package.json:
{
"scripts": {
    "db:migrate": "npx prisma migrate dev --name post-entity --create-only --schema ./server/prisma/schema.prisma",
    "db:generate": " npx prisma generate --schema ./server/prisma/schema.prisma",
    "db:push": "npx prisma db push --schema ./server/prisma/schema.prisma"
  },
}
  • db:migrate – Lệnh này sẽ tạo các file database migration mà không áp dụng nó. 
  • db:generate – Lệnh này sẽ generate các Prisma client.
  • db:push – Lệnh này sẽ đẩy các migration file vào cơ sở dữ liệu và giữ cho schema Prisma đồng bộ với schema cơ sở dữ liệu.
Như đã nói, hãy chạy lệnh này để tạo migration file và đẩy các thay đổi vào cơ sở dữ liệu.
Đảm bảo rằng PostgreSQL Docker container đang chạy
yarn db:migrate && yarn db:generate && yarn db:push
Mở bất kỳ ứng dụng Postgres client nào và đăng nhập bằng thông tin xác thực được cung cấp trong tệp .env để xem bảng SQL được Prisma CLI thêm vào.


Tiếp theo, hãy tạo một hàm để tạo kết nối giữa máy chủ Node.js và máy chủ PostgreSQL. Chức năng này cũng sẽ xuất Prisma Client để sử dụng trong bộ điều khiển RPC.

Tạo tệp server/utils/prisma.ts và thêm mã sau:

server/utils/prisma.ts
import { PrismaClient } from '@prisma/client';

declare global {
  var prisma: PrismaClient | undefined;
}

export const prisma = global.prisma || new PrismaClient();

if (process.env.NODE_ENV !== 'production') {
  global.prisma = prisma;
}

async function connectDB() {
  try {
    await prisma.$connect();
    console.log('? Database connected successfully');
  } catch (error) {
    console.log(error);
    process.exit(1);
  } finally {
    await prisma.$disconnect();
  }
}

export default connectDB;
Trong mọi kiến ​​trúc API, tôi luôn khuyến nghị tạo các service để truy cập và thay đổi cơ sở dữ liệu thay vì thực hiện việc đó trực tiếp trong controllers.

Để thực hiện việc này, hãy tạo tệp server/services/post.service.ts và thêm các định nghĩa service sau:

server/services/post.service.ts
import { Prisma, Post } from '@prisma/client';
import { prisma } from '../utils/prisma';

export const createPost = async (input: Prisma.PostCreateInput) => {
  return (await prisma.post.create({
    data: input,
  })) as Post;
};

export const findPost = async (
  where: Partial<Prisma.PostWhereInput>,
  select?: Prisma.PostSelect
) => {
  return (await prisma.post.findFirst({
    where,
    select,
  })) as Post;
};

export const findUniquePost = async (
  where: Prisma.PostWhereUniqueInput,
  select?: Prisma.PostSelect
) => {
  return (await prisma.post.findUnique({
    where,
    select,
  })) as Post;
};

export const findAllPosts = async (
  {page, limit, select}:
 { page: number,
  limit: number,
  select?: Prisma.PostSelect},
) => {
  const take = limit || 10;
  const skip = (page - 1 ) * limit
  return (await prisma.post.findMany({
    select,
    skip,
    take,
  })) as Post[];
};


export const updatePost = async (
  where: Partial<Prisma.PostWhereUniqueInput>,
  data: Prisma.PostUpdateInput,
  select?: Prisma.PostSelect
) => {
  return (await prisma.post.update({ where, data, select })) as Post;
};

export const deletePost = async (where: Prisma.PostWhereUniqueInput)=> {
  return await prisma.post.delete({where})
}
Tạo một trình xử lý RPC thêm bản ghi mới 

Trình xử lý RPC này sẽ gọi service createPost mà chúng ta đã định nghĩa ở trên để thêm thực thể mới vào cơ sở dữ liệu. Vì chúng ta đã thêm một ràng buộc duy nhất vào trường tiêu đề nên Prisma sẽ trả về một lỗi trùng lặp nếu bản ghi có tiêu đề đó đã tồn tại.

server/controllers/post.controller.ts
// [...] imports

// [...] Create a New Record RPC Handler
export const createPostHandler = async (
  req: grpc.ServerUnaryCall<CreatePostRequest__Output, PostResponse>,
  res: grpc.sendUnaryData<PostResponse>
) => {
  try {
   const post = await createPost({
    title: req.request.title,
    content: req.request.content,
    image: req.request.image,
    category: req.request.category,
    published: true,
   })

   res(null, {
    post: {
      id: post.id,
      title: post.title,
      content: post.content,
      image: post.image,
      published: post.published,
      created_at: {
          seconds: post.created_at.getTime() / 1000,
        },
        updated_at: {
          seconds: post.updated_at.getTime() / 1000,
        },
    }
   })
  } catch (err: any) {
    if (err.code === 'P2002') {
      res({
        code: grpc.status.ALREADY_EXISTS,
        message: 'Post with that title already exists',
      });
    }
    res({
      code: grpc.status.INTERNAL,
      message: err.message,
    });
  }
};

Trình xử lý RPC Update bản ghi

Ở đây, chúng ta sẽ get tham số ID từ request và truy vấn cơ sở dữ liệu để kiểm tra xem bản ghi có ID đó có tồn tại hay không.

Tiếp theo, chúng ta sẽ call updatePost service để update bản ghi nếu nó tồn tại trong cơ sở dữ liệu

server/controllers/post.controller.ts
// [...] imports

// [...] Create a New Record RPC Handler

// [...] Update Record RPC Handler
export const UpdatePostHandler = async (req: grpc.ServerUnaryCall<UpdatePostRequest__Output, PostResponse>, res: grpc.sendUnaryData<PostResponse>)=> {
try {
 const postExists =  await findPost({id: req.request.id})

  if (!postExists) {
      res({
        code: grpc.status.NOT_FOUND,
        message: 'No post with that ID exists',
      });
      return
    }
  const updatedPost = await updatePost({id: req.request.id},{
    title: req.request.title,
    content: req.request.content,
    category: req.request.category,
    image: req.request.image,
    published: req.request.published,
  })

  res(null, {
    post: {
      id: updatedPost.id,
      title: updatedPost.title,
      content: updatedPost.content,
      image: updatedPost.image,
      published: updatedPost.published,
      created_at: {
          seconds: updatedPost.created_at.getTime() / 1000,
        },
        updated_at: {
          seconds: updatedPost.updated_at.getTime() / 1000,
        },
    }
  })
} catch (err: any) {
  res({
      code: grpc.status.INTERNAL,
      message: err.message,
    });
}
}

Trình xử lý RPC truy xuất tất cả bản ghi

Trình xử lý RPC này chịu trách nhiệm truyền danh sách các bản ghi đến client gRPC. Đầu tiên, chúng ta sẽ gọi dịch vụ findAllPosts để trả về danh sách các bản ghi được phân trang.

Tiếp theo, chúng ta sẽ lặp qua các kết quả và gọi phương thức call.write() để ghi dữ liệu vào stream.

Sau khi tất cả các bản ghi tìm thấy đã được ghi vào luồng, chúng ta sẽ gọi phương thức call.end() để đóng stream.

server/controllers/post.controller.ts
// [...] imports

// [...] Create a New Record RPC Handler

// [...] Update Record RPC Handler

// [...] Retrieve a Single Record RPC Handler

// [...] Delete a Record RPC Handler

// [...] Retrieve all Records RPC Handler
export const findAllPostsHandler = async (call: grpc.ServerWritableStream<GetPostsRequest__Output, Post>)=>{
  try {
    const {page, limit} = call.request
    const posts = await findAllPosts({page: parseInt(page), limit: parseInt(limit)})

    for(let i= 0; i < posts.length; i++){
      const post = posts[i]
      call.write({
        id: post.id,
        title: post.title,
        content: post.content,
        category: post.category,
        image: post.image,
        published: post.published,
        created_at: {
          seconds: post.created_at.getTime() / 1000,
        },
        updated_at: {
          seconds: post.updated_at.getTime() / 1000,
        },

      })
    }
    call.end()
    
  } catch (error: any) {
    console.log(error)
  }
}
Bài viết cũng khá dài rồi nên tôi xin tạm dừng bài viết tại đây các bạn có thể xem phần 2 tại đây.

Nhận xét

Bài đăng phổ biến từ blog này

Cài đặt SSL cho website sử dụng certbot

Xây dựng một hệ thống comment real-time hoặc chat đơn giản sử dụng Pusher

CÁC BÀI TẬP SQL CƠ BẢN - PART 1

Xây dựng một hệ thống tracking hành vi người dùng (phần 1)

Xây dựng một hệ thống tracking hành vi người dùng (phần 2)

Enterprise architecture trên 1 tờ A4

Web caching (P2)

Bàn về async/await trong vòng lặp javascript

Web caching (P1)

Cài đặt môi trường để code website Rails