Spring Cloud —— Zuul 動態路由
推薦 Spring Boot/Cloud 視訊:
前言
Zuul 是Netflix 提供的一個開源元件,致力於在雲平臺上提供動態路由,監控,彈性,安全等邊緣服務的框架。也有很多公司使用它來作為閘道器的重要組成部分,碰巧今年公司的架構組決定自研一個閘道器產品,集動態路由,動態許可權,限流配額等功能為一體,為其他部門的專案提供統一的外網呼叫管理,最終形成產品(這方面阿里其實已經有成熟的閘道器產品了,但是不太適用於個性化的配置,也沒有整合許可權和限流降級)。
不過這裡並不想介紹整個閘道器的架構,而是想著重於討論其中的一個關鍵點,並且也是經常在交流群中聽人說起的:動態路由怎麼做?
再闡釋什麼是動態路由之前,需要介紹一下架構的設計。
傳統網際網路架構圖
上圖是沒有閘道器參與的一個最典型的網際網路架構(本文中統一使用book代表應用例項,即真正提供服務的一個業務系統)
加入eureka的架構圖
book註冊到eureka註冊中心中,zuul本身也連線著同一個eureka,可以拉取book眾多例項的列表。服務中心的註冊發現一直是值得推崇的一種方式,但是不適用與閘道器產品。因為我們的閘道器是面向眾多的其他部門的已有或是異構架構的系統,不應該強求其他系統都使用eureka,這樣是有侵入性的設計。
最終架構圖
要強調的一點是,gateway最終也會部署多個例項,達到分散式的效果,在架構圖中沒有畫出,請大家自行腦補。
本部落格的示例使用最後一章架構圖為例,帶來動態路由的實現方式,會有具體的程式碼。
動態路由
動態路由需要達到可持久化配置,動態重新整理的效果。如架構圖所示,不僅要能滿足從spring的配置檔案properties載入路由資訊,還需要從資料庫載入我們的配置。另外一點是,路由資訊在容器啟動時就已經載入進入了記憶體,我們希望配置完成後,實施釋出,動態重新整理記憶體中的路由資訊,達到不停機維護路由資訊的效果。
zuul–HelloWorldDemo
專案結構
<groupId>com.sinosoft</groupId> <artifactId>zuul-gateway-demo</artifactId> <packaging>pom</packaging> <version>1.0</version> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>1.5.2.RELEASE</version> </parent> <modules> <module>gateway</module> <module>book</module> </modules> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Camden.SR6</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
tip:springboot-1.5.2對應的springcloud的版本需要使用Camden.SR6,一開始想專門寫這個demo時,只替換了springboot的版本1.4.0->1.5.2,結果啟動就報錯了,最後發現是版本不相容的鍋。 gateway專案: 啟動類:GatewayApplication.java
@EnableZuulProxy
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
配置:application.properties
#配置在配置檔案中的路由資訊
zuul.routes.books.url=http://localhost:8090
zuul.routes.books.path=/books/**
#不使用註冊中心,會帶來侵入性
ribbon.eureka.enabled=false
#閘道器埠
server.port=8080
book專案: 啟動類:BookApplication.java
@RestController
@SpringBootApplication
public class BookApplication {
@RequestMapping(value = "/available")
public String available() {
System.out.println("Spring in Action");
return "Spring in Action";
}
@RequestMapping(value = "/checked-out")
public String checkedOut() {
return "Spring Boot in Action";
}
public static void main(String[] args) {
SpringApplication.run(BookApplication.class, args);
}
}
配置類:application.properties
server.port=8090
上述demo是一個簡單的靜態路由,簡單看下原始碼,zuul是怎麼做到轉發,路由的。
@Configuration
@EnableConfigurationProperties({ ZuulProperties.class })
@ConditionalOnClass(ZuulServlet.class)
@Import(ServerPropertiesAutoConfiguration.class)
public class ZuulConfiguration {
@Autowired
//zuul的配置檔案,對應了application.properties中的配置資訊
protected ZuulProperties zuulProperties;
@Autowired
protected ServerProperties server;
@Autowired(required = false)
private ErrorController errorController;
@Bean
public HasFeatures zuulFeature() {
return HasFeatures.namedFeature("Zuul (Simple)", ZuulConfiguration.class);
}
//核心類,路由定位器,最最重要
@Bean
@ConditionalOnMissingBean(RouteLocator.class)
public RouteLocator routeLocator() {
//預設配置的實現是SimpleRouteLocator.class
return new SimpleRouteLocator(this.server.getServletPrefix(),
this.zuulProperties);
}
//zuul的控制器,負責處理鏈路呼叫
@Bean
public ZuulController zuulController() {
return new ZuulController();
}
//MVC HandlerMapping that maps incoming request paths to remote services.
@Bean
public ZuulHandlerMapping zuulHandlerMapping(RouteLocator routes) {
ZuulHandlerMapping mapping = new ZuulHandlerMapping(routes, zuulController());
mapping.setErrorController(this.errorController);
return mapping;
}
//註冊了一個路由重新整理監聽器,預設實現是ZuulRefreshListener.class,這個是我們動態路由的關鍵
@Bean
public ApplicationListener<ApplicationEvent> zuulRefreshRoutesListener() {
return new ZuulRefreshListener();
}
@Bean
@ConditionalOnMissingBean(name = "zuulServlet")
public ServletRegistrationBean zuulServlet() {
ServletRegistrationBean servlet = new ServletRegistrationBean(new ZuulServlet(),
this.zuulProperties.getServletPattern());
// The whole point of exposing this servlet is to provide a route that doesn't
// buffer requests.
servlet.addInitParameter("buffer-requests", "false");
return servlet;
}
// pre filters
@Bean
public ServletDetectionFilter servletDetectionFilter() {
return new ServletDetectionFilter();
}
@Bean
public FormBodyWrapperFilter formBodyWrapperFilter() {
return new FormBodyWrapperFilter();
}
@Bean
public DebugFilter debugFilter() {
return new DebugFilter();
}
@Bean
public Servlet30WrapperFilter servlet30WrapperFilter() {
return new Servlet30WrapperFilter();
}
// post filters
@Bean
public SendResponseFilter sendResponseFilter() {
return new SendResponseFilter();
}
@Bean
public SendErrorFilter sendErrorFilter() {
return new SendErrorFilter();
}
@Bean
public SendForwardFilter sendForwardFilter() {
return new SendForwardFilter();
}
@Configuration
protected static class ZuulFilterConfiguration {
@Autowired
private Map<String, ZuulFilter> filters;
@Bean
public ZuulFilterInitializer zuulFilterInitializer() {
return new ZuulFilterInitializer(this.filters);
}
}
//上面提到的路由重新整理監聽器
private static class ZuulRefreshListener
implements ApplicationListener<ApplicationEvent> {
@Autowired
private ZuulHandlerMapping zuulHandlerMapping;
private HeartbeatMonitor heartbeatMonitor = new HeartbeatMonitor();
@Override
public void onApplicationEvent(ApplicationEvent event) {
if (event instanceof ContextRefreshedEvent
|| event instanceof RefreshScopeRefreshedEvent
|| event instanceof RoutesRefreshedEvent) {
//設定為髒,下一次匹配到路徑時,如果發現為髒,則會去重新整理路由資訊
this.zuulHandlerMapping.setDirty(true);
}
else if (event instanceof HeartbeatEvent) {
if (this.heartbeatMonitor.update(((HeartbeatEvent) event).getValue())) {
this.zuulHandlerMapping.setDirty(true);
}
}
}
}
}
我們要解決動態路由的難題,第一步就得理解路由定位器的作用。
很失望,因為從介面關係來看,spring考慮到了路由重新整理的需求,但是預設實現的SimpleRouteLocator沒有實現RefreshableRouteLocator介面,看來我們只能借鑑DiscoveryClientRouteLocator去改造SimpleRouteLocator使其具備重新整理能力。
public interface RefreshableRouteLocator extends RouteLocator {
void refresh();
}
DiscoveryClientRouteLocator比SimpleRouteLocator多了兩個功能,第一是從DiscoveryClient(如Eureka)發現路由資訊,之前的架構圖已經給大家解釋清楚了,我們不想使用eureka這種侵入式的閘道器模組,所以忽略它,第二是實現了RefreshableRouteLocator介面,能夠實現動態重新整理。 對SimpleRouteLocator.class的原始碼加一些註釋,方便大家閱讀:
public class SimpleRouteLocator implements RouteLocator {
//配置檔案中的路由資訊配置
private ZuulProperties properties;
//路徑正則配置器,即作用於path:/books/**
private PathMatcher pathMatcher = new AntPathMatcher();
private String dispatcherServletPath = "/";
private String zuulServletPath;
private AtomicReference<Map<String, ZuulRoute>> routes = new AtomicReference<>();
public SimpleRouteLocator(String servletPath, ZuulProperties properties) {
this.properties = properties;
if (servletPath != null && StringUtils.hasText(servletPath)) {
this.dispatcherServletPath = servletPath;
}
this.zuulServletPath = properties.getServletPath();
}
//路由定位器和其他元件的互動,是最終把定位的Routes以list的方式提供出去,核心實現
@Override
public List<Route> getRoutes() {
if (this.routes.get() == null) {
this.routes.set(locateRoutes());
}
List<Route> values = new ArrayList<>();
for (String url : this.routes.get().keySet()) {
ZuulRoute route = this.routes.get().get(url);
String path = route.getPath();
values.add(getRoute(route, path));
}
return values;
}
@Override
public Collection<String> getIgnoredPaths() {
return this.properties.getIgnoredPatterns();
}
//這個方法在閘道器產品中也很重要,可以根據實際路徑匹配到Route來進行業務邏輯的操作,進行一些加工
@Override
public Route getMatchingRoute(final String path) {
if (log.isDebugEnabled()) {
log.debug("Finding route for path: " + path);
}
if (this.routes.get() == null) {
this.routes.set(locateRoutes());
}
if (log.isDebugEnabled()) {
log.debug("servletPath=" + this.dispatcherServletPath);
log.debug("zuulServletPath=" + this.zuulServletPath);
log.debug("RequestUtils.isDispatcherServletRequest()="
+ RequestUtils.isDispatcherServletRequest());
log.debug("RequestUtils.isZuulServletRequest()="
+ RequestUtils.isZuulServletRequest());
}
String adjustedPath = adjustPath(path);
ZuulRoute route = null;
if (!matchesIgnoredPatterns(adjustedPath)) {
for (Entry<String, ZuulRoute> entry : this.routes.get().entrySet()) {
String pattern = entry.getKey();
log.debug("Matching pattern:" + pattern);
if (this.pathMatcher.match(pattern, adjustedPath)) {
route = entry.getValue();
break;
}
}
}
if (log.isDebugEnabled()) {
log.debug("route matched=" + route);
}
return getRoute(route, adjustedPath);
}
private Route getRoute(ZuulRoute route, String path) {
if (route == null) {
return null;
}
String targetPath = path;
String prefix = this.properties.getPrefix();
if (path.startsWith(prefix) && this.properties.isStripPrefix()) {
targetPath = path.substring(prefix.length());
}
if (route.isStripPrefix()) {
int index = route.getPath().indexOf("*") - 1;
if (index > 0) {
String routePrefix = route.getPath().substring(0, index);
targetPath = targetPath.replaceFirst(routePrefix, "");
prefix = prefix + routePrefix;
}
}
Boolean retryable = this.properties.getRetryable();
if (route.getRetryable() != null) {
retryable = route.getRetryable();
}
return new Route(route.getId(), targetPath, route.getLocation(), prefix,
retryable,
route.isCustomSensitiveHeaders() ? route.getSensitiveHeaders() : null);
}
//注意這個類並沒有實現refresh介面,但是卻提供了一個protected級別的方法,旨在讓子類不需要重複維護一個private AtomicReference<Map<String, ZuulRoute>> routes = new AtomicReference<>();也可以達到重新整理的效果
protected void doRefresh() {
this.routes.set(locateRoutes());
}
//具體就是在這兒定位路由資訊的,我們之後從資料庫載入路由資訊,主要也是從這兒改寫
/**
* Compute a map of path pattern to route. The default is just a static map from the
* {@link ZuulProperties}, but subclasses can add dynamic calculations.
*/
protected Map<String, ZuulRoute> locateRoutes() {
LinkedHashMap<String, ZuulRoute> routesMap = new LinkedHashMap<String, ZuulRoute>();
for (ZuulRoute route : this.properties.getRoutes().values()) {
routesMap.put(route.getPath(), route);
}
return routesMap;
}
protected boolean matchesIgnoredPatterns(String path) {
for (String pattern : this.properties.getIgnoredPatterns()) {
log.debug("Matching ignored pattern:" + pattern);
if (this.pathMatcher.match(pattern, path)) {
log.debug("Path " + path + " matches ignored pattern " + pattern);
return true;
}
}
return false;
}
private String adjustPath(final String path) {
String adjustedPath = path;
if (RequestUtils.isDispatcherServletRequest()
&& StringUtils.hasText(this.dispatcherServletPath)) {
if (!this.dispatcherServletPath.equals("/")) {
adjustedPath = path.substring(this.dispatcherServletPath.length());
log.debug("Stripped dispatcherServletPath");
}
}
else if (RequestUtils.isZuulServletRequest()) {
if (StringUtils.hasText(this.zuulServletPath)
&& !this.zuulServletPath.equals("/")) {
adjustedPath = path.substring(this.zuulServletPath.length());
log.debug("Stripped zuulServletPath");
}
}
else {
// do nothing
}
log.debug("adjustedPath=" + path);
return adjustedPath;
}
}
重寫過後的自定義路由定位器如下:
public class CustomRouteLocator extends SimpleRouteLocator implements RefreshableRouteLocator{
public final static Logger logger = LoggerFactory.getLogger(CustomRouteLocator.class);
private JdbcTemplate jdbcTemplate;
private ZuulProperties properties;
public void setJdbcTemplate(JdbcTemplate jdbcTemplate){
this.jdbcTemplate = jdbcTemplate;
}
public CustomRouteLocator(String servletPath, ZuulProperties properties) {
super(servletPath, properties);
this.properties = properties;
logger.info("servletPath:{}",servletPath);
}
//父類已經提供了這個方法,這裡寫出來只是為了說明這一個方法很重要!!!
// @Override
// protected void doRefresh() {
// super.doRefresh();
// }
@Override
public void refresh() {
doRefresh();
}
@Override
protected Map<String, ZuulRoute> locateRoutes() {
LinkedHashMap<String, ZuulRoute> routesMap = new LinkedHashMap<String, ZuulRoute>();
//從application.properties中載入路由資訊
routesMap.putAll(super.locateRoutes());
//從db中載入路由資訊
routesMap.putAll(locateRoutesFromDB());
//優化一下配置
LinkedHashMap<String, ZuulRoute> values = new LinkedHashMap<>();
for (Map.Entry<String, ZuulRoute> entry : routesMap.entrySet()) {
String path = entry.getKey();
// Prepend with slash if not already present.
if (!path.startsWith("/")) {
path = "/" + path;
}
if (StringUtils.hasText(this.properties.getPrefix())) {
path = this.properties.getPrefix() + path;
if (!path.startsWith("/")) {
path = "/" + path;
}
}
values.put(path, entry.getValue());
}
return values;
}
private Map<String, ZuulRoute> locateRoutesFromDB(){
Map<String, ZuulRoute> routes = new LinkedHashMap<>();
List<ZuulRouteVO> results = jdbcTemplate.query("select * from gateway_api_define where enabled = true ",new BeanPropertyRowMapper<>(ZuulRouteVO.class));
for (ZuulRouteVO result : results) {
if(org.apache.commons.lang3.StringUtils.isBlank(result.getPath()) || org.apache.commons.lang3.StringUtils.isBlank(result.getUrl()) ){
continue;
}
ZuulRoute zuulRoute = new ZuulRoute();
try {
org.springframework.beans.BeanUtils.copyProperties(result,zuulRoute);
} catch (Exception e) {
logger.error("=============load zuul route info from db with error==============",e);
}
routes.put(zuulRoute.getPath(),zuulRoute);
}
return routes;
}
public static class ZuulRouteVO {
/**
* The ID of the route (the same as its map key by default).
*/
private String id;
/**
* The path (pattern) for the route, e.g. /foo/**.
*/
private String path;
/**
* The service ID (if any) to map to this route. You can specify a physical URL or
* a service, but not both.
*/
private String serviceId;
/**
* A full physical URL to map to the route. An alternative is to use a service ID
* and service discovery to find the physical address.
*/
private String url;
/**
* Flag to determine whether the prefix for this route (the path, minus pattern
* patcher) should be stripped before forwarding.
*/
private boolean stripPrefix = true;
/**
* Flag to indicate that this route should be retryable (if supported). Generally
* retry requires a service ID and ribbon.
*/
private Boolean retryable;
private Boolean enabled;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getServiceId() {
return serviceId;
}
public void setServiceId(String serviceId) {
this.serviceId = serviceId;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public boolean isStripPrefix() {
return stripPrefix;
}
public void setStripPrefix(boolean stripPrefix) {
this.stripPrefix = stripPrefix;
}
public Boolean getRetryable() {
return retryable;
}
public void setRetryable(Boolean retryable) {
this.retryable = retryable;
}
public Boolean getEnabled() {
return enabled;
}
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
}
}
配置這個自定義的路由定位器:
@Configuration
public class CustomZuulConfig {
@Autowired
ZuulProperties zuulProperties;
@Autowired
ServerProperties server;
@Autowired
JdbcTemplate jdbcTemplate;
@Bean
public CustomRouteLocator routeLocator() {
CustomRouteLocator routeLocator = new CustomRouteLocator(this.server.getServletPrefix(), this.zuulProperties);
routeLocator.setJdbcTemplate(jdbcTemplate);
return routeLocator;
}
}
現在容器啟動時,就可以從資料庫和配置檔案中一起載入路由資訊了,離動態路由還差最後一步,就是實時重新整理,前面已經說過了,預設的ZuulConfigure已經配置了事件監聽器,我們只需要傳送一個事件就可以實現重新整理了。
public class RefreshRouteService {
@Autowired
ApplicationEventPublisher publisher;
@Autowired
RouteLocator routeLocator;
public void refreshRoute() {
RoutesRefreshedEvent routesRefreshedEvent = new RoutesRefreshedEvent(routeLocator);
publisher.publishEvent(routesRefreshedEvent);
}
}
具體的重新整理流程其實就是從資料庫重新載入了一遍,有人可能會問,為什麼不自己是手動重新載入Locator.dorefresh?非要用事件去重新整理。這牽扯到內部的zuul內部元件的工作流程,不僅僅是Locator本身的一個變數,具體想要了解的還得去看原始碼。
到這兒我們就實現了動態路由了,所以的例項程式碼和建表語句我會放到github上,下載的時候記得給我star QAQ
專家推薦
“隨著微服務架構的發展,Spring Cloud 使用得越來越廣泛。馳狼課堂 Spring Boot 快速入門,Spring Boot 與Spring Cloud 整合,docker+k8s,大型電商商城等多套免費實戰教程可以幫您真正做到快速上手,將技術點切實運用到微服務專案中。”