多租戶個性化服務路由


場景描述

  • 不同租戶訪問同一個地址,tenant100租戶有一個個性化服務service-b-100,在API層需要將其路由到service-b-100服務,其它租戶則路由到service-b,達到個性化需求。
  • 在服務間,service-a調用service-b,tenant100租戶訪問時需要調用他的個性化服務service-b-100

Alt text

解決方案

設計一張個性化服務表存儲租戶的個性化服務,如果租戶沒有個性化服務,則走通用服務,可能需要一個個性化配置頁面。路由(path)是租戶訪問的服務路由;服務名(serviceName)代表某類服務,通常這個服務可以有多個版本或者個性化服務;每個版本或者個性化服務都有一個唯一的服務ID(serviceId),並且這個服務會注冊到Eureka;租戶ID(tenantId)則關聯了個性化服務。

Alt text

個性化需求的最終目標是找到租戶的個性化服務ID,在保證程序代碼不變,低侵入性的情況下, 在API層,可以通過路由+租戶ID找到服務ID;在服務間調用時,可以通過服務名+租戶ID找到服務ID。API層,就算有個性化服務,版本升級,但配置依然保持不變;服務間FeignClient調用,服務名保持不變。

API層解決方案

通過查看Zuul的相關源碼發現,ZuulHandlerMapping在處理路由映射的時候,會通過路由定位器DiscoveryClientRouteLocator獲取配置的路由,從配置ZuulProperties獲取路由列表ZuulRoute,即配置的zuul.routes,最終目的也是獲取路由對應的服務ID。

Alt text

同時,會從Eureka獲取服務列表,並轉換成路由映射。

Alt text

Alt text

所以,我們只需要在獲取路由這一步將路由對應的服務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);
}
  • 測試可以看出已經更改成個性化服務了,但是我們的配置文件並沒有做任何改動。

Alt text

服務間調用解決方案

  • 服務間調用方式
  • 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.HardCodedTargetTarget封裝了服務名和地址,這種類型的地址(http://service-id)就是用來做負載均衡的,即請求服務,而不是具體的某個IP地址。

Alt text

feign.SynchronousMethodHandler是具體的執行處理器,封裝了TargetClient,在executeAndDecode方法里,通過client發起負載均衡請求。核心關注的是Request request = targetRequest(template),在調用client.execute之前,會先通過targetRequest生成feign.Request對象。

Alt text

targetRequest方法里,首先應用所有的RequestInterceptor攔截器對RequestTemplate做處理,比如在Header中加入一些信息等。然后調用target.apply對地址做處理,注意此時RequestTemplate中的地址是不帶服務上下文地址的,即http://file-service

Alt text

apply方法中,判斷RequestTemplateurl是否帶有http,如果沒有,則將服務上下文地址拼接到地址前面。

Alt text

通過以上分析不難發現,我們只需要更改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的服務名稱並沒有改變。

Alt text

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM