Apache HttpClientはjavaからHTTP通信を簡単(?)に行うライブラリです。
仕事でこれを使って通信する予定なのですが、接続先のサーバーが手元にないのでこのままではテストができません。
普通なら仮でサーバー立ててテストすると思いますが、HttpClientの中を見ていたらVM上でもうまいことできるような気がしたので簡単に調べて実験してみました。
方法
HttpClientの中ではSocketを利用して通信しているので、SocketをMock化し実際は通信せずVM上で完結させます。
下準備
mavenプロジェクトで作業するのでpom.xmlに下記を追記します。
httpclientと、無くてもいいけどlombokいれてます。
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.2</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.6</version>
<scope>provided</scope>
</dependency>
送信用Mockソケット
@RequiredArgsConstructor
public class MockSocket extends Socket {
//Mockサーバー
private final MockServer mockServer;
private final Deque<byte[]> responses = new ConcurrentLinkedDeque<>();
private InputStream input;
@Override
public InputStream getInputStream() throws IOException {
class MockInputStream extends FilterInputStream {
protected MockInputStream() {
super(null);
}
@Override
public int read(byte[] b, int off, int len) throws IOException {
if (in == null) {
in = new ByteArrayInputStream(responses.poll());
}
int ret = super.read(b, off, len);
if (ret == -1 && !responses.isEmpty()) {
in = new ByteArrayInputStream(responses.poll());
return super.read(b, off, len);
}
return ret;
}
@Override
public void close() throws IOException {
if (in != null) {
super.close();
}
responses.clear();
}
}
input = new MockInputStream();
return input;
}
@Override
public void shutdownInput() throws IOException {
if (input != null) {
input.close();
}
input = null;
}
@Override
public OutputStream getOutputStream() throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
return new FilterOutputStream(output) {
@Override
public void flush() throws IOException {
super.flush();
byte[] request = output.toByteArray();
if (request.length == 0) {
return;
}
try {
//Request byte配列をMockサーバーに渡してResponses byte配列を取得する
responses.add(mockServer.execute(request));
} catch (HttpException e) {
throw new IllegalStateException(e);
}
output.reset();
}
};
}
}
OutputStreamをラップしてflushされたときに、後述するMockServerに処理を渡します。
InputStreamもラップしてMockServerからの返り値を頑張って返します。
(連続したデータの取り回し処理のせいで少し煩雑になりました。)
タイムアウトは考慮していません。
Mockサーバー処理
MockServerという名前を付けましたが、実際のサーバーではなく、サーバーっぽい処理をして値を返すだけです。
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpException;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.MethodNotSupportedException;
import org.apache.http.ProtocolVersion;
import org.apache.http.RequestLine;
import org.apache.http.StatusLine;
import org.apache.http.impl.DefaultBHttpServerConnection;
import org.apache.http.impl.DefaultBHttpServerConnectionFactory;
import org.apache.http.impl.DefaultHttpRequestFactory;
import org.apache.http.impl.io.DefaultHttpRequestParser;
import org.apache.http.io.HttpMessageParserFactory;
import org.apache.http.message.BasicHttpEntityEnclosingRequest;
import org.apache.http.message.BasicHttpResponse;
import org.apache.http.message.BasicLineParser;
import org.apache.http.message.BasicStatusLine;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public final class MockServer {
//HTTPメソッド処理
private final BiConsumer<HttpRequest, HttpResponse> method;
private static final ProtocolVersion PROTOCOL_VERSION_HTTP1_1 = new ProtocolVersion("HTTP", 1, 1);
private static final StatusLine STATUS_LINE_OK = new BasicStatusLine(PROTOCOL_VERSION_HTTP1_1, HttpStatus.SC_OK,
"OK");
@SuppressWarnings("resource")
public byte[] execute(byte[] request)
throws IOException, HttpException {
//Mockサーバー側用Socket生成
MockServerSocket socket = new MockServerSocket(request);
//DefaultBHttpServerConnection生成
DefaultBHttpServerConnection connection = new DefaultBHttpServerConnectionFactory()
.createConnection(socket);
//リクエスト生成
HttpRequest httpRequest = connection.receiveRequestHeader();
if (httpRequest instanceof HttpEntityEnclosingRequest) {
HttpEntityEnclosingRequest enclosingRequest = (HttpEntityEnclosingRequest) httpRequest;
connection.receiveRequestEntity(enclosingRequest);
}
//レスポンス生成
HttpResponse httpResponse = new BasicHttpResponse(STATUS_LINE_OK);
//メソッド実行
method.accept(httpRequest, httpResponse);
//Stream(MockServerSocket)に書き込む
connection.sendResponseHeader(httpResponse);
connection.sendResponseEntity(httpResponse);
return socket.getResponse();
}
}
httpcoreに入っている、DefaultBHttpServerConnectionを使ってサーバー処理を仮想的に行います。
DefaultBHttpServerConnectionはSocketを必要とするのでここでも新しくMock化したSocket、MockServerSocketを作成しました。
MockServerSocketは後述します。
サーバー用Mockソケット
@RequiredArgsConstructor
public class MockServerSocket extends Socket {
private final byte[] request;
@Getter
private byte[] response;
@Override
public InputStream getInputStream() throws IOException {
return new ByteArrayInputStream(request);
}
@Override
public OutputStream getOutputStream() throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
return new FilterOutputStream(output) {
@Override
public void flush() throws IOException {
super.flush();
byte[] bs = output.toByteArray();
if (bs.length == 0) {
return;
}
//書き込まれた値をresponse変数に残す
response = bs;
output.reset();
}
};
}
}
byte配列を横流ししているだけです。
実行用
(httpsは今回未対応)
public static void main(String... a) throws URISyntaxException, ClientProtocolException, IOException {
String host = "localhost";
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
//Mock Socketを渡すためのインスタンス作成。
Registry<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory> create()
.register("http", new ConnectionSocketFactory() {
@Override
public Socket createSocket(HttpContext context) throws IOException {
//Mock Socketを返す
return createMockSocket();
}
@Override
public Socket connectSocket(int connectTimeout, Socket sock, HttpHost host,
InetSocketAddress remoteAddress, InetSocketAddress localAddress, HttpContext context)
throws IOException {
return sock;
}
})
.build();
HttpClientContext httpClientContext = new HttpClientContext();
//登録
httpClientContext.setAttribute("http.socket-factory-registry", registry);
//GET
HttpGet httpGet = new HttpGet(new URIBuilder()
.setScheme("http")
.setHost(host)
.setPath("/string")
.build());
try (CloseableHttpResponse response = client.execute(httpGet, httpClientContext)) {
String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
System.out.println("■GET");
System.out.println(body);
}
//POST
HttpPost httpPost = new HttpPost(new URIBuilder()
.setScheme("http")
.setHost(host)
.setPath("/string")
.build());
httpPost.setEntity(new StringEntity("data"));
try (CloseableHttpResponse response = client.execute(httpPost, httpClientContext)) {
String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
System.out.println("■POST");
System.out.println(body);
}
}
}
/**
* Mock Socket生成
*
* @return
*/
private static Socket createMockSocket() {
MockServer mockServer = new MockServer((req, res) -> {
//Mockメソッド
try {
if (req.getRequestLine().getMethod().equals("GET") && req.getRequestLine().getUri().equals("/string")) {
res.setEntity(new StringEntity("body", StandardCharsets.UTF_8));
} else if (req.getRequestLine().getMethod().equals("POST")
&& req.getRequestLine().getUri().equals("/string")) {
HttpEntityEnclosingRequest entityEnclosingRequest = (HttpEntityEnclosingRequest) req;
String body = EntityUtils.toString(entityEnclosingRequest.getEntity(), StandardCharsets.UTF_8);
res.setEntity(new StringEntity("request body:" + body, StandardCharsets.UTF_8));
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
return new MockSocket(mockServer);
}
Mockソケットを返すRegistryを作成し、HttpClientContextに"http.socket-factory-registry"
というKeyで登録することで、HttpClientはMockソケットで通信します。(実際は通信しないけど)
結果
■GET
body
■POST
request body:data
サーバがいなくてもHTTPのやり取りができそうなところまでできました。
問題点①
上記のコードはlocalhost
でHTTP通信をしていますが、普通のhostを指定し、その指定したhostが自分の環境から繋げないとうまく動きません。
(Exceptionになります。)
例
String host = "localhost";
とある部分を
String host = "unittest.jp";
にすると動きません。
Exception in thread "main" java.net.UnknownHostException: unittest.jp
at java.net.Inet6AddressImpl.lookupAllHostAddr(Native Method)
at java.net.InetAddress$2.lookupAllHostAddr(InetAddress.java:928)
at java.net.InetAddress.getAddressesFromNameService(InetAddress.java:1323)
at java.net.InetAddress.getAllByName0(InetAddress.java:1276)
at java.net.InetAddress.getAllByName(InetAddress.java:1192)
at java.net.InetAddress.getAllByName(InetAddress.java:1126)
解決策
DnsResolverというのを差し替えればうまくいきそうです。
ただこれをHttpClientに渡すにはConnectionManagerを渡さなければいけないようで、
コードは面倒なことになってしまいました。
PoolingHttpClientConnectionManager poolingmgr = new PoolingHttpClientConnectionManager(
RegistryBuilder.<ConnectionSocketFactory> create().build(),
null, null,
h -> {
return new InetAddress[] { null };//Mockソケットでは使ってないのでnullが1つある配列を返す。
//元のコード
//return InetAddress.getAllByName(h);
}, -1, TimeUnit.MILLISECONDS);
try (CloseableHttpClient client = HttpClientBuilder.create()
.setConnectionManager(poolingmgr)
.build()) {
問題点②
GET・POSTは動きます。
他も大体動くのですが、HttpRequestの実装クラスたちのHttpClient標準の中で、PATCHだけ動きません。
DefaultBHttpServerConnectionの中を追ってみると、
DefaultHttpRequestFactory#newHttpRequestでPATCHだけはぶられています。RFC5789だからですか?
解決策
DefaultBHttpServerConnection生成を下記のようにすることで解決します。
//DefaultHttpRequestFactoryはPATCHに対応していないので拡張する。
HttpMessageParserFactory<HttpRequest> parserFactory = (buffer, constraints) -> {
return new DefaultHttpRequestParser(buffer, BasicLineParser.INSTANCE, new DefaultHttpRequestFactory() {
@Override
public HttpRequest newHttpRequest(RequestLine requestline) throws MethodNotSupportedException {
String method = requestline.getMethod();
if ("PATCH".equals(method)) {
return new BasicHttpEntityEnclosingRequest(requestline);
}
return super.newHttpRequest(requestline);
}
}, constraints);
};
//DefaultBHttpServerConnection生成
DefaultBHttpServerConnection connection = new DefaultBHttpServerConnectionFactory(null, parserFactory, null)
.createConnection(socket);
だいぶネジ込まないと、動かないのでテスト用で利用するには本実装も工夫する必要がありそうです。。。
もっと簡単な方法はないのかな?