Skip to content

同一个人,几种角色:大实体、接口还是角色对象 ​

培训平台上线时只有学员。后来学员可以申请成为讲师,于是用户表加了 is_instructor、bio、hourly_fee 三列。再后来有了助教、企业管理员、审核员,用户表长到四十多列,每个字段旁边都要注释「只对 XX 有效」。

「一个人在不同场景下扮演不同角色」是业务里最常见的建模问题之一:同一个人可以是学员也是讲师,同一家公司可以是客户也是供应商,同一个员工可以是申请人也是审批人。它有三种常见的表达方式,各自在不同的地方出问题。

本文用一个培训平台的例子比较这三种方式:角色撤销之后剩下什么、接口能不能限制调用方、角色的历史在哪里、每个角色的规则放在哪里。实验在 JDK 25 上运行,见文末。

大实体 + 标志一个类实现多个接口参与方 + 角色对象Memberinstructor、learner 布尔bio、hourlyFee、level 可空Person implementsLearner, InstructorLearner 与 Instructor 是同一个对象Party(id, name)讲师角色有效期、课时费学员角色有效期、等级撤销讲师标志置 false课时费 500 仍在没有对应操作只能再加一个标志设置结束日期数据与历史保留「9 月份是不是讲师」只有当前值 false同左9/15 是,11/15 不是两段经历,课时费 500、600规则与能力带标志的 if 到处重复转换回实现类后 grantAdmin 成功课时费为 0 的讲师角色被拒绝
图 1 · 实测(JDK 25.0.4.1):大实体撤销讲师只是改了一个标志,课时费留在原地,历史没了;一个类实现多个接口,只拿到学员视图的代码一次类型转换就拿到了管理员能力;角色对象把有效期、数据和规则都放在角色里,两段讲师经历各自保留,学员不受讲师规则影响。

一、先问角色和人是什么关系 ​

动手之前,用几个业务问题确认角色的性质:

问题答案的含义
一个人能同时有几种角色?能同时有多种,就不能用一个「类型」字段区分人
角色能撤销、能再次获得吗?能,角色就有自己的生命周期,不等于人的生命周期
需要知道「某天他是不是讲师」吗?需要,角色要带有效期,撤销不是覆盖
不同角色的规则不同吗?讲师要有课时费,学员要有水平等级:规则属于角色,不属于人
不同角色由不同的模块使用吗?课程发布只关心讲师能力,选课只关心学员能力

如果答案都是「否」,比如一个用户永远只有一种身份、不撤销、没有专属规则,那么一个类型字段就够了,下面的讨论都不必要。

二、一个大实体:数据和规则都散开了 ​

java
class Member {
    String name; boolean instructor; boolean learner;
    String bio; Integer hourlyFee;    // 只对讲师有意义
    String level;                     // 只对学员有意义
}
text
大实体撤销讲师:instructor=false,但 bio=十年后端、hourlyFee=500 仍在;按「有课时费」统计讲师会把她算进去
只是学员的 bob:讲师字段有 2 个为 null;「讲师必须有课时费」只能写成 if (instructor && hourlyFee == null),在每个修改入口重复
撤销后再问「alice 9 月份是不是讲师」:只有当前的布尔值 false,历史没有了

三个问题都来自同一个原因:角色没有成为一个概念。角色的数据挂在人身上,撤销角色只改了标志,数据留了下来;角色的规则只能写成「如果有这个标志,那么……」,散落在每个修改它的地方;角色的时间维度根本无处存放。

大实体的优点是简单,查询一个人的所有信息只需要一行。角色少、几乎不变时,它是合理的起点。信号是:开始出现「只对 XX 有效」的字段注释,开始有人问「他什么时候成为讲师的」。

三、一个类实现多个接口:视图不是边界 ​

第二种做法用接口区分能力:

java
interface Learner { void enroll(String course); }
interface Instructor { void publish(String course); }
class Person implements Learner, Instructor { ... public void grantAdmin() { ... } }

选课模块只拿到 Learner,发布模块只拿到 Instructor,看起来每个模块只能用自己的那部分能力。这是接口隔离原则的典型用法,它让依赖变窄、让测试替身更好写。但它不是权限隔离:

text
把 carol 作为 Learner 传给只该用学员能力的代码:它 instanceof 转回 Person 并调用 grantAdmin(),admin=true;接口是视图,不是权限边界
Learner 与 Instructor 是同一个对象:true;两个角色共享一份状态和一个生命周期,撤销讲师角色只能靠再加一个标志

接口只是编译期的视图,对象本身拥有全部能力。只要调用方能看到实现类,一次类型转换就能拿到全部能力。要真正限制能力,需要让调用方看不到实现:实现类放在不导出的包里(JPMS 模块),或者传给它一个只包装了部分能力的独立对象。

另一个问题是生命周期:两个接口背后是同一个对象,「撤销讲师角色」没有对应的操作,只能退回到加一个标志。所以接口适合表达「同一个对象在不同调用方眼里的不同用途」,不适合表达「一个人获得和失去的角色」。接口隔离与权限的区别,在设计原则里也有讨论。

四、参与方加角色对象:角色成为概念 ​

第三种做法把人和角色分开:人(参与方)只保留身份信息,每个角色是一个单独的对象,有自己的有效期、字段和规则。

java
record Party(String id, String name) { }
sealed interface Role permits InstructorRole, LearnerRole { Party party(); LocalDate from(); Optional<LocalDate> to(); }
record InstructorRole(Party party, LocalDate from, Optional<LocalDate> to, int hourlyFee) implements Role {
    InstructorRole { if (hourlyFee <= 0) throw new IllegalArgumentException("讲师课时费必须为正:" + hourlyFee); }
}
text
alice 在 9 月 15 日是讲师:true;11 月 15 日:false;讲师角色共 2 段,课时费分别是 500、600
讲师的规则放在讲师角色自己的构造器里,创建课时费为 0 的讲师角色:被拒绝(讲师课时费必须为正:0);学员角色不需要这条检查

撤销讲师是给角色设置结束日期,数据和历史都保留;再次成为讲师是一个新的角色对象,可以有不同的课时费;讲师的规则写在讲师角色里,学员完全不受影响。每个模块只拿它关心的角色对象:发布课程的模块拿到 InstructorRole,它根本没有办法触及管理员能力,因为那是另一个对象。

代价是:

  • 查询变复杂:「列出所有当前讲师」要按角色类型和有效期查询。
  • 身份要统一:所有角色都指向同一个参与方,参与方的身份由谁分配、跨角色的引用怎样落库,和子类型、类型字段还是组合里的共同父表是同一个问题。
  • 容易过度抽象:把人和组织都抽象成「参与方」、把所有关系都抽象成「责任」,能表达一切,也让每个简单需求都要穿过几层。只有当不同的参与方确实共享身份规则和角色时才值得这样做。组织之间的上下级关系怎样建模,见组织结构怎样建模。

五、怎样选 ​

情况选择
一种身份、不撤销、没有专属规则类型字段
同一个对象被不同调用方以不同用途使用接口(视图),不当权限边界
角色可以叠加、撤销、再获得,有专属数据和规则,需要历史角色对象,带有效期
不同角色属于不同的限界上下文各上下文有自己的模型,只共享参与方的 id

最后一行值得单独说:讲师在课程上下文里有课时费和简介,在结算上下文里有银行账户和税务信息。把这些都放进一个 InstructorRole,又会长成一个大实体。按上下文分开时,每个上下文只保留它需要的那部分,用参与方的 id 关联。上下文之间怎样翻译同一个名词,见上下文集成。

六、从大实体演进到角色对象 ​

已有的大实体不必一次改完:

  1. 先把一个角色的字段和规则提取成角色对象,大实体上保留一个只读的委托(member.instructorRole()),新代码只用角色对象。
  2. 数据迁移:为当前 is_instructor = true 的人创建一段从「已知最早日期」开始的讲师角色;没有历史的,开始日期要标明来自迁移。
  3. 把撤销、重新获得改为操作角色对象的有效期;删除旧标志。
  4. 每提取一个角色,跑一次字符化测试,确认统计、权限检查的结果没有变化。

七、检查清单 ​

  • 先用业务问题确认角色能否叠加、撤销、有历史、有专属规则。
  • 角色的数据和规则放在角色里,不放在人身上加标志。
  • 接口用来收窄依赖,不当作权限边界;需要隔离能力时让调用方看不到实现。
  • 角色带有效期,撤销是设置结束日期。
  • 不同上下文的「同一个角色」各自建模,只共享参与方 id。
  • 不为了将来可能的角色提前建通用参与方模型。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权