在介绍了构建我们后端 API 服务器所需的 Hono、Bun 和 TypeScript 的基本构建模块之后,我们现在终于可以开始实现我们聊天应用程序所需的所有后端功能了。

在本章中,我们将致力于构建数据模型、存储、中间件、路由、身份验证和授权机制,以及聊天和消息控制器。所有这些内容在聊天应用程序的上下文中,为我们提供了构建现实世界后端应用程序所需的核心知识。

以下是我们将详细探讨的主要主题:

• 实现内存存储

• 实现身份验证和授权

• 实现聊天控制器

我们将从概述我们应用程序的数据模型并搭建一个内存存储解决方案开始。

技术要求

在本章中,我们不需要安装任何额外的库。我们讨论的所有代码示例都可以在 GitHub 仓库中找到: Github上面的代码库

实现内存存储

为了开发一个可运行的应用程序,我们需要将数据存储在某个地方。在本节中,我们将定义用于存储的接口,并为用户、聊天和消息创建内存存储的实现。内存存储是一种将数据直接存储在计算机主内存(RAM)中的方法。由于存储在内存中的数据是易失性的,因此当应用程序停止时,数据会丢失。

定义接口

让我们从定义将在数据库类中使用的数据接口开始。在以下代码中,我们将创建与数据库交互时将要使用的对象的结构: src/models/db. ts

export type Email = `${string}@${string}.${string}`;

这一行定义了一个名为 Email 的自定义类型。它使用模板字面量类型来确保符合此类型的任何字符串都必须遵循传统的电子邮件格式:一个字符串,后跟一个 @ 符号,另一个字符串,一个点(.),最后再跟一个字符串。这是一种方便且强大的方式,用于强制执行电子邮件地址的基本结构。

接下来,我们定义数据接口:

export interface DBEntity {
    id: string;
    createdAt: Date;
    updatedAt: Date;
}
 
export interface DBUser extends DBEntity {
    name: string;
    email: Email;
    password: string;
}
 
export interface DBChat extends DBEntity {
    ownerId: DBUser["id"];
    name: string;
}

在这里,我们定义了 DBEntity 接口,它包含所有数据库实体共有的字段,如 idcreatedAtupdatedAt。然后,我们定义了 DBUser 接口,它扩展了 DBEntity 并添加了用户特有的字段,如 nameemailpassword。最后,我们定义了 DBChat 接口,它也扩展了 DBEntity 并添加了聊天特有的字段,如 ownerIdname

通过这些接口,我们可以确保与数据库交互时的数据结构一致性和类型安全性。

接口解析

让我们来解析这些接口:

• DBEntity:这个接口作为其他数据库实体接口的基础。它包含三个属性:id(一个字符串,唯一标识实体)、createdAt(一个 Date 对象,表示实体创建的时间)和 updatedAt(一个 Date 对象,表示实体最后更新的时间)。

• DBUser:这个接口扩展了 DBEntity,意味着它继承了 DBEntity 的所有属性(idcreatedAtupdatedAt),并添加了三个额外的属性:name(用户的姓名)、email(用户的电子邮件地址,必须符合 Email 类型)和 password(用户的密码)。

• DBChat:这个接口与 DBUser 类似,因为它也扩展了 DBEntity。它表示一个聊天实体,包含两个额外的属性:ownerId(指向拥有这个聊天的用户的 id)和 name(聊天的名称)。

MessageType 定义了一个名为 MessageType 的联合类型,它可以是 "system""user"。这用于对聊天中的消息来源进行分类:

export type MessageType = "system" | "user";

DBMessage 扩展了 DBEntity 并表示聊天中的消息。它包括 chatId(将消息与聊天关联起来)、type(由 MessageType 定义的消息类别)和 message(消息的实际文本):

export interface DBMessage extends DBEntity {
    chatId: DBChat["id"];
    type: MessageType;
    message: string;
}

以下接口用于创建相应的数据库类型。它们使用 TypeScript 的 Pick 工具类型,创建只包含原始接口部分属性的类型:

export type DBCreateUser = Pick<DBUser, "email" | "password" | "name">;
export type DBCreateChat = Pick<DBChat, "name" | "ownerId">;
export type DBCreateMessage = Pick<DBMessage, "chatId" | "message" | "type">;

例如,DBCreateUser 只包含 DBUser 中的 email、password 和 name,专注于创建新用户时所需的属性,因为其他属性将由代码生成。

本节中定义的接口是我们与数据库交互时将要使用的接口。然而,在 API 层操作时,引入一个额外的类型层是一个良好的实践,这些类型是我们将从端点接收和返回的。在一个大型应用程序中,数据库类型和 API 类型可能只部分重叠,因为两层都处理相同的数据,但具有不同的细节和结构,以适应它们在应用程序中的特定角色;例如,我们可以从一个端点创建多个数据库对象。然而,在我们的应用程序中,它们大多重叠,因为我们有直接的创建、读取、编辑、删除(CRUD)端点。因此,让我们创建 API 类型,这些类型将作为我们端点的输入和输出数据形式。

创建数据库和 API 类型

我们创建的所有类型都是数据库类的简单别名。唯一不同的类是 APIUser,它在其定义中省略了 password,因为我们不希望在返回用户信息时暴露密码: src/models/api. ts

import type {
    DBChat,
    DBCreateChat,
    DBCreateMessage,
    DBCreateUser,
    DBMessage,
    DBUser,
} from "./db";
 
export type APICreateUser = DBCreateUser;
export type APIUser = Omit<DBUser, "password">;
export type APICreateChat = DBCreateChat;
export type APIChat = DBChat;
export type ApiCreateMessage = DBCreateMessage;
export type ApiMessage = DBMessage;

完成这些后,我们可以继续实现我们的内存存储。我们将要实现的存储类将是一个抽象类,因此它可以被用作用户信息、聊天和消息的存储,而无需改变类型和我们的应用程序。我们将从定义 IDatabaseResource 接口开始,它将表示一种抽象的方式来存储和操作数据。

然后,我们将在应用程序中传递存储类接口,而不是具体的实现。这将使我们能够轻松地替换具体的实现,无论是内存存储、SQL 还是 ORM 实现,而无需更改类型和我们的应用程序。这展示了抽象的使用,我们在第 2 章中介绍了这种实践。

src/storage/types. ts

export interface IDatabaseResource<T, S> {
    create(data: S): Promise<T>;
    update(id: string, data: Partial<S>): Promise<T | null>;
    get(id: string): Promise<T | null>;
    find(data: Partial<T>): Promise<T | null>;
    findAll(data: Partial<T>): Promise<T[]>;
    delete(id: string): Promise<T | null>;
}

这是一个数据库资源的 CRUD 实现的定义,包含了所有标准的数据操作方法。它是一个带有两个泛型参数 TS 的泛型接口,TS 是泛型参数,用于指定具体类型。这是我们在第 2 章中讨论过的泛型的一个例子,它将为我们的代码增加可重用性和灵活性:

S 表示一个包含创建实体所需所有字段的数据类型。

T 是包含数据库实体所有字段的类型。

例如,在 createupdate 方法中,我们使用 S 作为输入类型,因为我们只需要创建时所需的字段。但在 find 方法中,我们检索的是整个资源中的任意字段的对象。这是因为我们可能会通过 id 等字段来搜索对象,而这些字段在创建或更新对象时并不提供。

让我们根据每个方法背后的思想来分解类的其他部分:

create(data: S): Promise<T>:使用 S 类型的输入数据创建一个新实体,返回一个解析为 T 类型创建实体的承诺。这里使用 Promise 类型是因为该方法将是异步的,也就是说它会返回一个带有某种类型的 Promise

update(id: string, data: Partial<S>): Promise<T | null>:通过 id 标识的实体用提供的数据进行更新。它返回一个解析为更新后的实体或 null 的承诺。这里的 Partial 类型使得我们可以发送不是整个对象来进行更新,而是原始类型中的一些选定字段。

get(id: string): Promise<T | null>:通过 id 检索一个实体,返回一个解析为实体或 null 的承诺。

find(data: Partial<T>): Promise<T | null>:查找符合部分数据标准的实体,返回一个解析为实体或 null 的承诺。

findAll(data: Partial<T>): Promise<T[]>:查找所有符合部分数据标准的实体,返回一个解析为实体数组的承诺。

delete(id: string): Promise<T | null>:通过 id 删除一个实体,返回一个解析为删除的实体或 null 的承诺。

你还会看到我们将返回类型包装在 Promise 类型中。这是在 TypeScript 中处理异步函数的方式;如果我们的函数是异步的,其返回类型将始终是 Promise<T>