我正在参加「掘金·启航计划」
本专栏 将通过以下几块内容来搭建一个 模块化:可以根据项目的功能需求和体量进行任意模块的组合或扩展 的后端服务
v2.0:扩展模块实现技术解耦(本文)
未完待续……
在上一篇文章 v2.0:项目结构优化升级 中,我们将项目的结构进行了优化,优化之后的项目结构如下
简单说明
考虑到可能有读者是第一次看这个专栏,所以我还是先简单介绍一下,详细的内容大家可以看我之前的专栏文章
这里模块化的设想其实就是可插拔的功能模块,如果需要这个功能,就把这个模块用Gradle
或Maven
的方式编译进来,如果不需要,去掉对应的依赖就行了,避免改动代码,因为一旦涉及到代码改动的话,就会变得“改不断,理还乱”
当然了,需要达到每个模块都能够任意拆卸的程度其实并不简单,所以我借鉴了DDD
的思想来达成这个目的,专栏中也有这一块的内容,另外后续也会再写一篇针对DDD
优化的文章
所以当我们把所有模块都编译进来的时候,那么就是一个单体应用了,如果把多个模块分开编译,那么就变成了微服务
以我们juejin
项目的三个模块(用户,沸点,通知)为例:
在最开始的时候,我们可以将这三个模块打包成一个服务,作为单体应用来运行,适合项目前期体量较小的情况
之后,当我们发现沸点的流量越来越大,就可以将沸点模块拆分出来作为单独的一个服务方便扩容,用户模块和通知模块打包在一起作为另一个服务,这样就从单体应用变成了微服务
注:从单体应用到微服务的切换是不需要修改代码的
扩展模块
在v2.0:项目结构优化升级这篇文章中,我们还遗留了3个模块juejin-token
,juejin-login
,juejin-rpc
没有进行说明
这几个模块我称之为扩展模块,其核心思想是能够将技术概念与技术实现进行解耦
Token 扩展
首先我在juejin-token
下定义了TokenCodec
/**
* Token 编解码
*/
public interface TokenCodec {
/**
* 生成 Token
*/
String encode(User user);
/**
* 解析 Token
*/
User decode(String token);
}
用于抽象Token
和User
的编码和解码
然后在juejin-token
下创建一个token-jwt
模块
用JWT
来实现具体的编解码
/**
* JWT token 编解码实现
*/
public class JwtTokenCodec implements TokenCodec {
@Override
public String encode(User user) {
//使用jwt生成token,将userId存到属性中
}
@Override
public User decode(String token) {
//从jwt中获得userId,然后获得对应的用户
}
}
这样的一个好处就是
当我们需要生成Token
或是通过Token
获取用户的时候
只需要依赖juejin-token
这个抽象模块就行了
具体的实现会放在application
模块中方便更换
Token
这个概念就和具体的JWT
实现解耦开了
当需要扩展的时候
比如又实现了一个token-session
那么直接替换application
模块中依赖的具体实现
也就是把token-jwt
改为token-session
即可
对于引用到TokenCodec
的代码不需要任何的改动
Login 扩展
juejin-login
依赖juejin-token
来实现登录功能
定义LoginAuthorizer
抽象登录授权
public interface LoginAuthorizer {
LoginAuthorization authorize(User user);
}
然后提供一个默认实现
@Getter
@AllArgsConstructor
public class LoginAuthorizerImpl implements LoginAuthorizer {
private TokenCodec tokenCodec;
@Override
public LoginAuthorization authorize(User user) {
String token = tokenCodec.encode(user);
LoginAuthorization la = new LoginAuthorization();
la.setId(user.getId());
la.setNickname(user.getNickname());
la.setAvatar(user.getAvatar());
la.setToken(token);
return la;
}
}
我们可以看到调用了TokenCodec
的encode
方法
但是对于具体使用哪种方式生成Token
并不在意
如果在application
中依赖的是token-jwt
那么就是使用JWT
生成Token
如果在application
中依赖的是token-session
那么就是返回的session
在juejin-login
下创建一个login-username
模块
表示使用用户名登录,添加UsernameLoginController
@Tag(name = "登录")
@RestController
@RequestMapping("/login")
public class UsernameLoginController {
@Autowired
protected UserRepository userRepository;
@Autowired
protected LoginAuthorizer loginAuthorizer;
@Operation(summary = "用户名登录")
@PostMapping("/username")
public LoginAuthorization usernameLogin(@RequestParam String username, @RequestParam String password) {
User user = userRepository.get(new LambdaConditions().equal(User::getUsername, username));
if (user == null || !user.getPassword().equals(password)) {
throw new IllegalArgumentException("login.username-or-password.error");
}
if (!user.getEnabled()) {
throw new IllegalStateException("login.account.disabled");
}
return loginAuthorizer.authorize(user);
}
}
我们拿到User
之后通过LoginAuthorizer
来进行授权登录
也是不需要关注具体的登录细节
另外我们可以在juejin-login
下创建使用其他方式的登录模块
比如login-phone
使用手机号登录
或是login-email
使用邮箱登录
或是login-qrcode
使用二维码登录
最后只需要在application
中添加对应的依赖即可
比如只支持手机号登录,那么就依赖login-phone
比如所有的方式都支持,那么就把全部的依赖都加上
RPC 扩展
和之前的思路也差不多
RPC
有很多实现方式,Feign
,Dubbo
,gRPC
等等
我们可以先定义用于远程传输的对象以及对应的转换器
以用户User
为例
/**
* 用户远程对象 remote object
*/
@Data
public class UserRO implements Identifiable {
//ID
protected String id;
//用户名
protected String username;
//密码
protected String password;
//昵称
protected String nickname;
//头像
protected String avatar;
//启用
protected Boolean enabled;
//创建时间
protected Long createTime;
}
然后用户远程对象和用户领域模型的转换器
/**
* 用户领域模型和用户远程对象转换适配器
*/
public interface RPCUserFacadeAdapter extends RemoteObjectFacadeAdapter<User, UserRO> {
}
/**
* 远程对象转换适配器
*
* @param <T>
* @param <R>
*/
public interface RemoteObjectFacadeAdapter<T extends DomainObject, R extends Identifiable> {
/**
* 领域模型转远程对象
*/
R do2ro(T object);
/**
* 远程对象转领域模型
*/
T ro2do(R ro);
}
接下来我们来实现基于Feign
的RPC
调用
在juejin-rpc
下创建rpc-feign
首先定义UserFeignClient
(这里的name
只需要填写user
即可,在网关路由模块化支持与条件配置中实现了动态路由的思路)
/**
* 用户 Feign 客户端
*/
@FeignClient(name = "user")
public interface UserFeignClient {
/**
* 根据 id 获得用户信息
*/
@GetMapping("/feign/user/{id}")
Response<UserRO> get(@PathVariable String id);
}
接着实现一个FeignUserController
用于给UserFeignClient
提供调用接口
/**
* 提供给 Feign 调用的用户接口
*/
@RestController
@RequestMapping("/feign/user")
public class FeignUserController {
@Autowired
private UserRepository userRepository;
@Autowired
private RPCUserFacadeAdapter rpcUserFacadeAdapter;
/**
* 根据用户 id 获得用户信息
*/
@GetMapping("/{id}")
public UserRO get(@PathVariable String id) {
User user = userRepository.get(id);
if (user == null) {
return null;
}
return rpcUserFacadeAdapter.do2ro(user);
}
}
然后实现一个基于Feign
的UserRepository
/**
* 基于 Feign 的用户存储
*/
@Component
public class FeignUserRepository extends QueryDomainRepository<User, Users, UserRO> implements UserRepository {
@Autowired
private RPCUserFacadeAdapter rpcUserFacadeAdapter;
@Autowired
private UserFeignClient userFeignClient;
@Override
public UserRO do2po(User user) {
return rpcUserFacadeAdapter.do2ro(user);
}
@Override
public User po2do(UserRO ro) {
return rpcUserFacadeAdapter.ro2do(ro);
}
@Override
protected UserRO doGet(String id) {
Response<UserRO> response = userFeignClient.get(id);
if (response.getResult()) {
return response.getObject();
}
throw new RuntimeException(response.getMessage());
}
//省略其他代码
}
主要就是调用UserFeignClient
来获取数据
最后,最重要的一步,添加配置类
@Configuration
@EnableFeignClients(basePackages = "com.bytedance.juejin.rpc.feign.*")
public class FeignAutoConfiguration {
//方便分组不同的业务
@Configuration
public static class UserConfiguration {
@Bean
@ConditionalOnBean(UserRepository.class)
public FeignUserController feignUserController() {
return new FeignUserController();
}
@Bean
@ConditionalOnMissingBean
public UserRepository userRepository() {
return new FeignUserRepository();
}
}
}
到这里可能有些读者已经有点迷糊了,这是怎么样的调用流程?
那就让我们以application-cloud-pin
和application-cloud-system
为例来走一遍流程
前提,application-cloud-pin
包含了沸点模块,application-cloud-system
包含了用户和通知模块
- application-cloud-pin 启动注入
application-cloud-pin
启动的时候,开始注入User Feign
相关的实例
FeignUserController
需要存在UserRepository
实例才会注入,所以FeignUserController
不会注入在application-cloud-pin
中
由于不存在UserRepository
,所以会注入FeignUserRepository
- application-cloud-system 启动注入
application-cloud-system
启动的时候,开始注入User Feign
相关的实例
因为集成了用户模块所以存在UserRepository(基于MBP的实现)
所以会注入FeignUserController
由于存在UserRepository
,所以不会注入FeignUserRepository
- 当前注入状态
application-cloud-pin
注入了FeignUserRepository
application-cloud-system
注入了FeignUserController
- 调用流程
现在我们调用沸点接口(沸点中需要获得发布沸点的用户信息)
application-cloud-pin
通过UserRepository(FeignUserRepository)
来获取用户接口
FeignUserRepository
通过UserFeignClient
调用application-cloud-system
中的FeignUserController
FeignUserController
调用自身的UserRepository(基于MBP的实现)
获得数据
结束(指调用和文章)
总结
最后再说说RPC
这块的设计,是怎么适配模块化的
我们通过domain-user
来引用UserRepository
module-user
会实现基于数据库的MBPUserRepository
rpc-feign
会实现基于Feign
的FeignUserRepository
当application
中集成了module-user
就会直接调用数据库
当application
中没有集成module-user
就会通过Feign
调用
所以我们只需要使用UserRepository
的接口
不需要关注其具体实现,也没有办法关注具体实现
因为变化莫测的依赖会对应各种各样的实现