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

 




Chào mừng quay trở lại với inovationthinking. Đây là phần 2 của loạt series "Xây dựng hoàn thiện CRUD gRPC API với Node.js và Express" . Để đọc lại phần 1 các bạn truy cập vào đây để xem lại nhé.


Tạo server gRPC Node.js

Bây giờ chúng ta đã định nghĩa tất cả các trình xử lý RPC, giờ chúng ta đã sẵn sàng tạo server gRPC để lắng nghe các yêu cầu. Nhưng trước đó, hãy tạo tệp server/config/default.ts và thêm đoạn mã sau để tải các biến môi trường từ tệp .env.
yarn add dotenv
server/config/default.ts
import path from 'path';
require('dotenv').config({ path: path.join(__dirname, '../../.env') });

const customConfig: {
  port: number;
  dbUri: string;
} = {
  port: 8000,
  dbUri: process.env.DATABASE_URL as string,
};

export default customConfig;
Tiếp theo, tạo tệp server/app.ts và thêm mã sau để tải tệp Protobuf và thiết lập server gRPC Node.js.

server/app.ts
import path from 'path';
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import { ProtoGrpcType } from '../pb/services';
import {PostServiceHandlers} from "../pb/PostService"
import customConfig from './config/default';
import connectDB from './utils/prisma';
import { createPostHandler, deletePostHandler, findAllPostsHandler, findPostHandler, UpdatePostHandler } from './controllers/post.controller';

const options: protoLoader.Options = {
  keepCase: true,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
};

const PORT = customConfig.port;
const PROTO_FILE = '../proto/services.proto';
const packageDef = protoLoader.loadSync(
  path.resolve(__dirname, PROTO_FILE),
  options
);

const proto = grpc.loadPackageDefinition(
  packageDef
) as unknown as ProtoGrpcType;

const server = new grpc.Server();

// Post Services
server.addService(proto.PostService.service, {
  CreatePost: (req, res)=> createPostHandler(req,res),
  UpdatePost: (req,res)=> UpdatePostHandler(req,res),
  DeletePost: (req,res)=> deletePostHandler(req,res),
  GetPost: (req, res)=> findPostHandler(req,res),
  GetPosts: (call)=> findAllPostsHandler(call)
  
} as PostServiceHandlers)
server.bindAsync(
  `0.0.0.0:${PORT}`,
  grpc.ServerCredentials.createInsecure(),
  (err, port) => {
    if (err) {
      console.error(err);
      return;
    }
    server.start();
    connectDB();
    console.log(`? Server listening on ${port}`);
  }
);
Có khá nhiều điều đang diễn ra ở trên, hãy chia nhỏ nó ra. Đầu tiên, chúng ta sử dụng gói @grpc/proto-loader để tải tệp Protobuf.

Tiếp theo, chúng ta đã cung cấp đối tượng định nghĩa gói được trả về cho hàm LoadPackageDefinition do thư viện @grpc/grpc-js cung cấp.

Tiếp theo, chúng ta gọi phương thức grpc.Server() để khởi tạo server gRPC mới sau khi đối tượng gRPC được trả về.

Trên server, chúng ta đã gọi phương thức addService() để liệt kê tất cả các dịch vụ RPC và gọi các trình xử lý thích hợp của chúng.

Cuối cùng, chúng ta gọi phương thức bindAsync() để kết nối server với một cổng và IP được chỉ định. Vì gRPC hoạt động trên HTTP/2 nên chúng ta phải tạo một kết nối không an toàn để gRPC hoạt động trên máy chủ cục bộ của chúng ta.

Chúng ta đã hoàn tất với máy chủ gRPC của Node.js. Cài đặt dependencies này để giúp chúng ta khởi động và hot-reload server gRPC.
yarn add -D ts-node-dev
Tiếp theo, thêm các tập lệnh sau vào tệp package.json:

package.json
{
"scripts": {
    "start:server": "ts-node-dev --respawn --transpile-only server/app.ts",
    "start:client": "ts-node-dev --respawn --transpile-only client/app.ts",
    "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"
  }
}
Mở terminal của bạn và  yarn start:server để khởi động server gRPC trên cổng 8000.

Bây giờ chúng ta đã thiết lập và chạy server, chúng ta cần tạo ứng dụng client gRPC hiểu giao diện Protobuf mà máy chủ phục vụ.

Tạo gRPC client với Node.js

Bây giờ hãy xây dựng ứng dụng client, bắt đầu bằng cách cài đặt các dependencies sau:
yarn add zod express && yarn add -D morgan @types/express @types/morgan
  • zod – Thư viện xác thực schema của TypeScript. 
  • express – một framework web Node.js. 
  • morgan – Thư viện này sẽ ghi lại các yêu cầu HTTP vào console.
Tạo các Valdidation schema với Zod

Tiếp theo, tạo các schema Zod sau sẽ được cung cấp cho Framework web Express để xác thực request body đến. Điều này sẽ đảm bảo rằng các trường bắt buộc được cung cấp trong request payload.

client/schema/post.schema.ts
import { z } from 'zod';

export const createPostSchema = z.object({
  body: z.object({
    title: z.string({
      required_error: 'Title is required',
    }),
    content: z.string({
      required_error: 'Content is required',
    }),
    category: z.string({
      required_error: 'Category is required',
    }),
    published: z.boolean({
      required_error: 'Published is required',
    }),
    image: z.string({
      required_error: 'Image is required',
    }),
  }),
});

const params = {
  params: z.object({
    postId: z.string(),
  }),
};

export const getPostSchema = z.object({
  ...params,
});

export const updatePostSchema = z.object({
  ...params,
  body: z
    .object({
      title: z.string(),
      content: z.string(),
      category: z.string(),
      published: z.boolean(),
      image: z.string(),
    })
    .partial(),
});

export const deletePostSchema = z.object({
  ...params,
});

export type CreatePostInput = z.TypeOf<typeof createPostSchema>['body'];
export type GetPostInput = z.TypeOf<typeof getPostSchema>['params'];
export type UpdatePostInput = z.TypeOf<typeof updatePostSchema>;
export type DeletePostInput = z.TypeOf<typeof deletePostSchema>['params'];
Tạo một Middleware để xác thực các request

Bây giờ, hãy tạo file client/middleware/validate.ts và thêm middleware Zod sau đây để chấp nhận lược đồ đã được định nghĩa làm đối số và xác thực yêu cầu trước khi trả về lỗi xác thực thích hợp cho client.

client/middleware/validate.ts
import { z } from 'zod';
import {Request, Response, NextFunction} from "express"

const validate =
  (schema: z.AnyZodObject) =>
  async (req: Request,res: Response, next: NextFunction): Promise<any> => {
    try {
      schema.parse({
        params:req.params,
        query: req.query,
        body: req.body,
      });

      next();
    } catch (err: any) {
      if (err instanceof z.ZodError) {
        return res.status(400).json({
          status: "fail",
          message: err.errors
        })
    
      }
      return res.status(500).json({
          status: "error",
          message: err.message
        })
    }
  };
export default validate;
Set up và Export các gRPC Client

Bây giờ chúng tôi đã sẵn sàng tạo ứng dụng client gRPC để xử lý giao tiếp với API gRPC của Node.js.Tạo file client/client.ts và thêm đoạn mã bên dưới:

client/client.ts
import path from 'path';
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import { ProtoGrpcType } from '../pb/services';

const options: protoLoader.Options = {
  keepCase: true,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
};

const PROTO_FILE = '../proto/services.proto';
const packageDef = protoLoader.loadSync(
  path.resolve(__dirname, PROTO_FILE),
  options
);

export const proto = grpc.loadPackageDefinition(
  packageDef
) as unknown as ProtoGrpcType;
Các đoạn mã trên tương tự như file server/app.ts vì các đối tượng gRPC giống nhau xử lý các phiên bản client và server. Tất cả những gì chúng ta đã làm là export đối tượng proto gRPC để sử dụng trong các file khác.

Set up Express Server cho client

Bây giờ chúng ta đã có đối tượng proto gRPC, hãy tạo một service mới trên cùng IP và cổng mà server đang chạy.Để sử dụng service này, chúng ta cần tạo service Express và thêm tất cả các trình xử lý router CRUD.

client/app.ts
import * as grpc from '@grpc/grpc-js';
import customConfig from '../server/config/default';
import { proto } from "./client";
import express, { Request, Response } from "express"
import morgan from 'morgan';
import validate from './middleware/validate';
import { CreatePostInput, createPostSchema, DeletePostInput, GetPostInput, UpdatePostInput, updatePostSchema } from './schema/post.schema';
import { Post } from '@prisma/client';

const client = new proto.PostService(
  `0.0.0.0:${customConfig.port}`,
  grpc.credentials.createInsecure()
);
const deadline = new Date();
deadline.setSeconds(deadline.getSeconds() + 1);
client.waitForReady(deadline, (err) => {
  if (err) {
    console.error(err);
    return;
  }
  onClientReady();
});

function onClientReady() {
  console.log("? gRPC Client is ready")
}

const app = express()
app.use(express.json())
app.use(morgan('dev'))
app.post("/api/posts", validate(createPostSchema), async (req: Request<{},{},CreatePostInput>, res: Response)=>{
const {title,image,category,content,published} = req.body
  client.CreatePost(
    {
      title,
      content,
      category,
      image,
      published
    },
    (err, data) => {
      if (err) {
        return res.status(400).json({
          status: "fail",
          message: err.message
        })
      }
      return res.status(201).json({
          status: "success",
          post: data?.post
        })
    }
  );
})

app.patch("/api/posts/:postId", validate(updatePostSchema), async (req: Request<UpdatePostInput['params'],{},UpdatePostInput['body']>, res: Response)=>{
const {title,image,category,content,published} = req.body
  client.UpdatePost(
    {
      id: req.params.postId,
      title,
      content,
      category,
      image,
      published
    },
    (err, data) => {
      if (err) {
        return res.status(400).json({
          status: "fail",
          message: err.message
        })
      }
      return res.status(200).json({
          status: "success",
          post: data?.post
        })
    }
  );
})

app.get("/api/posts/:postId", async (req: Request<GetPostInput>, res: Response)=>{
  client.GetPost(
    {
      id: req.params.postId,
    },
    (err, data) => {
      if (err) {
        return res.status(400).json({
          status: "fail",
          message: err.message
        })
      }
      return res.status(200).json({
          status: "success",
          post: data?.post
        })
    }
  );
})

app.delete("/api/posts/:postId", async (req: Request<DeletePostInput>, res: Response)=>{
  client.DeletePost(
    {
      id: req.params.postId,
    },
    (err, data) => {
      if (err) {
        return res.status(400).json({
          status: "fail",
          message: err.message
        })
      }
      return res.status(204).json({
          status: "success",
          data: null
        })
    }
  );
})

app.get("/api/posts", async (req: Request, res: Response)=>{
  const limit = parseInt(req.query.limit as string) || 10
  const page = parseInt(req.query.page as string) || 1
  const posts: Post[] = []

  const stream = client.GetPosts({page, limit})
  stream.on("data", (data: Post)=> {
    posts.push(data)
  })

  stream.on("end", ()=> {
    console.log("? Communication ended")
    res.status(200).json({
          status: "success",
          results: posts.length,
          posts
          
        })
  })

  stream.on("error", (err)=> {
    res.status(500).json({
          status: "error",
          message: err.message
        })
  })
})

const port = 8080
app.listen(port, ()=>{
  console.log("? Express client started successfully on port: "+ port)
})
Trong mỗi HTTP CRUD controller, chúng ta đã gọi từng action được cung cấp bởi interface contract để thực hiện request đến server

Vì server gRPC đã chạy trên cổng 8000 nên chúng ta đã định cấu hình server Express để chạy trên cổng 8080.

Bây giờ hãy mở 2 terminal ở project của bạn, chạy lệnh yarn start:server để khởi động server gRPC và yarn start:client để khởi động client gRPC và server Express.Đảm bảo rằng PostgreSQL Docker container đang hoạt động.

Testing

1. Mở ứng dụng postman lên và chạy thử api create post

METHOD: POST
URL: localhost:8080/api/posts
BODY:
{
    "title": "My second post demo",
    "content": "My second content",
    "category": "node.js",
    "published": false,
    "image": "test.png"
}







2. Api update post theo id

METHOD: PATCH
URL: localhost:8080/api/posts/:postId
BODY:
{
  "title": "My second post demo update",
  "content": "My second content update",
  "category": "Node.js",
  "published": true,
  "image": "demo2.png"
}



3. Api get 1 bản ghi Post theo id

METHOD: GET
URL: localhost:8080/api/posts/:postId


4. Api delete 1 bản ghi post theo id

METHOD: DELETE
URL: localhost:8080/api/posts/:postId





5. Api lấy tất danh sách bản ghi Post

METHOD: GET
URL: localhost:8080/api/posts



Tổng kết

Trong bài viết này, bạn đã học cách xây dựng máy chủ và máy khách gRPC bằng TypeScript trong môi trường Node.js sử dụng cơ sở dữ liệu Postgres. Nhưng có rất nhiều thứ bạn có thể làm với gRPC.

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