构建一个即时消息应用(三):对话
创始人
2024-03-02 04:44:36
0

本文是该系列的第三篇。

在我们的即时消息应用中,消息表现为两个参与者对话的堆叠。如果你想要开始一场对话,就应该向应用提供你想要交谈的用户,而当对话创建后(如果该对话此前并不存在),就可以向该对话发送消息。

就前端而言,我们可能想要显示一份近期对话列表。并在此处显示对话的最后一条消息以及另一个参与者的姓名和头像。

在这篇帖子中,我们将会编写一些 端点 endpoint 来完成像“创建对话”、“获取对话列表”以及“找到单个对话”这样的任务。

首先,要在主函数 main() 中添加下面的路由。

router.HandleFunc("POST", "/api/conversations", requireJSON(guard(createConversation)))
router.HandleFunc("GET", "/api/conversations", guard(getConversations))
router.HandleFunc("GET", "/api/conversations/:conversationID", guard(getConversation))

这三个端点都需要进行身份验证,所以我们将会使用 guard() 中间件。我们也会构建一个新的中间件,用于检查请求内容是否为 JSON 格式。

JSON 请求检查中间件

func requireJSON(handler http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
            http.Error(w, "Content type of application/json required", http.StatusUnsupportedMediaType)
            return
        }
        handler(w, r)
    }
}

如果 请求 request 不是 JSON 格式,那么它会返回 415 Unsupported Media Type(不支持的媒体类型)错误。

创建对话

type Conversation struct {
    ID                string   `json:"id"`
    OtherParticipant  *User    `json:"otherParticipant"`
    LastMessage       *Message `json:"lastMessage"`
    HasUnreadMessages bool     `json:"hasUnreadMessages"`
}

就像上面的代码那样,对话中保持对另一个参与者和最后一条消息的引用,还有一个 bool 类型的字段,用来告知是否有未读消息。

type Message struct {
    ID             string    `json:"id"`
    Content        string    `json:"content"`
    UserID         string    `json:"-"`
    ConversationID string    `json:"conversationID,omitempty"`
    CreatedAt      time.Time `json:"createdAt"`
    Mine           bool      `json:"mine"`
    ReceiverID     string    `json:"-"`
}

我们会在下一篇文章介绍与消息相关的内容,但由于我们这里也需要用到它,所以先定义了 Message 结构体。其中大多数字段与数据库表一致。我们需要使用 Mine 来断定消息是否属于当前已验证用户所有。一旦加入实时功能,ReceiverID 可以帮助我们过滤消息。

接下来让我们编写 HTTP 处理程序。尽管它有些长,但也没什么好怕的。

func createConversation(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Username string `json:"username"`
    }
    defer r.Body.Close()
    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    input.Username = strings.TrimSpace(input.Username)
    if input.Username == "" {
        respond(w, Errors{map[string]string{
            "username": "Username required",
        }}, http.StatusUnprocessableEntity)
        return
    }

    ctx := r.Context()
    authUserID := ctx.Value(keyAuthUserID).(string)

    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        respondError(w, fmt.Errorf("could not begin tx: %v", err))
        return
    }
    defer tx.Rollback()

    var otherParticipant User
    if err := tx.QueryRowContext(ctx, `
        SELECT id, avatar_url FROM users WHERE username = $1
    `, input.Username).Scan(
        &otherParticipant.ID,
        &otherParticipant.AvatarURL,
    ); err == sql.ErrNoRows {
        http.Error(w, "User not found", http.StatusNotFound)
        return
    } else if err != nil {
        respondError(w, fmt.Errorf("could not query other participant: %v", err))
        return
    }

    otherParticipant.Username = input.Username

    if otherParticipant.ID == authUserID {
        http.Error(w, "Try start a conversation with someone else", http.StatusForbidden)
        return
    }

    var conversationID string
    if err := tx.QueryRowContext(ctx, `
        SELECT conversation_id FROM participants WHERE user_id = $1
        INTERSECT
        SELECT conversation_id FROM participants WHERE user_id = $2
    `, authUserID, otherParticipant.ID).Scan(&conversationID); err != nil && err != sql.ErrNoRows {
        respondError(w, fmt.Errorf("could not query common conversation id: %v", err))
        return
    } else if err == nil {
        http.Redirect(w, r, "/api/conversations/"+conversationID, http.StatusFound)
        return
    }

    var conversation Conversation
    if err = tx.QueryRowContext(ctx, `
        INSERT INTO conversations DEFAULT VALUES
        RETURNING id
    `).Scan(&conversation.ID); err != nil {
        respondError(w, fmt.Errorf("could not insert conversation: %v", err))
        return
    }

    if _, err = tx.ExecContext(ctx, `
        INSERT INTO participants (user_id, conversation_id) VALUES
            ($1, $2),
            ($3, $2)
    `, authUserID, conversation.ID, otherParticipant.ID); err != nil {
        respondError(w, fmt.Errorf("could not insert participants: %v", err))
        return
    }

    if err = tx.Commit(); err != nil {
        respondError(w, fmt.Errorf("could not commit tx to create conversation: %v", err))
        return
    }

    conversation.OtherParticipant = &otherParticipant

    respond(w, conversation, http.StatusCreated)
}

在此端点,你会向 /api/conversations 发送 POST 请求,请求的 JSON 主体中包含要对话的用户的用户名。

因此,首先需要将请求主体解析成包含用户名的结构。然后,校验用户名不能为空。

type Errors struct {
    Errors map[string]string `json:"errors"`
}

这是错误消息的结构体 Errors,它仅仅是一个映射。如果输入空用户名,你就会得到一段带有 422 Unprocessable Entity(无法处理的实体)错误消息的 JSON 。

{
    "errors": {
        "username": "Username required"
    }
}

然后,我们开始执行 SQL 事务。收到的仅仅是用户名,但事实上,我们需要知道实际的用户 ID 。因此,事务的第一项内容是查询另一个参与者的 ID 和头像。如果找不到该用户,我们将会返回 404 Not Found(未找到) 错误。另外,如果找到的用户恰好和“当前已验证用户”相同,我们应该返回 403 Forbidden(拒绝处理)错误。这是由于对话只应当在两个不同的用户之间发起,而不能是同一个。

然后,我们试图找到这两个用户所共有的对话,所以需要使用 INTERSECT 语句。如果存在,只需要通过 /api/conversations/{conversationID} 重定向到该对话并将其返回。

如果未找到共有的对话,我们需要创建一个新的对话并添加指定的两个参与者。最后,我们 COMMIT 该事务并使用新创建的对话进行响应。

获取对话列表

端点 /api/conversations 将获取当前已验证用户的所有对话。

func getConversations(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    authUserID := ctx.Value(keyAuthUserID).(string)

    rows, err := db.QueryContext(ctx, `
        SELECT
            conversations.id,
            auth_user.messages_read_at < messages.created_at AS has_unread_messages,
            messages.id,
            messages.content,
            messages.created_at,
            messages.user_id = $1 AS mine,
            other_users.id,
            other_users.username,
            other_users.avatar_url
        FROM conversations
        INNER JOIN messages ON conversations.last_message_id = messages.id
        INNER JOIN participants other_participants
            ON other_participants.conversation_id = conversations.id
                AND other_participants.user_id != $1
        INNER JOIN users other_users ON other_participants.user_id = other_users.id
        INNER JOIN participants auth_user
            ON auth_user.conversation_id = conversations.id
                AND auth_user.user_id = $1
        ORDER BY messages.created_at DESC
    `, authUserID)
    if err != nil {
        respondError(w, fmt.Errorf("could not query conversations: %v", err))
        return
    }
    defer rows.Close()

    conversations := make([]Conversation, 0)
    for rows.Next() {
        var conversation Conversation
        var lastMessage Message
        var otherParticipant User
        if err = rows.Scan(
            &conversation.ID,
            &conversation.HasUnreadMessages,
            &lastMessage.ID,
            &lastMessage.Content,
            &lastMessage.CreatedAt,
            &lastMessage.Mine,
            &otherParticipant.ID,
            &otherParticipant.Username,
            &otherParticipant.AvatarURL,
        ); err != nil {
            respondError(w, fmt.Errorf("could not scan conversation: %v", err))
            return
        }

        conversation.LastMessage = &lastMessage
        conversation.OtherParticipant = &otherParticipant
        conversations = append(conversations, conversation)
    }

    if err = rows.Err(); err != nil {
        respondError(w, fmt.Errorf("could not iterate over conversations: %v", err))
        return
    }

    respond(w, conversations, http.StatusOK)
}

该处理程序仅对数据库进行查询。它通过一些联接来查询对话表……首先,从消息表中获取最后一条消息。然后依据“ID 与当前已验证用户不同”的条件,从参与者表找到对话的另一个参与者。然后联接到用户表以获取该用户的用户名和头像。最后,再次联接参与者表,并以相反的条件从该表中找出参与对话的另一个用户,其实就是当前已验证用户。我们会对比消息中的 messages_read_atcreated_at 两个字段,以确定对话中是否存在未读消息。然后,我们通过 user_id 字段来判定该消息是否属于“我”(指当前已验证用户)。

注意,此查询过程假定对话中只有两个用户参与,它也仅仅适用于这种情况。另外,该设计也不很适用于需要显示未读消息数量的情况。如果需要显示未读消息的数量,我认为可以在 participants 表上添加一个unread_messages_count INT 字段,并在每次创建新消息的时候递增它,如果用户已读则重置该字段。

接下来需要遍历每一条记录,通过扫描每一个存在的对话来建立一个 对话切片 slice of conversations 并在最后进行响应。

找到单个对话

端点 /api/conversations/{conversationID} 会根据 ID 对单个对话进行响应。

func getConversation(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    authUserID := ctx.Value(keyAuthUserID).(string)
    conversationID := way.Param(ctx, "conversationID")

    var conversation Conversation
    var otherParticipant User
    if err := db.QueryRowContext(ctx, `
        SELECT
            IFNULL(auth_user.messages_read_at < messages.created_at, false) AS has_unread_messages,
            other_users.id,
            other_users.username,
            other_users.avatar_url
        FROM conversations
        LEFT JOIN messages ON conversations.last_message_id = messages.id
        INNER JOIN participants other_participants
            ON other_participants.conversation_id = conversations.id
                AND other_participants.user_id != $1
        INNER JOIN users other_users ON other_participants.user_id = other_users.id
        INNER JOIN participants auth_user
            ON auth_user.conversation_id = conversations.id
                AND auth_user.user_id = $1
        WHERE conversations.id = $2
    `, authUserID, conversationID).Scan(
        &conversation.HasUnreadMessages,
        &otherParticipant.ID,
        &otherParticipant.Username,
        &otherParticipant.AvatarURL,
    ); err == sql.ErrNoRows {
        http.Error(w, "Conversation not found", http.StatusNotFound)
        return
    } else if err != nil {
        respondError(w, fmt.Errorf("could not query conversation: %v", err))
        return
    }

    conversation.ID = conversationID
    conversation.OtherParticipant = &otherParticipant

    respond(w, conversation, http.StatusOK)
}

这里的查询与之前有点类似。尽管我们并不关心最后一条消息的显示问题,并因此忽略了与之相关的一些字段,但是我们需要根据这条消息来判断对话中是否存在未读消息。此时,我们使用 LEFT JOIN 来代替 INNER JOIN,因为 last_message_id 字段是 NULLABLE(可以为空)的;而其他情况下,我们无法得到任何记录。基于同样的理由,我们在 has_unread_messages 的比较中使用了 IFNULL 语句。最后,我们按 ID 进行过滤。

如果查询没有返回任何记录,我们的响应会返回 404 Not Found 错误,否则响应将会返回 200 OK 以及找到的对话。


本篇帖子以创建了一些对话端点结束。

在下一篇帖子中,我们将会看到如何创建并列出消息。


via: https://nicolasparada.netlify.com/posts/go-messenger-conversations/

作者:Nicolás Parada 选题:lujun9972 译者:PsiACE 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

相关内容

硬核观察 #353 X.o...
X.org “大眼睛” 11 年来发布第一个小版本X.Org/X1...
2024-03-02 08:11:09
构建一个即时消息应用(九)...
本文是该系列的第九篇,也是最后一篇。第一篇: 模式第二篇: OAu...
2024-03-02 05:59:04
构建一个即时消息应用(八)...
本文是该系列的第八篇。第一篇: 模式第二篇: OAuth第三篇: ...
2024-03-02 05:58:13
构建一个即时消息应用(七)...
本文是该系列的第七篇。第一篇: 模式第二篇: OAuth第三篇: ...
2024-03-02 05:56:54
构建一个即时消息应用(四)...
本文是该系列的第四篇。第一篇: 模式第二篇: OAuth第三篇: ...
2024-03-02 05:55:36
构建一个即时消息应用(六)...
本文是该系列的第六篇。第一篇: 模式第二篇: OAuth第三篇: ...
2024-03-02 05:55:24

热门资讯

Helix:高级 Linux ... 说到 基于终端的文本编辑器,通常 Vim、Emacs 和 Nano 受到了关注。这并不意味着没有其他...
使用 KRAWL 扫描 Kub... 用 KRAWL 脚本来识别 Kubernetes Pod 和容器中的错误。当你使用 Kubernet...
JStock:Linux 上不... 如果你在股票市场做投资,那么你可能非常清楚投资组合管理计划有多重要。管理投资组合的目标是依据你能承受...
通过 SaltStack 管理... 我在搜索Puppet的替代品时,偶然间碰到了Salt。我喜欢puppet,但是我又爱上Salt了:)...
Epic 游戏商店现在可在 S... 现在可以在 Steam Deck 上运行 Epic 游戏商店了,几乎无懈可击! 但是,它是非官方的。...
《Apex 英雄》正式可在 S... 《Apex 英雄》现已通过 Steam Deck 验证,这使其成为支持 Linux 的顶级多人游戏之...
如何在 Github 上创建一... 学习如何复刻一个仓库,进行更改,并要求维护人员审查并合并它。你知道如何使用 git 了,你有一个 G...
2024 开年,LLUG 和你... Hi,Linuxer,2024 新年伊始,不知道你是否已经准备好迎接新的一年~ 2024 年,Lin...
什么是 KDE Connect... 什么是 KDE Connect?它的主要特性是什么?它应该如何安装?本文提供了基本的使用指南。科技日...
Opera 浏览器内置的 VP... 昨天我们报道过 Opera 浏览器内置了 VPN 服务,用户打开它可以防止他们的在线活动被窥视。不过...