Skip to main content

基本 CRUD 应用

¥Basic CRUD application

虽然对基本 CRUD 应用使用 Socket.IO(或普通 WebSockets)可能听起来有点大材小用,但轻松通知所有用户的能力确实很强大。

¥While using Socket.IO (or plain WebSockets) for a basic CRUD application might sound a bit overkill, the ability to easily notify all users is really powerful.

在本指南中,我们将基于出色的 TodoMVC 项目 创建一个基本的 CRUD(代表创建/读取/更新/删除)应用:

¥In this guide we will create a basic CRUD (standing for Create/Read/Update/Delete) application, based on the awesome TodoMVC project:

Video of the application in action

我们将讨论以下主题:

¥We will cover the following topics:

开始吧!

¥Let's start!

安装

¥Installation

代码可以在主存储库的 examples 目录中找到:

¥The code can be found in the examples directory of the main repository:

git clone https://github.com/socketio/socket.io.git
cd socket.io/examples/basic-crud-application/

你应该看到两个目录:

¥You should see two directories:

  • server/:服务器实现

    ¥server/: the server implementation

  • angular-client/:基于 Angular 的客户端实现

    ¥angular-client/: a client implementation based on Angular

  • vue-client/:基于 Vue 的客户端实现

    ¥vue-client/: a client implementation based on Vue

运行前端

¥Running the frontend

该项目是一个使用 角度 CLI 创建的基本 Angular 应用。

¥The project is a basic Angular application which was created with the Angular CLI.

运行它:

¥To run it:

cd angular-client
npm install
npm start

然后,如果你在浏览器中打开 http://localhost:4200,你应该看到:

¥Then if you open http://localhost:4200 in your browser, you should see:

Screenshot of the application

到目前为止,一切都很好。

¥So far, so good.

运行服务器

¥Running the server

现在让我们关注服务器:

¥Let's focus on the server now:

cd ../server
npm install
npm start

你现在可以打开多个选项卡,并且待办事项列表应该神奇地在它们之间同步:

¥You can now open several tabs, and the list of todos should magically be synced between them:

Video of the application in action

工作原理

¥How it works

服务器结构

¥Server structure

├── lib
│ ├── index.ts
│ ├── app.ts
│ ├── todo-management
│ │ ├── todo.handlers.ts
│ | └── todo.repository.ts
│ └── util.ts
├── package.json
├── test
│ └── todo-management
│ └── todo.tests.ts
└── tsconfig.json

让我们详细说明每个文件的职责:

¥Let's detail the duty of each file:

  • index.ts:创建组件并初始化应用的服务器的入口点

    ¥index.ts: the entrypoint of the server which creates the components and initializes the application

  • app.ts:应用本身,其中创建 Socket.IO 服务器并注册处理程序

    ¥app.ts: the application itself, where the Socket.IO server is created, and the handlers are registered

  • todo.handlers.ts:Todo 实体上的操作处理程序

    ¥todo.handlers.ts: the handlers of the operations on the Todo entities

  • todo.repository.ts:用于从数据库中保存/检索 Todo 实体的存储库

    ¥todo.repository.ts: the repository for persisting/retrieving the Todo entities from the database

  • util.ts:项目中使用的一些常用的实用方法

    ¥util.ts: some common utility methods that are used in the project

  • todo.tests.ts:集成测试

    ¥todo.tests.ts: the integration tests

初始化

¥Initialization

首先我们关注一下 lib/app.ts 文件中的 createApplication 方法:

¥First, let's focus on the createApplication method in the lib/app.ts file:

const io = new Server<ClientEvents, ServerEvents>(httpServer, serverOptions);

我们使用以下选项创建 Socket.IO 服务器:

¥We create the Socket.IO server with the following options:

{
cors: {
origin: ["http://localhost:4200"]
}
}

因此,允许在 http://localhost:4200 上提供服务的前端应用进行连接。

¥So the frontend application, which is served at http://localhost:4200, is allowed to connect.

文档:

¥Documentation:

<ClientEvents, ServerEvents> 部分特定于 TypeScript 用户。它允许显式指定服务器和客户端之间交换的事件,以便你获得自动补齐和类型检查:

¥The <ClientEvents, ServerEvents> part is specific to TypeScript users. It allows to explicitly specify the events that are exchanged between the server and the client, so you get autocompletion and type checking:

Screenshot of the IDE autocompletion

Screenshot of the IDE type checking

回到我们的应用!然后我们通过注入应用组件来创建处理程序:

¥Back to our application! We then create our handlers by injecting the application components:

const {
createTodo,
readTodo,
updateTodo,
deleteTodo,
listTodo,
} = createTodoHandlers(components);

我们注册它们:

¥And we register them:

io.on("connection", (socket) => {
socket.on("todo:create", createTodo);
socket.on("todo:read", readTodo);
socket.on("todo:update", updateTodo);
socket.on("todo:delete", deleteTodo);
socket.on("todo:list", listTodo);
});

文档:监听事件

¥Documentation: Listening to events

注意:事件后缀(:create:read...)替换 REST API 中常见的 HTTP 动词:

¥Note: the event suffixes (:create, :read, ...) replace the usual HTTP verbs in a REST API:

  • POST /todos => todo:create

  • GET /todos/:id => todo:read

  • PUT /todos/:id => todo:update

  • ...

事件处理程序

¥Event handler

现在让我们关注 lib/todo-management/todo.handlers.ts 文件中的 createTodo 处理程序:

¥Let's focus on the createTodo handler now, in the lib/todo-management/todo.handlers.ts file:

首先,我们检索 Socket 实例:

¥First, we retrieve the Socket instance:

createTodo: async function (
payload: Todo,
callback: (res: Response<TodoID>) => void
) {
const socket: Socket<ClientEvents, ServerEvents> = this;
// ...
}

请注意,使用箭头函数 (createTodo: async () => {}) 在这里不起作用,因为 this 不会指向 Socket 实例。

¥Please note that using an arrow function (createTodo: async () => {}) wouldn't work here, since the this wouldn't point to the Socket instance.

然后,借助出色的 joi 库,我们验证了有效负载:

¥Then, we validate the payload thanks to the great joi library:

const { error, value } = todoSchema.tailor("create").validate(payload, {
abortEarly: false, // return all errors and not just the first one
stripUnknown: true, // remove unknown attributes from the payload
});

文档:https://joi.dev/api/

¥Documentation: https://joi.dev/api/

如果存在验证错误,我们只需调用确认回调并返回:

¥If there are validation errors, we just call the acknowledgement callback and return:

if (error) {
return callback({
error: Errors.INVALID_PAYLOAD,
errorDetails: error.details,
});
}

我们在客户端处理错误:

¥And we handle the error on the client side:

// angular-client/src/app/store.ts

this.socket.emit("todo:create", { title, completed: false }, (res) => {
if ("error" in res) {
// handle the error
} else {
// success!
}
});

文档:回执

¥Documentation: Acknowledgements

如果有效负载成功匹配模式,我们可以生成一个新的 ID 并保留实体:

¥If the payload successfully matches the schema, we can generate a new ID and persist the entity:

value.id = uuid();

try {
await todoRepository.save(value);
} catch (e) {
return callback({
error: sanitizeErrorMessage(e),
});
}

如果出现意外错误(例如,如果数据库关闭),我们将使用通用错误消息调用确认回调(以免暴露应用的内部结构)。

¥If there is an unexpected error (for example, if the database is down), we call the acknowledgement callback with a generic error message (in order not to expose the internals of our application).

否则,我们只需使用新 ID 调用回调:

¥Else, we just call the callback with the new ID:

callback({
data: value.id,
});

最后(这是神奇的部分),我们通知所有其他用户进行创建:

¥And finally (that's the magic part), we notify all the other users for the creation:

socket.broadcast.emit("todo:created", value);

文档:广播事件

¥Documentation: Broadcasting events

在客户端,我们为此事件注册一个处理程序:

¥On the client-side, we register a handler for this event:

// angular-client/src/app/store.ts

this.socket.on("todo:created", (todo) => {
this.todos.push(mapTodo(todo));
});

瞧!

¥And voilà!

测试

¥Tests

由于我们是相当理性的开发者,因此我们现在将为我们的处理程序添加一些测试。让我们打开 test/todo-management/todo.tests.ts 文件:

¥Since we are quite reasonable developers, we'll now add a few tests for our handler. Let's open the test/todo-management/todo.tests.ts file:

该应用是在 beforeEach 钩子中创建的:

¥The application is created in the beforeEach hook:

beforeEach((done) => {
const partialDone = createPartialDone(2, done);

httpServer = createServer();
todoRepository = new InMemoryTodoRepository();

createApplication(httpServer, {
todoRepository,
});

// ...
});

我们创建两个客户端,一个用于发送有效负载,另一个用于接收通知:

¥And we create two clients, one for sending the payload and the other for receiving the notifications:

httpServer.listen(() => {
const port = (httpServer.address() as AddressInfo).port;
socket = io(`http://localhost:${port}`);
socket.on("connect", partialDone);

otherSocket = io(`http://localhost:${port}`);
otherSocket.on("connect", partialDone);
});

重要的提示:这两个客户端在 afterEach 钩子中显式断开连接,因此它们不会阻止进程退出。

¥Important note: those two clients are explicitly disconnected in the afterEach hook, so they don't prevent the process from exiting.

文档:https://mocha.nodejs.cn/#hooks

¥Documentation: https://mocha.nodejs.cn/#hooks

我们的第一个测试(快乐路径)非常简单:

¥Our first test (the happy path) is quite straightforward:

describe("create todo", () => {
it("should create a todo entity", (done) => {
const partialDone = createPartialDone(2, done);

// send the payload
socket.emit(
"todo:create",
{
title: "lorem ipsum",
completed: false,
},
async (res) => {
if ("error" in res) {
return done(new Error("should not happen"));
}
expect(res.data).to.be.a("string");

// check the entity stored in the database
const storedEntity = await todoRepository.findById(res.data);
expect(storedEntity).to.eql({
id: res.data,
title: "lorem ipsum",
completed: false,
});

partialDone();
}
);

// wait for the notification of the creation
otherSocket.on("todo:created", (todo) => {
expect(todo.id).to.be.a("string");
expect(todo.title).to.eql("lorem ipsum");
expect(todo.completed).to.eql(false);
partialDone();
});
});
});

我们也使用无效的有效负载进行测试:

¥Let's test with an invalid payload too:

describe("create todo", () => {
it("should fail with an invalid entity", (done) => {
const incompleteTodo = {
completed: "false",
description: true,
};

socket.emit("todo:create", incompleteTodo, (res) => {
if (!("error" in res)) {
return done(new Error("should not happen"));
}
expect(res.error).to.eql("invalid payload");
// check the details of the validation error
expect(res.errorDetails).to.eql([
{
message: '"title" is required',
path: ["title"],
type: "any.required",
},
]);
done();
});

// no notification should be received
otherSocket.on("todo:created", () => {
done(new Error("should not happen"));
});
});
});

你可以使用 npm test 运行完整的测试套件:

¥You can run the full test suite with npm test:

Screenshot of the test results

这就是大家!其他处理程序与第一个处理程序非常相似,这里不再赘述。

¥That's all folks! The other handlers are quite similar to the first one, and will not be detailed here.

下一步

¥Next steps

谢谢阅读!

¥Thanks for reading!