ある調べ物をしているときにJAX-RSを使うとWebAPIのクライアントを簡単に実装できることを知ったので、ちょっとJAX-RSのサーバーとクライアントを実装する構成を考えてみました。
試しに作ったものは以下で公開しています。
- Github jersey-spring-sample
JAX-RSを使う方法はいくつかありますが、今回は私が聞き覚えのあるものとしてJerseyを使っています。また、DIのためにSpringを併用しています。
実際には、Jersey単体でもhk2というモジュールにてDIができるようだったのですが、ちょっと触った感じだと上手くいかなかったので、私がわかるSpringを使っています。
バージョンは以下の通りです。
- jersey: 2.22.2
- spring: 3.2.8.RELEASE
- gradle: 2.13
クライアントライブラリの作り方
クライアントライブラリの肝となる部分を抜粋します。必要なimportなどはGithubを参照してください。
@Path("/api/v1/data")
public interface DataResource {
@GET
@Produces(MediaType.APPLICATION_JSON)
Data getData(@QueryParam("name") String name);
}
public class ClientFactory {
public static DataResource instance(String baseUrl) {
Client c = ClientBuilder.newClient();
WebTarget target = c.target(baseUrl);
// 全てのHttpリクエストにつけるヘッダーを設定
MultivaluedHashMap<String, Object> headers = new MultivaluedHashMap<>();
headers.put("User-Agent", Arrays.<Object>asList("myapp/1.0"));
return WebResourceFactory.newResource(
DataResource.class,
target,
false,
headers,
Collections.<Cookie>emptyList(),
new Form());
}
}
まず、JAX-RSのアノテーションをつかってDataResourceインターフェースを作成します。クライアントとしてはインターフェースに対応する実装は不要です。
このアノテーションがつけられたインターフェースを使って、WebResourceFactory.newResource()
にてDataResource
のインスタンスを作成します。このインスタンスのメソッドを実行すると、@Path
などのアノテーションで指定されたAPIにhttpリクエストを投げてレスポンスをモデルオブジェクトとして取得できるようになります。
今後、APIを拡張するときは、APIの定義にあわせてインターフェースにメソッドを追加し、適切にアノテーションをつけるだけというかなり簡単な作業になります。
なお、Jerseyでのアノテーションのつけ方は以下を参照してください。
- Chapter 3. JAX-RS Application, Resources and Sub-Resources
サーバーアプリの作り方
次に、サーバーアプリ側でAPIの実際の処理を作成する方法を説明します。
APIの定義は、クライアントを作成したときのインターフェースがそのままつかえます。このインターフェースをサーバーとクライアントで共有することで両方を同時に開発することができます。
@Component
public class DataResourceImpl implements DataResource {
private static Logger log = Logger.getLogger(DataResourceImpl.class.getName());
@Resource
private HttpServletRequest httpRequest;
@Resource
private DataService dataService;
@Override
public Data getData(@QueryParam("name") String name) {
return dataService.create(
name,
"UserAgent:" + httpRequest.getHeader("User-Agent")
);
}
}
サーバー側ではSpringを使ったアプリ実装を行うだけです。ポイントは、この具象クラスがSpringのComponentとして登録されるように@Component
アノテーションをつけておくことです。
実際にはサーバー側で問題になってくるのは、JerseyとSpringを組み合わせるための基礎部分の用意だと思います。
まずは、Webアプリケーションのweb.xmlを以下の様に定義します。Springのためのlistenerと、アプリ本体といえるJerseryのservletを定義します。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE web-app>
<web-app>
<display-name>Jersey Spring Web Application</display-name>
<!-- Springの起動・停止をおこなうlistenerを追加 -->
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<!-- SpringでHttpリクエスト情報をComponentとしてDIできるようにするためのlistenerを追加 -->
<listener>
<listener-class>org.springframework.web.context.request.RequestContextListener</listener-class>
</listener>
<!-- SpringのApplicationContextを定義するファイルパスを定義 -->
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:applicationContext.xml</param-value>
</context-param>
<!-- Jerseyをアプリケーションのサーブレットしてを設定する -->
<servlet>
<servlet-name>SpringApplication</servlet-name>
<servlet-class>org.glassfish.jersey.servlet.ServletContainer</servlet-class>
<init-param>
<!-- jerseryの初期化用クラス名を定義 -->
<param-name>javax.ws.rs.Application</param-name>
<param-value>myapp.Application</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>SpringApplication</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>
</web-app>
Springの設定としては以下になります。
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context.xsd">
<context:annotation-config />
<context:component-scan base-package="myapp.*" />
</beans>
実際に機能が追加されていけば、もっといろいろな定義が増えると思いますが、現状の機能には上記で十分です。
次に、Jerseryの初期化のためのクラスをJerseyのResourceConfig
クラスを継承して実装します。
package myapp;
import org.glassfish.jersey.filter.LoggingFilter;
import org.glassfish.jersey.server.ResourceConfig;
public class Application extends ResourceConfig {
/**
* Register JAX-RS application components.
*/
public Application(){
// Resourceクラスを定義しているパスを指定する
// ここから`@Path`がついたクラスを走査してルーティングに追加している
packages("myapp.resources");
register(LoggingFilter.class);
}
}
初期化と言ってもこれだけです。おまけとしてHttpリクエストをログ出力するためにFilterを追加しています。これと同じ様にfilterを追加して、リクエストの前後処理を追加することができるようです。
基本的に、上記で下地の準備は完了です。これでサーバーアプリを起動すれば、リクエストに応じてDataResourceImpl
クラスのインスタンスが作成され、getData
メソッドが実行されます。
ビルドモジュール構成
最後に、これらのモジュールをビルドするGradleの設定です。
モジュールとしては、クライアント、サーバーと両者共通部分(インターフェースとか)の3つになります。これらをGadleのMulti Module構成にして管理します。
フォルダ構成としては以下のようになります。
app-root
├── common : 共通モジュール
│ └── src
├── client : クライアントモジュール
│ └── src
├── server : サーバーモジュール
│ └── src
├── README.md
├── build.gradle
└── settings.gradle
サブフォルダになる各モジュールは、それぞれが1つの一般的なJavaアプリケーションのモジュール構成になります。フォルダ名がアプリケーション名になります。
まず、settings.gradle
ファイルでサブモジュールを指定します。
// Add sub projects
include 'common', 'server', 'client'
そして、メインとなるbuild.gradle
を作成します。
// Intellij Idea向けのプロジェクト設定作成プラグインを追加
// `gradle idea` を実行する
apply plugin: 'idea'
def jerseyVersion = '2.22.2'
def springVersion = '3.2.8.RELEASE'
// idea向け設定
idea {
module {
// ビルドの出力先を、デフォルトを使うように指定
// これがなければ、出力先が無指定になってビルドできなかった
// gradle v2.13以降で利用可
inheritOutputDirs = true
}
}
// サブモジュール全てに対しての共通設定
subprojects {
apply plugin: 'java'
sourceCompatibility = 1.7
targetCompatibility = 1.7
// アプリバージョン
version = '1.0'
// Javaソースファイルのエンコードを明示
def defaultEncoding = 'UTF-8'
[compileJava, compileTestJava]*.options*.encoding = defaultEncoding
// 依存Jarの取得元を明記
repositories {
mavenCentral()
}
// 全モジュール共通の依存を定義
// ログ関連、テスト関連、JAVA-RS関連のものなど
dependencies {
compile "org.slf4j:slf4j-api:1.7.2"
compile "ch.qos.logback:logback-classic:1.1.3"
compile "org.slf4j:jcl-over-slf4j:1.7.12"
compile 'org.apache.commons:commons-lang3:3.4'
compile "javax.ws.rs:javax.ws.rs-api:2.0.1"
testCompile 'junit:junit:4.11'
testCompile 'org.hamcrest:hamcrest-all:1.3'
}
}
// serverモジュールのみの設定
project(':server') {
// 最終的にwarを作成するためのプラグイン
apply plugin: 'war'
// 開発時のサーバー起動をjettyで行うためのプラグイン
apply plugin: 'jetty'
// サーバーのみに必要な依存を定義
dependencies {
compile "javax.servlet:javax.servlet-api:3.1.0"
compile "org.glassfish.jersey.containers:jersey-container-servlet:${jerseyVersion}"
compile "org.glassfish.jersey.ext:jersey-spring3:${jerseyVersion}"
compile "org.glassfish.jersey.media:jersey-media-json-jackson:${jerseyVersion}"
compile "org.springframework:spring-core:${springVersion}", {
exclude module:"commons-logging:commons-logging"
}
compile "org.springframework:spring-context:${springVersion}"
compile "org.springframework:spring-web:${springVersion}"
// 共通モジュールを依存に追加することで利用できる
compile project(':common')
}
// 動作確認用のjettyの設定
// `gradle jettyRun` でサーバー起動
// http://localhost:8080/ でアクセスできる
jettyRun {
httpPort = 8080
}
}
// clientモジュール固有の設定
project(':client') {
// クライアントのみに必要な依存を定義
dependencies {
compile "org.glassfish.jersey.core:jersey-client:${jerseyVersion}"
compile "org.glassfish.jersey.ext:jersey-proxy-client:${jerseyVersion}"
compile "org.glassfish.jersey.media:jersey-media-json-jackson:${jerseyVersion}"
// 共通モジュールを依存に追加することで利用できる
compile project(':common')
}
// clientモジュールのtest実行のための設定
// テスト実行時にアクセスするサーバーを起動する
// Start/Stop the app server
// client:test タスク実行の前処理を追加
test.doFirst {
def port = 8180
def server = project(':server')
// serverモジュールの jettyRun、jettyStopタスクの設定を追加
// jettyStopでサーバーを停止できるように停止用ポートとキーワードを設定する
[server.jettyRun, server.jettyStop].each {
it.stopPort = 8081
it.stopKey = 'stopKey'
}
server.jettyRun.httpPort = port
server.jettyRun.daemon = true
// jettyサーバー起動
server.jettyRun.execute()
// テストで使うAPIのURLを環境変数に設定
// テストコードにてこの環境変数を取得して使う
environment 'API_BASE_URL', "http://localhost:${port}/server"
}
// client:test 終了後に実行する処理を追加
test.doLast {
// jettyサーバーを停止する
project(':server').jettyStop.execute()
}
// clent:test 実行前にserver側のモジュールをビルドするためにタスクの依存関係を追加
test.dependsOn project(':server').assemble
}
基本的に、各モジュールをビルドするためのプラグインと依存モジュールの定義をしています。
その上で、クライアントのテスト実行時に実際にサーバーにアクセスできるようにする設定を作ってみました。この設定により、クライアントのテスト実行中はアプリサーバーが起動した状態になります。
以前にMavenで同じ様なテスト時のサーバー実行方法を調べたことがあるのですが、Gradleの方が圧倒的にわかりやすいし、細かな制御ができていいです。
- 参考: MavenでアプリをJetty起動しながらintegrationテストを実行 - Qiita
実際のGradleコマンドの使い方は以下の様になります。
> gradle idea
Intellij Idea用のプロジェクト設定を作成
> gradle build
全モジュールをビルド、テストし、jar・warを作成する
> gradle build -x test
testをskipしてbuildする
> gradle jettyRun
動作確認用のアプリサーバーを起動する
> gradle test
全モジュールをテストを実行する。
> gradle client:test
clientモジュールのみのテストを実行する
補足
JerseyへのComponentの追加について
最初に調べていたときに、JerseyにてResourceクラスをインターフェースと実装クラスに分ける場合はAbstractBinder
を使ってインターフェースと実装クラスとの対応関係を明記する必要があるらしいとの記事を見かけました。
実装例としては、以下のような感じになります。
public Application(){
packages("myapp.resources");
// org.glassfish.hk2.utilities.binding.AbstractBinder
register(new AbstractBinder() {
@Override
protected void configure() {
bind(DataResourceImpl.class).to(DataResource.class);
}
});
}
確かに、これでAPIアクセス時にDataResourceImpl#getData
が実行されるのですが、SpringのDIが効きかずdataService
などがnull
になってしまいます。
どうも、このbind機能はJerseyのHK2モジュールの機能で、Springとは独立したもののようです。Jersey+Springの構成ではこのAbstractBinder
の部分は寧ろ邪魔になってしまうようでした。
JavaのビルドバージョンをJava7にしている理由
build.gradle
にてJavaのビルドのバージョンをJava7(1.7)にしています。
これは、SpringでJava8に対応できていない部分があるようで、以下の様なエラーが発生するための対処です。
ASM ClassReader failed to parse class file - probably due to a new Java class file version that isn't supported yet:
file [server/build/classes/main/myapp/resources/DataResourceImpl.class];
nested exception is java.lang.IllegalArgumentException:
なぜ、こういうエラーがでるのかの詳しいところは調べられていません。
参考リンク
- Jersey2 + Spring3 + Jackson - NextInstruction