👋

SpringFramework + Jersey2構成でAPIを構築する

commits11 min read

これは何

SpringFramework + Jersey2でAPIを作ります。

想定読者

  • Java書いたことがある。
  • SpringFramework はだいたい雰囲気わかる。
  • Jersey2に興味がある。(Jerseyは3がそろそろ出ます)
  • 業務でJersey2で書かれたアプリケーションの改修する必要があるんだけどJerseyって何って人

Jerseyとは

そもそものところからですが,JerseyとはJAX-RS (Java API for RESTful Web Services)のリファレンス実装です。
(※リファレンス実装という単語がわかりにくいと思うのですが,「APIの仕様に準拠している実装例の1つ」という認識で問題ないかと思います。)

Jersey自体はそろそろ3が出そうですが今回扱うのは2になります。

SpringFramework + Jersey2

環境

  • SpringFramework 5.3.1
  • Jersey 2.32
  • Apache Maven 3.6.3

コンパイルし,warでtomcatあたりにデプロイする想定です。
SpringBootは使いません,基本xml設定を書きます。

依存ライブラリ

以下をpom.xmlに追加しました。

SpringFramework

特に書くことがないFrameworkです

pom.xml
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-core</artifactId>
    <version>${spring.version}</version>
</dependency>

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>${spring.version}</version>
</dependency>

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-web</artifactId>
    <version>${spring.version}</version>
</dependency>

Jersey

Jerseyの本体です。

pom.xml
<dependency>
    <groupId>org.glassfish.jersey.containers</groupId>
    <artifactId>jersey-container-servlet</artifactId>
    <version>${jersey2.version}</version>
</dependency>
<dependency>
    <groupId>org.glassfish.jersey.core</groupId>
    <artifactId>jersey-server</artifactId>
    <version>${jersey2.version}</version>
</dependency>

jersey-bean-validation

JerseyにRequestが渡される前にBean-Validationを実行するのに必要です。

pom.xml
<dependency>
    <groupId>org.glassfish.jersey.ext</groupId>
    <artifactId>jersey-bean-validation</artifactId>
    <version>${jersey2.version}</version>
</dependency>

jersey-media-json-jackson

Jerseyでjsonを扱えるようにします。

pom.xml
<dependency>
    <groupId>org.glassfish.jersey.media</groupId>
    <artifactId>jersey-media-json-jackson</artifactId>
    <version>${jersey2.version}</version>
</dependency>

jersey-spring5

JerseyとSpringで連携させる際に必要です。
本来JerseyにはHK2 というコンテナもあるのですが,Springを使用するので追加します。

pom.xml
<dependency>
    <groupId>org.glassfish.jersey.ext</groupId>
    <artifactId>jersey-spring5</artifactId>
    <version>${jersey2.version}</version>
</dependency>

lombok

特筆することはないただのlombokです。

pom.xml
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.16</version>
    <scope>provided</scope>
</dependency>

中身

Sample001.java

Jerseyのエンドポイントを定義します。

@javax.ws.rs.Path

valueとしてエンドポイントを指定します。
クラス,メソッドの両方に付与することができます。
その場合 クラスのPath+メソッドのPath というurlになります。
パスにパラメータを設定することが可能ですが,ここでは記述しません。

@javax.ws.rs.POST,@GET,@PUT...

HTTPのMethodを指定します

@javax.ws.rs.Consumes

requestで受け取るデータの形式を指定します。

@javax.ws.rs.Produces

responseで返却するデータの形式を指定します。

@javax.validation.Valid

今回入力値チェックにBeanValidationを使用するので,引数のオブジェクトに設定しています。

Sample001.java
@Path("/sample001")
public interface Sample001 {
    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response getsample001(@Valid Sample001Form form);
}

Sample001Form.java

Formオブジェクトです。
BeanValidationを使用するのでフィールドにアノテーションを付与しています。
今回は書いていませんがネストしたオブジェクトを持ち,内部でもバリデーションが必要な場合はフィールドに**@Valid**アノテーションを付与します。

Sample001Form.java
@Data
public class Sample001Form {

    @NotNull
    private int id;

    @NotBlank
    private String name;

}

Sample001Impl.java

Sample001.javaでエンドポイントを定義したので実装クラスを作成します。
こちらは特に特殊なことは行っておらず,Requestをラップして返却します。
jersey-media-json-jacksonを依存に含めているので,**entity()**メソッドへMapオブジェクトを渡した場合,自動的にjsonへ変換されResponseが作成されます。

Sample001Impl.java
@Component
public class Sample001Impl implements Sample001 {

    @Override
    public Response getsample001(Sample001Form form) {
        Map<String, Object> map = new HashMap<>();
        map.put("request", form);

        return Response.ok().entity(map).build();

    }

}

ConstraintViolationExceptionMapper.java

今回BeanValidationを使用しているのでRequestに問題があった場合の処理を書きます。
こちらでExceptionMapperを用意しない場合はtomcatのデフォルトのHttpStatus 400エラーを返します。
しかし異常なリクエストの場合にもログを出力したいなどの要件があることがあるので,こちらで定義します。

ConstraintViolationException

BeanValidation異常時にthrowされる例外です。
異常の情報は持っていますが,リクエスト関連の情報は持っていません。
ログ出力の要件でリクエスト情報が必要な場合は@Context アノテーションでHttpServletRequest型をインジェクトしてください。
(その際にBodyを取得しようとすると(getReader,getInputStream等)既に呼ばれており,もう呼べないといった内容の例外を吐きますので,HttpServletRequestを拡張したクラスなどでキャッシュ機構を作り,Filterなどでキャストしておく必要があります。)

ConstraintViolationExceptionMapper.java
@Provider
@Component
public class ConstraintViolationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {

    @Override
    public Response toResponse(ConstraintViolationException exception) {
        return Response.status(Response.Status.BAD_REQUEST).entity(prepareMessage(exception)).type("text/plain")
                .build();
    }

    private String prepareMessage(ConstraintViolationException exception) {
        StringBuilder message = new StringBuilder();
        for (ConstraintViolation<?> cv : exception.getConstraintViolations()) {
            message.append(cv.getPropertyPath() + " " + cv.getMessage() + " " + cv.getConstraintDescriptor() + "\n");
        }
        return message.toString();
    }
}

web.xml

jerseyのサーブレットを登録する方法はいくらかありますが,今回はweb.xmlを使用する方法を採用しています。
servletの部分で,jerseyであること,対象クラスをパッケージ指定すること,対象パッケージを指定しています。
今回はcom.example.demo.controllerの階層にエンドポイントを定義したinterfaceを配置します。
com.example.demo.exception.handlerには例外ハンドラを配置しています。

web.xml
<web-app>

    <display-name>Spring Jersey Sample Application</display-name>

    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>classpath:applicationContext.xml</param-value>
    </context-param>

    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>

    <servlet>
        <servlet-name>jersey-serlvet</servlet-name>
        <servlet-class>org.glassfish.jersey.servlet.ServletContainer</servlet-class>
        <init-param>
            <param-name>jersey.config.server.provider.packages</param-name>
            <param-value>com.example.demo.controller;com.example.demo.exception.handler</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>jersey-serlvet</servlet-name>
        <url-pattern>/*</url-pattern>
    </servlet-mapping>

</web-app>

applicationContext.xml

特筆すべき事はありません。
com.example.demoパッケージ以下をスキャンしています。

applicationContext.xml
<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-3.0.xsd 
    http://www.springframework.org/schema/context
    http://www.springframework.org/schema/context/spring-context-3.0.xsd">
    
    <context:component-scan base-package="com.example.demo" />

</beans>

動作確認

mvn packageでwarを生成し,tomcatにデプロイを行い動作確認をします。
クライアントとしてVSCodeのREST Clientを使用します。
urlの先頭に**/demo**がついているのはwarの名前をそのままエンドポイントの前につけているtomcatの設定です。

正常リクエスト

request
POST http://localhost:8080/demo/sample001 HTTP/1.1
content-type: application/json

{
    "id": 1,
    "name": "name"
}
response
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 34
Date: Thu, 10 Dec 2020 21:21:24 GMT
Connection: close

{
  "request": {
    "id": 1,
    "name": "name"
  }
}

異常リクエスト

nameが空文字

request
POST http://localhost:8080/demo/sample001 HTTP/1.1
content-type: application/json

{
    "id": 1,
    "name": ""
}

response
HTTP/1.1 400 
Content-Type: text/plain
Content-Length: 478
Date: Thu, 10 Dec 2020 21:40:41 GMT
Connection: close

getsample001.arg0.name must not be blank 
ConstraintDescriptorImpl{annotation=j.v.c.NotBlank,
  payloads=[], 
  hasComposingConstraints=true,
  isReportAsSingleInvalidConstraint=false,
  constraintLocationKind=FIELD,
  definedOn=DEFINED_LOCALLY,
  groups=[interface javax.validation.groups.Default],
  attributes={groups=[Ljava.lang.Class;@419a7a90,
    message={javax.validation.constraints.NotBlank.message},
    payload=[Ljava.lang.Class;@3908e0d7
  },
  constraintType=GENERIC,
  valueUnwrapping=DEFAULT
}

(改行を加えています)

異常リクエスト2

idを削除

request
POST http://localhost:8080/demo/sample001 HTTP/1.1
content-type: application/json

{
    "name": "name"
}
response
HTTP/1.1 200 
Content-Type: application/json
Content-Length: 34
Date: Thu, 10 Dec 2020 21:48:54 GMT
Connection: close

{
  "request": {
    "id": 0,
    "name": "name"
  }
}

正常レスポンスが帰って来てしまいました。
ここから推測なのですが,オブジェクト作成→ マッピング→BeanValidationで行うためintの初期値が入ってしまい,そもそもnullという状況はありえない。
ということでint→Integerに変更し挑戦。

response
HTTP/1.1 400 
Content-Type: text/plain
Content-Length: 473
Date: Thu, 10 Dec 2020 21:56:01 GMT
Connection: close

getsample001.arg0.id must not be null 
ConstraintDescriptorImpl{
  annotation=j.v.c.NotNull, 
  payloads=[], 
  hasComposingConstraints=true, 
  isReportAsSingleInvalidConstraint=false, 
  constraintLocationKind=FIELD, 
  definedOn=DEFINED_LOCALLY, 
  groups=[interface javax.validation.groups.Default], 
  attributes={
    groups=[Ljava.lang.Class;@388c3cb3, 
    message={javax.validation.constraints.NotNull.message}, 
    payload=[Ljava.lang.Class;@2b68db1a
  }, 
  constraintType=GENERIC, 
  valueUnwrapping=DEFAULT
}

想定通りのレスポンス

おわり

一旦jsonでのrequest,responseを行い,requestにBeanValidationをかけハンドリングまで実装できたので終わりとします。
intでnullが入らないのはうっかりしてました。
今回のコードです。

https://github.com/uesugi6111/spring-jersey

参考サイト

https://eclipse-ee4j.github.io/jersey.github.io/documentation/latest/index.html
https://docs.oracle.com/javaee/7/tutorial/jaxrs-advanced002.htm
https://www.baeldung.com/jersey-bean-validation
GitHubで編集を提案

Discussion

ログインするとコメントできます