## 前言
前面4篇文章介绍了材料库系统多租户改造的一些设计方案。
今天这篇文章介绍一下,多租户功能的一些公共代码,比如:如何创建的租户上下文,在微服务之间如何传递租户上下文。
## 1 租户上下文
说起租户上下文,其实跟用户上下文类似。
用户上下文是用户登录之后,在代码中通过调用一个类似于这样的代码:
```java
UserInfo user = UserContext.getUser();
```
就能获取到当前登录的用户信息。
租户上下文需要类似的功能。
我们可以这样定义租户上下文类:
```java
@Data
public class TenantContext {
private TenantInfo tenantInfo;
private static final TransmittableThreadLocal
public static String getCurrentTenantCode() {
TenantContext context = THREAD_LOCAL.get();
if (context == null) {
return null;
}
TenantInfo tenantInfo = context.getTenantInfo();
if (tenantInfo == null) {
return null;
}
return tenantInfo.getCode();
}
public static void setCurrentTenantCode(String tenantCode) {
TenantContext context = THREAD_LOCAL.get();
if (context == null) {
context = new TenantContext();
THREAD_LOCAL.set(context);
}
TenantInfo tenantInfo = new TenantInfo();
tenantInfo.setCode(tenantCode);
context.setTenantInfo(tenantInfo);
}
public static void remove() {
THREAD_LOCAL.remove();
}
}
```
接下来,如何创建租户上下文呢?
这就需要分多种情况了。
## 2 API如何获取?
在服务中API接口是调用最多的。
那么,API接口如何获取租户上下文呢?
方法1:写一个公共方法,在进入API接口的第一行代码中调用一下该方法。
这样做虽然很简单,但基本上每个API接口都需要调用这个公共方法,会产生很多重复的调用代码。
方法2:如果API接口没有使用gateway(比如:zuul),则可以写一个AOP拦截器,在API接口调用之前,触发一次写入租户上下文的逻辑,在API接口调用结束之后,在finally中清空当前租户上下文。
方法3:如果API接口使用了gateway(比如:zuul),则直接在gateway中写入租户上下文,在finally中清空当前租户上下文。
我们以方法3为例:
```java
@Service
public TenantFilter extends ZuulFilter {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public int filterOrder() {
return FilterConstants.PRE_DECORATION_FILTER_ORDER;
}
@Override
public Object run() {
return injectTenant();
}
private TenantInfo injectTenant() {
RequestContext ctx = RequestContext.getCurrentContext();
String host = request.getServerName();
HttpServletRequest request = ctx.getRequest();
String tenantCode = request.getHeader("x-tenant-code");
TenantInfo tenantInfo;
if(StringUtils.isNotEmpty(tenantCode)) {
tenantInfo = new TenantInfo();
tenantInfo.setCode(tenantCode);
} else {
String key = getKey(host);
String json = redisTemplate.opsForValue().get(key);
if(StringUtils.isNotEmpty(json)) {
tenantInfo = JsonUtils.parseEntity(json,TenantInfo.class);
}
}
if(tenantInfo != null) {
TenantContext.setCurrentTenantCode(tenantInfo.getCode());
}
return tenantInfo;
}
}
```
以zuul网关为例,在TenantFilter类中处理租户上下文。
接受用户请求header中的租户上下文,如果有则直接用header中的租户code。
如果用户请求header中没有租户上下文,则从redis中根据域名获取租户code。
最后将租户信息添加到租户上下文中,即:ThreadLocal当中。
这样同一个线程的请求,可以在API接口后续的流程中,可以通过下面的方法:
```java
String currentTenantCode = TenantContext.getCurrentTenantCode();
```
非常轻松可以获取到当前租户code。
## 3 job如何处理租户?
API接口可以在网关中添加租户上下文,后面的代码就可以直接获取租户上下文了。
但job不经过网关层,又该怎么处理租户信息呢?
之前材料库系统的数据不区分租户,在job中处理数据,所有数据混在一起。
现在区分租户之后,数据是隔离的,不同的租户之间,只能处理自己的数据。
也就是说,job要改造成按租户执行。
我们以xxl-job为例。
定义一个抽象类BaseTenantJobHandler,它会调用接口查询所有租户信息,保存到一个租户列表中,然后遍历该租户列表,一个租户一个租户的处理。
```java
public abstract class BaseTenantJobHandler extends ITraceJobHandler {
@Override
public ReturnT
List
for(TenantInfo tenantInfo: tenantInfoList){
TenantContext.setCurrentTenantCode(tenantInfo.getCode());
try {
runByOneTenant(param);
} finally {
TenantContext.remove();
}
}
return ReturnT.SUCCESS;
}
public abstract ReturnT
}
```
其中runByOneTenant是一个钩子方法,跟子类重写的时候用到,这是一直模板方法模式的用法。
然后再定义一个子类UserJobHandler:
```java
@Slf4j
@Component
@JobHandler(value = "userJobHandler")
public class UserJobHandler extends BaseTenantJobHandler {
@Autowired
private UserService userService;
@Override
public ReturnT
try {
userService.notifyUser();
return SUCCESS;
} catch (Exception ex) {
log.error("通知发生失败:", ex);
return FAIL;
}
}
}
```
这个类的处理逻辑是,给租户下的用户发消息通知。
当然如果你想指定租户手动执行job,也可以接收param中的参数。
可以在该参数中指定具体的租户code。
此外,不同的job处理的业务逻辑不一样。
有可能只有一部分租户,才需要给用户发消息通知,其他租户不需要,我们不需要跑全量的job。
这就需要将上面的逻辑做一下改造:
```java
@Slf4j
@Component
@JobHandler(value = "userJobHandler")
public class UserJobHandler extends BaseTenantJobHandler {
@Autowired
private UserService userService;
@Value("#{'${susan.mgt.notfiyUserTenant:T0001}'.split(',')}")
private List
@Override
public ReturnT
try {
if(StringUtils.isNotEmtpty(param)) {
String tenantCode = parseTenantCode(param);
if(StringUtils.isNotEmtpty(tenantCode)) {
TenantContext.setCurrentTenantCode(tenantCode);
}
}
String currentTenantCode = TenantContext.getCurrentTenantCode();
if(!notfiyUserTenantList.contains(currentTenantCode)) {
return;
}
userService.notifyUser();
return SUCCESS;
} catch (Exception ex) {
log.error("通知发生失败:", ex);
return FAIL;
}
}
}
```
先从参数中获取租户信息,如果有租户信息,则设置到当前租户上下文当中。
然后获取当前租户code,如果在配置的租户列表中,则允许给用户发通知消息。
否则,直接忽略,不发通知消息。
## 4 Fegin如何传递租户?
对于微服务之间通过Fegin内部调用的情况,如何传递租户上下文呢?
我们可以在Fegin调用之前,将租户code写入到header中,然后在被调用方中解析header中的租户code。
定义restTemplate的拦截器,其实是Fegin的底层是基于restTemplate的。
```java
public class ZcApplicationClientHttpRequestInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
HttpHeaders headers = request.getHeaders();
String currentTenantCode = TenantContext.getCurrentTenantCode();
Map
tenantHeaders.put("x-tenant-code",currentTenantCode);
tenantHeaders.forEach(headers::add);
return execution.execute(request, body);
}
}
```
这样可以保证在发生fegin调用之前,将当前租户上下文中的租户code,添加到请求的header当中。
然后在Spring mvc的拦截器中,获取到该请求,解析header中的租户code,添加到当前租户上下文当中。
```java
@Slf4j
public class TenantInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String tenantCode = headers.get("x-tenant-code");
TenantContext.setCurrentTenantCode(tenantCode);
return true;
}
}
```
这样在fegin被调用的接口中,就可以通TenantContext获取当前租户了。
## 5 mq如何传递租户?
还有一个问题:如果项目中使用了mq发生和消费消息,又该如何传递租户信息呢?
我们可以跟上面Fegin的处理用相同的思路,在消息生产者中发mq消息时,将租户code写入header中。
在消息消费者中,解析header中的租户code,然后添加到租户上下文当中。
如何在消息生产者中发消息时,将当前租户code写入到header中呢?
我们以RocketMQ为例。
这就需要自定义RocketMQTemplate:
```java
public class TenantRocketMQTemplate extends RocketMQTemplate {
@Override
public SendResult syncSend(String destination, Collection
if (Objects.isNull(messages) || messages.size() == 0) {
throw new IllegalArgumentException("`messages` can not be empty");
}
Collection
for (Message> msg : messages) {
if (Objects.isNull(msg) || Objects.isNull(msg.getPayload())) {
continue;
}
messageList.add(handleTenantContext(msg));
}
return super.syncSend(destination, messageList, timeout);
}
public Message handleTenantContext(handleTenantContext) {
String currentTenantCode = TenantContext.getCurrentTenantCode();
MessageBuilder builder = withPayload(message.getPayload());
builder.setHeader("x-tenant-code", currentTenantCode);
return builder.build();
}
}
```
增加一个类TenantRocketMQTemplate继承RocketMQTemplate类,重写它发送消息的方法,我们这里重写了syncSend方法,在该方法里面通过handleTenantContext方法往header中设置了租户code。
然后在消息消费者中,通过一个Spring AOP拦截器处理。
```java
@Aspect
@Component
public class MqTenantListenerAspect {
@Pointcut("execution(* onMessage(..))&&@within(org.apache.rocketmq.spring.annotation.RocketMQMessageListener)")
public void messageListener() {
}
@Pointcut("messageListener()")
public void pointCut() {
}
@Around(value = "pointCut()")
public void around(ProceedingJoinPoint point) throws Throwable {
Object[] args = point.getArgs();
if(args != null) {
if(args[0] instanceOf MessageExt) {
MessageExt message = (MessageExt)args[0];
String tenantCode = message.getUserProperty("x-tenant-code");
TenantContext.setCurrentTenantCode(tenantCode);
}
}
try {
point.proceed(point.getArgs());
} catch (InvocationTargetException e) {
throw e.getTargetException();
} finally {
TenantContext.remove();
}
}
}
```
该拦截器会拦截使用了RocketMQMessageListener注解的onMessage方法,在执行该方法执行之前,会先从消息的header中获取租户code,然后将该租户code添加到租户上下文当中。
这样在mq的消费者的onMessage方法,以及后续的代码中都可以通过TenantContext获取当前租户上下文了。