## 前言

前面4篇文章介绍了材料库系统多租户改造的一些设计方案。

今天这篇文章介绍一下,多租户功能的一些公共代码,比如:如何创建的租户上下文,在微服务之间如何传递租户上下文。

## 1 租户上下文

说起租户上下文,其实跟用户上下文类似。

用户上下文是用户登录之后,在代码中通过调用一个类似于这样的代码:

```java
UserInfo user = UserContext.getUser();
```

就能获取到当前登录的用户信息。

租户上下文需要类似的功能。

我们可以这样定义租户上下文类:

```java
@Data
public class TenantContext {

private TenantInfo tenantInfo;
private static final TransmittableThreadLocal THREAD_LOCAL = new 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 run(String param) throws Exception {
List tenantInfoList = getAllTenant();
for(TenantInfo tenantInfo: tenantInfoList){
TenantContext.setCurrentTenantCode(tenantInfo.getCode());

try {
runByOneTenant(param);
} finally {
TenantContext.remove();
}
}
return ReturnT.SUCCESS;
}

public abstract ReturnT runByOneTenant(String param) throws Exception;
}
```

其中runByOneTenant是一个钩子方法,跟子类重写的时候用到,这是一直模板方法模式的用法。

然后再定义一个子类UserJobHandler:

```java
@Slf4j
@Component
@JobHandler(value = "userJobHandler")
public class UserJobHandler extends BaseTenantJobHandler {
@Autowired
private UserService userService;

@Override
public ReturnT runByOneTenant(String param) throws Exception {
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 notfiyUserTenantList;

@Override
public ReturnT runByOneTenant(String param) throws Exception {
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 = new HashMap();
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> messages, long timeout) {
if (Objects.isNull(messages) || messages.size() == 0) {
throw new IllegalArgumentException("`messages` can not be empty");
}
Collection> messageList = new ArrayList<>();
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获取当前租户上下文了。

最后修改:2026 年 06 月 06 日
如果觉得我的文章对你有用,请随意赞赏