服务端模块化架构设计 2.0|结合DDD与MVC的中庸之道(优化与插件)

1,424 阅读12分钟

本专栏 将通过以下几块内容来搭建一个 模块化:可以根据项目的功能需求和体量进行任意模块的组合或扩展 的后端服务

项目结构与模块化构建思路

RESTful与API设计&管理

网关路由模块化支持与条件配置

DDD领域驱动设计与业务模块化(概念与理解)

DDD领域驱动设计与业务模块化(落地与实现)

DDD领域驱动设计与业务模块化(薛定谔模型)

DDD领域驱动设计与业务模块化(优化与重构)

RPC模块化设计与分布式事务

v2.0:项目结构优化升级

v2.0:项目构建+代码生成「插件篇」

v2.0:扩展模块实现技术解耦

v2.0:结合DDD与MVC的中庸之道(启发与思路)

v2.0:结合DDD与MVC的中庸之道(标准与实现)

v2.0:结合DDD与MVC的中庸之道(优化与插件)(本文)

未完待续......

在之前的文章 v2.0:项目结构优化升级 中,我们将项目的结构进行了优化,优化之后的项目结构如下

项目结构v2.png

简单说明

考虑到可能有读者是第一次看这个专栏,所以我还是先简单介绍一下,详细的内容大家可以看我之前的专栏文章

这里模块化的设想其实就是可插拔的功能模块,如果需要这个功能,就把这个模块用GradleMaven的方式编译进来,如果不需要,去掉对应的依赖就行了,避免改动代码,因为一旦涉及到代码改动的话,就会变得“改不断,理还乱”

当然了,需要达到每个模块都能够任意拆卸的程度其实并不简单,所以我借鉴了DDD的思想来达成这个目的,专栏中也有这一块的内容,今天这篇文章就是对DDD的进一步优化

当我们把所有模块都编译进来的时候,那么就是一个单体应用,如果把多个模块分开编译,那么就变成了微服务

以我们juejin项目的三个模块(用户,沸点,通知)为例:

在最开始的时候,我们可以将这三个模块打包成一个服务,作为单体应用来运行,适合项目前期体量较小的情况

之后,当我们发现沸点的流量越来越大,就可以将沸点模块拆分出来作为单独的一个服务方便扩容,用户模块和通知模块打包在一起作为另一个服务,这样就从单体应用变成了微服务

注:从单体应用到微服务的切换是不需要修改代码的

领域模型嵌套查询

在上一篇文章结合DDD与MVC的中庸之道(标准与实现)中我们的用户存储是这样实现的

//基于 MBP 的用户存储实现
//UserPO 为数据库表对应的数据模型
@Repository
public class MBPUserRepository extends MBPDomainRepository<User, Users, UserPO> implements UserRepository {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private DomainValidator validator;

    @Override
    public UserPO do2po(User user) {
        UserPO po = new UserPO();
        po.setId(user.getId());
        po.setUsername(user.getUsername());
        po.setPassword(user.getPassword());
        po.setNickname(user.getNickname());
        po.setCreateTime(user.getCreateTime());
        return po;
    }

    @Override
    public User po2do(UserPO po) {
        return new UserImpl.Builder()
                .id(po.getId())
                .username(po.getUsername())
                .password(po.getPassword())
                .nickname(po.getNickname())
                .createTime(po.getCreateTime())
                .build(validator);
    }

    @Override
    public BaseMapper<UserPO> getBaseMapper() {
        return userMapper;
    }
}

对于简单的模型来说没什么问题

我们只要将查询出来的数据转为领域模型即可

嵌套领域对象,单个查询

但是当出现下面这样的模型时

//示例
public interface Sample extends DomainEntity {

    String getSample();

    User getUser();
}

领域模型中还持有了其他的领域模型

很明显是和其他的表产生了关联

而我们的t_sample表可能是这样的结构

idsampleuser_id
sample1sample1user1
sample2sample2user2

这个时候可以这样实现用户存储

//基于 MBP 的示例存储实现
@Repository
public class MBPSampleRepository extends MBPDomainRepository<Sample, Samples, SamplePO> implements SampleRepository {

    @Autowired
    private SampleMapper sampleMapper;

    @Autowired
    private DomainFactory factory;

    @Autowired
    private DomainValidator validator;

    @Override
    public SamplePO do2po(Sample sample) {
        SamplePO po = new SamplePO();
        po.setId(sample.getId());
        po.setUserId(sample.getUser().getId());
        return po;
    }

    @Override
    public Sample po2do(SamplePO po) {
        //根据 userId 生成一个 User,该方法不会立即查询数据库,而会等到调用 User 的方法获取数据时才会触发查询
        User user = factory.createObject(User.class, po.getUserId());
        return new SampleImpl.Builder()
                .id(po.getId())
                .sample(po.getSample())
                .user(user)
                .users(users)
                .build(validator);
    }

    @Override
    public BaseMapper<SamplePO> getBaseMapper() {
        return sampleMapper;
    }
}

通过DomainFactory生成一个User对象(实际上就是使用了动态代理暂时占了个位置)

只有当调用用户数据的时候才会触发查询(通过传入的id查询数据)

嵌套领域对象,批量查询

查询列表的时候就又出现了一个问题

我们之前的AbstractDomainRepository是这样实现的

//领域存储抽象类
//P 为数据模型
public abstract class AbstractDomainRepository<T extends DomainObject, C extends DomainCollection<T>,
        P extends Identifiable> implements DomainRepository<T, C> {

    //数据模型转领域模型
    public abstract T po2do(P po);

    //数据模型转领域模型
    public Collection<T> pos2dos(Collection<? extends P> pos) {
        return pos.stream().map(this::po2do).collect(Collectors.toList());
    }

    //根据 id 获得一个领域模型
    @Override
    public T get(String id) {
        P po = doGet(id);
        if (po == null) {
            return null;
        }
        return po2do(po);
    }

    //根据 id 获得一条数据
    protected abstract P doGet(String id);

    //根据 ids 获得多个领域模型
    @Override
    public C select(Collection<String> ids) {
        return wrap(pos2dos(doSelect(ids)));
    }

    //根据 ids 获得多条数据
    protected abstract Collection<P> doSelect(Collection<String> ids);

    //省略其他方法
}

select方法查询列表的时候pos2dos默认实现会循环调用po2do

也就是说每次调用User信息都会根据id单独查询一次

而我们平时的做法一般是先获得所有的userIds然后一次性查询出来

这种情况下可以重写pos2dos来优化

//基于 MBP 的示例存储实现
@Repository
public class MBPSampleRepository extends MBPDomainRepository<Sample, Samples, SamplePO> implements SampleRepository {

    @Autowired
    private SampleMapper sampleMapper;

    @Autowired
    private DomainFactory factory;

    @Autowired
    private DomainValidator validator;

    //查询列表或分页查询时会多次调用 {@link #po2do(SamplePO)} 方法导致多次查询数据库
    //可重写该方法实现只查询一次数据库
    @Override
    public Collection<Sample> pos2dos(Collection<? extends SamplePO> pos) {
        List<Sample> samples = new ArrayList<>();

        //获得 Sample id 集合
        Set<String> sampleIds = pos.stream()
                .map(SamplePO::getId)
                .collect(Collectors.toSet());

        //获得 sample id 和 User 的 Map,该方法不会立即查询数据库,而会等到调用 Users 的方法获取数据时才会触发查询
        Map<String, User> userMap = factory.createObject(Users.class, sampleIds, ids -> {
            //Sample id 和 userId 的关联 Map
            return pos.stream().collect(Collectors.toMap(SamplePO::getId, SamplePO::getUserId));
        });

        for (SamplePO po : pos) {
            Sample sample = new SampleImpl.Builder()
                    .id(po.getId())
                    .sample(po.getSample())
                    .user(userMap.get(po.getId()))
                    .build(validator);
            samples.add(sample);
        }
        return samples;
    }

    @Override
    public BaseMapper<SamplePO> getBaseMapper() {
        return sampleMapper;
    }
}

DomainFactory中定义了一个方法

传入sampleIds以及根据sampleIds获得userIds的函数

会返回一个sampleIdUser的关联Map

内部的原理其实就是当调用User的方法时,会先根据sampleIds获得userIds,然后根据userIds查询数据并缓存,最后根据userId从查询到的数据中匹配对应的User数据返回

嵌套领域集合,单个查询

说完Sample中持有User之后,我们来说说Sample中持有Users该怎么办

//示例
public interface Sample extends DomainEntity {

    String getSample();

    Users getUsers();
}

现在t_sample表是这样的

idsample
sample1sample1
sample2sample2

同时有一张关联表t_sample_user来存储关联数据

idsample_iduser_id
1sample1user1
2sample1user2
3sample2user3
4sample2user4

这个时候可以这样实现用户存储

//基于 MBP 的示例存储实现
@Repository
public class MBPSampleRepository extends MBPDomainRepository<Sample, Samples, SamplePO> implements SampleRepository {

    @Autowired
    private SampleMapper sampleMapper;
    
    @Autowired
    private SampleUserMapper sampleUserMapper;

    @Autowired
    private DomainFactory factory;

    @Autowired
    private DomainValidator validator;

    @Override
    public Sample po2do(SamplePO po) {
        //获得和 sampleId 关联的数据对象
        List<SampleUserPO> sampleUsers = sampleUserMapper
                .selectList(Wrappers.<SampleUserPO>lambdaQuery()
                        .eq(SampleUserPO::getSampleId, po.getId()));
        //处理获得 sampleId 关联的 userId 集合
        Set<String> userIds = sampleUsers.stream()
                .map(SampleUserPO::getUserId)
                .collect(Collectors.toSet());
        //根据 userId 集合生成 Users,该方法不会立即查询数据库,而会等到调用 Users 的方法获取数据时才会触发查询
        Users users = factory.createCollection(Users.class, userIds);
        return new SampleImpl.Builder()
                .id(po.getId())
                .sample(po.getSample())
                .users(users)
                .build(validator);
    }
    
    //如果有关联表,可以重写该方法添加关联关系
    @Transactional
    @Override
    public void create(Sample sample) {
        super.create(sample);
        sample.getUsers().list().stream().map(it -> {
            SampleUserPO sup = new SampleUserPO();
            sup.setSampleId(sample.getId());
            sup.setUserId(it.getId());
            return sup;
        }).forEach(sampleUserMapper::insert);

    }

    //如果有关联表,可以重写该方法删除关联关系
    @Transactional
    @Override
    public void delete(Sample sample) {
        super.delete(sample);
        sampleUserMapper.delete(Wrappers.<SampleUserPO>lambdaQuery()
                .eq(SampleUserPO::getSampleId, sample.getId()));
    }
    
    @Override
    public BaseMapper<SamplePO> getBaseMapper() {
        return sampleMapper;
    }
}

通过DomainFactory生成一个Users对象(也是使用动态代理)

等到调用Users的方法时才会触发查询

同时重写createdelete方法新增关联关系和删除关联关系

嵌套领域集合,批量查询

和之前一样,如果是列表查询那么也是重写pos2dos

//基于 MBP 的示例存储实现
@Repository
public class MBPSampleRepository extends MBPDomainRepository<Sample, Samples, SamplePO> implements SampleRepository {

    @Autowired
    private SampleMapper sampleMapper;
    
    @Autowired
    private SampleUserMapper sampleUserMapper;

    @Autowired
    private DomainFactory factory;

    @Autowired
    private DomainValidator validator;

    //查询列表或分页查询时会多次调用 {@link #po2do(SamplePO)} 方法导致多次查询数据库
    //可重写该方法实现只查询一次数据库
    @Override
    public Collection<Sample> pos2dos(Collection<? extends SamplePO> pos) {
                List<Sample> samples = new ArrayList<>();

        //获得 Sample id 集合
        Set<String> sampleIds = pos.stream()
                .map(SamplePO::getId)
                .collect(Collectors.toSet());

        //获得 sample id 和 Users 的 Map,该方法不会立即查询数据库,而会等到调用 Users 的方法获取数据时才会触发查询
        Map<String, Users> usersMap = factory.createCollection(Users.class, sampleIds, ids -> {
            //根据 sampleId 集合获得所有的关联对象
            List<SampleUserPO> sampleUserList = sampleUserMapper
                    .selectList(Wrappers.<SampleUserPO>lambdaQuery()
                            .in(SampleUserPO::getSampleId, sampleIds));
            //处理获得 sampleId 和 userIds 的关联 Map
            return sampleUserList.stream()
                    .collect(Collectors.groupingBy(SampleUserPO::getSampleId,
                            Collectors.mapping(SampleUserPO::getUserId, Collectors.toSet())));
        });

        for (SamplePO po : pos) {
            Sample sample = new SampleImpl.Builder()
                    .id(po.getId())
                    .sample(po.getSample())
                    .users(usersMap.get(po.getId()))
                    .build(validator);
            samples.add(sample);
        }
        return samples;
    }

    @Override
    public BaseMapper<SamplePO> getBaseMapper() {
        return sampleMapper;
    }
}

DomainFactory也提供了对应的方法来简化步骤

传入sampleIds以及根据sampleIds获得userIds的函数

这里的sampleIdsuserIds的关联关系需要通过数据库来查询

测试

接下来我们不妨测试一下

定义几个测试方法

@Slf4j
@RestController
@RequestMapping("test")
public class TestController {

    @Autowired
    private SampleRepository sampleRepository;

    @GetMapping
    public void test(int i) {
        if (i == 0) {
            testNoGet();
        } else if (i == 1) {
            testGetId();
        } else if (i == 2) {
            testGetUsername();
        }
    }

    //只获取 Sample 自身的数据
    private void testNoGet() {
        Samples samples = sampleRepository.select(new Conditions());
        for (Sample sample : samples.list()) {
            String s = sample.getSample();
            doSomething(s);
        }
    }

    //获取 User 的 id
    private void testGetId() {
        Samples samples = sampleRepository.select(new Conditions());
        for (Sample sample : samples.list()) {
            Users users = sample.getUsers();
            for (User user : users.list()) {
                String id = user.getId();
                doSomething(id);
            }
        }
    }
    
    //获取 User 的 username
    private void testGetUsername() {
        Samples samples = sampleRepository.select(new Conditions());
        for (Sample sample : samples.list()) {
            Users users = sample.getUsers();
            for (User user : users.list()) {
                String username = user.getUsername();
                doSomething(username);
            }
        }
    }

    private void doSomething(Object o) {
        //模拟业务处理方法
    }
}

分别对应了不处理User,只处理Userid,处理User的其他数据

首先是testNoGet只获取Sample自身的数据

Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@58fb5336] was not registered for synchronization because synchronization is not active
JDBC Connection [HikariProxyConnection@1446819417 wrapping com.mysql.cj.jdbc.ConnectionImpl@47f69a6c] will not be managed by Spring
==>  Preparing: SELECT id,sample,user_id,deleted FROM t_sample WHERE deleted=0
==> Parameters: 
<==    Columns: id, sample, user_id, deleted
<==        Row: sample1, sample1, user1, 0
<==        Row: sample2, sample2, user2, 0
<==      Total: 2
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@58fb5336]

可以看到只查询了t_sample表,并没有查询关联表t_sample_user和用户表t_user

接下来是testGetId只获取Userid

Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5265c6b3] was not registered for synchronization because synchronization is not active
JDBC Connection [HikariProxyConnection@1554133647 wrapping com.mysql.cj.jdbc.ConnectionImpl@47f69a6c] will not be managed by Spring
==>  Preparing: SELECT id,sample,user_id,deleted FROM t_sample WHERE deleted=0
==> Parameters: 
<==    Columns: id, sample, user_id, deleted
<==        Row: sample1, sample1, user1, 0
<==        Row: sample2, sample2, user2, 0
<==      Total: 2
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5265c6b3]
Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@1d9f38b4] was not registered for synchronization because synchronization is not active
JDBC Connection [HikariProxyConnection@1639130602 wrapping com.mysql.cj.jdbc.ConnectionImpl@47f69a6c] will not be managed by Spring
==>  Preparing: SELECT id,sample_id,user_id FROM t_sample_user WHERE (sample_id IN (?,?))
==> Parameters: sample1(String), sample2(String)
<==    Columns: id, sample_id, user_id
<==        Row: 1, sample1, user1
<==        Row: 2, sample1, user2
<==        Row: 3, sample2, user3
<==        Row: 4, sample2, user4
<==      Total: 4
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@1d9f38b4]

可以看到查询了t_sample表和关联表t_sample_user,没有查询用户表t_user

因为很多情况下我们可能只需要id,所以当我们只获取id的时候只是查询关联表并不会去查询内部的数据

当然,如果是一对多的关系,id和数据本身就在一张表中,就直接在调用方法的时候一次性查询出来就好了,这里只是针对存在中间表的情况

最后是testGetUsername会获取Userusername

Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@1ed61470] was not registered for synchronization because synchronization is not active
JDBC Connection [HikariProxyConnection@1076178012 wrapping com.mysql.cj.jdbc.ConnectionImpl@47f69a6c] will not be managed by Spring
==>  Preparing: SELECT id,sample,user_id,deleted FROM t_sample WHERE deleted=0
==> Parameters: 
<==    Columns: id, sample, user_id, deleted
<==        Row: sample1, sample1, user1, 0
<==        Row: sample2, sample2, user2, 0
<==      Total: 2
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@1ed61470]
Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@45ebafd0] was not registered for synchronization because synchronization is not active
JDBC Connection [HikariProxyConnection@187180819 wrapping com.mysql.cj.jdbc.ConnectionImpl@47f69a6c] will not be managed by Spring
==>  Preparing: SELECT id,sample_id,user_id FROM t_sample_user WHERE (sample_id IN (?,?))
==> Parameters: sample1(String), sample2(String)
<==    Columns: id, sample_id, user_id
<==        Row: 1, sample1, user1
<==        Row: 2, sample1, user2
<==        Row: 3, sample2, user3
<==        Row: 4, sample2, user4
<==      Total: 4
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@45ebafd0]
Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@67ae035b] was not registered for synchronization because synchronization is not active
JDBC Connection [HikariProxyConnection@2133042513 wrapping com.mysql.cj.jdbc.ConnectionImpl@47f69a6c] will not be managed by Spring
==>  Preparing: SELECT id,username,password,nickname,avatar,enabled,create_time FROM t_user WHERE id IN ( ? , ? , ? , ? )
==> Parameters: user1(String), user2(String), user3(String), user4(String)
<==    Columns: id, username, password, nickname, avatar, enabled, create_time
<==        Row: user1, user1, user1, user1, null, 1, 2001-01-01 00:00:00
<==        Row: user2, user2, user2, user2, null, 1, 2002-02-02 00:00:00
<==        Row: user3, user3, user3, user3, null, 1, 2003-03-03 00:00:00
<==        Row: user4, user4, user4, user4, null, 1, 2004-04-04 00:00:00
<==      Total: 4
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@67ae035b]
c

可以看到会查询t_sample表,关联表t_sample_user和用户表t_user

也就是说我们现在的实现方式能够根据我们需要的数据合理的进行动态查询

动态代理回收复用

根据我们上面的实现,几乎都用到了动态代理,而且生成的还不少,某几个方法甚至会生成10多个动态代理对象

印象中动态代理是不是存在一定的性能问题

所以我就想着说如果把这些对象缓存起来是不是可以对性能有一些提升

于是我就定义了一个回收器

public interface DomainRecycler {

    <T extends DomainObject> boolean recycle(Object recycleType, Class<T> domainType, T recyclable);

    <T extends DomainObject> T reuse(Object recycleType, Class<T> domainType, Supplier<T> supplier);
}

当我们的领域对象用完之后仍到回收池里面

等下次需要同样类型的对象时就可以从里面拿出来复用

具体的实现就是当使用动态代理生成了一个对象之后就扔到ThreadLocal里面

然后注册一个HandlerInterceptorafterCompletion的时候将ThreadLocal中保存的代理对象回收

这样的话就不需要手动回收了

性能对比

我写了一个方法循环调用testGetUsername来比较两者的性能

次数原始参照
范围/平均(ms)
回收复用
范围/平均(ms)
100次558-784/680563-723/630
1000次5691-8532/65115511-7064/6221
10000次58979-64294/6120161352-66494/63396

在100次和1000次的时候好像快了那么一点点

10000次的时候却反而更慢了

不过从总体来说回不回收都没有太大的区别

这个测试虽说是更接近平时的情况

但是每次都会去查询数据库,不能排除有这方面的影响

于是我打算单纯测试一下动态代理本身回收和不回收的性能差距(流程中不再请求数据库了)

次数原始参照
范围/平均(ms)
回收复用
范围/平均(ms)
100次17-50/2816-33/21
1000次35-58/4725-69/36
10000次102-143/11767-149/94
100000次996-2330/1280397-607/463
1000000次11332-15855/138093382-4974/4084

到达10w次的时候差距才开始明显变大,但是也不过1s左右

而且对比两个测试数据就能发现,时间主要是在数据库查询上

动态代理生成对象的时间占比都不到5%

虽说回收复用确实是能提升一点效率

但是平时一个分页查询才几条数据,根本没必要

而如果数据量大到10w,100w的任务处理也不会去在意多几秒

结论:优化了个寂寞

领域模块代码生成

这里就顺带提一嘴吧

之前有写了一个插件来生成基本的代码

生成domain代码.gif

具体的可以看项目构建+代码生成「插件篇」这篇文章

因为我们实现的类除了名称很多都是一模一样的

所以就实现了这个插件

另外这个插件其实是基于我的项目结构来自动填充一些数据

如果单独使用的话需要手动配置

不过也比手写来的方便

直接搜索插件Concept Cloud就可以啦

总结

如果大家对DomainFactory这个将动态代理玩弄于股掌之中的类有兴趣的话,可以尝试看看源码,毕竟现在的我看了也只能说一句:当时的我真NB

上一篇:v2.0:结合DDD与MVC的中庸之道(标准与实现)