更新日志:
2018/3/3 21:05:43 新建
2018/3/11 7:11:59 新增注冊到Eureka、從Eureka注銷、新增Feign,更新配置文件,更新代碼
部門項目的技術框架從 ZooKeeper+Dubbo 轉型為Spring Cloud 微服務,轉型順利、開發方便、使用良好,於是完全廢棄了ZooKeeper+Dubbo,而Web端后台管理界面的項目由於種種原因不希望大規模重構為Spring Boot項目,繼續保持原有的SSM框架,並使用http調用微服務接口。為避免將微服務地址寫死,這就需要Web項目連接到Spring Cloud Eureka 上通過服務名獲取微服務真實地址。
項目依賴
<!-- eureka 服務發現 -->
<dependency>
<groupId>com.netflix.eureka</groupId>
<artifactId>eureka-client</artifactId>
<version>1.7.0</version>
</dependency>
<!-- Ribbon 負載均衡 -->
<dependency>
<groupId>com.netflix.ribbon</groupId>
<artifactId>ribbon-core</artifactId>
<version>${netflix.ribbon.version}</version>
</dependency>
<dependency>
<groupId>com.netflix.ribbon</groupId>
<artifactId>ribbon-loadbalancer</artifactId>
<version>${netflix.ribbon.version}</version>
<exclusions>
<exclusion>
<groupId>io.reactivex</groupId>
<artifactId>rxjava</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.netflix.ribbon</groupId>
<artifactId>ribbon-eureka</artifactId>
<version>${netflix.ribbon.version}</version>
</dependency>
<!-- Feign 包裝http請求 -->
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-hystrix</artifactId>
<version>${netflix.feign.version}</version>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-ribbon</artifactId>
<version>${netflix.feign.version}</version>
</dependency>
<!-- <dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-gson</artifactId>
<version>${netflix.feign.version}</version>
</dependency> -->
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-slf4j</artifactId>
<version>${netflix.feign.version}</version>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-jackson</artifactId>
<version>${netflix.feign.version}</version>
</dependency>
<dependency>
<groupId>io.reactivex</groupId>
<artifactId>rxjava</artifactId>
<version>1.1.1</version>
</dependency>
這里使用netflix項目下的eureka、ribbon、Feign、Hystrix。
ribbon-開頭的項目都是同一個版本號,所以就抽取出${netflix.ribbon.version}
統一管理。feign-開頭的項目也都是同一個的版本號,抽取${netflix.feign.version}統一管理。
需要注意:
- Maven依賴jar包沖突問題:
rxjava
項目在ribbon-loadbalancer
和feign-hystrix
依賴的hystrix-core
中都有使用。當前最新版2.2.4的ribbon-loadbalancer
使用rxjava:1.0.9
。而feign-hystrix
依賴的hystrix-core
使用rxjava:1.1.1
。因為依賴沖突,ribbon-loadbalancer
中的rxjava:1.0.9
代替掉了hystrix-core
中的rxjava:1.1.1
。這樣當程序運行時會瘋狂報找不到類Error,找不到rx/Single
,這個類在2.0.9中並沒有,2.1.1中有hystrix-core
用到了,但是由於依賴沖突使用2.0.9的rxjava沒有該類,所以報錯。
解決辦法:ribbon-loadbalancer
使用exclusion
排除依賴rxjava
即可。 - feign-core在中央倉庫有兩個groupId:
com.netflix.feign
和io.github.openfeign
。groupIdcom.netflix.feign
在2016年7月提交到8.18.0后就沒有再提交,而groupIdio.github.openfeign
已經在2018年三月份提交到9.6.0。
配置文件
Ribbon配置
# ribbon.properties
# xxx-service對應的微服務名
xxx-service.ribbon.DeploymentContextBasedVipAddresses=xxx-service
# 固定寫法,xxx-service使用的ribbon負載均衡器
xxx-service.ribbon.NIWSServerListClassName=com.netflix.niws.loadbalancer.DiscoveryEnabledNIWSServerList
# 每分鍾更新xxx-service對應服務的可用地址列表
xxx-service.ribbon.ServerListRefreshInterval=60000
Eureka配置
Eureka默認在classpath中尋找eureka-client.properties配置文件
# 控制是否注冊自身到eureka中,本項目雖然不對外提供服務,但需要Eureka監控,在Eureka列表上顯示
eureka.registration.enabled=true
# eureka相關配置
# 默認為true,以實現更好的基於區域的負載平衡。
eureka.preferSameZone=true
# 是否要使用基於DNS的查找來確定其他eureka服務器
eureka.shouldUseDns=false
# 由於shouldUseDns為false,因此我們使用以下屬性來明確指定到eureka服務器的路由(eureka Server地址)
eureka.serviceUrl.default=http://username:password@localhost:8761/eureka/
eureka.decoderName=JacksonJson
# 客戶識別此服務的虛擬主機名,這里指的是eureka服務本身
eureka.vipAddress=XXXplatform
#服務指定應用名,這里指的是eureka服務本身
eureka.name=XXXlatform
#服務將被識別並將提供請求的端口
eureka.port=8080
初始化Ribbon、注冊Eureka
之前初始化Ribbon、Eureka、注冊到Eureka和獲取地址的方法寫在靜態代碼塊和靜態方法中,這樣在項目停止時無法從Eureka中取消注冊,這會使Eureka進入安全模式,死掉的項目一直顯示在Eureka列表中。
繼承ServletContextListener,重寫contextInitialized、contextDestroyed,在應用上下文啟動時初始化Ribbon、Eureka、注冊到Eureka,應用上下文銷毀時注銷Eureka。
import java.io.IOException;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.netflix.appinfo.ApplicationInfoManager;
import com.netflix.appinfo.InstanceInfo;
import com.netflix.config.ConfigurationManager;
import com.netflix.discovery.DefaultEurekaClientConfig;
import com.netflix.discovery.DiscoveryManager;
/**
* @ClassName: EurekaInitAndRegisterListener
* @Description: 服務器啟動初始化Ribbon和注冊到Eureka Server
* @author SuXun
* @date 2018年3月7日 上午9:21:12
*/
@SuppressWarnings("deprecation")
public class EurekaInitAndRegisterListener implements ServletContextListener {
private static final Logger LOGGER = LoggerFactory.getLogger(EurekaInitAndRegisterListener.class);
/**
* 默認的ribbon配置文件名, 該文件需要放在classpath目錄下
*/
public static final String RIBBON_CONFIG_FILE_NAME = "ribbon.properties";
@Override
public void contextInitialized(ServletContextEvent sce) {
LOGGER.info("開始初始化ribbon");
try {
// 加載ribbon配置文件
ConfigurationManager.loadPropertiesFromResources(RIBBON_CONFIG_FILE_NAME);
} catch (IOException e) {
e.printStackTrace();
LOGGER.error("ribbon初始化失敗");
throw new IllegalStateException("ribbon初始化失敗");
}
LOGGER.info("ribbon初始化完成");
// 初始化Eureka Client
LOGGER.info("Eureka初始化完成,正在注冊Eureka Server");
DiscoveryManager.getInstance().initComponent(new MyInstanceConfig(), new DefaultEurekaClientConfig());
ApplicationInfoManager.getInstance().setInstanceStatus(InstanceInfo.InstanceStatus.UP);
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
DiscoveryManager.getInstance().shutdownComponent();
}
}
這里有個自定義的類MyInstanceConfig,這個類作用是將注冊到Eureka的hostName從主機名換成IP地址加端口號的形式。
import java.io.IOException;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.UnknownHostException;
import java.util.Enumeration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.netflix.appinfo.MyDataCenterInstanceConfig;
/**
* @ClassName: MyInstanceConfig
* @Description:
* @author SuXun
* @date 2018年3月7日 上午9:33:31
*/
public class MyInstanceConfig extends MyDataCenterInstanceConfig {
private static final Logger LOG = LoggerFactory.getLogger(MyInstanceConfig.class);
@Override
public String getHostName(boolean refresh) {
try {
return findFirstNonLoopbackAddress().getHostAddress();
} catch (Exception e) {
return super.getHostName(refresh);
}
}
public InetAddress findFirstNonLoopbackAddress() {
InetAddress result = null;
try {
int lowest = Integer.MAX_VALUE;
for (Enumeration<NetworkInterface> nics = NetworkInterface
.getNetworkInterfaces(); nics.hasMoreElements();) {
NetworkInterface ifc = nics.nextElement();
if (ifc.isUp()) {
LOG.trace("Testing interface: " + ifc.getDisplayName());
if (ifc.getIndex() < lowest || result == null) {
lowest = ifc.getIndex();
}
else if (result != null) {
continue;
}
// @formatter:off
for (Enumeration<InetAddress> addrs = ifc
.getInetAddresses(); addrs.hasMoreElements();) {
InetAddress address = addrs.nextElement();
if (address instanceof Inet4Address
&& !address.isLoopbackAddress()) {
LOG.trace("Found non-loopback interface: "
+ ifc.getDisplayName());
result = address;
}
}
// @formatter:on
}
}
}
catch (IOException ex) {
LOG.error("Cannot get first non-loopback address", ex);
}
if (result != null) {
return result;
}
try {
return InetAddress.getLocalHost();
}
catch (UnknownHostException e) {
LOG.warn("Unable to retrieve localhost");
}
return null;
}
}
雖然DiscoveryManager.getInstance().initComponent()
方法已經被標記為@Deprecated
了,但是ribbon的DiscoveryEnabledNIWSServerList
組件代碼中依然是通過DiscoveryManager
來獲取EurekaClient對象的。
獲取服務地址
mport java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.netflix.client.ClientFactory;
import com.netflix.loadbalancer.DynamicServerListLoadBalancer;
import com.netflix.loadbalancer.RoundRobinRule;
import com.netflix.loadbalancer.Server;
/**
* @ClassName: AlanServiceAddressSelector
* @Description: 獲取到目標服務注冊在Eureka地址
* @author SuXun
* @date 2018年3月2日 下午5:23:24
*/
public class AlanServiceAddressSelector {
private static final Logger log = LoggerFactory.getLogger(AlanServiceAddressSelector.class);
private static RoundRobinRule chooseRule = new RoundRobinRule();
/**
* 根據輪詢策略選擇一個地址
* @param clientName ribbon.properties配置文件中配置項的前綴名, 如myclient
* @return null表示該服務當前沒有可用地址
*/
public static AlanServiceAddress selectOne(String clientName) {
// ClientFactory.getNamedLoadBalancer會緩存結果, 所以不用擔心它每次都會向eureka發起查詢
@SuppressWarnings("rawtypes")
DynamicServerListLoadBalancer lb = (DynamicServerListLoadBalancer) ClientFactory
.getNamedLoadBalancer(clientName);
Server selected = chooseRule.choose(lb, null);
if (null == selected) {
log.warn("服務{}沒有可用地址", clientName);
return null;
}
log.debug("服務{}選擇結果:{}", clientName, selected);
return new AlanServiceAddress(selected.getPort(), selected.getHost());
}
/**
* 選出該服務所有可用地址
* @param clientName
* @return
*/
public static List<AlanServiceAddress> selectAvailableServers(String clientName) {
@SuppressWarnings("rawtypes")
DynamicServerListLoadBalancer lb = (DynamicServerListLoadBalancer) ClientFactory
.getNamedLoadBalancer(clientName);
List<Server> serverList = lb.getServerList(true);
// List<Server> serverList = lb.getReachableServers();
if (serverList.isEmpty()) {
log.warn("服務{}沒有可用地址", clientName);
return Collections.emptyList();
}
log.debug("服務{}所有選擇結果:{}", clientName, serverList);
List<AlanServiceAddress> address = new ArrayList<AlanServiceAddress>();
for (Server server : serverList) {
address.add(new AlanServiceAddress(server.getPort(), server.getHost()));
}
return address;
}
}
地址實體類:
/**
* @ClassName: AlanServiceAddress
* @Description: 地址實體類
* @author SuXun
* @date 2018年3月2日 下午2:14:17
*/
public class AlanServiceAddress {
private int port;
private String host;
public AlanServiceAddress() {
}
public AlanServiceAddress(int port, String host) {
this.port = port;
this.host = host;
}
public int getPort() {
return port;
}
public void setPort(int port) {
this.port = port;
}
public String getHost() {
return host;
}
public void setHost(String host) {
this.host = host;
}
/**
* 將服務地址轉換為 http://主機名:端口/ 的格式
* @return
*/
@Override
public String toString() {
StringBuilder sb = new StringBuilder(15 + host.length());
sb.append("http://").append(host).append(":").append(port).append("/");
return sb.toString();
}
}
使用方法
// 選擇出myclient對應服務全部可用地址
List<AlanServiceAddress> list = AlanServiceAddressSelector.selectAvailableServers("myclient");
System.out.println(list);
// 選擇出myclient對應服務的一個可用地址(輪詢), 返回null表示服務當前沒有可用地址
AlanServiceAddress addr = AlanServiceAddressSelector.selectOne("myclient");
System.out.println(addr);
構建Feign客戶端
根據服務名在Eureka獲取地址,構建Feign,如果緩存有則返回緩存里的Feign,避免重復構建Feign。
構建Feign方法:
import java.lang.reflect.Method;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.LoggerFactory;
import com.netflix.hystrix.HystrixCommand;
import com.netflix.hystrix.HystrixCommand.Setter;
import com.netflix.hystrix.HystrixCommandGroupKey;
import com.netflix.hystrix.HystrixCommandProperties;
import com.xxx.platform.common.netflex.eureka.AlanServiceAddress;
import com.xxx.platform.common.netflex.eureka.AlanServiceAddressSelector;
import feign.Feign;
import feign.Logger;
import feign.Request.Options;
import feign.Retryer;
import feign.Target;
import feign.codec.Decoder;
import feign.codec.Encoder;
import feign.hystrix.HystrixFeign;
import feign.hystrix.SetterFactory;
import feign.jackson.JacksonDecoder;
import feign.jackson.JacksonEncoder;
import feign.slf4j.Slf4jLogger;
/**
* @ClassName: BaseFeignBuilder
* @Description: 用於構建Feign
* @author SuXun
* @date 2018年3月5日 下午3:50:18
*/
public class BaseFeignBuilder {
private static final org.slf4j.Logger log = LoggerFactory.getLogger(BaseFeignBuilder.class);
private static ConcurrentHashMap<String, Object> cacheFeignMap = new ConcurrentHashMap<String, Object>();
private static ConcurrentHashMap<String, String> cacheAddressMap = new ConcurrentHashMap<String, String>();
/**
* 構建HystrixFeign,具有Hystrix提供的熔斷和回退功能,JacksonEncoder、JacksonDecoder、Slf4jLogger、Logger.Level.FULL
* @param apiType 使用feign訪問的接口類,如MedBodyClient.class
* @param clientName 配置文件中的ribbon client名字
* @param fallback 回退類
* @param url 添加網址
* @return
*/
public static <T> T buildHystrixFeign(Class<T> apiType, T fallback, String url) {
// 之前用GsonEncoder()和GsonDecoder()對Date類型支持不好,改成JacksonEncoder和JacksonDecoder,日期轉換正常
return buildHystrixFeign(apiType, fallback, url, new JacksonEncoder(), new JacksonDecoder(),
new Slf4jLogger(BaseFeignBuilder.class), Logger.Level.FULL);
}
/**
* 構建HystrixFeign,具有Hystrix提供的熔斷和回退功能
* @param apiType 使用feign訪問的接口類,如MedBodyClient.class
* @param clientName clientName 配置文件中的ribbon client名字
* @param fallback 回退類
* @param url 添加網址
* @param encoder 編碼器
* @param decoder 解碼器
* @param logger 日志對象
* @param logLevel 日志級別
* @return
*/
public static <T> T buildHystrixFeign(Class<T> apiType, T fallback, String url, Encoder encoder, Decoder decoder,
Logger logger, Logger.Level logLevel) {
return HystrixFeign.builder().encoder(encoder).decoder(decoder).logger(logger).logLevel(logLevel)
//options添加Feign請求響應超時時間
.options(new Options(60 * 1000, 60 * 1000)).retryer(Retryer.NEVER_RETRY)
.setterFactory(new SetterFactory() {
@Override
public Setter create(Target<?> target, Method method) {
//添加Hstrix請求響應超時時間
return HystrixCommand.Setter
.withGroupKey(HystrixCommandGroupKey.Factory.asKey(apiType.getClass().getSimpleName()))
.andCommandPropertiesDefaults(
HystrixCommandProperties.Setter().withExecutionTimeoutInMilliseconds(60 * 1000) // 超時配置
);
}
}).target(apiType, url, fallback);
}
/**
* 獲取HystrixFeign。緩存有在緩存取,緩存沒有重新構建Feign
* @param apiType 使用feign訪問的接口類,如MedBodyClient.class
* @param clientName clientName 配置文件中的ribbon client名字
* @param separator 添加網址分割
* @return
*/
@SuppressWarnings("unchecked")
public static <T> T getCacheFeign(Class<T> apiType, String clientName, T fallback, String separator) {
String resultAddress = getResultAddress(clientName);
String cacheKey = apiType.getName() + "-" + clientName + "-" + fallback.getClass().getName() + "-"
+ resultAddress + separator;
Object cacheFeign = cacheFeignMap.get(cacheKey);
if (cacheFeign == null) {
T buildFeign = buildHystrixFeign(apiType, fallback, resultAddress + separator);
cacheFeignMap.put(cacheKey, buildFeign);
return buildFeign;
} else {
return (T) cacheFeign;
}
}
/**
* 獲取服務地址,取不到最新地址在緩存取舊地址,有新地址則返回新地址並刷新緩存
* @param clientName
* @return
*/
public static String getResultAddress(String clientName) {
String recentAddress = null;
AlanServiceAddress alanServiceAddress = AlanServiceAddressSelector.selectOne(clientName);
recentAddress = alanServiceAddress == null ? "" : alanServiceAddress.toString();
String cacheAddress = cacheAddressMap.get(clientName);
String resultAddress = "";
if (StringUtils.isBlank(recentAddress)) {
if (StringUtils.isBlank(cacheAddress)) {
log.error("服務" + clientName + "無可用地址");
throw new RuntimeException("服務" + clientName + "無可用地址");
} else {
resultAddress = cacheAddress;
}
} else {
resultAddress = recentAddress;
cacheAddressMap.put(clientName, recentAddress);
}
return resultAddress;
}
}
通用Feign接口
利用Feign繼承特性,特殊需求接口只要繼承通用接口就可獲得訪問通用接口的能力.
import java.util.Map;
import com.xxx.commons.base.ResultJsonEntity;
import feign.Headers;
import feign.Param;
import feign.RequestLine;
/**
* @ClassName: BaseFeignClient
* @Description: Feign基類
* @author SuXun
* @date 2018年3月5日 下午1:25:59
*/
// @Herders里邊的鍵值對冒號后面必須有個空格!
@Headers({ "Content-Type: application/json", "Accept: application/json" })
public interface BaseFeignClient {
@RequestLine("POST /select")
ResultJsonEntity select(Object obj);
@RequestLine("GET /selectAll")
ResultJsonEntity selectAll();
// 因為Example無法被序列化成json,所以參數為Map
@RequestLine("POST /selectByExample")
ResultJsonEntity selectByExample(Map<String, Object> map);
@RequestLine("GET /selectByPrimaryKey/{key}")
ResultJsonEntity selectByPrimaryKey(@Param("key") String key);
@RequestLine("POST /insertSelective")
ResultJsonEntity insertSelective(Object obj);
@RequestLine("POST /updateByPrimaryKeySelective")
ResultJsonEntity updateByPrimaryKeySelective(Object obj);
/**
* @param map key包含:int pageNum,int rowNum,T record和查詢條件
* @return
*/
@RequestLine("POST /getPageExampleList")
ResultJsonEntity getPageExampleList(Map<String, Object> map);
}
通用回退類
因為使用HystrixClient.build(),使得Feign擁有熔斷器、回退的功能。這里根據通用接口實現的回退類。
這里的ResultJsonEntity、ResultEnum、ResultJsonUtil用於返回平台無關的json數據。
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.xxx.commons.base.ResultJsonEntity;
import com.xxx.commons.enums.ResultEnum;
import com.xxx.commons.util.ResultJsonUtil;
/**
* @ClassName: BaseFeignClientFallback
* @Description: Feign基類的回退類
* @author SuXun
* @date 2018年3月5日 下午5:04:13
*/
public class BaseFeignClientFallback implements BaseFeignClient {
protected static final Logger LOG = LoggerFactory.getLogger(BaseFeignClientFallback.class);
@Override
public ResultJsonEntity select(Object obj) {
LOG.error("{} select 出錯 進入熔斷 ", this.getClass().getName());
return ResultJsonUtil.returnResult(ResultEnum.FAIL);
}
@Override
public ResultJsonEntity selectAll() {
LOG.error("{} selectAll 出錯 進入熔斷", this.getClass().getName());
return ResultJsonUtil.returnResult(ResultEnum.FAIL);
}
@Override
public ResultJsonEntity selectByExample(Map<String, Object> map) {
LOG.error("{} selectByMap 出錯 進入熔斷 ", this.getClass().getName());
return ResultJsonUtil.returnResult(ResultEnum.FAIL);
}
@Override
public ResultJsonEntity selectByPrimaryKey(String key) {
LOG.error("{} selectByPrimaryKey 出錯 進入熔斷 ", this.getClass().getName());
return ResultJsonUtil.returnResult(ResultEnum.FAIL);
}
@Override
public ResultJsonEntity insertSelective(Object obj) {
LOG.error("{} insertSelective 出錯 進入熔斷 ", this.getClass().getName());
return ResultJsonUtil.returnResult(ResultEnum.FAIL);
}
@Override
public ResultJsonEntity updateByPrimaryKeySelective(Object obj) {
LOG.error("{} updateByPrimaryKeySelective 出錯 進入熔斷 ", this.getClass().getName());
return ResultJsonUtil.returnResult(ResultEnum.FAIL);
}
@Override
public ResultJsonEntity getPageExampleList(Map<String, Object> map) {
LOG.error("{} getPageExampleList 出錯 進入熔斷 ", this.getClass().getName());
return ResultJsonUtil.returnResult(ResultEnum.FAIL);
}
}
FeignClient使用方法
為了使Feign擁有負載均衡的能力,需要在 @ModelAttribute
注解的方法重復調用 getCacheFeign
,getCacheFeign
方法可以獲取最新的地址,根據地址構建Feign或者在緩存取出Feign。
private BaseFeignClient xxxClient = null;
@ModelAttribute
public xxxEntity get(@RequestParam(required = false) String id) {
xxxClient = BaseFeignBuilder.getCacheFeign(BaseFeignClient.class,
"xxx-service", new BaseFeignClientFallback(), "xxx");
}
遺憾
非常遺憾,這里是手動獲取Eureka中的地址,看起來還不夠優雅。
Feign可以結合Ribbon使用,通過傳入服務名找地址,之前實現后發現需要兩次訪問才可以正確訪問到服務,兩次中必然有一次返回找不到地址,所以沒有使用。我會繼續研究,下次更新使Feign進行Http調用更優雅一些。