服务端模块化架构设计|RPC 模块化设计与分布式事务

2,285 阅读10分钟

我正在参加「掘金·启航计划」

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

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

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的中庸之道(优化与插件)

未完待续......

在之前的文章 服务端模块化架构设计|项目结构与模块化构建思路 中,我们以掘金的部分功能为例,搭建了一个支持模块化的后端服务项目juejin,其中包含三个模块:juejin-user(用户)juejin-pin(沸点)juejin-message(消息)

通过添加启动模块来任意组合和扩展功能模块

  • 示例1:通过启动模块juejin-appliaction-systemjuejin-user(用户)juejin-message(消息)合并成一个服务减少服务器资源的消耗,通过启动模块juejin-appliaction-pin来单独提供juejin-pin(沸点)模块服务以支持大流量功能模块的精准扩容

  • 示例2:通过启动模块juejin-appliaction-singlejuejin-user(用户)juejin-message(消息)juejin-pin(沸点)直接打包成一个单体应用来运行,适合项目前期体量较小的情况

PS:示例基于IDEA + Spring Cloud

模块化项目结构.jpg

为了能更好的理解本专栏中的模块化,建议读者先阅读 服务端模块化架构设计|项目结构与模块化构建思路

模块间的调用问题

由于我们的模块是可以任意组合的,所以就会有一个问题:

  • 当两个模块是打包在一起的时候,相当于是内部调用

  • 当两个模块是在两个不同的服务的时候,就变成的远程调用

也就是说,我们需要为每一种组合都适配一遍

这不要了命了么?

不要急,我有办法,一套代码适配两种情况

用户接口示例

我们在之前实现的juejin-pin(沸点)模块中,就有用户模型,比如发布沸点的用户,评论的用户等等

而用户的相关业务我们有单独的juejin-user(用户)模块,所以juejin-pin(沸点)模块中的用户信息就需要从juejin-user(用户)模块中获取

这里就会出现我们之前说的问题

如果juejin-pin(沸点)juejin-user(用户)是合并在一起的,就像juejin-appliaction-single,那么可以直接进行内部调用

如果juejin-pin(沸点)juejin-user(用户)是分开的,就像juejin-appliaction-systemjuejin-appliaction-pin,那么需要通过远程服务调用

抽象模块接口

对于获得用户信息这个功能,我们先定义一个接口UserApi

public interface UserApi {

    /**
     * 通过id获得用户信息
     */
    UserRO get(String id);
}

其中UserROuser remote object,表示远程的,非本模块的用户对象

我们可以把这个接口放在juejin-basic中,这样其他的模块也能进行复用

RemoteUserRepository

我们为juejin-pin(沸点)模块中的UserRepository实现一个RemoteUserRepository

@Repository
public class RemoteUserRepository implements UserRepository {

    @Autowired
    private UserApi userApi;
    
    /**
     * 根据 id 获得一个领域模型
     */
    @Override
    public User get(String id) {
        return ro2do(userApi.get(id));
    }

    public User ro2do(UserRO ro) {
        //模型转换
    }
    
    //省略其他代码
}

当我们的juejin-pin(沸点)模块调用UserRepository#get(id)时,实际是通过UserApi#get(id)来获得用户信息,再通过ro2doUserRO转为我们juejin-pin(沸点)模块中指定的User模型

实现UserApi

接下来我们分别实现内部调用和远程服务调用这两种用户获取方式

InnerUserApi

juejin-user(用户)模块中实现InnerUserApi

@Component
public class InnerUserApi implements UserApi {

    /**
     * 这个是juejin-user中的UserRepository
     */
    @Autowired
    private UserRepository userRepository;

    /**
     * 这个是juejin-user中的UserFacadeAdapter
     */
    @Autowired
    private UserFacadeAdapter userFacadeAdapter;

    @Override
    public UserRO get(String id) {
        User user = userRepository.get(id);
        return userFacadeAdapter.do2ro(user);
    }
}

我们只需要直接调用UserRepository就行了

这条链路是这样的:

用户和沸点合并.png

如果模块是合并的,那么直接通过内部的juejin-user(用户)模块提供的InnerUserApi就能获得用户信息了

FeignUserApi

juejin-basic(基础)模块中实现FeignUserApi

public class FeignUserApi implements UserApi {

    @Autowired
    private UserFeignClient userFeignClient;

    @Override
    public UserRO get(String id) {
        Response<UserRO> response = userFeignClient.get(id);
        if (response.isSuccess()) {
            return response.getObject();
        }
        throw new RuntimeException(response.getMessage());
    }
}

@FeignClient(name = "juejin-user")
public interface UserFeignClient {

    @GetMapping("/user/{id}")
    Response<UserRO> get(@PathVariable String id);
}

这里需要集成UserFeignClient,通过Feign的方式来获得用户的信息

这条链路是这样的:

用户和沸点分开.png

如果模块间是分开的,分别位于不同的服务中,就需要通过FeignRPC方式了

Feign路由映射

我们的juejin-user(用户)模块对应的服务实际上是juejin-appliaction-system,或者是其他的名称(不同的模块组合可能会有不同的命名)

但是如果每种组合方式都要手动修改对应的名称,那肯定不行,太麻烦了

我们可以看到在上面的示例中指定为对应的模块名称juejin-user,也就是UserFeignClient上的注解@FeignClient的参数是juejin-user

但是只是这样还不行,毕竟我们没有一个叫juejin-user的服务

所以我们要想办法让juejin-user能够根据不同模块组合动态的映射为对应的服务名称

这个功能其实我们已经在 网关路由模块化支持与条件配置 实现过了,大概的流程是

  • build.gradle中添加额外的脚本生成router.properties,其中记录当前服务包含的模块
processResources {
    //资源文件处理之前
    doFirst {
        Set<String> mSet = new HashSet<>()
        //遍历所有的依赖
        project.configurations.forEach(configuration -> {
            configuration.allDependencies.forEach(dependency -> {
                //如果是我们项目中的业务模块则添加该模块名称
                if (dependency.group == 'com.bytedance.juejin') {
                    mSet.add(dependency.name)
                }
            })
        })
        //移除,基础模块不需要路由
        mSet.remove('juejin-basic')
        //如果包含了业务模块
        if (!mSet.isEmpty()) {
            //获得资源目录
            File resourcesDir = new File(project.projectDir, '/src/main/resources')
            //创建路由文件
            File file = new File(resourcesDir, 'router.properties')
            if (!file.exists()) {
                file.createNewFile()
            }
            //将模块信息写入文件
            Properties properties = new Properties()
            properties.setProperty("routers", String.join(',', mSet))
            OutputStream os = new FileOutputStream(file)
            properties.store(os, "Routers generated file")
            os.close()
        }
    }
}
  • 读取router.properties将数据同步到注册中心
@Component
public class RouterRegister {

    /**
     * 监听服务注册前置事件
     */
    @EventListener
    public void register(InstancePreRegisteredEvent event) throws Exception {
        //读取 router.properties 资源文件
        ClassPathResource resource = new ClassPathResource("router.properties");
        //加载到 Properties 中
        Properties properties = new Properties();
        try (InputStream is = resource.getInputStream()) {
            properties.load(is);
        }
        //获得 routers 值
        String routers = properties.getProperty("routers");
        //写入 metadata 中
        Map<String, String> metadata = event.getRegistration().getMetadata();
        metadata.put("routers", routers);
    }
}

(上面两块更详细的内容可以看 网关路由模块化支持与条件配置 中的实现)

  • 监听心跳事件刷新模块和服务的映射关系

这里我们只要把网关的路由刷新逻辑移过来就行了

@Slf4j
public class RouterLoadBalancerClientFactory extends LoadBalancerClientFactory {

    private final DiscoveryClient discoveryClient;

    private volatile Map<String, String> routerMap = Collections.emptyMap();

    public RouterLoadBalancerClientFactory(LoadBalancerClientsProperties properties, DiscoveryClient discoveryClient) {
        super(properties);
        this.discoveryClient = discoveryClient;
    }

    @Override
    public <T> T getInstance(String name, Class<T> type) {
        String router = getRouter(name);
        log.info("Router mapping: {} => {}", name, router);
        return super.getInstance(router, type);
    }

    protected String getRouter(String name) {
        return routerMap.getOrDefault(name, name);
    }

    /**
     * 监听心跳事件
     */
    @EventListener
    public void refreshRouters(HeartbeatEvent event) {
        //新的路由映射
        Map<String, String> newRouterMap = new HashMap<>();
        //获得服务名
        List<String> services = discoveryClient.getServices();
        for (String service : services) {
            //获得服务实例
            List<ServiceInstance> instances = discoveryClient.getInstances(service);
            if (instances.isEmpty()) {
                continue;
            }
            //这里直接拿第一个
            ServiceInstance instance = instances.get(0);
            //获得 metadata 中的 routers
            String routersMetadata = instance.getMetadata()
                    .getOrDefault("routers", "");
            String[] routers = routersMetadata.split(",");

            for (String router : routers) {
                newRouterMap.put(router, service);
            }
        }
        if (!this.routerMap.equals(newRouterMap)) {
            log.info("Update router map => {}", newRouterMap);
        }
        //更新缓存
        this.routerMap = newRouterMap;
    }
}

通过监听服务注册的心跳,同步模块和服务的映射关系

扩展LoadBalancerClientFactory,在中间添加一步将模块名称映射为服务名称的逻辑

这里高版本的Spring Cloud用的是spring-cloud-loadbalancer做的负载均衡,所以我们扩展LoadBalancerClientFactory就行了

如果是低版本,用的是ribbon,扩展的类是不一样的,有需要的话可以看 【Spring Cloud】协同开发利器之动态路由|Ribbon & LoadBalancer 解析篇,也可以参考这个库的源码来扩展ribbon

条件配置

最后还需要添加一个配置类

@Configuration
@AutoConfigureBefore(LoadBalancerAutoConfiguration.class)
@EnableFeignClients(basePackages = "com.bytedance.juejin.basic.rpc.feign")
public class FeignAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public UserApi userApi() {
        return new FeignUserApi();
    }

    @Bean
    public LoadBalancerClientFactory routerLoadBalancerClientFactory(LoadBalancerClientsProperties properties,
                                                                     DiscoveryClient discoveryClient) {
        return new RouterLoadBalancerClientFactory(properties, discoveryClient);
    }
}

@ConditionalOnMissingBean标记FeignUserApi

juejin-pin(沸点)juejin-user(用户)是合并在一起的时候,Spring会识别到InnerUserApi,于是不会注入FeignUserApi,所有的用户接口都会走本地用户模块的UserRepository

juejin-pin(沸点)juejin-user(用户)是分开的时候, FeignUserApi会被注入,所有的用户接口都会走Feign

这样我们只需要根据需求定义对应的xxApi,然后分别实现InnerApiFeignApi或是DubboApi的方式,之后无论我们对模块进行怎么样的自由组合都能够自动适配,不需要额外的手动处理

分布式事务问题

如果我们的模块间调用需要用到分布式事务是否存在一些方式能够做到兼容呢,当两个模块合并在一起的时候就用本地事务,当两个模块分开的时候就用分布式事务,根据模块间的组合方式自动识别切换

目前我的答案是不太好做(当然如果有大佬想到比较好的方式也可以分享一下)

现在有如下的代码

@PostMapping("/test")
@SmartTransactional//我们自己实现事务切面
public void test() {
    a.a();//本地调用
    b.b();//本地调用或服务间调用
}

如果我们自己实现事务切面

我们什么时候能知道是不是服务间调用?b.b()调用的时候,我们可以根据不同的实现确定是本地调用还是服务间调用

当我们调用b.b()确定了服务间调用需要选择分布式事务的时候,a.a()已经执行了

所以我们其实没办法在方法开始之前确定方法中是否会有服务间调用,更何况还会有嵌套事务等复杂场景

如果一定要用分布式事务的话,还是单独处理比较好,可以额外加一个方法

@PostMapping("/test-local")
@Transactional
public void testLocal() {
    a.a();//本地调用
    b.b();//本地调用
}

@PostMapping("/test-seata")
@GlobalTransactional
public void testSeata() {
    a.a();//本地调用
    b.b();//服务间调用
}

这样的写的话也不需要频繁修改,只需要让前端调不同的接口就行了

而且一般来说需要用到分布式事务的也就几个核心场景,不会特别多

所以这种方式虽说加入了一些人工判断但应该也不会特别麻烦

总结

要一套代码适配不同的场景其实就是定义一个接口然后进行多种实现,其优势在于借助接口的特性在不同场景下适配不同的实现,不仅不需要频繁修改代码,还可以实现InnerUserApiFeignUserApiDubboUserApi等多种方式,甚至其他系统的用户信息,如DouYinUserApi

同时借助已有的组件为我们服务,如Spring的条件配置,注册中心的组件能力等

源码

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

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