根据Restful风格,对于资源操作后需要返回当前资源(即使是DELETE操作),也就意味着对于一个资源的多种业务操作都需要返回一个统一的Response格式
另外在分布式环境下为了解除耦合,常常使用发消息来完成通信,由于业务操作的多种多样,对于Listener而言(特别是QueryService接收CommandService的消息)而言,也需要一个统一的Message格式
为了方便起见,把上面两种情况使用同一个结构,定义为DataTransferObject,这是一个实体类,主要是完成两个服务间通信.在这种情况下,DTO暗含了一种承诺,那就是其中的任意一个字段发生变动时就要发送一条消息从而更新QueryService的数据
在实际代码中会出现在以下几种情况
@RestController
class XxxController{
@PutMapping("/api/xxx/{id}")
public XxxDTO doSome(@PathVariable("id")String id,@RequestBody SomeCommand command){
//业务逻辑
return new XxxDTO(...);
}
}
class XxxListener{
public void onMessage(XxxDTO dto){
//响应事件
}
}
VO:ValueObject 相对于DomainObject而言,只有getter,是外界访问DomainObject的窗口
VO vs DTO:他俩最大的差别在于感知的不同,对于VO而言是OO的一种延续,它的返回应该也是VO,它似乎是在一个单体应用中.而DTO正是由于分布式环境而产生的,所以它必然能感知到服务与服务间的边界.
体现在代码上他们会有以下差别
interface DepartmentValueObject{
}
interface StaffValueObject{
DepartmentValueObject getDepartment(){
//...
}
}
上面的VO的getter返回的仍然是一个VO对象.这里VO可以是class也可以是interface,关于这个的探讨我在别的文章在做说明
class StaffDTO{
private String departmentId;
String getDepartmentId(){
return departmentId;
}
void setDepartmentId(String departmentId){
this.departmentId=departmentId;
}
}
class BaseDTO{
private String id;
//getter & setter
}
class StaffDTO{
private BaseDTO department;
BaseDTO getDepartment(){
return department;
}
void setDepartment(BaseDTO department){
this.department=department;
}
}
在上面的两个例子中,区别是DTO的结构是否努力与VO保持一致(getter是否同名).后面的方法任然保留了OO的痕迹,对于前者则彻底放弃OO,从而更加方便.
对于一些原始类型(特别是int,其他的也许有专有对象,例如Money),如果必然非空,VO可以使用int,而DTO需要使用Integer
对于一个业务上必然非空的布尔字段而言,VO可以返回boolean格式从而解除调用方担心null的忧虑,而DTO按照规范需要返回的是Boolean来确保不会篡改另一个服务的原始数据.
另外这种差别也导致了他们的getter方法的名字的差别从而影响字段名的差别
interface AccountValueObject{
boolean isActive();
}
class AccountDTO{
private Boolean active;
public Boolean getActive(){
return active;
}
public void setActive(Boolean active){
this.active=active;
}
}
class AccountDTO{
private Boolean isActive;
public Boolean getIsActive(){
return isActive;
}
public void setIsActive(Boolean isActive){
this.isActive=isActive;
}
}
上面又有两种DTO的写法,差别在于是否有is
一般而言,为了声明不能修改的特性,VO的返回值可以是Stream,从而可以更方便的实现懒加载.而DTO考虑的json格式的转换,需要是List格式,并且一般不适用?格式的泛型
interface DepartmentValueObject{
Stream<? extends StaffValueObject> getStaffs();
}
class DepartmentDO implements DepartmentValueObject{
private List<String> staffIds;
private final StaffRepository staffRepository;
@Override
public Stream<? extends StaffValueObject> getStaffs(){
return staffIds.stream().map(staffRepository::lazyLoad);
}
}
对于ValueObject而言,返回值可以是enum格式,但是对于DTO则不能这样做.因为当业务变动时enum需要增加一项时VO可以考虑增加,而DTO由于是产生关联的两个服务间共享的,另一个服务无法同时增加(甚至永远无法增加),为了保持兼容性,DTO不能使用enum.此时有两种做法,简单点就是使用String,但是这样仅仅解决报错的问题,对于QueryService而言,最好把name一并返回.
结合上面的,如果VO也被共享,那么使用enum也是有风险的.
以上这些返回值的差别直接导致了VO和DTO之间不能有任何class extends class或class implements interface或interface extends interface的关系
PO:PersistenceObject 是Repository所关心的东西.一般来说RMDB中的每个表对应的Entity和NoSQL中的存储的对象都算是PO.在CQRS设计中,PO可以完全遵循设计范式而不用在乎冗余字段,而DTO的目的就是为了体现出这些容易字段.他们之间有类似关系
P e r s i s t e n c t O b j e c t ⇒ D o m a i n O b j e c t → V a l u e O b j e c t D a t a T r a n s f e r O b j e c t PersistenctObject \xRightarrow{DomainObject\rightarrow ValueObject} DataTransferObject PersistenctObjectDomainObject→ValueObjectDataTransferObject
这样看上去DTO和PO可以实现extends的关系.但其实不然.有以下两个原因
class LessonPO{
Map<String,StudentRecordPO> studentRecords;
}
class LessonDTO{
List<StudentRecordDTO> studentRecords;
}
由于难以确定唯一性,所以DTO最好使用List而不是Map,另外Domain使用Map可以完成更方便的查找,而QueryService接收DTO时一般是进行遍历操作,所以list更方便.另外上面还需要注意的一点是StudentRecordPO里可以没有studentId,因为这是entry的key,而StudentRecordDTO中必须有studentId
另外在PO中使用enum是完全可以的,因为不会被共享.另外PO也无法完全OO而需要考虑各种格式问题,所以PO相对于VO甚至和DTO的格式更加接近.另外DTO适合引入VO(否则客户端在import时也需要import),所以向DTO的转换就会出现toDTO来从Domain直接获得DTO.
由于上面承诺的限制,DTO不能包含太多的字段,另外由于ViewObject的会有多个并且频繁变动,所以DTO和ViewObject应该完全切割没有联系.QueryService需要自行完成ViewObject的组装.
为了避免增加字段后的遗漏,代码中使用全参构造来创建对象,并且这个全参构造方法需要有lombok自动产生.另外为了满足框架的序列化要求,还需要保留无参构造方法,所以字段也不能定义为final的,虽然逻辑上来说应该是final的
同样由于全参构造,对于内部类而言需要声明为static的,毕竟在创建Outter对象的时候需要把创建好的Inner对象作为参数传入.
如果业务中出现了extends的时候(这个时候DomainObject,至少是BusinessObject和ValueObject应该出现继承),DTO是否也需要继承呢?
我们假设把Department分为两类,School和Office.本身这二者是使用了type字段作为区分.现在的问题是School需要加额外的操作,这个时候也许可以不增加DTO,但是当School需要增加新的字段,特别是School和Office各自需要增加各自的字段的时候,可能就需要SchoolDTO和OfficeDTO了.为了向前兼容从而减少客户端的改动仍然需要保留DepartmentDTO,更重要的是作为一个业务概念也许后面还会出现新的子类(例如Company),那么为了向后兼容也需要保留DepartmentDTO.那么现在的问题是SchoolDTO需要继承Office么?
我们先从一个细节着手,那就是SchoolDTO是否需要DepartmentDTO的所有字段?其他的都差不多,那我们回到type字段,对于SchoolDTO而言肯定是SCHOOL,那么这个字段似乎多次一举.如果SchoolDTO没有type字段那么自然不能继承DepartmentDTO.但是这里的问题是type只会有这两种么,以后会不会增加,特备是School是否会有两种type.考虑到这种可能type就是需要的了.但这个时候对于客户端就不能做出type只有两种的假设,更加不能做出SchoolDTO.type==SCHOOL的假设.那这个时候如何确定是不是SCHOOL呢,也许要加上isSchool这个方法了.而在这个时候SchoolDTO.isSchool==true.结合上面对于enum的讨论,type可以是一个DTO从而直接将name返回.
在上面的问题解决后我们再来看是否需要extends呢?如果实现了extends的,一个好处随之而来
class DepartmentValueObject{
DepartmentDTO toDTO();
}
class SchoolDTO extends DepartmentDTO{
}
class SchoolValueObject{
@Override
SchoolDTO toDTO();
}
那就是toDTO可以被Override.但是让我们抵制住这种诱惑来看看两个DTO
让我们考虑下面这种复杂的数据结构
@Data
@AllArgumentConstrator
@NoArguamentConstrator
class ParentDTO{
class InnerDTO{
}
private InnerDTO inner;
}
@Data
@AllArgumentConstrator
@NoArgumentConstrator
class ChildDTO extends ParentDTO{
class InnerDTO extends ParentDTO.InnerDTO{
}
private InnerDTO inner;
}
上面的咋一看没有问题,但其实隐含了一个很大的问题,那就是子类的setInner未能正确的重写父类的方法.那么在反序列化时可能会出现问题.由于上面所说的全参构造的问题,即使如果让父类把inner定义成protected的也并不能解决问题