多对多关系的设计实现
如题,DDD该如何落地呢?前面我通过三期的内容,讲解了DDD落地的关键在于“关系”,也就是通过前面我们对业务的理解先形成领域模型,然后将领域模型的原貌,形成程序代码中的服务、实体、值对象。在这个过程中,将实体与值对象形成领域对象的同时,还要保留领域模型中对象之间的关系。这种关系,除了有“一对一”、“多对一”、“一对多”、“多对多”关系以外,还可以有“继承”关系。在DDD落地编码时,要非常准确地将这五种关系,通过程序表达出来。因此,在编码过程中,仅仅依靠领域对象是不足够的,还需要通过DSL辅助表达。譬如,订单与客户、地址、明细之间的关系,先编写一个“订单”对象:
@Data
@EqualsAndHashCode(callSuper = true)
public class Order extends Entity<Long> {
private Long id;
private Long customerId;
private Long addressId;
private Double amount;
private Date orderTime;
private Date modifyTime;
private String status;
private Customer customer;
private Address address;
private Payment payment;
private List<OrderItem> orderItems;
...
}
在这样的基础上,再为订单对象编写DSL,对它们之间的关系进行补充说明:
<do class="com.edev.trade.order.entity.Order" tableName="t_order">
<property name="id" column="id" isPrimaryKey="true"/>
<property name="customerId" column="customer_id"/>
<property name="addressId" column="address_id"/>
<property name="amount" column="amount"/>
<property name="orderTime" column="order_time"/>
<property name="modifyTime" column="modify_time"/>
<property name="status" column="status"/>
<join name="customer" joinKey="customerId" joinType="manyToOne"
class="com.edev.trade.order.entity.Customer"/>
<join name="address" joinKey="addressId" joinType="manyToOne"
class="com.edev.trade.order.entity.Address"/>
<join name="payment" joinType="oneToOne" isAggregation="true"
class="com.edev.trade.order.entity.Payment"/>
<join name="orderItems" joinKey="orderId" joinType="oneToMany"
isAggregation="true" class="com.edev.trade.order.entity.OrderItem"/>
</do>
除了这五种关系以外,它们之间还可能存在聚合关系,也在DSL中进行说明,譬如订单与明细之间就有聚合关系。只有“一对一”与“一对多”关系才可能出现聚合关系。有了DSL的详细描述,后面的所有增删改与查询操作,都遵循以上这些关系进行底层数据库的操作。对于业务开发人员来说,只要根据领域模型完成了领域对象、DSL与领域服务的开发,所有底层数据库的操作都不必操心了,开发就得到了简化,从而使得DDD落地变得容易。DDD落地实现的思路就在于此。
然而,对于业务开发人员来说,不必关注底层数据库的操作,对于底层平台开发的人员却必须要关注。前面,我已经讲解了“一对一”、“多对一”和“一对多”这三个关系的设计实现,即通过开发一个DDD的通用平台,实现通用的仓库与工厂。所有的Service只要注入了这个通用仓库就可以完成底层数据库的持久化。那么,除了它们,“多对多”和“继承”的关系又该如何实现呢?今天我们先来看看“多对多”关系。
在现实世界中,多对多关系其实并不常见,但也还是有的。比较典型的例子就是“用户”与“权限”的关系。一个用户可以申请多个权限,同时一个权限也可以分配给多个用户,它们之间就形成了“多对多”关系。然而,要将这个多对多关系落地实现会比较困难,因此通常会在它们之间增加一个关联类。有了这个关联类,就可以将这个多对多关系转变成两个多对一关系,或者两个一对多关系。这样,对于多对多关系的设计实现就有两种,我们先来看两个多对一关系的实现。
如上图,我们在“用户”与“权限”之间增加了一个关联类:用户-权限关联类。这样的设计就将多对多关系变成了两个多对一关系:关联类与用户是多对一关系、关联类与权限也是多对一关系。有了这三个类,它们就分别对应三个数据库的表:用户表、权限表、用户-权限关联表。如下图,可以看到,用户-权限关联表的主键,是由用户ID与权限ID组成的联合主键,或者先设计一个无意义的自动生成主键,然后由它俩形成一个唯一键。
按照这样的思路,就可以分别编写这三个领域对象及其对应的DSL,并创建相应的表。用户表存储用户信息,并通过用户Service进行增删改查的操作;权限表存储权限信息,并通过权限Service进行增删改查的操作;关联表存储用户授权的信息,并通过用户授权Service进行增删改查的操作,就可以完成多对多的设计实现,思路也并不复杂。
然而,基于以上的设计,一个用户要查询它的所有权限,或者一个权限要查询它分配给哪些用户,查询起来就比较麻烦。首先,要获取该用户的ID,去关联表中查找它的所有授权。然后,还要对所有这些授权的ID,到授权表中查询它们的详细信息。最后,将这个授权集合,写入用户对象中的“授权”属性中。以上这些操作都必须由业务开发人员来完成,开发工作量就会比较大。
除了以上的设计思路以外,另一个思路就是将多对多关系变成两个一对多关系,即用户与关联类是一对多关系,权限与关联类是一对多关系(如上图)。这样的设计,表结构不变,业务开发人员只需要在用户对象中增加一个“授权”的集合属性,在授权对象中增加一个“用户”的集合属性:
@Data
@EqualsAndHashCode(callSuper = true)
public class User extends Entity<Long> {
private Long id;
private String username;
private String password;
private int accountExpired;
private int accountLocked;
private int credentialsExpired;
private int disabled;
private String userType;
private Collection<Authority> authorities = new ArrayList<>();
public void addAuthority(Authority authority) {
this.authorities.add(authority);
}
}
然后在DSL中进行如下配置,就可以实现多对多关系:
<do class="com.edev.emall.authority.entity.User" tableName="t_user" subclassType="joined">
<property name="id" column="id" isPrimaryKey="true"/>
<property name="username" column="username"/>
<property name="password" column="password"/>
<property name="accountExpired" column="account_expired"/>
<property name="accountLocked" column="account_locked"/>
<property name="credentialsExpired" column="credentials_expired"/>
<property name="disabled" column="disabled"/>
<property name="userType" column="user_type" isDiscriminator="true"/>
<join name="authorities" joinKey="userId" joinType="manyToMany" joinClassKey="authorityId"
joinClass="com.edev.emall.authority.entity.UserGrantedAuthority"
class="com.edev.emall.authority.entity.Authority"/>
</do>
可以看到,在DSL中配置多对多关系时,joinClass配置的就是那个关联类,joinKey是用户与关联类进行关联的字段,joinClassKey是关联类与权限进行关联的字段。通过这样的配置,业务开发人员很容易就可以完成多对多关系的开发。
当然,除了编写用户和授权的领域对象与DSL,还要编写关联类的领域对象与DSL:
@Data
@EqualsAndHashCode(callSuper = true)
public class UserGrantedAuthority extends Entity<Long> {
private Long id;
private String available;
private Long userId;
private Long authorityId;
public Boolean getAvailable() {
return "Y".equals(available);
}
public void setAvailable(Boolean available) {
this.available = (available!=null&&available ? "Y" : "N");
}
}
显然,我们不需要编写对关联类和关联表操作的代码,对它们的操作都封装在了底层平台中了。更详细的编码,可以查看我的示例:
有了以上的设计,当需要为用户授权时,授权信息是作为用户对象中的一个属性,由前端进行提交:
{
"id": 1,
"username": "Mooodo",
"password": "{noop}4321",
"accountExpired": false,
"accountLocked": false,
"credentialsExpired": false,
"disabled": false,
"userType": "administrator",
"authorities": [
{"id": 999},{"id": 998}
]
}
紧接着,后台在完成对用户信息的增删改的同时,就可以完成对该用户的授权。比如,在添加新用户时添加授权的信息,就可以同时对该用户进行授权。这时,该用户通过用户Service创建用户时,后台的仓库就会同时插入用户表和关联表,完成用户添加与授权的操作。当该用户已经创建好了,现在要对他的授权进行增删改操作时,只需要在用户对象中对“授权”这个集合属性进行增删改,然后更新该用户,那么后台的仓库就会比对“授权”属性是否存在变更。如果有变更,就会完成对关联表的增删改操作,从而完成对授权的变更。通过这样的设计,业务开发人员只需要按照领域模型的要求设计领域对象,将DSL配置成多对多关系,然后在Service中直接操作相应的领域对象就可以了,而不必再关心数据库的持久化,使设计得到了简化。
除了增删改以外,多对多关系的查询该怎么做呢?在以上案例中,只要完成了对用户与权限的对象编写与DSL配置,查询用户时就可以自动带出该用户的所有授权。对于业务开发人员来说,这个操作非常简单,但DDD的底层平台却需要做很多事情。底层平台首先通过读取DSL配置文件获取它们的多对多关系,然后根据用户对象去查找对应的关联对象,然后对所有的关联对象去查找对应的授权对象。最后,将这个授权对象的列表放到用户对象的“权限”属性中,完成整个查询的过程。
这时,如果查找的是一个用户列表,是否会存在性能的问题呢?比如现在要制作一个用户查询的功能,如上一期所讲的,先编写一个MyBatis的mapper对用户表进行查询,然后配置一个AutofillQueryServiceImpl来补填用户对象的关联信息。现在通过一个条件查询了100个用户,但通常不会往前端直接返回这100个用户,而是通过分页只返回这一页的20个用户。那么,对这20个用户,先获得它们的用户ID列表,通过这个列表在关联表中一次性查询这20个用户的所有授权,然而对这些授权ID的列表,在权限表中一次性查询出对应的权限信息。最后,再由通用工厂对所有这些信息进行拼装,将每个用户的权限放进该用户的“权限”属性中。底层通过这样的设计,既可以完成对所有用户对象的补填,又可以最大程度保证性能。特别是,在补填的过程中还可以增加Redis缓存,进一步提升查询性能。
总之,多对多关系的设计实现需要中间增加一个关联类,从而形成了两种设计方案。第一个方案:转变成两个多对一关系,设计比较简单,但在查询用户的授权信息时比较麻烦,业务开发人员的工作量就会比较大;第二个方案:转变成两个一对多关系,需要比较强大的DDD底层平台的支持,但上层业务开发就会变得比较简单。两个方案都各有各自的优缺点,大家可以根据各自的情况权衡利弊,进行选择。下一期我们将探讨五种关系中设计最难、最复杂的关系:继承关系的实现。
相关的文章:
(待续)