場景描述
- 不同租戶訪問同一個地址,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, DiscoveryClient discovery, ZuulProperties properties, ServiceRouteMapper serviceRouteMapper) {
super(servletPath, discovery, 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(), discovery, 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 serviceName, String serviceId, String tenantId) {
this.path = path;
this.serviceName = serviceName;
this.serviceId = serviceId;
this.tenantId = tenantId;
}
// getter/setter
}
}
- 測試可以看到
RequestTemplate.url已經更改成功了,但是FeignClient的服務名稱並沒有改變。

通過以上設計方式,在API網關處和Feign調用時做一些處理,達到個性化服務的目的,通過配置頁面配置租戶的個性化服務,當然,所有的服務都需要注冊到Eureka,配置時可以從Eureka拉取服務列表。這樣一來,無論是服務多版本,還是定制化服務,都可以在不改代碼及配置文件的情況下完成特定服務路由。
