上一節如何創建一個服務提供者provider已經啟動了一個provider的server,提供用戶信息查詢接口。接下來,我們啟動另一個provider,由於是同一台機器本地測試,我們換一個端口
--server.port=8084
通過啟動傳參數覆蓋port。這樣,我們就有兩個provider實例了。接下來,可以使用我們consumer負載均衡的消費這兩個provider。
升級eureka依賴
eureka之前的pom依賴過期了,需要修改為
spring-cloud-starter-netflix-eureka-server
同樣的,所有的client都要修改為
spring-cloud-starter-netflix-eureka-client
創建一個consumer工程
創建一個子模塊。
https://github.com/Ryan-Miao/spring-cloud-Edgware-demo/tree/master/consumer-demo
配置基本和provider一致
<dependencies>
<!--springboot 依賴start-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<!--springboot 依賴結束-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
</dependency>
<!--工具類 start-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
</dependency>
<!--工具類end-->
<!--內部依賴-->
<dependency>
<groupId>com.test</groupId>
<artifactId>provider-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<!--內部依賴end-->
</dependencies>
spring-cloud-starter-netflix-eureka-client
eureka客戶端,負責維護注冊和心跳spring-cloud-starter-openfeign
聲明式的HttpClient Feign客戶端spring-cloud-starter-netflix-ribbon
客戶端負載均衡spring-cloud-starter-netflix-hystrix
http請求健康熔斷provider-api
我們定義好的provider請求的客戶端
啟動類
啟動類和provider相同,多了一行注解
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class ConsumerApplication {
public static void main(String[] args) {
SpringApplication.run(ConsumerApplication.class, args);
}
}
EnableFeignClients
啟用Feign
Swagger等基礎配置
同provider,提供幾個簡單api。省略敘述。
FeignClient 遠程調用
創建一個接口,繼承我們provider-api里聲明的接口
@FeignClient(value = "PROVIDER-DEMO", fallback = UserClientFallback.class)
public interface UserClient extends UserApi {
}
FeignClient
會標注這是一個Feign的客戶端,在項目啟動的時候就會掃描到,value是連接的service的名稱,這里即我們的provider, fallback則是當遠程請求失敗的時候,服務降級,我們來決定做什么。
如果不填寫fallback,則請求遇到非200會報錯,拋出一個RuntimeException, HystrixRuntimeException. 有可能是遠程返回500, 400等,也有可能是連接超時,還有可能是hystrix 熔斷。
而填寫了fallback, 則會在服務調用失敗的時候,轉調用我們對應的fallback方法。
fallback就是實現我們這個UserClient接口。
@Component
@RequestMapping("/userClientFallback")
public class UserClientFallback implements UserClient {
@Override
public List<UserVo> list() {
UserVo userVo = new UserVo();
userVo.setAge(1);
userVo.setBirth(LocalDate.now());
userVo.setId(1);
userVo.setName("fallback");
return Lists.newArrayList(userVo);
}
@Override
public String fallback() {
return "訪問失敗后調用此方法,進行服務降級.";
}
}
Component
是要把這個Fallback注冊到spring容器里,FeignClient在項目啟動的時候會讀取fallback, 然后從context里讀取這個instance,如果沒有找到,就啟動失敗、
見org.springframework.cloud.netflix.feign.HystrixTargeter#getFromContext
private <T> T getFromContext(String fallbackMechanism, String feignClientName, FeignContext context,
Class<?> beanType, Class<T> targetType) {
Object fallbackInstance = context.getInstance(feignClientName, beanType);
if (fallbackInstance == null) {
throw new IllegalStateException(String.format(
"No " + fallbackMechanism + " instance of type %s found for feign client %s",
beanType, feignClientName));
}
if (!targetType.isAssignableFrom(beanType)) {
throw new IllegalStateException(
String.format(
"Incompatible " + fallbackMechanism + " instance. Fallback/fallbackFactory of type %s is not assignable to %s for feign client %s",
beanType, targetType, feignClientName));
}
return (T) fallbackInstance;
}
@RequestMapping
則是不得已而為之了。前文provider-demo里,我們把api抽取成UserApi
@RequestMapping("/api/v1/users")
public interface UserApi {
@GetMapping("/")
List<UserVo> list();
@GetMapping("/fallback")
String fallback();
}
這里的RequestMapping會被spring啟動的到時候掃描到,在初始化RequestMappingHandlerMapping的時候,掃描所有的bean,把RequestMapping的bean給注冊RequestMapping. 這時候,它不管你是不是controller的。我們FeignClient所聲明的接口上有@RequestMapping,也會被掃描。而我們Fallback也繼承,也會有@RequestMapping,這時候重復定義RequestMapping會報錯
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'requestMappingHandlerMapping' defined in class path resource [org/springframework/boot/autoconfigure/web/WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]: Invocation of init method failed; nested exception is java.lang.IllegalStateException: Ambiguous mapping. Cannot map 'com.test.cloud.client.UserClient' method
public abstract java.util.List<com.test.cloud.vo.UserVo> com.test.cloud.api.UserApi.list()
to {[/api/v1/users/],methods=[GET]}: There is already 'userClientFallback' bean method
public java.util.List<com.test.cloud.vo.UserVo> com.test.cloud.client.UserClientFallback.list() mapped.
事實上,我們並不是要將FeignClient給注冊到RequestMapping里的,而且OpenFeign也有自己的一套注解方案。只是spring-cloud為了方便集成和簡化OpenFeign的用法,把Spring-Web的注解做了適配。不好的地方是RequestMapping的掃描並沒有排除。
以下代碼會找到方法注解@RequestMapping.
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping#createRequestMappingInfo(java.lang.reflect.AnnotatedElement)
private RequestMappingInfo createRequestMappingInfo(AnnotatedElement element) {
RequestMapping requestMapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class);
RequestCondition<?> condition = (element instanceof Class ?
getCustomTypeCondition((Class<?>) element) : getCustomMethodCondition((Method) element));
return (requestMapping != null ? createRequestMappingInfo(requestMapping, condition) : null);
}
而RequestMapping這個bean創建完后會掃描所有bean, 並注冊
org.springframework.web.servlet.handler.AbstractHandlerMethodMapping.MappingRegistry#register
public void register(T mapping, Object handler, Method method) {
this.readWriteLock.writeLock().lock();
try {
HandlerMethod handlerMethod = createHandlerMethod(handler, method);
assertUniqueMethodMapping(handlerMethod, mapping);
if (logger.isInfoEnabled()) {
logger.info("Mapped \"" + mapping + "\" onto " + handlerMethod);
}
this.mappingLookup.put(mapping, handlerMethod);
List<String> directUrls = getDirectUrls(mapping);
for (String url : directUrls) {
this.urlLookup.add(url, mapping);
}
String name = null;
if (getNamingStrategy() != null) {
name = getNamingStrategy().getName(handlerMethod, mapping);
addMappingName(name, handlerMethod);
}
CorsConfiguration corsConfig = initCorsConfiguration(handler, method, mapping);
if (corsConfig != null) {
this.corsLookup.put(handlerMethod, corsConfig);
}
this.registry.put(mapping, new MappingRegistration<T>(mapping, handlerMethod, directUrls, name));
}
finally {
this.readWriteLock.writeLock().unlock();
}
}
private void assertUniqueMethodMapping(HandlerMethod newHandlerMethod, T mapping) {
HandlerMethod handlerMethod = this.mappingLookup.get(mapping);
if (handlerMethod != null && !handlerMethod.equals(newHandlerMethod)) {
throw new IllegalStateException(
"Ambiguous mapping. Cannot map '" + newHandlerMethod.getBean() + "' method \n" +
newHandlerMethod + "\nto " + mapping + ": There is already '" +
handlerMethod.getBean() + "' bean method\n" + handlerMethod + " mapped.");
}
}
總之,由於這個沖突,fallback必須制定一個隨意不相干的url地址。等后面我學會怎么手動排除RequestMapping的時候就不用了。
接下來,直接調用FeignClient
@Api
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
private final UserClient userClient;
@Autowired
public UserController(UserClient userClient) {
this.userClient = userClient;
}
@GetMapping("/feign")
public List<UserVo> feign() {
return userClient.list();
}
@GetMapping("/feign-fallback")
public String fallback() {
return userClient.fallback();
}
}
在provider-api里,我設計userClient.list()
返回用戶列表,userClient.fallback()
隨機報500. 這樣,啟動,訪問兩個api可以觀察到服務降級了。
關於Feign,Hystrix,Ribbon的配置
我目前用到的配置有以下幾種,不全,暫時有這些
#eureka客戶端ribbon刷新時間
#默認30s
ribbon.ServerListRefreshInterval: 5000
# ribbon默認配置
#ribbon.ConnectTimeout=250
#ribbon.ReadTimeout=1000
#ribbon.OkToRetryOnAllOperations=true
#ribbon.MaxAutoRetriesNextServer=2
#ribbon.MaxAutoRetries=0
# feign日志配置, 指定某個service的日志級別
#logging.level.com.test.cloud.client.UserClient: info
# ribbon全局默認連接和等待時間
ribbon.ConnectTimeout: 1000
ribbon.ReadTimeout: 10000
# ribbon指定service的連接和等待時間,注意service的名稱要和在FeignClient注解里標注的內容一致, 要大寫
PROVIDER-DEMO.ribbon.ConnectTimeout: 1000
PROVIDER-DEMO.ribbon.ReadTimeout: 1000
# feign全局開啟hystrix支持,默認false
feign.hystrix.enabled: true
# hystrix全局默認超時時間
hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds: 5000
# hystrix指定request的單獨設置超時時間, commandkey的組成為ClientClassName#methodName(ParamTypeClassName..)
hystrix.command.UserClient#list().execution.isolation.thread.timeoutInMilliseconds: 5000
需要注意的是,需要理解幾個超時的概念。即,需要明白hystrix是干啥的,ribbon又是干啥的,Feign如何把它們集成的。
Feign
OpenFeign可以配置超時,日志,序列化和反序列化,重試等。只要手動聲明對應的bean即可。具體配置見
org.springframework.cloud.netflix.feign.FeignClientsConfiguration
值得注意的是,默認不會重試
@Bean
@ConditionalOnMissingBean
public Retryer feignRetryer() {
return Retryer.NEVER_RETRY;
}
以及,默認不會采用hystrix
@Configuration
@ConditionalOnClass({ HystrixCommand.class, HystrixFeign.class })
protected static class HystrixFeignConfiguration {
@Bean
@Scope("prototype")
@ConditionalOnMissingBean
@ConditionalOnProperty(name = "feign.hystrix.enabled", matchIfMissing = false)
public Feign.Builder feignHystrixBuilder() {
return HystrixFeign.builder();
}
}
需要引入hystrix class和配置
feign.hystrix.enabled: true
Hystrix
有關具體原理信息,參見官網。個人簡單理解,Hystrix為每個依賴的服務創建一個線程池,服務在線程池里執行,hystrix會有一些策略決定什么時候執行超時,還可以獲得執行結果的成功率。於是可以指定一些策略,比如超時后中斷線程,比如成功率在某一段時間低於閥值后拒絕服務執行。這樣就像一個保險絲一樣,當不滿足我們設置的策略時,直接燒斷了,從而起到保護服務資源的作用。當然,實現會更復雜,還有恢復機制。
所以,hystrix會有個超時的配置,決定線程執行時間。
# hystrix全局默認超時時間
hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds: 5000
# hystrix指定request的單獨設置超時時間, commandkey的組成為ClientClassName#methodName(ParamTypeClassName..)
hystrix.command.UserClient#list().execution.isolation.thread.timeoutInMilliseconds: 5000
在Feign集成Hystrix的時候,把ClientClassName#methodName(ParamTypeClassName..)
設置成Hystrix的CommandKey, CommandKey就是hystrix執行策略的最小單位,比如對應某個http請求,對應這個請求的最長時間即我們設置的超時。
feign.Feign#configKey(java.lang.Class, java.lang.reflect.Method)
public static String configKey(Class targetType, Method method) {
StringBuilder builder = new StringBuilder();
builder.append(targetType.getSimpleName());
builder.append('#').append(method.getName()).append('(');
for (Type param : method.getGenericParameterTypes()) {
param = Types.resolve(targetType, targetType, param);
builder.append(Types.getRawType(param).getSimpleName()).append(',');
}
if (method.getParameterTypes().length > 0) {
builder.deleteCharAt(builder.length() - 1);
}
return builder.append(')').toString();
}
Feign會把host當作groupkey, 這里則是我們的服務名。
當然,還有更多細節的配置,比如線程池,時間窗口大小等。見官網Configuration
Ribbon
Ribbon采用客戶端負載均衡。與服務端負載均衡對應,比如我們訪問baidu.com, 域名解析器后轉向某個負載均衡設備來決定我們的請求打到哪台機器上,對於我們請求者來說是透明的,我們不知道負載信息。
而Ribbon則是自己維護所有可用的服務列表,根據某種策略,去選擇請求哪個服務實例。比如隨機選取,線性輪詢選取,在線性輪詢的基礎上重試選取,權重選取,Zone優先選取等。
在Feign集成Ribbon的時候,把兩個超時時間委托給Ribbon。
public FeignLoadBalancer(ILoadBalancer lb, IClientConfig clientConfig,
ServerIntrospector serverIntrospector) {
super(lb, clientConfig);
this.setRetryHandler(RetryHandler.DEFAULT);
this.clientConfig = clientConfig;
this.connectTimeout = clientConfig.get(CommonClientConfigKey.ConnectTimeout);
this.readTimeout = clientConfig.get(CommonClientConfigKey.ReadTimeout);
this.serverIntrospector = serverIntrospector;
}
在不和Ribbon集成的時候,OpenFeign會設置連接超時和讀取超時
feign.Client.Default#convertAndSend
final HttpURLConnection
connection =
(HttpURLConnection) new URL(request.url()).openConnection();
if (connection instanceof HttpsURLConnection) {
HttpsURLConnection sslCon = (HttpsURLConnection) connection;
if (sslContextFactory != null) {
sslCon.setSSLSocketFactory(sslContextFactory);
}
if (hostnameVerifier != null) {
sslCon.setHostnameVerifier(hostnameVerifier);
}
}
connection.setConnectTimeout(options.connectTimeoutMillis());
connection.setReadTimeout(options.readTimeoutMillis());
而和Ribbon集成后,Feign會讀取ribbon的兩個時間設置,即
# ribbon全局默認連接和等待時間
ribbon.ConnectTimeout: 1000
ribbon.ReadTimeout: 10000
# ribbon指定service的連接和等待時間,注意service的名稱要和在FeignClient注解里標注的內容一致, 要大寫
PROVIDER-DEMO.ribbon.ConnectTimeout: 1
PROVIDER-DEMO.ribbon.ReadTimeout: 1
關於單獨執行某個服務的超時配置,區別Ribbon全局時間配置,這個idea沒有自動提示,debug了半天源碼,找到配置為服務名大寫+.ribbon.ConnectTimeout
com.netflix.client.config.DefaultClientConfigImpl#getInstancePropName(java.lang.String, java.lang.String)
public String getInstancePropName(String restClientName, String key) {
return restClientName + "." + this.getNameSpace() + "." + key;
}
這里設置為1只是為了測試超時設置。debug追蹤發現,確實如此。這種最佳實踐真的只能自己去實踐。
調優
由於http rest請求的復雜性,可能需要調整超時時間,心跳時間,甚至根據當前服務的請求速率設置線程池大小和排隊大小,設置熔斷條件等。這個只能在監控上線后,根據監控信息去對應修改需要的配置。目前我還沒有最佳實踐,不亂說了。
結尾
到這里,在啟動了eureka,provider之后,啟動consumer就可以實現遠程調用了。嗯,基本滿足開發需求了。訪問feign的接口,觀察admin里兩個provider的請求,可以發現我們的請求確實負載到不同的instance上了。訪問fallback接口,可以看到失敗的時候會執行我們的降級策略。
Miao語
基礎很重要,基礎很重要,基礎非常重要。