CLOVER🍀

That was when it all began.

RESTEasy ClientのClientHttpEngineを差し替える(java.net.HttpURLConnection/OkHttp3)

RESTEasy Clientを使う時、HTTPクライアントの実装として次の2つから選択することができます。

デフォルトは、Apache HttpComponents/Clientです。

Apache HTTP Client 4.x and other backends

今回は、この切り替えとHTTPクライアントの実装をOkHttp3にするサンプルを書いてみたいと思います。

準備

依存ライブラリは、次のようにしました。

libraryDependencies ++= Seq(
  // for RESTEasy Client
  "org.jboss.resteasy" % "resteasy-client" % "3.1.4.Final" % Compile exclude("junit", "junit"),
  "org.jboss.resteasy" % "resteasy-jackson2-provider" % "3.1.4.Final" % Compile,
  "com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.8.9" % Compile,

  // dependency retrieve
  "org.jboss.resteasy" % "resteasy-jaxrs" % "3.1.4.Final" % Compile,
  "org.jboss.logging" % "jboss-logging" % "3.3.0.Final" % Compile,
  "org.jboss.logging" % "jboss-logging-annotations" % "2.0.1.Final" % Compile,
  "org.jboss.logging" % "jboss-logging-processor" % "2.0.1.Final" % Compile,
  "org.apache.httpcomponents" % "httpclient" % "4.5.2" % Compile,
  "com.fasterxml.jackson.core" % "jackson-core" % "2.8.9" % Compile,
  "com.fasterxml.jackson.core" % "jackson-annotations" % "2.8.9" % Compile,
  "com.fasterxml.jackson.core" % "jackson-databind" % "2.8.9" % Compile,
  "com.fasterxml.jackson.jaxrs" % "jackson-jaxrs-json-provider" % "2.8.9" % Compile,
  "javax.activation" % "activation" % "1.1.1" % Compile,
  "org.jboss.spec.javax.servlet" % "jboss-servlet-api_3.1_spec" % "1.0.0.Final" % Compile,
  "org.jboss.spec.javax.annotation" % "jboss-annotations-api_1.2_spec" % "1.0.0.Final" % Compile,
  "org.jboss.spec.javax.ws.rs" % "jboss-jaxrs-api_2.0_spec" % "1.0.1.Beta1" % Compile,
  "commons-io" % "commons-io" % "2.5" % Compile,
  "net.jcip" % "jcip-annotations" % "1.0" % Compile,

  // for Customize HttpEngine
  "com.squareup.okhttp3" % "okhttp" % "3.9.0" % Compile,

  // for test
  "org.jboss.resteasy" % "resteasy-netty4" % "3.1.4.Final" % Test,
  "io.netty" % "netty-all" % "4.1.15.Final" % Test,
  "org.scalatest" %% "scalatest" % "3.0.4" % Test
)

えらい長いんですけど、これはsbtがbomをうまく理解できないからみたいです…。

依存している各種ライブラリのバージョンは、こちらを参照…。
https://github.com/resteasy/Resteasy/blob/3.1.4.Final/resteasy-dependencies-bom/pom.xml

ホントは、これくらいでいいのですが。「jackson-module-scala」があるのは、Scalaを使っている関係からですけどね。

  // for RESTEasy Client
  "org.jboss.resteasy" % "resteasy-client" % "3.1.4.Final" % Compile,
  "org.jboss.resteasy" % "resteasy-jackson2-provider" % "3.1.4.Final" % Compile,
  "com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.8.9" % Compile,

こちらは、テスト用です。

  // for test
  "org.jboss.resteasy" % "resteasy-netty4" % "3.1.4.Final" % Test,
  "io.netty" % "netty-all" % "4.1.15.Final" % Test,
  "org.scalatest" %% "scalatest" % "3.0.4" % Test

テスト用のJAX-RSサーバーは、Netty4+RESTEasyで作ることにします。

あと、Jackson2用モジュールを有効化しておきましょう。こういうクラスを作成。
src/test/scala/org/littlewings/javaee7/resteasy/ScalaObjectMapperProvider.scala

package org.littlewings.javaee7.resteasy

import javax.ws.rs.{Consumes, Produces}
import javax.ws.rs.core.MediaType
import javax.ws.rs.ext.{ContextResolver, Provider}

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule

@Provider
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
class ScalaObjectMapperProvider extends ContextResolver[ObjectMapper] {
  override def getContext(typ: Class[_]): ObjectMapper = {
    val objectMapper = new ObjectMapper
    objectMapper.registerModule(DefaultScalaModule)
    objectMapper
  }
}

このクラスが有効になるように、META-INF/servicesに仕込んでおきます。
src/test/resources/META-INF/services/javax.ws.rs.ext.Providers

org.littlewings.javaee7.resteasy.ScalaObjectMapperProvider

リクエスト/レスポンスで使うEntityクラス

単純に、書籍で。
src/test/scala/org/littlewings/javaee7/resteasy/Book.scala

package org.littlewings.javaee7.resteasy

case class Book(isbn: String, title: String, price: Int)

テスト用JAX-RSサーバー

テスト用のJAX-RSサーバーは、Netty4+RESTEasyを使ってこんな感じで。
src/test/scala/org/littlewings/javaee7/resteasy/TestRestServer.scala

package org.littlewings.javaee7.resteasy

import javax.ws.rs.core.MediaType
import javax.ws.rs._

import org.jboss.resteasy.plugins.server.netty.NettyJaxrsServer

object TestRestServer {
  def withServer(fun: => Unit): Unit = {
    val netty = new NettyJaxrsServer
    val deployment = netty.getDeployment
    deployment.setResourceClasses(java.util.Arrays.asList(classOf[TestResource].getName))
    netty.setRootResourcePath("")
    netty.setPort(8080)
    netty.setDeployment(deployment)
    netty.start()

    try {
      fun
    } finally {
      netty.stop()
    }
  }
}

@Path("test")
class TestResource {
  @GET
  @Path("get")
  @Produces(Array(MediaType.APPLICATION_JSON))
  def get: Book =
    Book("978-4798140926", "Java EE 7徹底入門 標準Javaフレームワークによる高信頼性Webシステムの構築", 4104)

  @POST
  @Path("post")
  @Consumes(Array(MediaType.APPLICATION_JSON))
  @Produces(Array(MediaType.APPLICATION_JSON))
  def post(book: Book): Book = book.copy(price = book.price * 2)

  @PUT
  @Path("put")
  @Consumes(Array(MediaType.APPLICATION_JSON))
  @Produces(Array(MediaType.APPLICATION_JSON))
  def put(book: Book): Book = book.copy(price = book.price * 2)

  @DELETE
  @Path("delete")
  @Consumes(Array(MediaType.APPLICATION_JSON))
  @Produces(Array(MediaType.APPLICATION_JSON))
  def delete(book: Book): Unit = ()
}

ちょっと例としては微妙ですが、POST/PUTは送信されてきた書籍情報の価格を2倍にして返すことにしました。

  @POST
  @Path("post")
  @Consumes(Array(MediaType.APPLICATION_JSON))
  @Produces(Array(MediaType.APPLICATION_JSON))
  def post(book: Book): Book = book.copy(price = book.price * 2)

  @PUT
  @Path("put")
  @Consumes(Array(MediaType.APPLICATION_JSON))
  @Produces(Array(MediaType.APPLICATION_JSON))
  def put(book: Book): Book = book.copy(price = book.price * 2)

こんな感じで使います。

TestRestServer.withServer {
  ....
}

テストコードの雛形

テストコードの雛形は、このように。
src/test/scala/org/littlewings/javaee7/resteasy/RestEasyHttpEngineSpec.scala

package org.littlewings.javaee7.resteasy

import javax.ws.rs.client.{ClientBuilder, Entity}
import javax.ws.rs.core.Response

import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder
import org.jboss.resteasy.client.jaxrs.engines.URLConnectionEngine
import org.scalatest.{FunSuite, Matchers}

class RestEasyHttpEngineSpec extends FunSuite with Matchers {
  // ここに、テストを書く!
}

では、順次書いていってみましょう。

標準(Apache HttpComponents/Client)

まずは、Apache HttpCompontents/Clientを使ったパターンから。

といっても、こちらはふつうに書けばOKです。

  test("use standard engine") {
    TestRestServer.withServer {
      val client = ClientBuilder.newBuilder.build

      try {
        val response =
          client
            .target("http://localhost:8080/test/get")
            .request
            .get()

        response.getStatus should be(Response.Status.OK.getStatusCode)
        response.readEntity(classOf[Book]) should be(
          Book("978-4798140926", "Java EE 7徹底入門 標準Javaフレームワークによる高信頼性Webシステムの構築", 4104)
        )

        response.close()
      } finally {
        client.close()
      }
    }
  }

java.net.HttpURLConnectionを使う

では、これをjava.net.HttpURLConnectionを使うように切り替えてみましょう。

こんな感じになります。

  test("use java.net.HttpConnection engine") {
    TestRestServer.withServer {
      val client = new ResteasyClientBuilder().httpEngine(new URLConnectionEngine).build()

      try {
        val response =
          client
            .target("http://localhost:8080/test/get")
            .request
            .get()

        response.getStatus should be(Response.Status.OK.getStatusCode)
        response.readEntity(classOf[Book]) should be(
          Book("978-4798140926", "Java EE 7徹底入門 標準Javaフレームワークによる高信頼性Webシステムの構築", 4104)
        )

        response.close()
      } finally {
        client.close()
      }
    }
  }

ポイントは、ここだけです。

      val client = new ResteasyClientBuilder().httpEngine(new URLConnectionEngine).build()

JAX-RSのClient.BuilderからResteasyClientBuilderに変え、httpEngineメソッドでURLConnectionEngineのインスタンスを設定します。
これだけで、RESTEasy Clientの内部で使われるHTTPクライアントの実装を切り替えることができます。

RESTEasy ClientのHTTPクライアントの実装を、OkHttp3に替えてみる

では、今度はRESTEasy Clientが使うHTTPクライアントの実装を、自前で用意したものに差し替えてみましょう。

今回のお題としては、OkHttp3を使うことにします。

OkHttp

先ほどのjava.net.HttpURLConnectionで差し替えた例である程度わかるかもしれませんが、ResteasyClientBuilder#httpEngineに渡すクラスを
作成すればOKです。

つまり、ClientHttpEngineインターフェースの実装クラスを作成します。
http://docs.jboss.org/resteasy/docs/3.1.4.Final/javadocs/org/jboss/resteasy/client/jaxrs/ClientHttpEngine.html

実装しなくてはいけないメソッドは、次の4つです。

  • close
  • getHostnameVerifier
  • getSslContext
  • invoke

参考にするのは、既存の実装でしょう。つまり、この2つ。
https://github.com/resteasy/Resteasy/blob/3.1.4.Final/resteasy-client/src/main/java/org/jboss/resteasy/client/jaxrs/engines/ApacheHttpClient4Engine.java
https://github.com/resteasy/Resteasy/blob/3.1.4.Final/resteasy-client/src/main/java/org/jboss/resteasy/client/jaxrs/engines/URLConnectionEngine.java

で、実装した結果はこちら。
src/main/scala/org/littlewings/javaee7/resteasy/OkHttpEngine.scala

package org.littlewings.javaee7.resteasy

import java.io.{ByteArrayOutputStream, InputStream}
import javax.net.ssl.{HostnameVerifier, SSLContext}
import javax.ws.rs.core.MultivaluedMap

import okhttp3.{MediaType, OkHttpClient, Request, RequestBody}
import org.jboss.resteasy.client.jaxrs.ClientHttpEngine
import org.jboss.resteasy.client.jaxrs.internal.{ClientInvocation, ClientResponse}
import org.jboss.resteasy.util.CaseInsensitiveMap

import scala.beans.BeanProperty

class OkHttpEngine extends ClientHttpEngine {
  val okHttpClient: OkHttpClient = new OkHttpClient

  @BeanProperty
  var sslContext: SSLContext = _

  @BeanProperty
  var hostnameVerifier: HostnameVerifier = _

  override def invoke(request: ClientInvocation): ClientResponse = {
    val okHttpRequestBuilder =
      new Request.Builder()
        .url(request.getUri.toURL)

    val body = Option(request.getEntity).map { _ =>
        val baos = new ByteArrayOutputStream
        request.getDelegatingOutputStream.setDelegate(baos)
        request.writeRequestBody(baos)
        baos.close()
        baos.toByteArray
    }

    val headers = request.getHeaders.asMap
    headers.entrySet.forEach(header => header.getValue.forEach(okHttpRequestBuilder.addHeader(header.getKey, _)))

    okHttpRequestBuilder.method(
      request.getMethod,
      body.map(RequestBody.create(MediaType.parse(request.getHeaders.getMediaType.toString), _)).getOrElse(null)
    )

    val okHttpRequest = okHttpRequestBuilder.build

    val okHttpResponse = okHttpClient.newCall(okHttpRequest).execute()

    val response = new ClientResponse(request.getClientConfiguration) {
      var inputStream: InputStream = _

      override def releaseConnection(): Unit = okHttpResponse.close()

      override def setInputStream(is: InputStream): Unit = inputStream = is

      override def getInputStream = {
        if (inputStream == null) {
          inputStream = okHttpResponse.body().byteStream()
        }

        inputStream
      }
    }

    response.setStatus(okHttpResponse.code())

    val responseHeaders: MultivaluedMap[String, String] = new CaseInsensitiveMap
    val headerNames = okHttpResponse.headers.names
    headerNames.forEach(name => okHttpResponse.headers(name).forEach(value => responseHeaders.add(name, value)))

    response.setHeaders(responseHeaders)
    response
  }

  override def close(): Unit = ()
}

OkHttpClientは特にcloseするものがないので、実装が必要な次の3つは簡単にまとめました。

  @BeanProperty
  var sslContext: SSLContext = _

  @BeanProperty
  var hostnameVerifier: HostnameVerifier = _

  override def close(): Unit = ()

あとは、invokeを実装するだけです。

invokeメソッドでは、RESTEasy側から渡されたClientInvocationを元にしって実際にHTTPリクエストを投げて、その結果を元に
レスポンスを作成して、RESTEasyに戻す処理を実装することになります。

OkHttp側へリクエストを設定。

  override def invoke(request: ClientInvocation): ClientResponse = {
    val okHttpRequestBuilder =
      new Request.Builder()
        .url(request.getUri.toURL)

    val body = Option(request.getEntity).map { _ =>
        val baos = new ByteArrayOutputStream
        request.getDelegatingOutputStream.setDelegate(baos)
        request.writeRequestBody(baos)
        baos.close()
        baos.toByteArray
    }

    val headers = request.getHeaders.asMap
    headers.entrySet.forEach(header => header.getValue.forEach(okHttpRequestBuilder.addHeader(header.getKey, _)))

    okHttpRequestBuilder.method(
      request.getMethod,
      body.map(RequestBody.create(MediaType.parse(request.getHeaders.getMediaType.toString), _)).getOrElse(null)
    )

    val okHttpRequest = okHttpRequestBuilder.build

    val okHttpResponse = okHttpClient.newCall(okHttpRequest).execute()

レスポンスは、ClientResponseのサブクラスとして返却します。

    val response = new ClientResponse(request.getClientConfiguration) {
      var inputStream: InputStream = _

      override def releaseConnection(): Unit = okHttpResponse.close()

      override def setInputStream(is: InputStream): Unit = inputStream = is

      override def getInputStream = {
        if (inputStream == null) {
          inputStream = okHttpResponse.body().byteStream()
        }

        inputStream
      }
    }

また、このレスポンスにOkHttpのレスポンスが持つステータスコードやヘッダーなどを設定します。

    response.setStatus(okHttpResponse.code())

    val responseHeaders: MultivaluedMap[String, String] = new CaseInsensitiveMap
    val headerNames = okHttpResponse.headers.names
    headerNames.forEach(name => okHttpResponse.headers(name).forEach(value => responseHeaders.add(name, value)))

    response.setHeaders(responseHeaders)
    response
  }

これで完成です。

では、使ってみましょう。

GET

  test("use OkHttp engine") {
    TestRestServer.withServer {
      val client = new ResteasyClientBuilder().httpEngine(new OkHttpEngine).build()

      try {
        val response =
          client
            .target("http://localhost:8080/test/get")
            .request
            .get()

        response.getStatus should be(Response.Status.OK.getStatusCode)
        response.readEntity(classOf[Book]) should be(
          Book("978-4798140926", "Java EE 7徹底入門 標準Javaフレームワークによる高信頼性Webシステムの構築", 4104)
        )

        response.close()
      } finally {
        client.close()
      }
    }
  }

POST

  test("use OkHttp engine, post") {
    TestRestServer.withServer {
      val client = new ResteasyClientBuilder().httpEngine(new OkHttpEngine).build()

      try {
        val response =
          client
            .target("http://localhost:8080/test/post")
            .request
            .post(Entity.json(Book("978-4774183169", "パーフェクト Java EE", 3456)))

        response.getStatus should be(Response.Status.OK.getStatusCode)
        response.readEntity(classOf[Book]) should be(
          Book("978-4774183169", "パーフェクト Java EE", 6912)
        )

        response.close()
      } finally {
        client.close()
      }
    }
  }

コードは省略しますが、PUT/DELETEでも特に問題なく。

まとめ

RESTEasy Clientで、内部的に使用するHTTPクライアントの実装の変更、自作をやってみました。

あんまり情報がないので、ClientHttpEngineを作るところは若干てこずりましたが、まあ最低限はこんなところかなぁと。とりあえず動かせたので
良しとしましょう。

今回作成したソースコードは、こちらに置いています。
https://github.com/kazuhira-r/javaee7-scala-examples/tree/master/resteasy-customize-http-engine

あと、こちらも参考にするとよいかも?
https://github.com/tbroyer/jaxrs-utils/tree/master/resteasy-client-okhttp3