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

模块间的调用问题

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

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

这不要了命了么?

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

用户接口示例

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

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

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

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

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

抽象模块接口

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

public interface UserApi {

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

其中UserRO是user 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)来获得用户信息,再通过ro2do将UserRO转为我们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就行了

这条链路是这样的:

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

FeignUserApi

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

public class FeignUserApi implements UserApi {

    @Autowired
    private UserFeignClient userFeignClient;

    @Override
    public UserRO get(String id) {
        Response 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 get(@PathVariable String id);
}
复制代码

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

这条链路是这样的:

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

Feign路由映射

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

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

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

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

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

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

processResources {
    //资源文件处理之前
    doFirst {
        Set 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()
        }
    }
}
复制代码
@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 metadata = event.getRegistration().getMetadata();
        metadata.put("routers", routers);
    }
}
复制代码

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

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

@Slf4j
public class RouterLoadBalancerClientFactory extends LoadBalancerClientFactory {

    private final DiscoveryClient discoveryClient;

    private volatile Map routerMap = Collections.emptyMap();

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

    @Override
    public  T getInstance(String name, Class 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 newRouterMap = new HashMap<>();
        //获得服务名
        List services = discoveryClient.getServices();
        for (String service : services) {
            //获得服务实例
            List 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,然后分别实现InnerApi和FeignApi或是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();//服务间调用
}
复制代码

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

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

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

总结

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

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

展开阅读全文

页面更新:2024-06-05

标签:分布式   事务   组合   沸点   路由   服务端   架构   模块   接口   代码   方式   用户

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号

Top