Skip to content

Commit

Permalink
使用双域名实现跨城容灾 (wechatpay-apiv3#175)
Browse files Browse the repository at this point in the history
  • Loading branch information
xy-peng authored Jun 2, 2023
1 parent c1a1d32 commit bcd46fb
Show file tree
Hide file tree
Showing 6 changed files with 328 additions and 0 deletions.
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,70 @@ SDK 的日志会跟你的日志记录在一起。

为了启用日志,你应在你的构建脚本中添加日志框架的依赖。如果不配置日志框架,默认是使用 SLF4j 提供的 空(NOP)日志实现,它不会记录任何日志。

## 网络配置

SDK 使用 [OkHttp](https://square.github.io/okhttp/) 作为默认的 HTTP 客户端。
如果开发者不熟悉 OkHttp,推荐使用 SDK 封装的 DefaultHttpClientBuilder 来构造 HTTP 客户端。

目前支持的网络配置方法见下表。

| 方法 | 说明 | 默认值 | 更多信息 |
|-------------------------------------|------------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|
| `readTimeoutMs()` | 设置新连接的默认读超时 | 10*1000(10秒) | [OkHttpClient/Builder/readTimeout](https://square.github.io/okhttp/4.x/okhttp/okhttp3/-ok-http-client/-builder/read-timeout/) |
| `writeTimeoutMs()` | 设置新连接的默认写超时 | 10*1000(10秒) | [OkHttpClient/Builder/writeTimeout](https://square.github.io/okhttp/4.x/okhttp/okhttp3/-ok-http-client/-builder/write-timeout/) |
| `connectTimeoutMs()` | 设置新连接的默认连接超时 | 10*1000(10秒) | [OkHttpClient/Builder/connectTimeout](https://square.github.io/okhttp/4.x/okhttp/okhttp3/-ok-http-client/-builder/connect-timeout/) |
| `proxy()` | 设置客户端创建的连接时使用的 HTTP 代理 || [OkHttpClient/Builder/proxy](https://square.github.io/okhttp/4.x/okhttp/okhttp3/-ok-http-client/-builder/proxy/) |
| `disableRetryOnConnectionFailure()` | 遇到网络问题时不重试下一个 IP | 默认重试 | [OkHttpClient/Builder/retryOnConnectionFailure](https://square.github.io/okhttp/4.x/okhttp/okhttp3/-ok-http-client/-builder/retry-on-connection-failure/) |
| `enableRetryMultiDomain()` | 遇到网络问题时重试备域名 | 默认不重试 | 推荐开启,详细说明见下 |

下面的示例演示了如何使用 DefaultHttpClientBuilder 初始化某个具体的业务 Service。

```java
HttpClient httpClient =
new DefaultHttpClientBuilder()
.config(config)
.connectTimeoutMs(500)
.build();

// 以JsapiService为例,使用 httpclient 初始化 service
JsapiService service = new JsapiService.Builder().httpclient(httpClient).build();
```

更多网络配置的说明,请看 [wiki - 网络配置](https://github.com/wechatpay-apiv3/wechatpay-java/wiki/SDK-%E9%85%8D%E7%BD%AE%E8%AF%A6%E8%A7%A3#%E7%BD%91%E7%BB%9C%E9%85%8D%E7%BD%AE)

### 双域名容灾

为提升商户系统访问 API 的稳定性,SDK 实现了双域名容灾。如果主要域名 `api.mch.weixin.qq.com` 因网络问题无法访问,我们的 SDK 可自动切换到备用域名 `api2.wechatpay.cn` 重试当前请求。
这个机制可以最大限度减少因微信支付 API 接入点故障或主域名问题(如 DNS 劫持)对商户系统的影响。

默认情况下,双域名容灾机制处于关闭状态,以避免重试降低商户系统的吞吐量。因为 OkHttp 默认会尝试主域名的多个IP地址(目前为2个),增加备用域名重试很可能会提高异常情况下的处理时间。

我们推荐开发者使用 `disableRetryOnConnectionFailure``enableRetryMultiDomain` 的组合,启用双域名容灾并关闭 OkHttp 默认重试,这样不会增加重试次数。

假设 `api.mch.weixin.qq.com` 解析得到 [ip1a, ip1b]`api2.wechatpay.cn` 解析得到 [ip2a, ip2b],不同的重试策略组合对应的尝试顺序为:

+ 默认:[ip1a, ip1b]
+ disableRetryOnConnectionFailure:[ip1a]
+ enableRetryMultiDomain:[ipa1, ip1b, ip2a, ip2b]
+ (推荐)disableRetryOnConnectionFailure + enableRetryMultiDomain: [ip1a, ip2a]

以下是采用推荐重试策略的示例代码:

```java
// 开启双域名重试,并关闭 OkHttp 默认的连接失败后重试
HttpClient httpClient =
new DefaultHttpClientBuilder()
.config(config)
.disableRetryOnConnectionFailure()
.enableRetryMultiDomain()
.build();

// 以JsapiService为例,使用 httpclient 初始化 service
JsapiService service = new JsapiService.Builder().httpclient(httpClient).build();
```

开发者应该仔细评估自己的商户系统容量,根据自身情况选择合适的超时时间和重试策略,并做好监控和告警。

## 使用国密

我们提供基于 [腾讯 Kona 国密套件](https://github.com/Tencent/TencentKonaSMSuite) 的国密扩展。文档请参考 [shangmi/README.md](shangmi/README.md)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.wechat.pay.java.core.http;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;

/** HTTP常量 */
public final class Constant {

Expand All @@ -24,5 +28,9 @@ public final class Constant {
public static final String ACCEPT = "Accept";
public static final String CONTENT_TYPE = "Content-Type";

public static final List<String> PRIMARY_API_DOMAIN =
Collections.unmodifiableList(Arrays.asList("api.mch.weixin.qq.com", "api.wechatpay.cn"));
public static final String SECONDARY_API_DOMAIN = "api2.wechatpay.cn";

private Constant() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.wechat.pay.java.core.auth.Credential;
import com.wechat.pay.java.core.auth.Validator;
import com.wechat.pay.java.core.http.okhttp.OkHttpClientAdapter;
import com.wechat.pay.java.core.http.okhttp.OkHttpMultiDomainInterceptor;
import java.net.Proxy;
import java.util.concurrent.TimeUnit;

Expand All @@ -21,6 +22,10 @@ public class DefaultHttpClientBuilder
private int writeTimeoutMs = -1;
private int connectTimeoutMs = -1;
private Proxy proxy;
private boolean retryMultiDomain = false;
private Boolean retryOnConnectionFailure = null;
private static final OkHttpMultiDomainInterceptor multiDomainInterceptor =
new OkHttpMultiDomainInterceptor();

/**
* 复制工厂,复制一个当前对象
Expand Down Expand Up @@ -120,6 +125,23 @@ public DefaultHttpClientBuilder proxy(Proxy proxy) {
this.proxy = proxy;
return this;
}

/**
* 启用双域名容灾
*
* @return defaultHttpClientBuilder
*/
public DefaultHttpClientBuilder enableRetryMultiDomain() {
this.retryMultiDomain = true;
return this;
}

/** OkHttp 在网络问题时不重试 */
public DefaultHttpClientBuilder disableRetryOnConnectionFailure() {
this.retryOnConnectionFailure = false;
return this;
}

/**
* 构建默认HttpClient
*
Expand All @@ -143,6 +165,12 @@ public AbstractHttpClient build() {
if (proxy != null) {
okHttpClientBuilder.proxy(proxy);
}
if (retryMultiDomain) {
okHttpClientBuilder.addInterceptor(multiDomainInterceptor);
}
if (retryOnConnectionFailure != null && !retryOnConnectionFailure) {
okHttpClientBuilder.retryOnConnectionFailure(false);
}
return new OkHttpClientAdapter(credential, validator, okHttpClientBuilder.build());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.wechat.pay.java.core.http.okhttp;

import com.wechat.pay.java.core.http.Constant;
import java.io.IOException;
import okhttp3.HttpUrl;
import okhttp3.Interceptor;
import okhttp3.Request;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* A dual-domain retry interceptor. This is an application interceptor that retries a request using
* "api2.wechatpay.cn" in case of network failure. It leverages disaster recovery in different city
* access data centers, while also avoiding failures due to DNS blocking.
*/
public class OkHttpMultiDomainInterceptor implements Interceptor {
private static final Logger logger = LoggerFactory.getLogger(OkHttpMultiDomainInterceptor.class);

@NotNull
@Override
public okhttp3.Response intercept(Chain chain) throws IOException {
Request request = chain.request();

if (shouldRetry(request)) {
try {
/*
* Implementations of this interface throw IOException to signal connectivity failures.
* This includes both natural exceptions such as unreachable servers,
* as well as synthetic exceptions when responses are of an unexpected type or cannot be decoded
*/
return chain.proceed(request);
} catch (IOException e) {
logger.warn("Retrying request due to connectivity failure: {}", e.getMessage(), e);

Request retryRequest = modifyRequestForRetry(request);
return chain.proceed(retryRequest);
}
}

return chain.proceed(request);
}

private boolean shouldRetry(Request request) {
return Constant.PRIMARY_API_DOMAIN.contains(request.url().host());
}

private Request modifyRequestForRetry(Request originalRequest) {
HttpUrl.Builder urlBuilder = originalRequest.url().newBuilder();
urlBuilder.host(Constant.SECONDARY_API_DOMAIN);

Request.Builder reqBuilder = originalRequest.newBuilder();
reqBuilder.url(urlBuilder.build());
reqBuilder.header(
Constant.USER_AGENT, originalRequest.header(Constant.USER_AGENT) + " (Retried-V1)");

return reqBuilder.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ void buildWithCredentialAndValidator() {
.validator(validator)
.okHttpClient(okHttpClient)
.proxy(new Proxy(Type.SOCKS, new InetSocketAddress("localhost", 8099)))
.enableRetryMultiDomain()
.disableRetryOnConnectionFailure()
.build();
assertNotNull(httpClient);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package com.wechat.pay.java.core.http;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import com.wechat.pay.java.core.http.okhttp.OkHttpMultiDomainInterceptor;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import okhttp3.Dns;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import okhttp3.mockwebserver.SocketPolicy;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;

class OkHttpMultiDomainInterceptorTest {
private FakeDns fakeDns;

@BeforeEach
void setUp() {
fakeDns = new FakeDns();
}

@Disabled("manual test")
@Test
void testOkHttpMultiDomain() throws IOException {
OkHttpMultiDomainInterceptor interceptor = new OkHttpMultiDomainInterceptor();
final OkHttpClient client =
new OkHttpClient.Builder().dns(fakeDns).addInterceptor(interceptor).build();

InetAddress nullAddress = InetAddress.getByName("10.0.0.1");
InetAddress realAddress = InetAddress.getByName("121.51.50.140");
fakeDns.addAddress("api.mch.weixin.qq.com", nullAddress);
fakeDns.addAddress("api2.wechatpay.cn", realAddress);

Request request =
new Request.Builder()
.url("https://api.mch.weixin.qq.com/v3/pay/transactions/id/1234?mchid=1234")
.header("User-Agent", "testOkHttpMultiDomain")
.header("Accept", "*/*")
.build();

try (Response response = client.newCall(request).execute()) {
assertEquals(401, response.code());
}
}

@Test
void testNormalOther() throws Exception {
MockWebServer server = new MockWebServer();
server.enqueue(new MockResponse().setBody("Successful response"));

OkHttpMultiDomainInterceptor interceptor = new OkHttpMultiDomainInterceptor();
final OkHttpClient client = new OkHttpClient.Builder().addInterceptor(interceptor).build();

// Act
Request request = new Request.Builder().url(server.url("/")).build();
Response response = client.newCall(request).execute();

// Assert
assertEquals(200, response.code());
server.shutdown();
}

@Test
void testNormalWeChatPay() throws Exception {
MockWebServer server = new MockWebServer();
server.enqueue(new MockResponse().setBody("Successful"));

OkHttpMultiDomainInterceptor interceptor = new OkHttpMultiDomainInterceptor();
final OkHttpClient client =
new OkHttpClient.Builder().dns(fakeDns).addInterceptor(interceptor).build();

HttpUrl mockUrl = server.url("/test");
fakeDns.addAddress(
"api.wechatpay.cn",
InetAddress.getByAddress("api.wechatpay.cn", new byte[] {127, 0, 0, 1}));

// Act
Request request =
new Request.Builder()
.url(mockUrl.newBuilder().host("api.wechatpay.cn").build())
.header("User-Agent", "testOkHttpMultiDomain")
.header("Accept", "*/*")
.build();

Response response = client.newCall(request).execute();

// Assert
assertEquals(200, response.code());

RecordedRequest request1 = server.takeRequest();
assertEquals("testOkHttpMultiDomain", request1.getHeader("User-Agent"));
server.shutdown();
}

@Test
void testOkHttpMultiDomainWithMock() throws Exception {
MockWebServer server = new MockWebServer();
OkHttpMultiDomainInterceptor interceptor = new OkHttpMultiDomainInterceptor();
final OkHttpClient client =
new OkHttpClient.Builder().dns(fakeDns).addInterceptor(interceptor).build();

server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST));
server.enqueue(new MockResponse().setBody("Successful"));

HttpUrl mockUrl = server.url("/test");
fakeDns.addAddress(
"api.mch.weixin.qq.com",
InetAddress.getByAddress("api.mch.weixin.qq.com", new byte[] {127, 0, 0, 1}));
fakeDns.addAddress(
"api2.wechatpay.cn",
InetAddress.getByAddress("api2.wechatpay.cn", new byte[] {127, 0, 0, 1}));

Request request =
new Request.Builder()
.url(mockUrl.newBuilder().host("api.mch.weixin.qq.com").build())
.header("User-Agent", "testOkHttpMultiDomain")
.header("Accept", "*/*")
.build();

try (Response response = client.newCall(request).execute()) {
assertEquals(200, response.code());

RecordedRequest request1 = server.takeRequest();
// Host: <host>:<port>
assertTrue(request1.getHeader("Host").contains("api.mch.weixin.qq.com"));

RecordedRequest request2 = server.takeRequest();
assertTrue(request2.getHeader("Host").contains("api2.wechatpay.cn"));
assertEquals("testOkHttpMultiDomain (Retried-V1)", request2.getHeader("User-Agent"));
}

server.shutdown();
}

private static class FakeDns implements Dns {
private final Map<String, List<InetAddress>> addressMappings = new HashMap<>();

void addAddress(String hostname, InetAddress address) {
addressMappings.computeIfAbsent(hostname, k -> new ArrayList<>()).add(address);
}

@NotNull
@Override
public List<InetAddress> lookup(@NotNull String hostname) throws UnknownHostException {
List<InetAddress> addresses = addressMappings.get(hostname);
if (addresses != null) {
return addresses;
} else {
throw new UnknownHostException("Unable to resolve the hostname: " + hostname);
}
}
}
}

0 comments on commit bcd46fb

Please sign in to comment.