我们这样设计消息中心,解决了业务反复折腾的顽疾

消息系统,大概是业务系统里最"精神分裂"的模块。

它一边要稳定存储------像日记一样,记下发生过的事。

另一边又要灵活展示------像实时播报,内容没了得知道变"失效"。

代码的复杂度,往往就从这里开始爆炸------我们把"是什么"(Data)和"怎么做"(Behavior)混在了一锅里炖。

今天,就聊聊我们如何通过三次关键的"分离手术",让消息中心从混乱走向清晰。这是系列的第三篇,我们进入最核心的战场:模型如何被使用

这是《面向对象开发实践之消息中心设计》系列的第三篇。

在前两篇中:

  • 第一篇 解决的是 "为什么要这样设计一个消息中心",从面向对象的视角拆解需求、边界与职责;
  • 第二篇 聚焦在 消息模型本身,从最小消息单元出发,一步步引入分类、业务关联、富文本、图片与布局。

从这一篇开始,我们正式进入"模型如何被使用"的阶段。

本篇重点讨论三个问题:

  1. 消息类型如何建模,而不失控
  2. 消息如何被渲染
  3. 消息行为(跳转、点击、实时数据)如何与模型解耦

消息类型的正确职责划分

在前面的模型中,我们已经有:

java 复制代码
enum MessageBizType {
    COMMENT,
    LIKE,
    MENTION,
    VIOLATION,
    LOTTERY
}

enum 只是标识,真正的行为不应该写在枚举里,常见的做法是:

java 复制代码
public interface MessageHandler {
    MessageBizType bizType();
    Message buildMessage(MessageCreateContext context);
}

每种消息类型,只关心三件事:

  1. 我处理哪种 bizType
  2. 如何构建 Message 模型
  3. 需要哪些业务数据

MessageCreateContext:为什么需要一个"创建上下文"?

上面提到,MessageHandler 构建消息时,传入的是一个 MessageCreateContext,但没有刻意第一时间展开。这是因为:Context 不是消息系统的起点,而是模型稳定之后的自然产物。

最原始的做法:参数直传(不可拓展)

在最初阶段,很多系统的消息创建代码大概是这样的:

java 复制代码
createCommentMessage(Long receiverId, Long commentId, String commentText);

这种方式的问题不在于"能不能用",而在于:

  • 参数列表会随着业务不断膨胀

  • 不同消息类型需要的参数完全不同

  • 调用方必须知道消息内部需要什么

业务代码被迫了解消息系统的细节

第一次抽象:统一参数对象

下一步的常见演进是:

java 复制代码
class MessageCreateDTO {
    Long receiverId;
    Long bizId;
    String extra;
}

这一步解决了"参数过多"的问题,但新的问题随之出现:

  • 字段语义不清晰(extra 是什么?)
  • 不同消息类型依赖同一个 DTO
  • DTO 会被不断污染

它只是"换了一种方式堆字段"。

Context 的真正职责:描述"一次消息创建的事实"

在面向对象的语义中,Context 并不是 DTO。

DTO 用来传数据,Context 用来描述一次行为发生时的完整上下文。

MessageCreateContext 的设计目标是:

  • 只描述"发生了什么"
  • 不关心"如何生成消息"
  • 不绑定具体消息类型
java 复制代码
public interface MessageCreateContext {
    Long getTargetUserId();
    MessageBizType getBizType();
    Long getBizId();
}

这是一个极度克制的最小接口。

向下演进:类型化 Context,而不是字段膨胀

当不同消息类型需要更多消息时,正确的演进方向不是给接口加方法,而是针对具体业务,定义具体的 Context 实现

例如,评论消息创建:

java 复制代码
public class CommentMessageContext implements MessageCreateContext {
    private final Long targetUserId;
    private final Long commentId;
    private final String commentSummary;
    // getters
}

违规消息创建:

java 复制代码
public class ViolationMessageContext implements MessageCreateContext {
    private final Long targetUserId;
    private final Long postId;
    private final String ruleName;
    // getters
}

这样做带来的好处是:

  • 每个 Context 都是强语义的
  • Handler 只依赖它需要的 Context
  • 不存在"通用但混乱"的字段

Context 与 Handler 的关系边界

一个非常重要的约束是:MessageHandler 只信任 Context,不反向依赖业务对象。

java 复制代码
public interface MessageHandler<C extends MessageCreateContext> {
    MessageBizType bizType();
    Message buildMessage(C context);
}

这意味着:

  • Context 是 Handler 的唯一输入
  • 消息系统与业务系统形成"单向依赖"

Context 是"防腐层",不是万能对象

最后需要特别强调的是:

  • Context 不应该:承载业务逻辑访问数据库变成一个 All-In-One 对象
  • Context 应该:稳定只读可测试

它的价值在于:

把一次消息创建,封装成一个可理解、可传递、可演进的事实对象。

示例一:评论消息(CommentMessageHandler)

1. 使用场景
  • A 评论了 B 的动态
  • B 收到一条评论通知
2. 关键设计点
  • 消息不持有 Comment 对象
  • 只保存 commentId
  • 文本支持「部分跳转」
3. Handler 实现
java 复制代码
public class CommentMessageHandler implements MessageHandler<CommentMessageCreateContext> {

    @Override
    public MessageBizType bizType() {
        return MessageBizType.COMMENT;
    }

    @Override
    public Message buildMessage(CommentMessageCreateContext ctx) {
        return Message.builder()
            .userId(ctx.getTargetUserId())
            .category(MessageCategory.INTERACTION)
            .bizType(MessageBizType.COMMENT)
            .bizId(ctx.getBizId())
            .layout(MessageLayout.IMAGE_TITLE_TEXT)
            .content(buildContent(ctx))
            .sendTime(LocalDateTime.now())
            .build();
    }

    private RichTextContent buildContent(CommentMessageCreateContext ctx) {
        return RichTextContent.of(
            RichTextSegment.text("你的动态收到了新的评论:"),
            RichTextSegment.link(
                ctx.getCommentSummary(),
                MessageJumpConfig.route("/comment/detail", Map.of("id", ctx.getBizId()))
            )
        );
    }
}

注意:

  • Handler 不关心"怎么存"
  • 只负责构建领域模型

示例二:违规通知(ViolationMessageHandler)

违规通知的特点:

  • 文本固定
  • 多处可点击跳转
  • 生命周期长
java 复制代码
public class ViolationMessageHandler implements MessageHandler<ViolationMessageCreateContext> {
    @Override
    public MessageBizType bizType() {
        return MessageBizType.VIOLATION;
    }

    @Override
    public Message buildMessage(ViolationMessageCreateContext ctx) {
        return Message.builder()
            .userId(ctx.getTargetUserId())
            .category(MessageCategory.SYSTEM)
            .bizType(MessageBizType.VIOLATION)
            .bizId(ctx.getBizId())
            .layout(MessageLayout.IMAGE_TITLE_TEXT)
            .title("内容审核未通过")
            .content(buildContent())
            .sendTime(LocalDateTime.now())
            .build();
    }

    private RichTextContent buildContent() {
        return RichTextContent.of(
            RichTextSegment.text("您发表的内容因违反《"),
            RichTextSegment.link("社区规范", MessageJumpConfig.url("https://example.com/rule")),
            RichTextSegment.text("》未能通过审核,请"),
            RichTextSegment.link("修改后重新发布>>", MessageJumpConfig.route("/post/edit"))
        );
    }
}

这里可以看到第二篇中"结构化富文本"的价值。

困境:静态存储 vs 动态业务

在第二篇中,我们刻意将 Message 设计得很轻,只存了 bizTypebizId,而没有存具体的业务文案(比如"张三评论了你")。

这是为了应对一个经典的业务难题:数据一致性与时效性

请看这个场景:

  1. 上午 10:00,用户 A 评论了用户 B:"你的文章写得真烂"。
  2. 上午 10:01,用户 B 还没来得及看消息列表。
  3. 上午 10:05,用户 A 删除了这条评论,或者评论被系统审核删除了。
  4. 上午 10:10,用户 B 打开消息中心。

如果我们在创建消息时,直接把文案写死在数据库里,用户 B 就会看到一条"幽灵消息"------点击进去却提示"内容不存在",体验极差。

但如果是"系统发放优惠券",我们又希望即使活动结束了,那条通知依然保留当时的快照。

结论很明显:不同类型的消息,对"实时性"的要求是完全不同的。

  • 快照型(Snapshot):系统通知、活动中奖。内容生成即固定。
  • 引用型(Reference):评论、点赞、@。内容依赖业务主体的当前状态。

策略模式:渲染器的多态设计

面向对象的精髓在于多态 。我们不需要在 MessageService 里写一堆 if (type == COMMENT) { ... } else if (type == SYSTEM) { ... },而是应该引入 渲染器(Renderer) 概念。

1. 定义渲染接口

渲染器的职责非常单一:把冰冷的数据库实体(DO),翻译成用户可读的视图对象(VO)。

java 复制代码
public interface MessageRenderer {
    // 策略判定:这个渲染器支持哪种业务类型?
    boolean support(MessageBizType bizType);

    // 核心行为:输入存储实体,输出展示视图
    MessageVO render(MessageDO message, Long viewerUserId);
}

2. 实现引用型渲染(以评论为例)

对于评论消息,渲染器需要实时查询业务服务,并处理"失效"逻辑。

java 复制代码
@Component
public class CommentReplyRenderer implements MessageRenderer {
    
    @Autowired
    private CommentService commentService; // 依赖业务服务

    @Override
    public boolean support(MessageBizType bizType) {
        return MessageBizType.COMMENT_REPLY == bizType;
    }

    @Override
    public MessageVO render(MessageDO message, Long viewerUserId) {
        // 1. 实时回查业务数据
        CommentDTO comment = commentService.findById(message.getBizId());

        // 2. 兜底处理:如果业务数据没了(被删、审核不通过)
        if (comment == null || comment.isDeleted()) {
             return MessageVO.builder()
                 .layout(MessageLayout.TEXT_ONLY)
                 .content(RichText.of("该评论已被删除")) // 降级展示
                 .action(Action.NONE) // 禁止跳转
                 .build();
        }

        // 3. 正常组装:利用第二篇定义的 RichText 和 Layout
        return MessageVO.builder()
            .layout(MessageLayout.IMAGE_TITLE_TEXT)
            .title(comment.getUserName() + " 回复了你")
            .image(comment.getUserAvatar())
            .content(RichText.fromSegments(
                Segment.text("回复内容:"),
                Segment.link(comment.getSummary(), JumpConfig.toComment(comment.getId()))
            ))
            .build();
    }
}

3. 实现快照型渲染(以系统通知为例)

对于系统通知,内容直接存在消息体的 content (JSON) 字段中,直接反序列化即可,无需查库。

java 复制代码
@Component
public class SystemNoticeRenderer implements MessageRenderer {
    // ... support 方法省略

    @Override
    public MessageVO render(MessageDO message, Long viewerUserId) {
        // 直接从 DO 中通过 JSON 反序列化出 RichTextContent
        RichTextContent content = JsonUtils.toObj(message.getContent(), RichTextContent.class);
        
        return MessageVO.builder()
            .layout(MessageLayout.TITLE_TEXT)
            .title(message.getTitle())
            .content(content)
            .build();
    }
}

这一设计的收益是巨大的

  • 隔离变化 :新增"订单发货通知",只需要新增一个 OrderRenderer 类,无需修改主流程代码。
  • 降级保护:业务数据删除或服务异常时,渲染器可以独立决定显示"内容已删除"还是直接隐藏。

总结:从数据到能力的跃迁

回顾这三篇文章的演进路径:

  1. 第一篇 :我们把"消息"从依附于业务的字符串,变成了一个独立的领域概念
  2. 第二篇 :我们通过设计 RichTextLayout,让模型拥有了结构化表达能力,不再依赖写死的前端代码。
  3. 第三篇(本篇) :我们引入了 MessageHandlerRenderer,让静态数据拥有了动态适应业务变化的能力

细心的你可能发现,我们目前的所有逻辑都是同步的。 如果这时候有一篇爆款文章,瞬间涌入 10 万条评论,我们的数据库能不能扛得住?渲染器在列表页进行 N+1 次 RPC 调用查询业务数据,接口会不会超时?

面向对象解决了逻辑的复杂度,但架构设计解决的是规模的复杂度。后面,我们可以尝试从"规模"的角度,继续拆解消息中心如何应对高并发、去重、防刷与异步化。

相关推荐
涡能增压发动积16 小时前
同样的代码循环 10次正常 循环 100次就抛异常?自定义 Comparator 的 bug 让我丢尽颜面
后端
云烟成雨TD16 小时前
Spring AI Alibaba 1.x 系列【6】ReactAgent 同步执行 & 流式执行
java·人工智能·spring
Wenweno0o16 小时前
0基础Go语言Eino框架智能体实战-chatModel
开发语言·后端·golang
行乾16 小时前
鸿蒙端 IMSDK 架构探索
架构·harmonyos
于慨16 小时前
Lambda 表达式、方法引用(Method Reference)语法
java·前端·servlet
石小石Orz16 小时前
油猴脚本实现生产环境加载本地qiankun子应用
前端·架构
swg32132116 小时前
Spring Boot 3.X Oauth2 认证服务与资源服务
java·spring boot·后端
tyung16 小时前
一个 main.go 搞定协作白板:你画一笔,全世界都看见
后端·go
gelald16 小时前
SpringBoot - 自动配置原理
java·spring boot·后端
殷紫川16 小时前
深入理解 AQS:从架构到实现,解锁 Java 并发编程的核心密钥
java