场景描述
- 不同租户访问同一个地址,tenant100租户有一个个性化服务
service-b-100 ,在API层需要将其路由到service-b-100 服务,其它租户则路由到service-b ,达到个性化需求。
- 在服务间,
service-a 调用service-b ,tenant100租户访问时需要调用他的个性化服务service-b-100 。

解决方案
设计一张个性化服务表存储租户的个性化服务,如果租户没有个性化服务,则走通用服务,可能需要一个个性化配置页面。路由(path) 是租户访问的服务路由;服务名(serviceName) 代表某类服务,通常这个服务可以有多个版本或者个性化服务;每个版本或者个性化服务都有一个唯一的服务ID(serviceId) ,并且这个服务会注册到Eureka;租户ID(tenantId) 则关联了个性化服务。

个性化需求的最终目标是找到租户的个性化服务ID,在保证程序代码不变,低侵入性的情况下, 在API层,可以通过路由+租户ID找到服务ID;在服务间调用时,可以通过服务名+租户ID找到服务ID。API层,就算有个性化服务,版本升级,但配置依然保持不变;服务间FeignClient 调用,服务名保持不变。
API层解决方案
通过查看Zuul的相关源码发现,ZuulHandlerMapping 在处理路由映射的时候,会通过路由定位器DiscoveryClientRouteLocator 获取配置的路由,从配置ZuulProperties 获取路由列表ZuulRoute ,即配置的zuul.routes ,最终目的也是获取路由对应的服务ID。

同时,会从Eureka获取服务列表,并转换成路由映射。


所以,我们只需要在获取路由这一步将路由对应的服务ID更改成租户的个性化服务ID即可。注意这里获取的服务ID要将其看成服务名,因为一个服务名会对应多个服务ID。
- 自定义
DiscoveryClientRouteLocator ,覆盖locateRoutes 方法,核心是customLocateRoutes 将从配置文件获取的路由服务与当前租户的个性化路由服务做对比,如果某个路由存在个性化服务,则将个性化服务ID替换成之前的服务ID即可。
/**
* 自定义路由定位器
*/
public class CustomDiscoveryClientRouteLocator extends DiscoveryClientRouteLocator {
private DiscoveryClient discovery;
private ZuulProperties properties;
public CustomDiscoveryClientRouteLocator(String servletPath,DiscoveryClient discovery,ZuulProperties properties) {
super(servletPath,discovery,properties);
this.discovery = discovery;
this.properties = properties;
}
public CustomDiscoveryClientRouteLocator(String servletPath,ZuulProperties properties,ServiceRouteMapper serviceRouteMapper) {
super(servletPath,properties,serviceRouteMapper);
this.discovery = discovery;
this.properties = properties;
}
/**
* 覆盖获取路由的方法
*/
@Override
protected LinkedHashMap<String,ZuulProperties.ZuulRoute> locateRoutes() {
LinkedHashMap<String,ZuulProperties.ZuulRoute> routesMap = new LinkedHashMap<String,ZuulProperties.ZuulRoute>();
// ****** 只改这一处 ****** //
routesMap.putAll(customLocateRoutes());
// 其它代码不变 复制即可
}
/**
* 获取当前租户的个性化服务,如果从配置文件获取的服务和租户的服务ID不同,则替换成个性化服务.
*/
protected Map<String,ZuulProperties.ZuulRoute> customLocateRoutes() {
// 获取当前用户的(特定)路由信息
List<TenantRoute> tenantRoutes = getCurrentTenantRoute();
HashMap<String,TenantRoute> tenantRouteMap = new HashMap<>();
tenantRoutes.forEach(tenantRoute -> {
tenantRouteMap.put(tenantRoute.getPath(),tenantRoute);
});
LinkedHashMap<String,ZuulProperties.ZuulRoute> routesMap = new LinkedHashMap<>();
for (ZuulProperties.ZuulRoute route : this.properties.getRoutes().values()) {
if (tenantRouteMap.containsKey(route.getPath())) {
TenantRoute tenantRoute = tenantRouteMap.get(route.getPath());
// 对于某个路由,如/iam/**,如果服务ID不一样,则将个性化服务ID添加进去.
if (!org.apache.commons.lang.StringUtils.equalsIgnoreCase(tenantRoute.getServiceId(),route.getServiceId())) {
routesMap.put(route.getPath(),new ZuulProperties.ZuulRoute(route.getPath(),tenantRoute.getServiceId()));
continue;
}
}
routesMap.put(route.getPath(),route);
}
return routesMap;
}
/**
* 模拟获取当前租户的个性化服务
*/
public List<TenantRoute> getCurrentTenantRoute() {
List<TenantRoute> routes = new ArrayList<>();
routes.add(new TenantRoute("/iam/**","iam-service","iam-service-100","tenant100"));
return routes;
}
/**
* 租户路由
*/
class TenantRoute {
private String path;
private String serviceName;
private String serviceId;
private String tenantId;
public TenantRoute(String path,String serviceName,String serviceId,String tenantId) {
this.path = path;
this.serviceName = serviceName;
this.serviceId = serviceId;
this.tenantId = tenantId;
}
// getter/setter
}
}
- 同时将其注册成Bean,覆盖默认的
DiscoveryClientRouteLocator
@Bean
public DiscoveryClientRouteLocator discoveryRouteLocator(DiscoveryClient discovery,ServiceRouteMapper serviceRouteMapper) {
return new CustomDiscoveryClientRouteLocator(this.server.getServletPrefix(),this.zuulProperties,serviceRouteMapper);
}
- 测试可以看出已经更改成个性化服务了,但是我们的配置文件并没有做任何改动。

服务间调用解决方案
- RestTemplate:实现HTTP调用,参数列表比较长。
- Ribbon:实现客户端负载均衡,添加
@LoadBalanced 注解让RestTemplate整合Ribbon,使其具备负载均衡的能力。
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}
- Feign:声明式、模板化的HTTP客户端,帮助我们更加快捷、优雅地调用HTTP API。
- RPC:基于TCP的远程过程调用,这种方式可确保调用性能更加高效,能支持更高的并发量。
这里主要研究了Feign调用的服务个性化。通过查看Feign的相关源码发现,在启动程序的时候,会扫描根路径下有@FeignClient 注解的接口,并为其生成代理对象,放入Spring容器中。这个代理类就是feign.Target 的实现类feign.Target.HardCodedTarget ,Target 封装了服务名和地址,这种类型的地址(http://service-id )就是用来做负载均衡的,即请求服务,而不是具体的某个IP地址。

feign.SynchronousMethodHandler 是具体的执行处理器,封装了Target 和Client ,在executeAndDecode 方法里,通过client 发起负载均衡请求。核心关注的是Request request = targetRequest(template) ,在调用client.execute 之前,会先通过targetRequest 生成feign.Request 对象。

在targetRequest 方法里,首先应用所有的RequestInterceptor 拦截器对RequestTemplate 做处理,比如在Header中加入一些信息等。然后调用target.apply 对地址做处理,注意此时RequestTemplate 中的地址是不带服务上下文地址的,即http://file-service 。

在apply 方法中,判断RequestTemplate 的url 是否带有http ,如果没有,则将服务上下文地址拼接到地址前面。

通过以上分析不难发现,我们只需要更改apply 的行为,在这一步将个性化服务ID替换成通用服务ID即可。但是我们无法直接扩展Target 并修改apply 方法,但可以通过一种变相的方法达到这个目的。通过拦截器,在进入apply方法之前,将个性化服务上下文(比如http://file-service-100 )拼接到RequestTemplate.url 前,这样apply 方法里就不会做处理了。
- 首先开发一个存储当前请求服务名称的
ThreadLocal
/**
* 存储当前线程请求服务的服务名称
*/
public class ServiceThreadLocal {
private static ThreadLocal<String> serviceNameLocal = new ThreadLocal<>();
public static void set(String serviceName) {
serviceNameLocal.set(serviceName);
}
public static String get() {
String serviceName = serviceNameLocal.get();
serviceNameLocal.remove();
return serviceName;
}
}
- 开发
@FeignClient 的切面AOP,拦截到Feign 请求时,将服务名放入ThreadLocal 中。
@Aspect
@Component
public class FeignClientAspect {
/**
* 拦截 *FeignClient 结尾的接口的所有方法
* 这里无法直接通过注解方式拦截 @FeignClient 注解的接口,因为 FeignClient 只有接口,没有实现(生成的是代理类)
*/
@Before("execution(* *..*FeignClient.*(..))")
public void keepServiceName(JoinPoint joinPoint) {
Type type = joinPoint.getTarget().getClass().getGenericInterfaces()[0];
Annotation annotation = ((Class)type).getAnnotation(FeignClient.class);
if (annotation != null && annotation instanceof FeignClient) {
FeignClient feignClient = (FeignClient) annotation;
// 将服务名放入ThreadLocal中
String serviceName = feignClient.value();
if (StringUtils.isEmpty(serviceName)) {
serviceName = feignClient.name();
}
ServiceThreadLocal.set(serviceName);
}
}
}
- 开发
RequestInterceptor 处理RequestTemplate
/**
* 拦截feign请求,根据服务名称和租户ID动态更改路由
*/
@Component
public class FeignRouteInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 当前租户ID
String currentTenantId = "tenant100";
// 获取当前请求的服务名称
String serviceName = ServiceThreadLocal.get();
// 根据租户ID和服务名称获取真正要请求的服务ID
String serviceId = getCurrentTenantServiceId(currentTenantId,serviceName);
// 核心代码
if (StringUtils.isNotBlank(serviceId)) {
String url;
// 拼接http://
if (!StringUtils.startsWith(serviceId,"http")) {
url = "http://" + serviceId;
} else {
url = serviceId;
}
// 将真正要请求的服务上下文路径拼接到url前
if (!StringUtils.startsWith(template.url(),"http")) {
template.insert(0,url);
}
}
}
/**
* 模拟 根据租户ID和服务名称获取服务ID
*/
public String getCurrentTenantServiceId(String tenantId,String serviceName) {
List<TenantRoute> tenantRoutes = getCurrentTenantRoute(tenantId);
for (TenantRoute tenantRoute : tenantRoutes) {
if (StringUtils.equalsIgnoreCase(serviceName,tenantRoute.getServiceName())) {
return tenantRoute.getServiceId();
}
}
return serviceName;
}
/**
* 获取租户的个性化服务路由信息
*/
public List<TenantRoute> getCurrentTenantRoute(String tenantId) {
// 根据tenantId获取个性化服务 一般在登录时就获取出来然后放到ThreadLocal中.
List<TenantRoute> routes = new ArrayList<>();
routes.add(new TenantRoute("/file/**","file-service","file-service-100","tenant100"));
return routes;
}
/**
* 租户路由信息
*/
class TenantRoute {
private String path;
private String serviceName;
private String serviceId;
private String tenantId;
public TenantRoute(String path,String tenantId) {
this.path = path;
this.serviceName = serviceName;
this.serviceId = serviceId;
this.tenantId = tenantId;
}
// getter/setter
}
}
- 测试可以看到
RequestTemplate.url 已经更改成功了,但是FeignClient 的服务名称并没有改变。

通过以上设计方式,在API网关处和Feign调用时做一些处理,达到个性化服务的目的,通过配置页面配置租户的个性化服务,当然,所有的服务都需要注册到Eureka,配置时可以从Eureka拉取服务列表。这样一来,无论是服务多版本,还是定制化服务,都可以在不改代码及配置文件的情况下完成特定服务路由。
(编辑:李大同)
【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容!
|