基于个人的经验,谈谈设计模式在项目开发中的应用。因为是经验之谈,没有绝对的对与错。
下面整理的是我最常使用的设计模式,我用设计模式的前提是
- 让代码的可读性变强
- 能支持日后功能扩展
单例
目的
保证全局只有一个实例,防止因为频繁的创建、销毁对象而造成不必要的性能开销。
在网关项目中,单例模式是出现频率最高的模式。同时,所有的单例对象被 IoC 框架 Guice 统一管理。
**场景 1 **
网关会处理各种逻辑。一般将业务逻辑从主流程中抽取出来,封装在一个独立对象中。可以使用单例模式来保证全局唯一,使用注解 Singleton
来表示这个一个单例:
java
@Singleton
public class HttpMethodPipeline {
private List<HttpMethodHandler> handlers = new ArrayList<>();
...
}
使用注解 Inject
来注入对象
java
public class ApiRewriteFilter extends HttpInboundSyncFilter{
@Inject
private HttpMethodPipeline pipeline;
@Override
public HttpRequestMessage apply(HttpRequestMessage request) {
...
pipeline.process(request);
...
}
}
减少 if-else
过多的 if-else 会导致
- 可读性变差
- 难以扩展维护
- 质量不可控,健壮性差
- 不利于单元测试
但是另一方面 if-else 是无法回避的代码。所以,为了让程序变得优雅,下面几种模式是我使用频次很高的模式,意在消除 if-else 代码段带来的负面影响。
1.表驱动法(策略)
目的
用表结构来驱动业务逻辑,减少 if-else 。这里的表结构可以参考
HashMap
,通过对 Key 计算出 hash 从而快速获取数据
示例
以之前的游戏项目中一段代码举例,需要计算出当前的英雄的级别:
- 小于 80:等级 G
- 80 至140:等级 F
- 140 至 200:等级 E
- ...
使用表驱动法来计算等级的话,非常方便,只要预先定义好表即可,整体不会出现一行 if-else 代码,如下所示:
java
public static String GetRoleAttributeClass(int attributeLv99) {
Map<Integer,String> attributes = new HashMap<Integer, String>()
{
{ 080, "G" },// <=80 -> G
{ 140, "F" },// >80 && <=140 -> F
{ 200, "E" },
{ 260, "D" },
{ 320, "C" },
{ 380, "B" },
{ 440, "A" },
{ 500, "S" },
};
var attributeClass = "?";
foreach (var key in attributes.Keys.OrderBy(o=>o))
{
if (attributeLv99 <= key)
{
attributeClass = attributes[key];
break;
}
}
return attributeClass;
}
当表驱动法+策略模式组合在一起时,可以极大的扩展系统。
场景 1
开放网关最初只支持 AppId+Secret 形式校验,但随着业务发展,为了满足不同的场景,需支持
- 简单认证,即 AppId+内网
- 携带请求头:X-Tsign-Open-Auth-Model=simple 来告知网关走哪种模式鉴权
- Token 认证
- 携带请求头:X-Tsign-Open-Auth-Mode=token 来告知网关走哪种模式鉴权
- 签名验签认证
- 携带请求头:X-Tsign-Open-Auth-Mode=signature 来告知网关走哪种模式鉴权
- 默认 AppId+Secret
- 携带请求头:X-Tsign-Open-Auth-Mode=signature 来告知网关走哪种模式鉴权
很显然,这是一种典型的横向扩展需求,鉴权模式会随着业务的发展而扩展。如果通过 if-else 将处理逻辑杂糅在主流程中,势必会造成越来越臃肿。
使用策略模式+表驱动法,可以有效缓解这种处境。
a.) 定义鉴权策略
java
public interface AuthStrategy {
Observable<HttpRequestMessage> auth(HttpRequestMessage request) throws Exception;
}
b.) 定义不同的策略实现类
- SimpleAuthStrategy
- TokenAuthStrategy
- SignatureAuthStrategy
- SecretAuthStrategy
c.)通过 Guice 来定义表,即映射关系,映射的 Key= X-Tsign-Open-Auth-Model 传递过来的鉴权模式,Value=具体的实现类
java
MapBinder<String, AbstractAuthStrategy> authStrategyMapBinder = MapBinder.newMapBinder(binder(), String.class, AbstractAuthStrategy.class);
authStrategyMapBinder.addBinding(OpenProtocol.SIMPLE_AUTH_STRATEGY).to(SimpleAuthStrategy.class);
authStrategyMapBinder.addBinding(OpenProtocol.TOKEN_AUTH_STRATEGY).to(TokenAuthStrategy.class);
authStrategyMapBinder.addBinding(OpenProtocol.SIGNATURE_AUTH_STRATEGY).to(SignatureAuthStrategy.class);
authStrategyMapBinder.addBinding(OpenProtocol.SECRET_AUTH_STRATEGY).to(SecretAuthStrategy.class);
d.) 在主流程中,根据鉴权模式,获取到对象的策略对象
java
@Slf4j
@Singleton
public class OpenAuthFilter extends HttpInboundFilter implements OpenProtocol {
@Inject
private Map<String, AbstractAuthStrategy> strategies;
@Configuration("${open.auth.default.mode}")
private String AUTH_DEFAULT_MODE ="secret";
@Override
public Observable<HttpRequestMessage> applyAsync(HttpRequestMessage request) {
//获取身份校验模式,如果不指定则使用默认的
String mode=StringUtils.defaultIfEmpty(request.getHeaders().getFirst(AUTH_MODE), AUTH_DEFAULT_MODE).toLowerCase();
//根据模式选择对应的策略
AbstractAuthStrategy authStrategy = strategies.get(mode);
if (authStrategy == null) {
route2Error(ctx, Problem.valueOf(Status.UNAUTHORIZED));
return Observable.just(request);
}
try {
return authStrategy.auth(request);
} catch (Exception cause) {
logger.error("authentication failed.{}", cause);
route2Error(ctx, Problem.valueOf(Status.UNAUTHORIZED));
return Observable.just(request);
}
}
}
2.职责链
目的
一个逻辑可能由多种处理模式,通过将这些处理模式连接成一条链,并且沿着这条链传递请求,直到有对象处理它为止。客户只需要将请求发送到职责链上即可,无须关心请求的处理细节和请求的传递。
场景 1
网关需要对 HTTP Method 进行适配,比如小程序客户端 Http Method 不支持 Put/Delete ,只支持 Get/Post。所以一般情况下使用 Post 来代替 Put/Delete,同时可能通过以下几种方式:
-
请求头
-
请求参数
-
请求 Body
来告诉网关真正的 Method,所以网关需要对 HTTP Method 支持适配。可以职责链来实现这个需求:
java
public class HttpMethodPipeline {
private List<HttpMethodHandler> handlers = new ArrayList<>();
@PostConstruct
private void init() {
//第一优先级
handlers.add(new InHeaderHttpMethodHandler());
//第二优先级
handlers.add(new InParameterHttpMethodHandler());
//默认优先级,兜底方案
handlers.add(new DefaultHttpMethodHandler());
}
public String process(HttpRequestMessage request) {
try {
for (HttpMethodHandler handler : handlers) {
if (handler.shouldFilter(request)) {
return handler.apply(request);
}
}
} catch (Exception cause) {
logger.error("{}", cause);
}
//容错方案
return request.getMethod();
}
}
场景 2
网关对用户 Token 鉴权时,需要对比 Token 中授权人的 Id是否与接口入参 accountId
保持一致。同时,这个 accountId 有可能位于
- Path
- Header
- Request Parameter
- Request Body
只要满足一个条件即可,通过职责链模式,可以有效解决问题,避免 if-else 带来的扩展麻烦
java
private HttpRequestMessage postAuthentication(HttpRequestMessage request, MatchApi matchApi) {
if (TokenMode.USER.toString().equals(tokenMode)) {
//验证不过
if (!validatorEngine.run(
AuthContext
.builder()
.request(request)
.matchApi(matchApi)
.build())) {
route2Error(ctx, Problem.valueOf(Status.UNAUTHORIZED));
}
}
return request;
}
定义验证引擎,本职上是一个处理链
java
class ValidatorEngine {
private List<AuthValidator> validators=new ArrayList<>();
ScopeValidator scopeValidator=new ScopeValidator();
ValidatorEngine(){
validators.add(new PathValidator());
validators.add(new HeaderValidatorEngine());
validators.add(new BodyValidator());
validators.add(new ParameterValidator());
}
boolean run(AuthContext authContext){
boolean pass=false;
try {
if (scopeValidator.validate(authContext)){
for (AuthValidator validator : validators) {
if (validator.validate(authContext)){
pass=true;
break;
}
}
}
}catch (Exception cause){
pass=true;
logger.error("",cause);
}
return pass;
}
}
简单工厂
目的
提供创建实例的功能,而无需关心具体实现,彼此之间互相解耦。往往和策略模式组合使用,即从工厂中获取一个策略。
场景 1
根据灰度配置获取灰度策略
java
public interface RuleStrategyFactory {
RuleStrategy getRuleStrategy(GrayRule grayRule);
}
场景 2
获取远程服务
java
public interface NettyOriginFactory {
NettyOrigin create(@Assisted("name") String name, @Assisted("vip") String vip, int defaultMaxRetry, Routing routing);
}
场景 3
根据 Uri 获取模板
java
public interface UriTemplateFactory {
UriTemplate create(String name);
}
场景 4
获取 WAF 拦截处理器
java
@Singleton
public class InboundRuleMatcherFactory {
public InboundRuleMatcher create(RuleDefinition definition) {
InboundRuleMatcher inboundRuleMatcher = null;
switch (definition.getRuleStage()) {
case CLIENT_IP:
inboundRuleMatcher = new ClientIPRuleMatcher(definition);
break;
case CONTENT_TYPE:
inboundRuleMatcher = new ContentTypeRuleMatcher(definition);
break;
case CONTENT_LENGTH:
inboundRuleMatcher = new ContentLengthRuleMatcher(definition);
break;
case USER_AGENT:
inboundRuleMatcher = new UserAgentRuleMatcher(definition);
break;
case REQUEST_ARGS:
inboundRuleMatcher = new RequestArgsRuleMatcher(definition);
break;
case COOKIES:
inboundRuleMatcher = new CookieRuleMatcher(definition);
break;
default:
break;
}
return inboundRuleMatcher;
}
}
简单工厂可以和表驱动法组合使用,这样会非常清爽:
java
@Singleton
@Slf4j
public class DefaultFlowStrategyFactory implements FlowStrategyFactory {
@Inject
private Injector injector;
private static final ImmutableMap<LBAlgorithmType, Class<? extends AbstractFlowStrategy>> map = ImmutableMap.of(
LBAlgorithmType.RANDOM, RandomFlowStrategy.class,
LBAlgorithmType.ROUND_ROBIN, RoundRobinFlowStrategy.class,
LBAlgorithmType.WEIGHTED, WeightedFlowStrategy.class);
@Override
public FlowStrategy getFlowStrategy(Flow flow) {
AbstractFlowStrategy strategy = null;
Class<? extends AbstractFlowStrategy> clazz = map.get(flow.getAlgorithm());
if (clazz != null) {
strategy = injector.getInstance(clazz);
}
if (strategy == null) {
//容错机制,如果配置了非 RANDOM、ROUND_ROBIN、WEIGHTED 算法,或者忘记设置,默认返回 RANDOM
strategy = new RandomFlowStrategy();
}
strategy.apply(flow.getValue());
return strategy;
}
}
模板方法
目的
定义处理逻辑的通用骨架,将差异化延迟到子类实现
场景 1
鉴权通过时,新老开放网关向下游传递的数据有差异,所有数据都会存储在 SessionContext
中,通过模板方法定义通用骨架:
java
public abstract class AbstractAppPropertyStash implements AppPropertyStash {
@Override
public void apply(AppEntity appEntity, HttpRequestMessage request){
SessionContext context = request.getContext();
//记得在使用方做容错处理:DefaultValue
context.set(APP_IP_WHITE_LIST_CTX_KEY, appEntity.getIps());
context.set(APP_THROTTLE_INTERVAL_CTX_KEY, appEntity.getInterval());
context.set(APP_THROTTLE_THRESHOLD_CTX_KEY, appEntity.getThreshold());
store(appEntity,request);
}
protected abstract void store(AppEntity appEntity, HttpRequestMessage request);
}
对于新开放网关,向下游传递USER_ID
java
public class DefaultAppPropertyStash extends AbstractAppPropertyStash {
@Override
protected void store(AppEntity appEntity, HttpRequestMessage request) {
SessionContext context = request.getContext();
request.getHeaders().set(USER_ID, appEntity.getGId());
context.set(USER_ID, appEntity.getGId());
}
}
对于老开放网关,向下游传递LOGIN_ID
java
public class DefaultAppPropertyStash extends AbstractAppPropertyStash {
@Override
protected void store(AppEntity appEntity, HttpRequestMessage request) {
String gId = appEntity.getGId();
String oId = appEntity.getOId();
if (StringUtils.isNotEmpty(oId)) {
request.getHeaders().add(X_TSIGN_LOGIN_ID, oId);
request.getContext().set(X_TSIGN_LOGIN_ID, oId);
} else {
request.getHeaders().add(X_TSIGN_LOGIN_ID, "GID$$" + gId);
request.getContext().set(X_TSIGN_LOGIN_ID, "GID$$" + gId);
}
}
所以 USER_ID
与LOGIN_ID
就是差异化的表现,由各子类负责。
场景 2
网关支持灰度发布,即通过服务分组来将请求路由到指定分组的服务,通过定义模板,获取分组信息:group
,然后差异化的路由由子类实现,比如:RandomFlowStrategy
,RoundRobinFlowStrategy
,WeightedFlowStrategy
等。
java
public abstract class AbstractFlowStrategy implements FlowStrategy {
protected List<String> groups;
public void apply(Map<String, String> value) {
groups = Arrays.asList(value.get("group").split(";"));
preHandle(value);
}
protected abstract void preHandle(Map<String, String> value);
}
观察者
目的
定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新,彼此之间解耦。
场景 1
为了提高网关的响应,一般会将常用数据 LRU 缓存到本地。比如 WAF 拦截规则会预先从数据库中读取出来,同时这部分数据存在变更的可能,虽然频次很低,但还是每隔 5min 从数据库读取到内存中。
对于构建 WAF 规则是耗时的事情,特别是它需要正则表达式编译。故通过观察者模式,当感知到数据发生变化时,才通知下游处理程序构建 WAF 规则,如下 WAF 拦截规则即主题:
java
public class DynamicValue implements Value {
private Set<Observer> observers = Collections.synchronizedSet(new HashSet<>());
private Set<RuleDefinition> value = null;
public DynamicValue() {
}
public DynamicValue(Set<RuleDefinition> value) {
super();
this.value = value;
}
@Override
public void addObserver(Observer observer) {
observers.add(observer);
}
@Override
public void removeObserver(Observer observer) {
observers.remove(observer);
}
@Override
public boolean updateValue(Set<RuleDefinition> newValue) {
if (isEqual(value, newValue)) {
return false;
}
value = newValue;
//规则变化,更新
for (Observer observer : observers) {
observer.onChange(newValue);
}
return true;
}
@Override
public void clear() {
observers.clear();
}
private boolean isEqual(Set<RuleDefinition> oldValue, Set<RuleDefinition> newValue) {
if (oldValue == null && newValue == null) {
return true;
}
if (oldValue == null) {
return false;
}
return oldValue.equals(newValue);
}
}
下游的处理程序作为观察者:
java
public class RuleManager {
private Value wafDynamicValue = new DynamicValue();
public void register(Observer observer){
wafDynamicValue.addObserver(observer);
}
}
场景 2
网关会将所有的 HTTP 请求、响应发送到 Kafka 中。虽然本身 Kafka Client 的 send
方法时异步的,但 Kafka 故障或者 Kafka 生产者的内存满时,会 block
主线程。虽然可以通过多线程的方式解决,但线程之间的频繁切换以及send
方法里的同步锁势必会造成性能影响。
借助 RxJava 中的生产者-消费者模式,可以有效的解决这个问题:
java
Observable.<GatewayApiEntity>create(
emitter -> {
consumer = emitter::onNext;
cleaner = emitter::onCompleted;
})
.onBackpressureBuffer(
BUFFER_CAPACITY, // 经调试最终Capacity:BUFFER_CAPACITY+128(默认)
() -> logger.info("Buffer is filling up now."),
BackpressureOverflow.ON_OVERFLOW_DROP_OLDEST) // 当 Buffer 满时,Drop 旧值,并添加新值
.filter(Objects::nonNull)
.observeOn(Schedulers.io())//切换到异步线程消费
.doOnCompleted(countDownLatch::countDown)
.subscribe(this::sendMessage);
使用异步被压策略好处
- Kafka 获取元数据或者当 buffer.memory >32 时,Kafka 生产者将阻塞 max.block.ms =60000 ms ,故不能将 Send 放到 Zuul IO 线程中
- 通过生产者-消费者,将 Kafka 生产者 Send 方式并行转变为串行,减少多线程的同步、锁竞争等问题
- 当 Kafka 故障、吞吐量降低时,背压的丢弃策略,可以防止 OOM
装饰者
目的
动态地给一个对象添加一些额外的功能,能在不影响原有功能的基础上,对其扩展功能。
场景 1
网关路由时,需要获取远程服务相关元数据,然后通过本地负载均衡选取具体的服务实例。默认情况下,NettyOriginManager
对象将远程的 Origin 缓存在内存中:ConcurrentHashMap
。从功能上来看,这是没问题的。但为了性能上的优化,试想一下,当网关重启时,这些缓存数据将丢失,又需要重新去获取一遍元数据,下游服务越多,第一次请求的性能影响越大。如果在网关重启时,默认同步所有服务元数据下来,是不是会更好?所以,需要确定哪些服务要被初始化,这就需要在 createOrigin
方法中额外增加这个保存Origin
的逻辑。
OriginManager
的实现类 NettyOriginManager
支持对 Origin
的管理,创建和获取
java
Slf4j
@Singleton
public class NettyOriginManager implements OriginManager<NettyOrigin>, Closeable {
private final ConcurrentHashMap<OriginKey, NettyOrigin> originMappings = new ConcurrentHashMap<>();
@Override
public NettyOrigin getOrigin(String name, String vip, String uri, SessionContext ctx{
}
@Override
public NettyOrigin createOrigin(String name, String vip, String uri, boolean useFullVipName, SessionContext ctx) {
}
}
将元数据保存原本NettyOriginManager
对象并不关心,同时如果NettyOriginManager
有三方框架提供,是无法修改其源码。故使用装饰者模式,可以有效解决这个尴尬的问题,如下所示:在不侵入NettyOriginManager
的情况下,对其增强
java
public interface OriginManagerDecorator extends OriginManager<NettyOrigin> {
void init();
void saveOrigin(String name, String vip, Map<String, Boolean> routingEntries);
void deleteOrigin(String data);
}
以保存到 Redis 为例,新增装饰对象:RedissonOriginManager
装饰 NettyOriginManager
,在原有能力上具备持久化的功能
java
@Singleton
@Slf4j
public class RedissonOriginManager implements OriginManagerDecorator {
@Inject
private RedissonReactiveClient redissonClient;
/*
被装饰对象
*/
@Inject
private NettyOriginManager nettyOriginManager;
@Override
@PostConstruct
public void init() {
//获取redis namespace,初始化
}
@Override
public void saveOrigin(String name, String vip, Map<String, Boolean> routingEntries){
}
@Override
public void deleteOrigin(String data) {
}
@Override
public NettyOrigin getOrigin(String name, String vip, String uri, SessionContext ctx{
//pass through
return nettyOriginManager.getOrigin(name, vip, uri, ctx);
}
@Override
public NettyOrigin createOrigin(String name, String vip, String uri, boolean useFullVipName, SessionContext ctx) {
//pass through
NettyOrigin origin = nettyOriginManager.createOrigin(name, vip, uri, useFullVipName, ctx);
//对原有的Origin Manager 进行增强,如果存在 Origin的话,对其缓存
if (origin != null && origin instanceof SimpleNettyOrigin) {
saveOrigin(name, vip, ((SimpleNettyOrigin) origin).getRouting().getRoutingEntries());
}
return origin;
}
}
在原有功能上新增了持久化到 Redis 的功能,可以根据不同的场景,装饰不同的实现方式:Redis、数据库、配置中心等
场景 2
网关在处理请求时,默认情况下只打印关键信息到日志,但是有时为了排查错误,需要打印更加丰富的日志。这是一种动态功能的增强,以开关的形式启用,关闭。如下,默认情况下RequestEndingHandler
将 TraceId
和 ElapseTime
返回到客户端:
typescript
public class RequestEndingHandler implements RequestHandler {
private Set<HeaderName> headers=new HashSet<>(
Arrays.asList(HttpHeaderNames.get(Inbound.X_Tsign_Elapse_Time),
HttpHeaderNames.get(Inbound.X_Tsign_Trace_Id)));
@Override
public void handle(Object obj) {
//一些服务先走应用网关,再走开放网关,清空下开放网关的响应头,使用应用网关的
response.getHeaders().removeIf(headerEntry -> headers.contains(headerEntry.getKey()));
//统计消耗的时间,放在响应头,便于排查问题
response.getHeaders().add(Inbound.X_Tsign_Elapse_Time,
String.valueOf(TimeUnit.MILLISECONDS.convert(request.getDuration(), TimeUnit.NANOSECONDS)));
//trace-id,用于调用链跟踪
//谨防 Null
response.getHeaders().add(Inbound.X_Tsign_Trace_Id, StringUtils
.defaultString( response.getOutboundRequest().getHeaders().getFirst(CerberusConstants.TRACE_ID), ""));
}
}
当开启了详细模式后,对原功能进行增强,支持所有的业务参数打印到日志:
java
public class SessionContextLogHandler extends RequestLogHandleDecorator {
private final static char DELIM = '\t';
protected SessionContextLogHandler(
RequestHandler handler) {
super(handler);
}
@Override
protected void log(Object obj) {
StringBuilder sb=new StringBuilder();
sb
.append(DELIM).append(context.getOrDefault(CerberusConstants.TRACE_ID,"-"))
.append(DELIM).append(context.getOrDefault(Inbound.X_TSIGN_LOGIN_ID,"-"))
.append(DELIM).append(context.getOrDefault(OpenProtocol.USER_ID,"-"))
;
logger.info(sb.toString());
}
建造者
目的
将复杂对象构建与主业务流程分离
场景 1
网关支持将所有经过网关的 HTTP 日志记录在 Kafka 中,这个 Message 对象是个大对象,并且对于其中的 requestHeader
,responseBody
构建算法复杂。
通过构建者模式,将复杂对象从业务中剥离,避免过多的 if-else 造成混乱。
java
private GatewayApiEntity construct(HttpResponseMessage response){
entity = GatewayApiEntity.builder()
.appId(request.getHeaders().getFirst(Inbound.X_TSIGN_APP_ID))
.clientIp(HttpUtils.getClientIP(request))
.method(request.getMethod())
.requestId(context.getUUID())
.serviceId(context.getRouteVIP())
.api((String) context.getOrDefault(CerberusConstants.ORIGINAL_API, ""))
.requestTime((Long) context.get(CerberusConstants.TIMING_START_CTX_KEY))
.source(getApplicationId())
.timestamp(System.currentTimeMillis())
.traceId((String) context.getOrDefault(CerberusConstants.TRACE_ID, ""))
.url(request.getInboundRequest().getPathAndQuery())
.userAgent(request.getHeaders().getFirst(HttpHeaders.USER_AGENT))
.status(response.getStatus())
.duration(getDuration(response))
.requestHeader(getRequestHeader(request))
.requestBody(getRequestBody(request))
.responseBody(getResponseBody(response))
.build();
}
private String getRequestHeader(HttpRequestMessage request) throws JsonProcessingException {
// 3.补充请求头 X-Tsign
}
private String getRequestBody(HttpRequestMessage request){
//4.请求数据,如果是大包的话,不进行收集,因为 Broker 端对 Producer 发送过来的消息也有一定的大小限制,这个参数叫 message.max.bytes
}
private String getResponseBody(HttpResponseMessage response) throws IOException {
// 5.处理 Body 里的数据,如果是大包的话,不进行收集,因为 Broker 端对 Producer 发送过来的消息也有一定的大小限制,这个参数叫 message.max.bytes
// Response body 被 gzip 压缩过
}
场景 2
网关核心功能即路由,比如对请求: v1/accounts/abcdefg/infos
路由到 v1/accounts/{accountId}/infos
后端接口上 ,所以这需要正则表达式的支持。网关通过建造者模式,构建出一个复杂的 API
对象来表示元数据。
java
Api.builder()
.serviceId(entity.getServiceId())
.url(url)
.originalUrl(StringUtils.prependIfMissing(entity.originalUrl, "/"))
.httpMethod(entity.getHttpMethod())
.readTimeout(readTimeout)
.uriTemplate(uriTemplateFactory.create(url))
.build();
写在最后
欢迎关注我的公众号:编程启示录,第一时间获取最新消息;
微信 | 公众号 |
---|---|