什麼是Feign?
Feign 的英文表意為“假裝,偽裝,變形”, 是一個http請求呼叫的輕量級框架,可以以Java介面註解的方式呼叫Http請求,而不用像Java中通過封裝HTTP請求報文的方式直接呼叫。Feign通過處理註解,將請求模板化,當實際呼叫的時候,傳入引數,根據引數再應用到請求上,進而轉化成真正的請求,這種請求相對而言比較直觀。 Feign被廣泛應用在Spring Cloud 的解決方案中,是學習基於Spring Cloud 微服務架構不可或缺的重要元件。 開源專案地址: github.com/OpenFeign/f…
Feign解決了什麼問題?
封裝了Http呼叫流程,更適合面向介面化的變成習慣 在服務呼叫的場景中,我們經常呼叫基於Http協議的服務,而我們經常使用到的框架可能有HttpURLConnection、Apache HttpComponnets、OkHttp3 、Netty等等,這些框架在基於自身的專注點提供了自身特性。而從角色劃分上來看,他們的職能是一致的提供Http呼叫服務。具體流程如下:
Feign是如何設計的?
PHASE 1. 基於面向介面的動態代理方式生成實現類
在使用feign 時,會定義對應的介面類,在介面類上使用Http相關的註解,標識HTTP請求引數資訊,如下所示:
interface GitHub {
@RequestLine("GET /repos/{owner}/{repo}/contributors")
List<Contributor> contributors(@Param("owner") String owner, @Param("repo") String repo);
}
public static class Contributor {
String login;
int contributions;
}
public class MyApp {
public static void main(String... args) {
GitHub github = Feign.builder()
.decoder(new GsonDecoder())
.target(GitHub.class, "https://api.github.com");
// Fetch and print a list of the contributors to this library.
List<Contributor> contributors = github.contributors("OpenFeign", "feign");
for (Contributor contributor : contributors) {
System.out.println(contributor.login + " (" + contributor.contributions + ")");
}
}
}
複製程式碼
在Feign 底層,通過基於面向介面的動態代理方式生成實現類,將請求呼叫委託到動態代理實現類,基本原理如下所示:
public class ReflectiveFeign extends Feign{
///省略部分程式碼
@Override
public <T> T newInstance(Target<T> target) {
//根據介面類和Contract協議解析方式,解析介面類上的方法和註解,轉換成內部的MethodHandler處理方式
Map<String, MethodHandler> nameToHandler = targetToHandlersByName.apply(target);
Map<Method, MethodHandler> methodToHandler = new LinkedHashMap<Method, MethodHandler>();
List<DefaultMethodHandler> defaultMethodHandlers = new LinkedList<DefaultMethodHandler>();
for (Method method : target.type().getMethods()) {
if (method.getDeclaringClass() == Object.class) {
continue;
} else if(Util.isDefault(method)) {
DefaultMethodHandler handler = new DefaultMethodHandler(method);
defaultMethodHandlers.add(handler);
methodToHandler.put(method, handler);
} else {
methodToHandler.put(method, nameToHandler.get(Feign.configKey(target.type(), method)));
}
}
InvocationHandler handler = factory.create(target, methodToHandler);
// 基於Proxy.newProxyInstance 為介面類建立動態實現,將所有的請求轉換給InvocationHandler 處理。
T proxy = (T) Proxy.newProxyInstance(target.type().getClassLoader(), new Class<?>[]{target.type()}, handler);
for(DefaultMethodHandler defaultMethodHandler : defaultMethodHandlers) {
defaultMethodHandler.bindTo(proxy);
}
return proxy;
}
//省略部分程式碼
複製程式碼
PHASE 2. 根據Contract協議規則,解析介面類的註解資訊,解析成內部表現:
Feign 定義了轉換協議,定義如下:
/**
* Defines what annotations and values are valid on interfaces.
*/
public interface Contract {
/**
* Called to parse the methods in the class that are linked to HTTP requests.
* 傳入介面定義,解析成相應的方法內部後設資料表示
* @param targetType {@link feign.Target#type() type} of the Feign interface.
*/
// TODO: break this and correct spelling at some point
List<MethodMetadata> parseAndValidatateMetadata(Class<?> targetType);
}
複製程式碼
預設Contract 實現 Feign 預設有一套自己的協議規範,規定了一些註解,可以對映成對應的Http請求,如官方的一個例子:
public interface GitHub {
@RequestLine("GET /repos/{owner}/{repo}/contributors")
List<Contributor> getContributors(@Param("owner") String owner, @Param("repo") String repository);
class Contributor {
String login;
int contributions;
}
}
複製程式碼
上述的例子中,嘗試呼叫GitHub.getContributors("foo","myrepo")的的時候,會轉換成如下的HTTP請求:
GET /repos/foo/myrepo/contributors
HOST XXXX.XXX.XXX
複製程式碼
Feign 預設的協議規範
註解 | 介面Target | 使用說明 |
---|---|---|
@RequestLine | 方法上 | 定義HttpMethod 和 UriTemplate. UriTemplate 中使用{} 包裹的表示式,可以通過在方法引數上使用@Param 自動注入 |
@Param | 方法引數 | 定義模板變數,模板變數的值可以使用名稱的方式使用模板注入解析 |
@Headers | 類上或者方法上 | 定義頭部模板變數,使用@Param 註解提供引數值的注入。如果該註解新增在介面類上,則所有的請求都會攜帶對應的Header資訊;如果在方法上,則只會新增到對應的方法請求上 |
@QueryMap | 方法上 | 定義一個鍵值對或者 pojo,引數值將會被轉換成URL上的 query 字串上 |
@HeaderMap | 方法上 | 定義一個HeaderMap, 與 UrlTemplate 和HeaderTemplate 型別,可以使用@Param 註解提供引數值 |
具體FeignContract 是如何解析的,不在本文的介紹範圍內,詳情請參考程式碼: github.com/OpenFeign/f…
基於Spring MVC的協議規範SpringMvcContract:
當前Spring Cloud 微服務解決方案中,為了降低學習成本,採用了Spring MVC的部分註解來完成 請求協議解析,也就是說 ,寫客戶端請求介面和像寫服務端程式碼一樣:客戶端和服務端可以通過SDK的方式進行約定,客戶端只需要引入服務端釋出的SDK API,就可以使用面向介面的編碼方式對接服務:
我們團隊內部就是按照這種思路,結合Spring Boot Starter 的特性,定義了服務端starter, 服務消費者在使用的時候,只需要引入Starter,就可以呼叫服務。這個比較適合平臺無關性,介面抽象出來的好處就是可以根據服務呼叫實現方式自有切換:
1.可以基於簡單的Http服務呼叫;
2.可以基於Spring Cloud 微服務架構呼叫;
3.可以基於Dubbo SOA服務治理
這種模式比較適合在SaSS混合軟體服務的模式下自有切換,根據客戶的硬體能力選擇合適的方式部署,也可以基於自身的服務叢集部署微服務
至於Spring Cloud 是如何實現 協議解析的,可參考程式碼:
github.com/spring-clou…
當然,目前的Spring MVC的註解並不是可以完全使用的,有一些註解並不支援,如@GetMapping,@PutMapping 等,僅支援使用@RequestMapping 等,另外註解繼承性方面也有些問題;具體限制細節,每個版本能會有些出入,可以參考上述的程式碼實現,比較簡單。
Spring Cloud 沒有基於Spring MVC 全部註解來做Feign 客戶端註解協議解析,個人認為這個是一個不小的坑。在剛入手Spring Cloud 的時候,就碰到這個問題。後來是深入程式碼才解決的.... 這個應該有人寫了增強類來處理,暫且不表,先MARK一下,是一個開原始碼練手的好機會。
PHASE 3. 基於 RequestBean,動態生成Request
根據傳入的Bean物件和註解資訊,從中提取出相應的值,來構造Http Request 物件:
PHASE 4. 使用Encoder 將Bean轉換成 Http報文正文(訊息解析和轉碼邏輯)
Feign 最終會將請求轉換成Http 訊息傳送出去,傳入的請求物件最終會解析成訊息體,如下所示:
在介面定義上Feign做的比較簡單,抽象出了Encoder 和decoder 介面:
public interface Encoder {
/** Type literal for {@code Map<String, ?>}, indicating the object to encode is a form. */
Type MAP_STRING_WILDCARD = Util.MAP_STRING_WILDCARD;
/**
* Converts objects to an appropriate representation in the template.
* 將實體物件轉換成Http請求的訊息正文中
* @param object what to encode as the request body.
* @param bodyType the type the object should be encoded as. {@link #MAP_STRING_WILDCARD}
* indicates form encoding.
* @param template the request template to populate.
* @throws EncodeException when encoding failed due to a checked exception.
*/
void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException;
/**
* Default implementation of {@code Encoder}.
*/
class Default implements Encoder {
@Override
public void encode(Object object, Type bodyType, RequestTemplate template) {
if (bodyType == String.class) {
template.body(object.toString());
} else if (bodyType == byte[].class) {
template.body((byte[]) object, null);
} else if (object != null) {
throw new EncodeException(
format("%s is not a type supported by this encoder.", object.getClass()));
}
}
}
}
public interface Decoder {
/**
* Decodes an http response into an object corresponding to its {@link
* java.lang.reflect.Method#getGenericReturnType() generic return type}. If you need to wrap
* exceptions, please do so via {@link DecodeException}.
* 從Response 中提取Http訊息正文,通過介面類宣告的返回型別,訊息自動裝配
* @param response the response to decode
* @param type {@link java.lang.reflect.Method#getGenericReturnType() generic return type} of
* the method corresponding to this {@code response}.
* @return instance of {@code type}
* @throws IOException will be propagated safely to the caller.
* @throws DecodeException when decoding failed due to a checked exception besides IOException.
* @throws FeignException when decoding succeeds, but conveys the operation failed.
*/
Object decode(Response response, Type type) throws IOException, DecodeException, FeignException;
/** Default implementation of {@code Decoder}. */
public class Default extends StringDecoder {
@Override
public Object decode(Response response, Type type) throws IOException {
if (response.status() == 404) return Util.emptyValueOf(type);
if (response.body() == null) return null;
if (byte[].class.equals(type)) {
return Util.toByteArray(response.body().asInputStream());
}
return super.decode(response, type);
}
}
}
複製程式碼
目前Feign 有以下實現:
Encoder/ Decoder實現 | 說明 |
---|---|
JacksonEncoder,JacksonDecoder | 基於 Jackson 格式的持久化轉換協議 |
GsonEncoder,GsonDecoder | 基於Google GSON 格式的持久化轉換協議 |
SaxEncoder,SaxDecoder | 基於XML 格式的Sax 庫持久化轉換協議 |
JAXBEncoder,JAXBDecoder | 基於XML 格式的JAXB 庫持久化轉換協議 |
ResponseEntityEncoder,ResponseEntityDecoder | Spring MVC 基於 ResponseEntity< T > 返回格式的轉換協議 |
SpringEncoder,SpringDecoder | 基於Spring MVC HttpMessageConverters 一套機制實現的轉換協議 ,應用於Spring Cloud 體系中 |
PHASE 5. 攔截器負責對請求和返回進行裝飾處理
在請求轉換的過程中,Feign 抽象出來了攔截器介面,用於使用者自定義對請求的操作:
public interface RequestInterceptor {
/**
* 可以在構造RequestTemplate 請求時,增加或者修改Header, Method, Body 等資訊
* Called for every request. Add data using methods on the supplied {@link RequestTemplate}.
*/
void apply(RequestTemplate template);
}
複製程式碼
比如,如果希望Http訊息傳遞過程中被壓縮,可以定義一個請求攔截器:
public class FeignAcceptGzipEncodingInterceptor extends BaseRequestInterceptor {
/**
* Creates new instance of {@link FeignAcceptGzipEncodingInterceptor}.
*
* @param properties the encoding properties
*/
protected FeignAcceptGzipEncodingInterceptor(FeignClientEncodingProperties properties) {
super(properties);
}
/**
* {@inheritDoc}
*/
@Override
public void apply(RequestTemplate template) {
// 在Header 頭部新增相應的資料資訊
addHeader(template, HttpEncoding.ACCEPT_ENCODING_HEADER, HttpEncoding.GZIP_ENCODING,
HttpEncoding.DEFLATE_ENCODING);
}
}
複製程式碼
PHASE 6. 日誌記錄
在傳送和接收請求的時候,Feign定義了統一的日誌門面來輸出日誌資訊 , 並且將日誌的輸出定義了四個等級:
級別 | 說明 |
---|---|
NONE | 不做任何記錄 |
BASIC | 只記錄輸出Http 方法名稱、請求URL、返回狀態碼和執行時間 |
HEADERS | 記錄輸出Http 方法名稱、請求URL、返回狀態碼和執行時間 和 Header 資訊 |
FULL | 記錄Request 和Response的Header,Body和一些請求後設資料 |
public abstract class Logger {
protected static String methodTag(String configKey) {
return new StringBuilder().append('[').append(configKey.substring(0, configKey.indexOf('(')))
.append("] ").toString();
}
/**
* Override to log requests and responses using your own implementation. Messages will be http
* request and response text.
*
* @param configKey value of {@link Feign#configKey(Class, java.lang.reflect.Method)}
* @param format {@link java.util.Formatter format string}
* @param args arguments applied to {@code format}
*/
protected abstract void log(String configKey, String format, Object... args);
protected void logRequest(String configKey, Level logLevel, Request request) {
log(configKey, "---> %s %s HTTP/1.1", request.method(), request.url());
if (logLevel.ordinal() >= Level.HEADERS.ordinal()) {
for (String field : request.headers().keySet()) {
for (String value : valuesOrEmpty(request.headers(), field)) {
log(configKey, "%s: %s", field, value);
}
}
int bodyLength = 0;
if (request.body() != null) {
bodyLength = request.body().length;
if (logLevel.ordinal() >= Level.FULL.ordinal()) {
String
bodyText =
request.charset() != null ? new String(request.body(), request.charset()) : null;
log(configKey, ""); // CRLF
log(configKey, "%s", bodyText != null ? bodyText : "Binary data");
}
}
log(configKey, "---> END HTTP (%s-byte body)", bodyLength);
}
}
protected void logRetry(String configKey, Level logLevel) {
log(configKey, "---> RETRYING");
}
protected Response logAndRebufferResponse(String configKey, Level logLevel, Response response,
long elapsedTime) throws IOException {
String reason = response.reason() != null && logLevel.compareTo(Level.NONE) > 0 ?
" " + response.reason() : "";
int status = response.status();
log(configKey, "<--- HTTP/1.1 %s%s (%sms)", status, reason, elapsedTime);
if (logLevel.ordinal() >= Level.HEADERS.ordinal()) {
for (String field : response.headers().keySet()) {
for (String value : valuesOrEmpty(response.headers(), field)) {
log(configKey, "%s: %s", field, value);
}
}
int bodyLength = 0;
if (response.body() != null && !(status == 204 || status == 205)) {
// HTTP 204 No Content "...response MUST NOT include a message-body"
// HTTP 205 Reset Content "...response MUST NOT include an entity"
if (logLevel.ordinal() >= Level.FULL.ordinal()) {
log(configKey, ""); // CRLF
}
byte[] bodyData = Util.toByteArray(response.body().asInputStream());
bodyLength = bodyData.length;
if (logLevel.ordinal() >= Level.FULL.ordinal() && bodyLength > 0) {
log(configKey, "%s", decodeOrDefault(bodyData, UTF_8, "Binary data"));
}
log(configKey, "<--- END HTTP (%s-byte body)", bodyLength);
return response.toBuilder().body(bodyData).build();
} else {
log(configKey, "<--- END HTTP (%s-byte body)", bodyLength);
}
}
return response;
}
protected IOException logIOException(String configKey, Level logLevel, IOException ioe, long elapsedTime) {
log(configKey, "<--- ERROR %s: %s (%sms)", ioe.getClass().getSimpleName(), ioe.getMessage(),
elapsedTime);
if (logLevel.ordinal() >= Level.FULL.ordinal()) {
StringWriter sw = new StringWriter();
ioe.printStackTrace(new PrintWriter(sw));
log(configKey, sw.toString());
log(configKey, "<--- END ERROR");
}
return ioe;
}
複製程式碼
PHASE 7 . 基於重試器傳送HTTP請求
Feign 內建了一個重試器,當HTTP請求出現IO異常時,Feign會有一個最大嘗試次數傳送請求,以下是Feign核心 程式碼邏輯:
final class SynchronousMethodHandler implements MethodHandler {
// 省略部分程式碼
@Override
public Object invoke(Object[] argv) throws Throwable {
//根據輸入引數,構造Http 請求。
RequestTemplate template = buildTemplateFromArgs.create(argv);
// 克隆出一份重試器
Retryer retryer = this.retryer.clone();
// 嘗試最大次數,如果中間有結果,直接返回
while (true) {
try {
return executeAndDecode(template);
} catch (RetryableException e) {
retryer.continueOrPropagate(e);
if (logLevel != Logger.Level.NONE) {
logger.logRetry(metadata.configKey(), logLevel);
}
continue;
}
}
}
複製程式碼
重試器有如下幾個控制引數:
重試引數 | 說明 | 預設值period |
---|---|---|
初始重試時間間隔,當請求失敗後,重試器將會暫停 | 初始時間間隔(執行緒 sleep 的方式)後再開始,避免強刷請求,浪費效能 | 100ms |
maxPeriod | 當請求連續失敗時,重試的時間間隔將按照:long interval = (long) (period * Math.pow(1.5, attempt - 1)); 計算,按照等比例方式延長,但是最大間隔時間為 maxPeriod, 設定此值能夠避免 重試次數過多的情況下執行週期太長 | 1000ms |
maxAttempts | 最大重試次數 | 5 |
具體的程式碼實現可參考: github.com/OpenFeign/f…
PHASE 8. 傳送Http請求
Feign 真正傳送HTTP請求是委託給 feign.Client 來做的:
public interface Client {
/**
* Executes a request against its {@link Request#url() url} and returns a response.
* 執行Http請求,並返回Response
* @param request safe to replay.
* @param options options to apply to this request.
* @return connected response, {@link Response.Body} is absent or unread.
* @throws IOException on a network error connecting to {@link Request#url()}.
*/
Response execute(Request request, Options options) throws IOException;
}
複製程式碼
Feign 預設底層通過JDK 的 java.net.HttpURLConnection 實現了feign.Client介面類,在每次傳送請求的時候,都會建立新的HttpURLConnection 連結,這也就是為什麼預設情況下Feign的效能很差的原因。可以通過擴充該介面,使用Apache HttpClient 或者OkHttp3等基於連線池的高效能Http客戶端,我們專案內部使用的就是OkHttp3作為Http 客戶端。 如下是Feign 的預設實現,供參考:
public static class Default implements Client {
private final SSLSocketFactory sslContextFactory;
private final HostnameVerifier hostnameVerifier;
/**
* Null parameters imply platform defaults.
*/
public Default(SSLSocketFactory sslContextFactory, HostnameVerifier hostnameVerifier) {
this.sslContextFactory = sslContextFactory;
this.hostnameVerifier = hostnameVerifier;
}
@Override
public Response execute(Request request, Options options) throws IOException {
HttpURLConnection connection = convertAndSend(request, options);
return convertResponse(connection).toBuilder().request(request).build();
}
HttpURLConnection convertAndSend(Request request, Options options) throws IOException {
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());
connection.setAllowUserInteraction(false);
connection.setInstanceFollowRedirects(true);
connection.setRequestMethod(request.method());
Collection<String> contentEncodingValues = request.headers().get(CONTENT_ENCODING);
boolean
gzipEncodedRequest =
contentEncodingValues != null && contentEncodingValues.contains(ENCODING_GZIP);
boolean
deflateEncodedRequest =
contentEncodingValues != null && contentEncodingValues.contains(ENCODING_DEFLATE);
boolean hasAcceptHeader = false;
Integer contentLength = null;
for (String field : request.headers().keySet()) {
if (field.equalsIgnoreCase("Accept")) {
hasAcceptHeader = true;
}
for (String value : request.headers().get(field)) {
if (field.equals(CONTENT_LENGTH)) {
if (!gzipEncodedRequest && !deflateEncodedRequest) {
contentLength = Integer.valueOf(value);
connection.addRequestProperty(field, value);
}
} else {
connection.addRequestProperty(field, value);
}
}
}
// Some servers choke on the default accept string.
if (!hasAcceptHeader) {
connection.addRequestProperty("Accept", "*/*");
}
if (request.body() != null) {
if (contentLength != null) {
connection.setFixedLengthStreamingMode(contentLength);
} else {
connection.setChunkedStreamingMode(8196);
}
connection.setDoOutput(true);
OutputStream out = connection.getOutputStream();
if (gzipEncodedRequest) {
out = new GZIPOutputStream(out);
} else if (deflateEncodedRequest) {
out = new DeflaterOutputStream(out);
}
try {
out.write(request.body());
} finally {
try {
out.close();
} catch (IOException suppressed) { // NOPMD
}
}
}
return connection;
}
Response convertResponse(HttpURLConnection connection) throws IOException {
int status = connection.getResponseCode();
String reason = connection.getResponseMessage();
if (status < 0) {
throw new IOException(format("Invalid status(%s) executing %s %s", status,
connection.getRequestMethod(), connection.getURL()));
}
Map<String, Collection<String>> headers = new LinkedHashMap<String, Collection<String>>();
for (Map.Entry<String, List<String>> field : connection.getHeaderFields().entrySet()) {
// response message
if (field.getKey() != null) {
headers.put(field.getKey(), field.getValue());
}
}
Integer length = connection.getContentLength();
if (length == -1) {
length = null;
}
InputStream stream;
if (status >= 400) {
stream = connection.getErrorStream();
} else {
stream = connection.getInputStream();
}
return Response.builder()
.status(status)
.reason(reason)
.headers(headers)
.body(stream, length)
.build();
}
}
複製程式碼
Feign 的效能怎麼樣? Feign 整體框架非常小巧,在處理請求轉換和訊息解析的過程中,基本上沒什麼時間消耗。真正影響效能的,是處理Http請求的環節。 如上所述,由於預設情況下,Feign採用的是JDK的HttpURLConnection,所以整體效能並不高,剛開始接觸Spring Cloud 的同學,如果沒注意這些細節,可能會對Spring Cloud 有很大的偏見。 我們專案內部使用的是OkHttp3 作為連線客戶端。