20190828のJavaに関する記事は17件です。

NLP for Java (NLP4J) (2)

以下のような感じで簡単なJavaプログラムを書くとテキストマイニングが楽しめるというコードを作りましたので、近々オープンソースとして公開しようと考えています。
「自然言語処理をやってみたい」「テキストマイニングをしてみたい」という方をターゲットに考えています。

形態素解析エンジンにはYahoo JapanのWebサービスを利用しています。
形態素解析の結果を利用して、文書に特徴的なキーワードを出力します

処理とInput

        List<Document> docs = new ArrayList<Document>();
        {
            docs.add(createDocument("Toyota", "ハイブリッドカーを作っています。"));
            docs.add(createDocument("Toyota", "ハイブリッドカーを売っています。"));
            docs.add(createDocument("Toyota", "自動車を作っています。"));
            docs.add(createDocument("Toyota", "自動車を売っています。"));
            docs.add(createDocument("Nissan", "EVを作っています。"));
            docs.add(createDocument("Nissan", "EVを売っています。"));
            docs.add(createDocument("Nissan", "自動車を売っています。"));
            docs.add(createDocument("Nissan", "ルノーと提携しています。"));
            docs.add(createDocument("Nissan", "軽自動車を売っています。"));
            docs.add(createDocument("Honda", "自動車を作っています。"));
            docs.add(createDocument("Honda", "自動車を売っています。"));
            docs.add(createDocument("Honda", "バイクを作っています。"));
            docs.add(createDocument("Honda", "バイクを売っています。"));
            docs.add(createDocument("Honda", "軽自動車を売っています。"));
            docs.add(createDocument("Honda", "軽自動車を作っています。"));
        }
        Annotator annotator = new YJpMaAnnotator();{
            // 形態素解析処理
            annotator.annotate(docs);
        }
        Index index = new SimpleDocumentIndex();{
            // キーワードインデックス作成処理
            index.addDocuments(docs);
        }
        {
            // 共起性の高いキーワードの取得
            List<Keyword> kwds = index.getKeywords("名詞", "item=Nissan");
            System.out.println("Keywords(名詞) for Nissan");
            for (Keyword kwd : kwds) {
                System.out.println(String.format("%.1f,%s", kwd.getCorrelation(), kwd.getLex()));
            }
        }
        {
            // 共起性の高いキーワードの取得
            List<Keyword> kwds = index.getKeywords("名詞", "item=Toyota");
            System.out.println("Keywords(名詞) for Toyota");
            for (Keyword kwd : kwds) {
                System.out.println(String.format("%.1f,%s", kwd.getCorrelation(), kwd.getLex()));
            }
        }
        {
            // 共起性の高いキーワードの取得
            List<Keyword> kwds = index.getKeywords("名詞", "item=Honda");
            System.out.println("Keywords(名詞) for Honda");
            for (Keyword kwd : kwds) {
                System.out.println(String.format("%.1f,%s", kwd.getCorrelation(), kwd.getLex()));
            }
        }
    }

Output: Nissanに特徴的なキーワードを係数の大きい順に表示する

Keywords for Nissan
3.0,EV
3.0,ルノー
3.0,提携
1.0,軽自動車
0.6,自動車

Toyota と Honda はこちら

Keywords(名詞) for Toyota
3.8,ハイブリッド
3.8,カー
1.5,自動車
Keywords(名詞) for Honda
2.5,バイク
1.7,軽自動車
1.0,自動車
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Javalinを調べたときのメモ

概要

Javalinとは

JavalinはJavaおよびKotlin用の軽量Web Frameworkです。Spark FrameworkというWeb Frameworkからforkされ、2017年5月にバージョン0.0.1、2019年8月現在は3.4.1がリリースされています。
以下に主な特徴をGitHubから引用します。(日本語訳はグーグル翻訳です)

https://github.com/tipsy/javalin

Javalin is more of a library than a framework. Some key points:

  • You don't need to extend anything
  • There are no @Annotations
  • There is no reflection
  • There is no other magic; just code.

Javelinは、フレームワークというよりもライブラリです。 いくつかのキーポイント:

  • 何も拡張する必要はありません
  • @Annotationsはありません
  • リフレクションはありません
  • 他にマジックはありません。 コードだけ

この記事の前半は、最小構成で"Hello World"を表示するアプリケーションの実装について、後半は簡単なWebアプリケーションの実装で調べたこと/試したことのメモになります。

環境

  • Windows 10 Professional 1903
  • OpenJDK 12.0.2
  • Javalin 3.4.1
    • Jetty-server 9.4.19
    • Jetty-webapp 9.4.19
  • Intellij IDEA 2019.2

参考

最小構成でHello World

プロジェクト構成

通常のMavenプロジェクトです。

[project_root]
 |
 `--- /src
 |      |
 |      `--- /main
 |      |      |
 |      |      `--- /java
 |      |      |      |
 |      |      |      `--- com.example.demo (package)
 |      |      |            |
 |      |      |            `--- App.java
 |      |      |
 |      |      `--- /resources
 |      |
 |      `--- /test
 |             |
 |             `--- /java
 |             |
 |             `--- /resources
 |
 `--- pom.xml

pom.xml

最小構成のJavalinアプリケーションに必要な依存関係はjavalinとslf4j-simpleです。

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>demo-java12-javalin3</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>demo java12 javalin 3.4.1</name>
    <description>Demo project for Javalin 3 with Java12</description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>12</java.version>
        <maven.clean.version>3.1.0</maven.clean.version>
        <maven.resources.version>3.1.0</maven.resources.version>
        <maven.compiler.version>3.8.1</maven.compiler.version>
        <maven.compiler.source>${java.version}</maven.compiler.source>
        <maven.compiler.target>${java.version}</maven.compiler.target>
        <maven.compiler.showWarnings>true</maven.compiler.showWarnings>
        <maven.jar.version>3.1.1</maven.jar.version>
        <maven.assembly.version>3.1.1</maven.assembly.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>io.javalin</groupId>
            <artifactId>javalin</artifactId>
            <version>3.4.1</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>1.7.26</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-clean-plugin</artifactId>
                <version>${maven.clean.version}</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-resources-plugin</artifactId>
                <version>${maven.resources.version}</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>${maven.compiler.version}</version>
                <configuration>
                    <compilerArgs>
                        <arg>-Xlint:all</arg>
                    </compilerArgs>
                    <release>${java.version}</release>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>${maven.jar.version}</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <version>${maven.assembly.version}</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                    <archive>
                        <manifest>
                            <mainClass>com.example.demo.App</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

アプリケーション起動ポイントの実装

Appクラス(クラス名は任意)のmainメソッドに、Javalinアプリケーションの起動ポイントを実装します。

  • (1) : Javalinアプリケーションのインスタンスを初期設定で生成しポート7000で起動します。
    • createメソッドにJavalinConfigクラスのインスタンスを渡すことでアプリケーションをカスタマイズできます。
  • (2) : Javalinアプリケーションに、パス/にGETリクエストハンドラを追加します。
import io.javalin.Javalin;

public class App {
  public static void main(String ... args) {
    // (1)
    Javalin app = Javalin.create().start(7000);
    // (2)
    app.get("/", ctx -> ctx.result("Hello World"));
  }
}

IDEから起動

App.mainメソッドを実行するとJavalinが起動し、以下のようなログがコンソールに出力されます。ログに出力されている通り私の開発環境では起動にかかる時間は1秒未満でした。
http://localhost:7000 にアクセスすると"Hello World"というtext/plainのレスポンスが返ります。

[main] INFO io.javalin.Javalin - 
           __                      __ _
          / /____ _ _   __ ____ _ / /(_)____
     __  / // __ `/| | / // __ `// // // __ \
    / /_/ // /_/ / | |/ // /_/ // // // / / /
    \____/ \__,_/  |___/ \__,_//_//_//_/ /_/

        https://javalin.io/documentation

[main] INFO org.eclipse.jetty.util.log - Logging initialized @827ms to org.eclipse.jetty.util.log.Slf4jLog
[main] INFO io.javalin.Javalin - Starting Javalin ...
[main] INFO io.javalin.Javalin - Listening on http://localhost:7000/
[main] INFO io.javalin.Javalin - Javalin started in 731ms \o/

jarファイルから起動

ビルドするとtargetディレクトリ下にdemo-java12-javalin3-1.0.0-SNAPSHOT-jar-with-dependencies.jarというjarファイルが生成されます。
Javalinアプリケーションは埋め込みJettyを利用しているのでjava -jar <jarファイル>でWebアプリケーションを起動することができます。

> java --version
openjdk 12.0.2 2019-07-16
OpenJDK Runtime Environment (build 12.0.2+10)
OpenJDK 64-Bit Server VM (build 12.0.2+10, mixed mode, sharing)

> java -jar demo-java12-javalin3-1.0.0-SNAPSHOT-jar-with-dependencies.jar

下記はJVMパラメータでデフォルトロケールを変更する場合の例です。

> java -Duser.language=en -Duser.country=US -jar demo-java12-javalin3-1.0.0-SNAPSHOT-jar-with-dependencies.jar

以上で『最小構成でHello World』のアプリケーションの実装は終了です。

Javalinの特徴について

ここからはJavalinで簡単なWebアプリケーションを実装したときのメモになります。

HTTPハンドラの実装の仕方

上記で書いたGETリクエストハンドラ(下記、パターン1)は、パターン2、パターン3のようにも実装することができます。

パターン1

app.get("/", ctx -> ctx.result("Hello World"));

パターン2

※便宜的にAppクラスにメソッドを追加していますが、通常はハンドラクラスを別途実装することになると思います。

public class App {

  void index2(@NotNull Context ctx) {
    ctx.result("Hello World2");
  }

  public static void main(String ... args) {
    Javalin app = Javalin.create().start(7000);
    App handler = new App();
    app.get("/2", handler::index2);
  }

}

パターン3

オフィシャルサイトのチュートリアルではstaticフィールドで記述する例もあります。

public class App {

  static Handler index3 = ctx -> {
    ctx.result("Hello World3");
  };

  public static void main(String ... args) {
    Javalin app = Javalin.create().start(7000);
    app.get("/3", App.index3);
  }

}

HTTPハンドラ

GETの他にPOST、PUT、PATCH、DELETE、HEAD、OPTIONSがあります。

HTTPメソッド シグネチャ
GET public Javalin get(@NotNull String path, @NotNull Handler handler)
POST public Javalin post(@NotNull String path, @NotNull Handler handler)
PUT public Javalin put(@NotNull String path, @NotNull Handler handler)
PATCH public Javalin patch(@NotNull String path, @NotNull Handler handler)
DELTE public Javalin delete(@NotNull String path, @NotNull Handler handler)
HEAD public Javalin head(@NotNull String path, @NotNull Handler handler)
OPTIONS public Javalin options(@NotNull String path, @NotNull Handler handler)

before / afterエンドポイント

HTTPハンドラの前後で実行するbefore / afterというエンドポイントを設定することができます。

エンドポイント シグネチャ
before public Javalin before(@NotNull String path, @NotNull Handler handler)
before public Javalin before(@NotNull Handler handler)
after public Javalin after(@NotNull String path, @NotNull Handler handler)
after public Javalin after(@NotNull Handler handler)

HTTPハンドラのグループ化

エンドポイント HTTPメソッド 機能
/api/v1/users GET ユーザ一覧を取得
/api/v1/user/:id GET ID指定でユーザを取得
/api/v1/user POST ユーザを登録
/api/v1/user/:id DELETE ID指定でユーザを削除
/api/v2/users GET ユーザ一覧を取得
/api/v2/user/:id GET ID指定でユーザを取得
/api/v2/user POST ユーザを登録
/api/v2/user/:id DELETE ID指定でユーザを削除

このようなv1とv2の2系統のエンドポイントがあった場合、以下のようにHTTPハンドラをグループ化して定義できます。

UserController v1Controller = /* version 1 の実装 */
UserController v2Controller = /* version 2 の実装 */

app.routes(() -> {

  // version 1
  ApiBuilder.path("/api/v1", () -> {
    // GET /api/v1/users
    ApiBuilder.get("/users", v1Controller::findAll);
    ApiBuilder.path("/user", () -> {
      // GET /api/v1/user/:id
      ApiBuilder.get(":id", v1Controller::findById);
      // POST /api/v1/user
      ApiBuilder.post(v1Controller::store);
      // DELETE /api/v1/user/:id
      ApiBuilder.delete(":id", v1Controller::remove);
    });
  });

  // version 2
  ApiBuilder.path("/api/v2", () -> {
    // GET /api/v2/users
    ApiBuilder.get("/users", v2Controller::findAll);
    ApiBuilder.path("/user", () -> {
      // GET /api/v2/user/:id
      ApiBuilder.get(":id", v2Controller::findById);
      // POST /api/v2/user
      ApiBuilder.post(v2Controller::store);
      // DELETE /api/v2/user/:id
      ApiBuilder.delete(":id", v2Controller::remove);
    });
  });

});

io.javalin.http.Contextについて

HTTPハンドラメソッドのパラメータio.javalin.http.Contextクラスは、HTTPリクエストおよびレスポンスに必要な機能とデータを提供します。
以下オフィシャルサイトのドキュメントページからの引用です。

https://javalin.io/documentation#context

The Context object provides you with everything you need to handle a http-request. It contains the underlying servlet-request and servlet-response, and a bunch of getters and setters. The getters operate mostly on the request-object, while the setters operate exclusively on the response object.

ContextにはHttpServletRequest、HttpServletResponseのインスタンスを保持するreq、resフィールドもあります。

javax.servlet.http.HttpServletRequest request = ctx.req;
javax.servlet.http.HttpServletResponse response = ctx.res;

リクエストパラメータの受け取り方

クエリパラメータ

クエリストリングで指定されたパラメータを受け取るメソッド

シグネチャ
String queryParam(String key)
String queryParam(String key, String default)
List<String> queryParams(String key)
Map<String, List<String>> queryParamMap()
Validator<T> queryParam(String key, Class<T> clazz)

フォームパラメータ

フォームもクエリパラメータと同様のメソッドがあります。残念ならがらフォーム全体のフィールドを任意のオブジェクトにバインドすることはできないようです。

シグネチャ
String formParam(String key)
String formParam(String key, String default)
List<String> formParams(String key)
Map<String, List<String>> formParamMap()
Validator<T> formParam(String key, Class<T> clazz)

パスパラメータ

/api/user/1のようなパスからその一部をパラメータとして受け取るメソッド

シグネチャ
String pathParam(String key)
Map<String, String> pathParamMap()
Validator<T> pathParam(String key, Class<T> clazz)

リクエストハンドラのパスにコロン(:)を付けた部分がパスパラメータとして識別されます。

app.get("/api/user/:id", ctx -> {
  Long userId = ctx.pathParam("id", Long.class).get();
});

Javalinアプリケーションのコンフィグレーション

Javalin.createメソッドにJavalinConfigクラスのインスタンスを渡すことでカスタマイズができます。
以下のようにJavalinConfigのインスタンスを渡す方法の他に、lambda式で記述する方法もあります。

インスタンス

configuration
JavalinConfig config = new JavalinConfig();
// configuration
Javalin
    .create(config)
// ... 省略 ...

lambda式

configuration
Javalin
    .create(config -> {
        // configuration
    })
// ... 省略 ...

主なコンフィグレーション

コンテキストパスの設定 (デフォルトは"/")

config.contextPath = "/app";

デフォルトのコンテンツタイプの設定 (デフォルトは"text/plain")

config.defaultContentType = "application/json";

ETagの設定 (デフォルトはfalse)

config.autogenerateEtags = true;

リクエストログの出力はJavalinConfig.requestLoggerで設定できます。
第二引数(execTime)はリクエストに掛かった時間(ms)です。

configuration
config.requestLogger((ctx, execTime) -> {
  LOG.debug("[{}] {} - {} ms.", ctx.fullUrl(), ctx.userAgent(), execTime);
});

開発用コンフィグレーション

開発時に有効にすると便利な機能もあります。

enableDevLogging

configuration
config.enableDevLogging();

情報量が多いので常時有効にしておくというよりは、問題が起きたときの調査時に有効化するといった使い方になると思います。

[qtp2092769598-18] INFO io.javalin.Javalin - JAVALIN REQUEST DEBUG LOG:
Request: GET [/overview]
    Matching endpoint-handlers: [GET=/overview]
    Headers: {Cookie=Idea-c29ae3c1=53d6d99f-1169-42ed-8d69-a63cfe80a87a, Accept=text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3, Upgrade-Insecure-Requests=1, Connection=keep-alive, User-Agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.100 Safari/537.36, Sec-Fetch-Site=none, Host=localhost:7000, Sec-Fetch-User=?1, DNT=1, Accept-Encoding=gzip, deflate, br, Accept-Language=ja,en-US;q=0.9,en;q=0.8,en-GB;q=0.7, Sec-Fetch-Mode=navigate}
    Cookies: {Idea-c29ae3c1=53d6d99f-1169-42ed-8d69-a63cfe80a87a}
    Body: 
    QueryString: null
    QueryParams: {}
    FormParams: {}
Response: [200], execution took 26.37 ms
    Headers: {Server=Javalin, Content-Encoding=gzip, Date=Wed, 21 Aug 2019 13:44:46 GMT, Content-Type=text/html}
    Body is gzipped (4464 bytes, not logged)
----------------------------------------------------------------------------------

RouteOverviewPlugin

configuration
config.registerPlugin(new RouteOverviewPlugin("/overview"));

このプラグインを登録して http://localhost:7000/overview にアクセスするとJavalinアプリケーションに追加されているリクエストハンドラの一覧を確認することができます。
overview.png

エラーレスポンスのカスタマイズ

JavalinのHttpResponseExceptionを継承したHTTPステータスに対応する例外クラスが用意されています。

Status 例外クラス メッセージ
302 RedirectResponse Redirected
400 BadRequestResponse Bad request
401 UnauthorizedResponse Unauthorized
403 ForbiddenResponse Forbidden
404 NotFoundResponse Not found
405 MethodNotAllowedResponse Method not allowed
409 ConflictResponse Conflict
410 GoneResponse Gone
500 InternalServerErrorResponse Internal server error
502 BadGatewayResponse Bad gateway
503 ServiceUnavailableResponse Service unavailable
504 GatewayTimeoutResponse Gateway timeout

固有の例外をハンドリングする

アプリケーション固有の例外をハンドリングして任意のエラーレスポンスを返すことができます。
下記のAppServiceExceptionというアプリケーション固有の例外がスローされた場合、

AppServiceException
public class AppServiceException extends RuntimeException {
  public AppServiceException() {
  }
  public AppServiceException(String message) {
    super(message);
  }
  public AppServiceException(String message, Throwable cause) {
    super(message, cause);
  }
}

この例外をハンドリングしてHTTPステータス返すにはJavalinアプリケーションに例外をマッピングします。
この例では500ステータスだけを返します。

app.exception(AppServiceException.class, (e, ctx) -> {
  ctx.status(500);
});

任意のエラーレスポンスメッセージとHTTPステータスを返すには以下のように実装します。
resultメソッドではなくjsonメソッドを使用すればjsonフォーマットのメッセージを返すこともできます。

app.exception(AppServiceException.class, (e, ctx) -> {
  ctx.result("Application Error : " + e.getMessage()).status(500);
});

リクエスト / レスポンスでjsonを扱う

リクエスト / レスポンスでjsonを扱えるように依存関係にjacksonを追加します。
2番目のjackson-datatype-jsr310はJava8 Date and Time APIを扱う場合に必要です。

pom.xml
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.10.0.pr1</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version>2.10.0.pr1</version>
</dependency>

jsonをレスポンスするGETリクエストハンドラを実装します。jsonをレスポンスするにはjsonメソッドを使用します。

public static void main(String ... args) {
  // ... 省略

  // (1)
  ObjectMapper objectMapper = new ObjectMapper();
  objectMapper.findAndRegisterModules();
  objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"));
  // (2)
  JavalinJackson.configure(objectMapper);

  // (3)
  app.get("/json", ctx -> {
    Map<String, Object> model = new HashMap<>();
    model.put("message", "Hello World");
    model.put("now", LocalDateTime.now());
    //jsonメソッドを使用します。
    ctx.json(model);
  });

  // (4)
  app.post("/json", ctx -> {
    Map<String, Object> model = ctx.bodyAsClass(Map.class);
    // HTTP Status 200を返します。
    ctx.status(200);
  });

}
  • (1) : ObjectMapperのインスタンスをカスタマイズします。
  • (2) : カスタマイズしたインスタンスをJavalinJacksonクラスを使って適用します。
  • (3) : jsonを返すGETリクエストハンドラを追加します。
  • (4) : jsonを受け取るPOSTリクエストハンドラを追加します。

静的ファイルを配信する

クラスパス上に配置したファイルを配信するにはJavalinConfigで設定を行います。

[project_root]
 |
 `--- /src
        |
        `--- /main
               |
               `--- /resources
                      |
                      `--- /public
                             |
                             `--- index.html

上記の場所に配置したindex.htmlを配信するにはJavalinConfig.addStaticFilesでパスを指定します。

configuration
config.addStaticFiles("/public");

『最小構成でHello World』のアプリケーションで追加した/に対するGETリクエストハンドラとパスが被るので、以下のコードをコメントアウトします。

// app.get("/", ctx -> ctx.result("Hello World"));

この状態でJavalinアプリケーションを起動し、http://localhost:7000/index.html または http://localhost:7000にアクセスするとpublic/index.htmlが返されます。

また、クラスパス外にあるディレクトリを扱うこともできます。
例えば以下のようなC:\var\staticディレクトリ以下のファイルを扱いたい場合は、

C:\var
    |
    `--- \static
           |
           `--- \images
                  |
                  `--- sample_1.jpg

JavalinConfig.addStaticFilesで絶対パスを指定します。なお、ここで指定したパスにアクセスできないとアプリケーション起動時にエラーが発生します。

configuration
config.addStaticFiles("C:\\var\\static", Location.EXTERNAL);

htmlファイルで下記のようにパスを指定します。

<img src="/images/sample_1.jpg" />

WebJarsを利用する

pom.xmlの依存関係に利用したいライブラリを追加します。
この例ではjQueryを利用しました。

pom.xml
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>jquery</artifactId>
    <version>3.4.1</version>
</dependency>

これだけではjQueryは有効になりません。Webjarsをアプリケーションで利用できるようにするためにJavalinConfig.enableWebjarsで有効化します。

configuration
config.enableWebjars();

htmlファイルで下記のようにパスを指定します。

<script src="/webjars/jquery/3.4.1/jquery.min.js"></script>

ロギングにlogbackを利用する

ロギングにslf4jとlogbackを利用します。slf4j-apiはすでにJavalinが依存しているので追加するのはlogback-classicだけになります。
また最初に追加していたslf4j-simpleはコメントアウトします。

pom.xml
<!--
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-simple</artifactId>
    <version>1.7.26</version>
</dependency>
-->
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.3</version>
    <scope>runtime</scope>
    <exclusions>
        <exclusion>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </exclusion>
    </exclusions>
</dependency>

logbackの設定ファイルlogback.xmlを以下の場所に作成します。

[project_root]
 |
 `--- /src
        |
        `--- /main
               |
               `--- /resources
                      |
                      `--- logback.xml

logback.xml

logback.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration status="INFO">
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <Pattern>%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n</Pattern>
        </encoder>
    </appender>

    <logger name="com.example.demo" level="debug" additivity="false">
        <appender-ref ref="CONSOLE"/>
    </logger>

    <logger name="org.jooq" level="info" additivity="false">
        <appender-ref ref="CONSOLE"/>
    </logger>
    <logger name="org.thymeleaf" level="info" additivity="false">
        <appender-ref ref="CONSOLE"/>
    </logger>
    <root level="info">
        <appender-ref ref="CONSOLE" />
    </root>
</configuration>

アプリケーションログの出力

ログを出力したいクラスに以下のようにロガーを定義します。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class App {
  static final Logger LOG = LoggerFactory.getLogger(App.class);

  // ...省略...

}

テンプレートエンジンにThymeleafを利用する

JavalinではVelocity、Freemarker、Thymeleafなどのテンプレートエンジンを簡単に利用できるようになっています。
この例ではThymeleafを利用します。

pom.xml
<dependency>
    <groupId>org.thymeleaf</groupId>
    <artifactId>thymeleaf</artifactId>
    <version>3.0.9.RELEASE</version>
</dependency>

以下の場所にhello_world.htmlというテンプレートファイルを配置します。同じファイル名のプロパティファイルはメッセージリソースです。

[project_root]
 |
 `--- /src
        |
        `--- /main
               |
               `--- /resources
                      |
                      `--- /WEB-INF
                             |
                             `--- /templates
                                   |
                                   `--- hello_world.html
                                   `--- hello_world.properties
                                   `--- hello_world_ja.properties
                                   `--- hello_world_en.properties

hello_world.html

<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <script th:src="@{/webjars/jquery/3.4.1/jquery.min.js}"></script>
    <title>Hello World</title>
</head>
<body>
<div>
    <p th:text="${message}">message</p>
    <p th:text="${now}">now</p>
    <p th:text="#{msg.hello}">message</p>
</div>
</body>
</html>

メッセージリソースの定義

hello_world.properties
msg.hello = Hello World (default)
hello_world_en.properties
msg.hello = Hello World
hello_world_ja.properties
msg.hello = ハローワールド

テンプレートエンジンを利用してレスポンスするにはrenderメソッドを使用します。
なお、どのテンプレートエンジンが利用されるかはテンプレートファイルの拡張子で決まります。Thymeleafの場合は.html.tl.thyme.thymeleafが該当します。

app.get("/hello", ctx -> {
  Map<String, Object> model = new HashMap<>();
  model.put("message", "Hello World");
  model.put("now", LocalDateTime.now());
  ctx.render("/WEB-INF/templates/hello_world.html", model);
});

localhost:7000/helloにアクセスするとテンプレートエンジンで描画されたhtmlページが表示されます。

ブラウザのAccept-Languageでメッセージを切り替える

デフォルトでは、アプリケーション起動時のデフォルトロケールでメッセージリソースが決まります。
ある言語のメッセージリソースを利用するには、Javalinアプリケーション起動時のJVMオプションにロケールを指定します。
例えばenのメッセージリソースを利用するには以下のオプションを指定します。

> java -Duser.language=en -Duser.country=US -jar <jarファイル>

Spring BootのようにブラウザのAccept-Languageに対応するメッセージリソースを利用するには、FileRendererを実装したクラスを作ります。

CustomThymeleafRenderer
import io.javalin.http.Context;
import io.javalin.plugin.rendering.FileRenderer;
import org.jetbrains.annotations.NotNull;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.WebContext;
import org.thymeleaf.extras.java8time.dialect.Java8TimeDialect;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;

import java.util.Map;
import java.util.concurrent.TimeUnit;

public class CustomThymeleafRenderer implements FileRenderer {

  private final TemplateEngine templateEngine;

  public CustomThymeleafRenderer() {
    templateEngine = templateEngine();
  }

  @Override
  public String render(@NotNull String filePath, @NotNull Map<String, Object> model, @NotNull Context ctx) {
    WebContext context = new WebContext(ctx.req, ctx.res, ctx.req.getServletContext(), ctx.req.getLocale());
    context.setVariables(model);
    return templateEngine.process(filePath, context);
  }

  private TemplateEngine templateEngine() {
    TemplateEngine templateEngine = new TemplateEngine();
    templateEngine.setTemplateResolver(templateResolver());
    templateEngine.addDialect(new Java8TimeDialect());
    return templateEngine;
  }

  private ITemplateResolver templateResolver() {
    ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver();
    templateResolver.setPrefix("/WEB-INF/templates/");
    templateResolver.setSuffix(".html");
    templateResolver.setCharacterEncoding("UTF-8");
    templateResolver.setCacheable(true);
    templateResolver.setCacheTTLMs(TimeUnit.MINUTES.toMillis(30));
    templateResolver.setTemplateMode(TemplateMode.HTML);
    return templateResolver;
  }

}

このCustomThymeleafRendererをJavalinアプリケーションに登録します。

JavalinRenderer.register(new CustomThymeleafRenderer(), ".html");

このカスタマイズを行うと、テンプレートファイルのパスを指定する必要は無くなるのでハンドラの実装は以下のようになります。

// ctx.render("/WEB-INF/templates/hello_world.html", model);
ctx.render("hello_world.html", model);

ちなみにデフォルトで登録されているrendererは以下のとおりです。

JavalinRenderer
object JavalinRenderer {

  init {
    register(JavalinVelocity, ".vm", ".vtl")
    register(JavalinFreemarker, ".ftl")
    register(JavalinMustache, ".mustache")
    register(JavalinJtwig, ".jtwig", ".twig", ".html.twig")
    register(JavalinPebble, ".peb", ".pebble")
    register(JavalinThymeleaf, ".html", ".tl", ".thyme", ".thymeleaf")
    register(JavalinCommonmark, ".md", ".markdown")
  }

  // ...省略...

}

データベースを利用する

データベースにMySQL 8.0、ORMにjOOQ、コネクションプールにHikariCPを利用しました。

※2019年8月現在、JavalinにはjOOQに関するプラグインやユーティリティ、コンフィグレーションクラスはないようです。なのでjOOQによるデータベースアクセス周りの実装は自前で行う必要があります。

データベースの準備

Javalinアプリケーションで使用するsample_dbデータベースとtest_userユーザーを管理者権限を持つユーザーで作成します。

CREATE DATABASE IF NOT EXISTS sample_db
  CHARACTER SET = utf8mb4
  COLLATE = utf8mb4_general_ci
;

CREATE USER IF NOT EXISTS 'test_user'@'localhost'
  IDENTIFIED BY 'test_user'
  PASSWORD EXPIRE NEVER
;

GRANT ALL ON sample_db.* TO 'test_user'@'localhost';

次にtest_userユーザーでuserテーブルを作成します。

DROP TABLE IF EXISTS user;
CREATE TABLE user (
  id BIGINT AUTO_INCREMENT                    COMMENT 'ユーザーID',
  nick_name VARCHAR(60) NOT NULL              COMMENT 'ニックネーム',
  sex CHAR(1) NOT NULL                        COMMENT '性別 M:男性 F:女性',
  prefecture_id TINYINT(1) NOT NULL DEFAULT 0 COMMENT '都道府県 0:不明、1:北海道 - 8:九州・沖縄',
  email VARCHAR(120)                          COMMENT 'メールアドレス',
  memo TEXT                                   COMMENT '備考欄',
  create_at DATETIME NOT NULL DEFAULT NOW(),
  update_at DATETIME NOT NULL DEFAULT NOW(),
  PRIMARY KEY (id)
)
ENGINE = INNODB
DEFAULT CHARSET = UTF8MB4
COMMENT = 'ユーザーテーブル';

CREATE INDEX idx_sex on user (sex) USING BTREE;
CREATE INDEX idx_pref on user (prefecture_id) USING BTREE;

依存関係を追加

pom.xmlを編集して以下の依存関係を追加します。

pom.xml
<dependency>
    <groupId>org.jooq</groupId>
    <artifactId>jooq</artifactId>
    <version>3.11.11</version>
</dependency>
<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>3.3.1</version>
    <exclusions>
        <exclusion>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.17</version>
    <scope>runtime</scope>
</dependency>

コードの自動生成

jOOQにはjooq-codegen-mavenプラグインというデータベーススキーマからモデル(エンティティ)のコードを自動生成する機能があります。

<plugin>
    <groupId>org.jooq</groupId>
    <artifactId>jooq-codegen-maven</artifactId>
    <version>3.11.11</version>
    <executions>
        <execution>
            <id>jooq-codegen</id>
            <phase>generate-sources</phase>
            <goals>
                <goal>generate</goal>
            </goals>
        </execution>
    </executions>
    <dependencies>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.17</version>
        </dependency>
    </dependencies>
    <configuration>
        <jdbc>
            <url>jdbc:mysql://localhost:3306</url>
            <user>test_user</user>
            <password>test_user</password>
        </jdbc>
        <generator>
            <database>
                <name>org.jooq.meta.mysql.MySQLDatabase</name>
                <includes>.*</includes>
                <inputSchema>sample_db</inputSchema>
                <!-- Configure type overrides for generated fields, attributes, sequences, parameters. -->
                <forcedTypes>
                    <forcedType>
                        <!--Specify the Java type of your custom type. This corresponds to the Converter's <U> type. -->
                        <userType>com.example.demo.converter.Prefecture</userType>
                        <!-- A converter implementation for the {@link #getUserType(). -->
                        <converter>com.example.demo.converter.PrefectureConverter</converter>
                        <!-- regex to match the column name -->
                        <expression>PREFECTURE_ID</expression>
                        <types>.*</types>
                    </forcedType>
                    <forcedType>
                        <userType>com.example.demo.converter.Sex</userType>
                        <converter>com.example.demo.converter.SexConverter</converter>
                        <expression>SEX</expression>
                        <types>.*</types>
                    </forcedType>
                </forcedTypes>
            </database>
            <target>
                <packageName>com.example.demo.model</packageName>
                <directory>src/main/java</directory>
            </target>
            <generate>
                <!-- A flag indicating whether Java 8's java.time types should be used by the source code generator, rather than JDBC's java.sql types. -->
                <javaTimeTypes>true</javaTimeTypes>
                <!-- Generate index information -->
                <indexes>true</indexes>
                <!-- Primary key / foreign key relations should be generated and used. This is a prerequisite for various advanced features -->
                <relations>true</relations>
                <!-- Generate deprecated code for backwards compatibility -->
                <deprecated>false</deprecated>
                <!-- Generate the {@link javax.annotation.Generated} annotation to indicate jOOQ version used for source code -->
                <generatedAnnotation>false</generatedAnnotation>
                <!-- Generate Sequence classes -->
                <sequences>true</sequences>
                <!-- Generate Key classes -->
                <keys>true</keys>
                <!-- Generate Table classes -->
                <tables>true</tables>
                <!-- Generate TableRecord classes -->
                <records>true</records>
                <!-- Generate POJOs -->
                <pojos>true</pojos>
                <!-- Generate basic equals() and hashCode() methods in POJOs -->
                <pojosEqualsAndHashCode>true</pojosEqualsAndHashCode>
                <!-- Generate basic toString() methods in POJOs -->
                <pojosToString>true</pojosToString>
                <!-- Generate serializable POJOs -->
                <serializablePojos>true</serializablePojos>
                <!-- Generated interfaces to be implemented by records and/or POJOs -->
                <interfaces>false</interfaces>
                <!-- Turn off generation of all global object references -->
                <globalObjectReferences>true</globalObjectReferences>
                <!-- Turn off generation of global catalog references -->
                <globalCatalogReferences>true</globalCatalogReferences>
                <!-- Turn off generation of global schema references -->
                <globalSchemaReferences>true</globalSchemaReferences>
                <!-- Turn off generation of global table references -->
                <globalTableReferences>true</globalTableReferences>
                <!-- Turn off generation of global sequence references -->
                <globalSequenceReferences>true</globalSequenceReferences>
                <!-- Turn off generation of global UDT references -->
                <globalUDTReferences>true</globalUDTReferences>
                <!-- Turn off generation of global routine references -->
                <globalRoutineReferences>true</globalRoutineReferences>
                <!-- Turn off generation of global queue references -->
                <globalQueueReferences>true</globalQueueReferences>
                <!-- Turn off generation of global database link references -->
                <globalLinkReferences>true</globalLinkReferences>
                <!-- Turn off generation of global key references -->
                <globalKeyReferences>true</globalKeyReferences>
                <!-- Generate fluent setters in records, POJOs, interfaces -->
                <fluentSetters>true</fluentSetters>
            </generate>
        </generator>
    </configuration>
</plugin>

以下のmvnコマンドでコード生成を行います。(mvn packageでも生成されます)

> mvn jooq-codegen:generate

コードはcom.example.demo.modelパッケージに出力されます。今回の例では以下のコードが生成されました。

[project_root]
 |
 `--- /src
        |
        `--- /main
               |
               `--- /java
                     |
                     `--- com.example.demo.model
                     |      |
                     |      `--- DefaultCatalog.java
                     |      `--- Indexes.java
                     |      `--- Keys.java
                     |      `--- SampleDb.java
                     |      `--- Tables.java
                     |
                     `--- com.example.demo.model.tables
                     |      |
                     |      `--- User.java                          // (1)
                     |
                     `--- com.example.demo.model.tables.records
                     |      |
                     |      `--- UserRecord.java                    // (2)
                     |
                     `--- com.example.demo.model.tables.pojos
                            |
                            `--- User.java                          // (3)
  • (1) : userテーブルに対するエンティティクラスです。
    • jOOQで操作するためのメソッドが備わっています。
  • (2) : userテーブルのカラムに対するエンティティレコードクラスです。
    • jOOQで操作するためのメソッドが備わっています。
  • (3) : userテーブルに対するPojoクラス(オプション)です。この記事ではjOOQで検索したエンティティはPojoへ変換して呼び出し元(コントローラ)へ返すように実装しました。

Javalinアプリケーション側の実装

接続プロパティファイル

接続情報はプロパティファイルで管理します。

datasource.properties
datasource.jdbcUrl = jdbc:mysql://localhost:3306/sample_db
datasource.userName = test_user
datasource.password = test_user
DbPropertyLoader
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.Properties;

public class DbPropertyLoader {
  private final Properties prop;

  private static final String JDBC_URL = "datasource.jdbcUrl";
  private static final String USER_NAME = "datasource.userName";
  private static final String PASSWORD = "datasource.password";

  public DbPropertyLoader(String fileName) {
    prop = loadProperties(fileName);
  }

  public String getJdbcUrl() {
    return prop.getProperty(JDBC_URL);
  }

  public String getUserName() {
    return prop.getProperty(USER_NAME);
  }

  public String getPassword() {
    return prop.getProperty(PASSWORD);
  }

  private Properties loadProperties(String fileName) {
    try (InputStream stream = getClass().getClassLoader().getResourceAsStream(fileName)) {
      if (stream == null) {
        throw new IOException("Not Found : " + fileName);
      }
      Properties prop = new Properties();
      prop.load(stream);
      return prop;
    } catch (IOException e) {
      throw new UncheckedIOException(e);
    }
  }

}

DSLContextの作成

jOOQでデータベースアクセスするにはjOOQのDSLContextというクラスのインスタンスが必要です。
以下のようにDSLContextのインスタンスを保持するシングルトンのホルダークラスを実装しました。
なお、DSLContextはスレッドセーフです。

DSLContext is thread safety?

DslConfigure
import org.jooq.Configuration;
import org.jooq.ConnectionProvider;
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
import org.jooq.TransactionProvider;
import org.jooq.conf.Settings;
import org.jooq.impl.DefaultConfiguration;

import javax.sql.DataSource;
import java.util.Optional;

public interface DslConfigure {
  DSLContext getDslContext();

  DataSource getDataSource();
  SQLDialect getSqlDialect();
  Settings getSettings();

  // optional settings
  Optional<ConnectionProvider> getConnectionProvider();
  Optional<TransactionProvider> getTransactionProvider();

  default Configuration configuration() {
    Configuration config = new DefaultConfiguration();
    config
        .set(getSqlDialect())
        .set(getSettings());
    getConnectionProvider().ifPresentOrElse(cp -> {
      config.set(cp);
      getTransactionProvider().ifPresent(tp -> {
        config.set(tp);
      });
    }, () -> {
      config.set(getDataSource());
    });
    return config;
  }
}
DslContextHolder
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.jooq.ConnectionProvider;
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
import org.jooq.TransactionProvider;
import org.jooq.conf.Settings;
import org.jooq.conf.StatementType;
import org.jooq.impl.DSL;
import org.jooq.impl.DataSourceConnectionProvider;
import org.jooq.impl.DefaultTransactionProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.sql.DataSource;
import java.io.Serializable;
import java.util.Optional;

public class DslContextHolder implements DslConfigure, Serializable {
  private static final long serialVersionUID = -8276859195238889686L;
  private static final Logger LOG = LoggerFactory.getLogger(DslContextHolder.class);

  private static DslContextHolder instance;

  private final DSLContext dslContext;
  private final DataSource dataSource;
  private final Settings settings;
  private final ConnectionProvider connectionProvider;
  private final TransactionProvider transactionProvider;

  private DslContextHolder() {
    dataSource = createDataSource();
    settings = createSettings();
    connectionProvider = new DataSourceConnectionProvider(dataSource);
    transactionProvider = new DefaultTransactionProvider(connectionProvider, true);
    // transactionProvider = new ThreadLocalTransactionProvider(connectionProvider, true);
    dslContext = DSL.using(configuration());
  }

  public static DslContextHolder getInstance() {
    if (instance == null) {
      synchronized (DslContextHolder.class) {
        if (instance == null) {
          instance = new DslContextHolder();
          LOG.debug("DSL Context create : {}", instance.getDslContext().toString());
        }
      }
    }
    return instance;
  }

  public static void destroy() {
    if (instance != null) {
      synchronized (DslContextHolder.class) {
        if (instance != null) {
          LOG.debug("DSL Context destroy : {}", instance.getDslContext().toString());
          instance.dslContext.close();
          instance = null;
        }
      }
    }
  }

  DataSource createDataSource() {
    DbPropertyLoader prop = new DbPropertyLoader("datasource.properties");
    HikariConfig config = new HikariConfig();
    config.setJdbcUrl(prop.getJdbcUrl());
    config.setUsername(prop.getUserName());
    config.setPassword(prop.getPassword());
    config.setPoolName("hikari-cp");
    config.setAutoCommit(false);
    config.setConnectionTestQuery("select 1");
    config.setMaximumPoolSize(10);
    DataSource dataSource = new HikariDataSource(config);
    return dataSource;
  }

  Settings createSettings() {
    return new Settings()
        .withStatementType(StatementType.STATIC_STATEMENT)
        .withQueryTimeout(10)
        .withMaxRows(1000)
        .withFetchSize(20)
        .withExecuteLogging(true);
  }

  @Override
  public DSLContext getDslContext() {
    return this.dslContext;
  }

  @Override
  public DataSource getDataSource() {
    return this.dataSource;
  }

  @Override
  public SQLDialect getSqlDialect() {
    return SQLDialect.MYSQL_8_0;
  }

  @Override
  public Settings getSettings() {
    return this.settings;
  }

  @Override
  public Optional<ConnectionProvider> getConnectionProvider() {
    //
    return Optional.ofNullable(this.connectionProvider);
  }

  @Override
  public Optional<TransactionProvider> getTransactionProvider() {
    //
    return Optional.ofNullable(this.transactionProvider);
  }

}

サービスの実装例

jOOQのDSLContextと自動生成したコード(エンティティクラス)を利用してデータベースアクセスを行うサービスクラスの実装例です。
※通常はビジネスロジックを記述することろですが、サンプルコードなのでリポジトリのような実装になってしまっています。

UserService
import com.example.demo.model.tables.pojos.User;

import java.util.List;
import java.util.Optional;

public interface UserService {
  Optional<User> findById(Long id);
  List<User> findByNickName(String nickName);
  List<User> findAll();
  User save(User user);
  void remove(Long id);
}
UserServiceImpl
import com.example.demo.AppServiceException;
import com.example.demo.model.tables.pojos.User;
import com.example.demo.model.tables.records.UserRecord;
import com.example.demo.service.UserService;
import org.jooq.DSLContext;
import org.jooq.Result;
import org.jooq.impl.DSL;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

import static com.example.demo.model.Tables.USER;

public class UserServiceImpl implements UserService {
  private static final Logger LOG = LoggerFactory.getLogger(UserServiceImpl.class);
  private final DSLContext dsl;

  public UserServiceImpl(DSLContext dsl) {
    this.dsl = dsl;
  }

  @Override
  public Optional<User> findById(Long id) {
    LOG.debug("find by Id : {}", id);
    UserRecord record = dsl
        .selectFrom(USER)
        .where(USER.ID.eq(id))
        .fetchOne();
    if (record != null) {
      return Optional.of(record.into(User.class));
    }
    return Optional.empty();
  }

  @Override
  public List<User> findByNickName(String nickName) {
    LOG.debug("find by nickName : {}", nickName);
    Result<UserRecord> result = dsl
        .selectFrom(USER)
        .where(USER.NICK_NAME.contains(nickName))
        .orderBy(USER.NICK_NAME.asc())
        .fetch();
    return result.into(User.class);
  }

  @Override
  public List<User> findAll() {
    LOG.debug("findAll");
    Result<UserRecord> result = dsl
        .selectFrom(USER)
        .orderBy(USER.ID.desc())
        .fetch();
    return result.into(User.class);
  }

  @Override
  public User save(User user) {
    LOG.debug("save : {}", user);
    UserRecord result = dsl.transactionResult(conf -> {
      if (user.getId() == null || user.getId().equals(0L)) {
        return DSL.using(conf)
            .insertInto(USER,
                USER.NICK_NAME,
                USER.SEX,
                USER.PREFECTURE_ID,
                USER.EMAIL,
                USER.MEMO)
            .values(
                user.getNickName(),
                user.getSex(),
                user.getPrefectureId(),
                user.getEmail(),
                user.getMemo())
            .returning()
            .fetchOne();
      } else {
        return DSL.using(conf)
            .update(USER)
            .set(USER.NICK_NAME, user.getNickName())
            .set(USER.SEX, user.getSex())
            .set(USER.PREFECTURE_ID, user.getPrefectureId())
            .set(USER.EMAIL, user.getEmail())
            .set(USER.MEMO, user.getMemo())
            .set(USER.UPDATE_AT, LocalDateTime.now())
            .where(USER.ID.eq(user.getId()))
            .returning()
            .fetchOne();
      }
    });
    LOG.debug("save result : {}", result);
    if (result == null) {
      throw new AppServiceException("[save] Not Found saved user record.");
    }
    return result.into(User.class);
  }

  @Override
  public void remove(Long id) {
    LOG.debug("remove by id : {}", id);
    int result = dsl.transactionResult(conf -> {
      int count = DSL.using(conf)
          .selectCount()
          .from(USER)
          .where(USER.ID.eq(id))
          .execute();
      if (count != 1) {
        return count;
      }
      return DSL.using(conf)
          .deleteFrom(USER)
          .where(USER.ID.eq(id))
          .execute();
    });
    LOG.debug("remove result : {}", result);
    if (result == 0) {
      throw new AppServiceException("[remove] Not Found delete user record.");
    }
  }

}

コントローラの実装例

上記のサービスを利用するコントローラの実装例です。

UserController
import com.example.demo.model.tables.pojos.User;
import com.example.demo.service.UserService;
import io.javalin.Javalin;
import io.javalin.apibuilder.ApiBuilder;
import io.javalin.http.Context;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;

public class UserController {
  private static final Logger LOG = LoggerFactory.getLogger(UserController.class);
  private final UserService userService;

  public UserController(UserService userService) {
    this.userService = userService;
  }

  void findById(@NotNull Context ctx) {
    Long id = ctx.pathParam("id", Long.class).get();
    userService.findById(id).ifPresentOrElse(user -> {
      ctx.json(user);
    },() -> {
      ctx.status(404);
    });
  }

  void findAll(@NotNull Context ctx) {
    List<User> users = userService.findAll();
    ctx.json(users);
  }

  void store(@NotNull Context ctx) {
    User user = ctx.bodyAsClass(User.class);
    User storedUser = userService.save(user);
    ctx.json(storedUser);
  }

  void remove(@NotNull Context ctx) {
    Long id = ctx.pathParam("id", Long.class).get();
    userService.remove(id);
    ctx.status(200);
  }

  public void bindEndpoints(@NotNull Javalin app) {
    app.routes(() -> {
      ApiBuilder.path("api/user", () -> {
        // GET /api/user
        ApiBuilder.get(this::findAll);
        // GET /api/user/:id
        ApiBuilder.get(":id", this::findById);
        // POST /api/user
        ApiBuilder.post(this::store);
        // DELETE /api/user/:id
        ApiBuilder.delete(":id", this::remove);
      });
    });
  }

}

ハンドラのバインドはJavalinアプリケーションの起動ポイントで行いました。

public static void main(String... args) {
  Javalin app = Javalin
    .create(config -> {
      // configuration
    })
    .start(7000);

  // ... 省略 ...

  // DSLContextの生成
  final DSLContext dsl = DslContextHolder.getInstance().getDslContext();

  // アプリケーションにハンドラをバインドする
  new UserController(new UserServiceImpl(dsl)).bindEndpoints(app);

  // ... 省略 ...
}

テストコード

テスティングフレームワークにJUnit 5、アサーションにAssertJを利用しました。

依存関係を追加

pom.xmlを編集して以下の依存関係を追加します。

pom.xml
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.5.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.13.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <!--<version>1.4.199</version>-->
    <version>1.4.193</version>
    <scope>test</scope>
</dependency>

サービスクラスのテスト

サービスクラスのテストは単体テストとして実装します。サービスが依存する外部リソースのデータベースにはH2を使用します。
テスト開始前にスキーマを作成、テストメソッド毎にテストデータをロード、テスト終了後にスキーマを削除するという方法でテスト環境を準備します。

テスト用の接続プロパティファイル

test-datasource.properties
#datasource.driver = org.h2.Driver
datasource.jdbcUrl = jdbc:h2:mem:sample_db;MODE=MySQL;DB_CLOSE_DELAY=-1;
#datasource.jdbcUrl = jdbc:h2:tcp://localhost:9092/./sample_db;MODE=MySQL;DATABASE_TO_UPPER=false
datasource.userName = sa
datasource.password =

テスト用DSLContextの作成

H2用のDSLContextを作成するコードを実装します。

TestDslContextHolder
import com.example.demo.config.DbPropertyLoader;
import com.example.demo.config.DslConfigure;
import org.h2.jdbcx.JdbcDataSource;
import org.jooq.ConnectionProvider;
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
import org.jooq.TransactionProvider;
import org.jooq.conf.Settings;
import org.jooq.conf.StatementType;
import org.jooq.impl.DSL;
import org.jooq.impl.DataSourceConnectionProvider;
import org.jooq.impl.DefaultTransactionProvider;

import javax.sql.DataSource;
import java.io.Serializable;
import java.util.Optional;

public class TestDslContextHolder implements DslConfigure, Serializable {
  private static final long serialVersionUID = -5728144007357037196L;

  private static TestDslContextHolder instance;

  private final DSLContext dslContext;
  private final DataSource dataSource;
  private final Settings settings;
  private final ConnectionProvider connectionProvider;
  private final TransactionProvider transactionProvider;

  public TestDslContextHolder() {
    dataSource = createDataSource();
    settings = createSettings();
    connectionProvider = new DataSourceConnectionProvider(dataSource);
    transactionProvider = new DefaultTransactionProvider(connectionProvider, true);
    // transactionProvider = new ThreadLocalTransactionProvider(connectionProvider, true);
    dslContext = DSL.using(configuration());
  }

  public static TestDslContextHolder getInstance() {
    if (instance == null) {
      synchronized (TestDslContextHolder.class) {
        if (instance == null) {
          instance = new TestDslContextHolder();
        }
      }
    }
    return instance;
  }

  public static void destroy() {
    if (instance != null) {
      synchronized (TestDslContextHolder.class) {
        if (instance != null) {
          instance.dslContext.close();
          instance = null;
        }
      }
    }
  }

  DataSource createDataSource() {
    DbPropertyLoader prop = new DbPropertyLoader("test-datasource.properties");
    JdbcDataSource dataSource = new JdbcDataSource();
    dataSource.setURL(prop.getJdbcUrl());
    dataSource.setUser(prop.getUserName());
    dataSource.setPassword(prop.getPassword());
    return dataSource;
  }

  Settings createSettings() {
    return new Settings()
        .withStatementType(StatementType.STATIC_STATEMENT)
        .withQueryTimeout(10)
        .withMaxRows(1000)
        .withFetchSize(20)
        .withExecuteLogging(true)
        .withDebugInfoOnStackTrace(true)
        .withRenderFormatted(true);
  }

  @Override
  public DSLContext getDslContext() {
    return this.dslContext;
  }

  @Override
  public DataSource getDataSource() {
    return this.dataSource;
  }

  @Override
  public SQLDialect getSqlDialect() {
    return SQLDialect.H2;
  }

  @Override
  public Settings getSettings() {
    return this.settings;
  }

  @Override
  public Optional<ConnectionProvider> getConnectionProvider() {
    //
    return Optional.ofNullable(this.connectionProvider);
  }

  @Override
  public Optional<TransactionProvider> getTransactionProvider() {
    //
    return Optional.ofNullable(this.transactionProvider);
  }

}

サービスのテストクラス
このコードはインメモリのH2を使用するパターンです。(サーバーモードのH2を利用するパターンは後述します。)

TestService
import org.jooq.DSLContext;
import org.jooq.Queries;
import org.jooq.Query;
import org.jooq.Schema;
import org.jooq.Table;
import org.jooq.exception.DataAccessException;
import org.jooq.impl.DSL;

import java.util.List;

public interface TestService {

  DSLContext getDSLContext();
  void setDSLContext(DSLContext dsl);

  default void createSchema(Schema schema) {
    Queries queries = getDSLContext().ddl(schema);
    for (Query query : queries.queries()) {
      getDSLContext().execute(query);
    }
  }

  default void dropSchema(Schema schema) {
    List<Table<?>> tables = schema.getTables();
    for (Table<?> table : tables) {
      getDSLContext().dropTableIfExists(table).execute();
    }
    getDSLContext().dropSchemaIfExists(schema).execute();
  }

  default void cleanUp(List<Table<?>> tables) {
    getDSLContext().transaction(conf -> {
      for (Table table : tables) {
        DSL.using(conf).deleteFrom(table).execute();
      }
    });
  }

  default void testDataRollback() {
    throw new DataAccessException("rollback");
  }

  default void isFailed(RuntimeException e) {
    if (!e.getMessage().equals("rollback")) {
      System.err.println(e);
      throw e;
    }
  }

}
UserServiceImplTests
import com.example.demo.TestDslContextHolder;
import com.example.demo.converter.Prefecture;
import com.example.demo.converter.Sex;
import com.example.demo.model.tables.pojos.User;
import com.example.demo.model.tables.records.UserRecord;
import com.example.demo.service.TestService;
import com.example.demo.service.UserService;
import org.assertj.core.groups.Tuple;
import org.jooq.DSLContext;
import org.jooq.exception.DataAccessException;
import org.jooq.impl.DSL;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static com.example.demo.model.SampleDb.SAMPLE_DB;
import static com.example.demo.model.Tables.*;

@TestInstance(value = TestInstance.Lifecycle.PER_CLASS)
public class UserServiceImplTests implements TestService {

  private DSLContext dsl;

  @Override
  public DSLContext getDSLContext() {
    return dsl;
  }

  @Override
  public void setDSLContext(DSLContext dsl) {
    this.dsl = dsl;
  }

  @BeforeAll
  public void setupSchema() {
    System.out.println("setup class");
    setDSLContext(TestDslContextHolder.getInstance().getDslContext());
    createSchema(SAMPLE_DB);
  }

  @AfterAll
  public void tearDownSchema() {
    System.out.println("teardown class");
    dropSchema(SAMPLE_DB);
    TestDslContextHolder.destroy();
  }

  @BeforeEach
  public void setup() {
    System.out.println("setup");
  }

  @AfterEach
  public void tearDown() {
    System.out.println("teardown");
    cleanUp(List.of(USER));
  }

  @Test
  public void findById() {
    System.out.println("findById");
    try {
      dsl.transaction(conf -> {
        // setup test data
        DSL.using(conf)
            .insertInto(USER,
                USER.ID,
                USER.NICK_NAME,
                USER.SEX,
                USER.PREFECTURE_ID,
                USER.EMAIL,
                USER.MEMO)
            .values(1L, "nick name AAA", Sex.FEMALE, Prefecture.HOKKAIDO, "test.user_a@example.com", "memo aaa")
            .values(2L, "nick name BBB", Sex.MALE, Prefecture.TOHOKU, "test.user_b@example.com", null)
            .values(3L, "nick name CCC", Sex.FEMALE, Prefecture.KANTOU, "test.user_c@example.com", "memo ccc")
            .execute();

        // exercise
        UserService sut = new UserServiceImpl(DSL.using(conf));
        Optional<User> actual = sut.findById(1L);

        // verify
        assertThat(actual.isPresent()).isTrue();
        actual.ifPresent(user -> {
          assertThat(user)
              .extracting(
                  "id",
                  "nickName",
                  "sex",
                  "prefectureId",
                  "email",
                  "memo")
              .contains(
                  1L,
                  "nick name AAA",
                  Sex.FEMALE,
                  Prefecture.HOKKAIDO,
                  "test.user_a@example.com",
                  "memo aaa");
        });

        testDataRollback();
      });
    } catch (DataAccessException e) {
      isFailed(e);
    }
  }

  @Test
  public void findByNickName() {
    System.out.println("findByNickName");
    try {
      dsl.transaction(conf -> {
        // setup test data
        DSL.using(conf)
            .insertInto(USER,
                USER.ID,
                USER.NICK_NAME,
                USER.SEX,
                USER.PREFECTURE_ID,
                USER.EMAIL,
                USER.MEMO)
            .values(1L, "nick name AAA", Sex.FEMALE, Prefecture.HOKKAIDO, "test.user_a@example.com", "memo aaa")
            .values(2L, "nick name BBB", Sex.MALE, Prefecture.TOHOKU, "test.user_b@example.com", null)
            .values(3L, "nick name CCC", Sex.FEMALE, Prefecture.KANTOU, "test.user_c@example.com", "memo ccc")
            .execute();

        // exercise
        UserService sut = new UserServiceImpl(DSL.using(conf));
        List<User> actual = sut.findByNickName("BBB");

        // verify
        assertThat(actual).hasSize(1);
        assertThat(actual)
            .extracting(
                "id",
                "nickName",
                "sex",
                "prefectureId",
                "email",
                "memo")
            .contains(Tuple.tuple(2L, "nick name BBB", Sex.MALE, Prefecture.TOHOKU, "test.user_b@example.com", null));

        testDataRollback();
      });
    } catch (DataAccessException e) {
      isFailed(e);
    }
  }

  @Test
  public void save() {
    try {
      dsl.transaction(conf -> {
        // exercise
        User testData = new User(null, "nick name DDD", Sex.MALE, Prefecture.KANTOU, "test.user_d@example.com", null, null, null);
        UserService sut = new UserServiceImpl(DSL.using(conf));
        User expected = sut.save(testData);

        // verify
        Optional<User> actual = sut.findById(expected.getId());
        assertThat(actual.isPresent()).isTrue();
        actual.ifPresent(user -> {
          assertThat(user)
              .extracting(
                  "id",
                  "nickName",
                  "sex",
                  "prefectureId",
                  "email",
                  "memo")
              .contains(
                  expected.getId(),
                  expected.getNickName(),
                  expected.getSex(),
                  expected.getPrefectureId(),
                  expected.getEmail(),
                  expected.getMemo());
        });

        testDataRollback();
      });
    } catch (DataAccessException e) {
      isFailed(e);
    }
  }

  @Test
  public void remove() {
    try {
      dsl.transaction(conf -> {
        // setup
        Long userId = 1L;
        DSL.using(conf)
            .insertInto(USER,
                USER.ID,
                USER.NICK_NAME,
                USER.SEX,
                USER.PREFECTURE_ID,
                USER.EMAIL,
                USER.MEMO)
            .values(
                userId,
                "nick name EEE",
                Sex.MALE,
                Prefecture.CHUBU,
                "test.user_e@example.com",
                "memo eee")
            .execute();

        // exercise
        UserService sut = new UserServiceImpl(DSL.using(conf));
        sut.remove(userId);

        // verify
        UserRecord actual = DSL.using(conf)
            .selectFrom(USER)
            .where(USER.ID.eq(userId))
            .fetchOne();
        assertThat(actual).isNull();

        testDataRollback();
      });
    } catch (DataAccessException e) {
      isFailed(e);
    }
  }

}

H2をサーバモードで使用する

サーバーモードではスキーマやテーブル、テストデータは以下の場所に配置したSQLスクリプトを実行して作成します。
テストを実行すると、H2データベースのデータベースファイルがsample_db.mv.dbファイルとして作成されます。

[project_root]
 |
 `--- /h2
        |
        `--- /sql
        |      |
        |      `--- 01_schema.sql     //スキーマを作成する
        |      `--- 02_table.sql      //テーブルを作成する
        |      `--- 03_data.sql       //データを挿入する
        |      `--- 04_table.sql      //テーブルを削除する
        |      `--- 05_schema.sql     //スキーマを削除する
        |
        `--- sample_db.mv.db
TestH2Server
import com.example.demo.config.DbPropertyLoader;
import org.h2.tools.RunScript;
import org.h2.tools.Server;

import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;

public class TestH2Server implements AutoCloseable {

  private final Server server;

  private final String jdbcUrl;
  private final String userName;
  private final String password;

  public TestH2Server() {
    DbPropertyLoader prop = new DbPropertyLoader("test-datasource.properties");
    jdbcUrl = prop.getJdbcUrl();
    userName = prop.getUserName();
    password = prop.getPassword();
    String[] params = new String[] {"-tcp", "-tcpPort", "9092", "-baseDir", "./h2"};
    try {
      server = Server.createTcpServer(params);
    } catch (SQLException e) {
      System.err.println(e.getMessage());
      throw new RuntimeException(e);
    }
  }

  public void start() {
    System.out.println("server start");
    try {
      server.start();
      status();
      Runtime.getRuntime().addShutdownHook(new Thread(() -> shutdown()));
    } catch (SQLException e) {
      throw new RuntimeException(e);
    }
  }

  public void runScript(List<String> scripts) {
    try {
      for (String script : scripts) {
        System.out.println("run script : " + script);
        RunScript.execute(jdbcUrl, userName, password, script, StandardCharsets.UTF_8, false);
      }
    } catch (SQLException e) {
      System.err.println("run script : " + e.getMessage());
      throw new RuntimeException(e);
    }
  }

  public void shutdown() {
    if (server.isRunning(true)) {
      System.out.println("server shutdown");
      server.shutdown();
    }
  }

  public void status() {
    System.out.println(server.getStatus());
  }

  // for test
  public static void main(String ... args) {
    try (TestH2Server h2 = new TestH2Server()) {
      h2.start();
      h2.status();
      h2.runScript(List.of("./h2/sql/01_schema.sql", "./h2/sql/02_table.sql", "./h2/sql/03_data.sql"));
      h2.test();
      h2.runScript(List.of("./h2/sql/04_table.sql", "./h2/sql/05_schema.sql"));
    }
    System.exit(0);
  }

  public void test() {
    System.out.println("test");
    try (Connection conn = DriverManager.getConnection(jdbcUrl, userName, password)) {
      PreparedStatement ps = conn.prepareStatement("select * from sample_db.user");
      ResultSet rs = ps.executeQuery();
      while (rs.next()) {
        System.out.println("id : " + rs.getLong("id"));
        System.out.println("nick_name : " + rs.getString("nick_name"));
        System.out.println("sex : " + rs.getString("sex"));
        System.out.println("prefecture_id : " + rs.getInt("prefecture_id"));
        System.out.println("email : " + rs.getString("email"));
        System.out.println("memo : " + rs.getString("memo"));
        System.out.println("createAt : " + rs.getTimestamp("create_at"));
        System.out.println("updateAt : " + rs.getTimestamp("update_at"));
      }
    } catch (SQLException e) {
      System.err.println("test error : " + e.getMessage());
      throw new RuntimeException(e);
    }
  }

  @Override
  public void close() {
    shutdown();
  }

}

サービスのテストクラスをH2サーバーを利用するように修正します。修正する箇所はsetupSchema、tearDownSchemaメソッドです。

@TestInstance(value = TestInstance.Lifecycle.PER_CLASS)
public class UserServiceImplTests implements TestService {

  private DSLContext dsl;
  private TestH2Server h2;

  private List<String> initScripts = List.of(
      "./h2/sql/01_schema.sql",
      "./h2/sql/02_table.sql"
  );

  private List<String> clearScripts = List.of(
      "./h2/sql/04_table.sql",
      "./h2/sql/05_schema.sql"
  );

  @Override
  public DSLContext getDSLContext() {
    return dsl;
  }

  @Override
  public void setDSLContext(DSLContext dsl) {
    this.dsl = dsl;
  }

  @BeforeAll
  public void setupSchema() {
    System.out.println("setup class");
    h2 = new TestH2Server();
    h2.start();
    h2.runScript(initScripts);
    setDSLContext(TestDslContextHolder.getInstance().getDslContext());
  }

  @AfterAll
  public void tearDownSchema() {
    System.out.println("teardown class");
    TestDslContextHolder.destroy();
    h2.runScript(clearScripts);
    h2.shutdown();
  }

  //... 省略...

}

コントローラクラスのテスト

コントローラクラスのテストは結合テストとして実装します。データベースは実環境と同じMySQLを使用するため、テスト実行時はMySQLサーバにアクセスできる必要があります。

コントローラのテストクラス

import com.example.demo.config.DslContextHolder;
import com.example.demo.config.JacksonConfig;
import com.example.demo.converter.Prefecture;
import com.example.demo.converter.Sex;
import com.example.demo.model.tables.pojos.User;
import com.example.demo.service.impl.UserServiceImpl;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.javalin.Javalin;
import io.javalin.plugin.json.JavalinJackson;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

import static org.assertj.core.api.Assertions.assertThat;

@TestInstance(value = TestInstance.Lifecycle.PER_CLASS)
public class UserControllerTests {

  Javalin app;
  int port;
  ObjectMapper objectMapper;

  @BeforeAll
  public void setup() {
    port = 7000;
    app = Javalin.create().start(port);
    objectMapper = JacksonConfig.getObjectMapper();
    JavalinJackson.configure(objectMapper);
  }

  @AfterAll
  public void tearDown() {
    app.stop();
  }

  @Test
  public void findById() throws Exception {
    // setup
    new UserController(new UserServiceImpl(DslContextHolder.getInstance().getDslContext()))
        .bindEndpoints(app);

    // exercise
    Long userId = 1L;
    String testTargetUrl = String.format("http://localhost:%d/api/user/%d", port, userId);

    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(testTargetUrl))
        .header("Accept", "application/json")
        .GET()
        .build();
    HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

    // verify
    assertThat(response.statusCode()).isEqualTo(200);
    User actual = objectMapper.readValue(response.body(), User.class);
    assertThat(actual)
        .extracting(
            "id",
            "nickName",
            "sex",
            "prefectureId",
            "email",
            "memo")
        .contains(
            1L,
            "test user 1",
            Sex.MALE,
            Prefecture.HOKKAIDO,
            "test.user1@example.com",
            "memo 1"
        );
  }

}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Spring がリクエストパラメータを処理する流れを理解したい

@ModelAttribute を利用したリクエストパラメータをオブジェクトにバインドする処理ってどうなってるんだっけ、と調べることがあったので結果をまとめておく。
あまりドキュメントにも記載がないことが多く、ソースコードを読んだ結果をまとめている。

なお、@RequestParam でもリクエストパラメータを取得できるが、今回は対象外。

環境

  • Spring Boot 2.1.6.RELEASE

@ModelAttribute が利用できる箇所

  • メソッド
  • Handler メソッドの引数

実は、Handler メソッドの引数には @ModelAttribute 付与しなくてもよかったりする。
デフォルトの挙動では、Handler メソッドの引数に BeanUtils.isSimplePropertyfalse と判定されるクラスを指定すると、@ModelAttribute が付与された場合と同様の動作となる。
具体的には、プリミティブ型とそのラッパー、EnumStringCharSequenceNumberDateURIURLLocaleClass、配列、以外のクラス。
ただ、優先度は一番低いので、他の HandlerMethodArgumentResolver が引数を解決できるのであれば、そちらで解決される。

ModelAttributeMethodProcessor

Spring では、HandlerAdapter が Handler メソッドを呼び出す。いくつかの実装クラスがあるが、@RequestMapiing を利用している場合は、RequestMappingHandlerAdapter が Handler メソッドの呼び出しを担う。
このクラスが Handler メソッドの引数に渡す値を解決したり、戻り値をハンドリングしたりしていて、Handler メソッドの引数に ModelRedirectAttributes を指定するといい感じに処理できるのは、このクラスが頑張っているから。

で、実際に引数に渡す値を解決するのは HandlerMethodArgumentResolver の実装クラスの役割で、@ModelAttribute が指定された場合には ModelAttributeMethodProcessor が処理を行っている。

メソッドに @ModelAttribute を付与した場合の挙動

ここは本題ではなかったが、@ModelAttribute を指定したメソッドの動作もまとめておく。

メソッドの戻り値を Model に格納する処理が Handler メソッドの実行前に処理が行われる。
(厳密には ModelAndViewContainer 内の ModelMap だが、ややこしくなりそうなので Model ということにしておく。)
例えば以下のような Controller を作ったとする。

@Controller
public class HelloController {}
    @ModelAttribute
    public User setUp() {
        return new User();
    }

    @GetMapping("/")
    public String index() {
        return "hello";
    }
}

その際の挙動は以下のようなイメージになる。(あくまでもイメージで厳密には違う。)

@Controller
public class HelloController {
    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute(new User());
        return "hello";
    }
}

以下のように@ModelAttriubte に属性名を指定した場合は、

@ModelAttribute("hoge")
public User setUp() {
    return new User();
}

model.addAttribute() の第一引数に追加されるイメージ。

@GetMapping("/")
public String index(Model model) {
    model.addAttribute("hoge", new User());
    return "index";
}

ちなみに、@ModelAttribute が付与されたメソッドを呼び出しているのは、ModelFactory というクラス。

Handler メソッドの引数に @ModelAttribute を付与した場合の挙動

ここからが本題。

オブジェクトの取得、生成

まず、Model から指定されたオブジェクトを取得する。@ModelAttributevalue or name を指定した場合は Model から取得する際の key を指定できる。省略した場合は、クラス名から Spring が自動的に決定する。

Model から取得できるパターンはいくつかあり、前述の @ModelAttribute を付与したメソッドで Model に格納していたり、リダイレクト時に Flash スコープを利用していたり、@SessionAttribute で Session スコープを利用したりしていると、Handler メソッド実行前(というより引数に渡す値の解決前)に Model に格納されていることがある。

Model からオブジェクトが取得できなかった場合は、オブジェクトを生成する。
コンストラクタが 1 つの場合はそれを利用し、複数ある場合はデフォルトコンストラクタを利用するが、このときデフォルトコンストラクタが無い場合は死ぬ。
引数ありコンストラクタを利用する場合は、リクエストパラメータをバインドする処理が挟まるが、これは後述。

リクエストパラメータのバインド

取得 or 生成したオブジェクトに対して、リクエストパラメータの値をバインドする。
リクエストパラメータの名前と一致するオブジェクトのフィールドに対して値がバインドされる。

オブジェクトの値を上書きするが、リクエストパラメータに含まれない値に対しては何も行わない。

@Controller
public class HelloController {
    @ModelAttribute
    public User setUp() {
        User user = new User();
        user.setName("なまえ");
        user.setEmail("メール");
        return user;
    }

    @GetMapping("/")
    public String index(User user) {
        return "hello";
    }
}

Usernameemail というフィールドを持っているとする。
で、以下のように name だけをリクエストパラメータに付与して送信する。

curl http://localhost:8080?name=hogehoge

User オブジェクトの namehogehoge に、emailメール となり、null で上書きされることはない。

パラメータありのコンストラクタでオブジェクトを生成する場合

パラメータありのコンストラクタを用いてオブジェクトを生成する場合は、そのタイミングでデータのバインドを試みる。
この場合、フィールド名ではなくコンストラクタのパラメータ名 or @ConstructorProperties で指定された値と一致するリクエストパラメータをバインドする。

public class User {
    private String name;
    private String email;

    public User(String n, String e) {
        this.name = n;
        this.email = e;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

上記のようなコンストラクタの場合、リクエストパラメータ名は ne にしないといけない。

public class User {
    private String name;
    private String email;

    @ConstructorProperties({"name", "email"})
    public User(String n, String e) {
        this.name = n;
        this.email = e;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

上記のように @ConstructorProperties を使えば、リクエストパラメータ名は nameemail にできる。なお、@ConstructorProperties がある場合はそちらが優先されてしまうため、ne では動作しなくなる。

ただ、オブジェクトの生成が完了した後に、通常と同様のリクエストパラメータをバインドする処理が実行されるので、setter がある場合や、ダイレクトフィールドアクセスが可能な DataBinder を利用している場合は、そちらの結果が優先されてしまう。

例えば、以下のようなクラスを作った場合、

public class User {
    private String name;
    private String email;

    public User(String name, String email) {
        this.name = name + "hoge";
        this.email = email + "fuga";
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setEmail(String email) {
        this.email = email;
    }
}

コンストラクタでは、hogefuga を付与してフィールドに保持しているが、その後 setter が実行されてしまうので、最終的にバインドされる値には、hogefuga が付与されていない。

リクエストパラメータのバインドを抑止する

@ModelAttributebindingfalse にすることで、リクエストパラメータのバインドを抑止することができる。

@Controller
public class HelloController {
    @ModelAttribute
    public User setUp() {
        User user = new User();
        user.setName("なまえ");
        user.setEmail("メール");
        return user;
    }

    @GetMapping("/")
    public String index(@ModelAttribute(binding = false) User user) {
        return "hello";
    }
}

上記のような状態で以下のリクエストを送信しても User のオブジェクトの nameなまえemailメール のままとなる。

curl http://localhost:8080?name=hogehoge&email=fugafuga

バリデーション

引数に @Validated が付与されるなど、バリデーションが必要な場合は実施する。

バリデーションの結果、エラーがある場合でかつ Handler メソッドの直後の引数に Errors がない場合、BindingException がスローされ、ある場合は BindingResult として結果を保持する。(ErrorsBindingResult の親インターフェース)

ちなみに Handler メソッドで BindingResult を引数に設定する際に、順番に気を付けなければならないのは、ここの実装によるもの。

話が横道にそれるが Handler メソッドの引数に BindingResult を設定した場合、ErrorsMethodArgumentResolver というクラスが引数の値を解決する。
このクラスは Model に格納された最後の要素が BindingResult かどうかをチェックし、そうであれば引数に設定するという処理を行っている。
そのため、バリデーションを行って BindingResultModel に格納した直後に実行されないと上手く動作しない可能性がある。
なので、Handler メソッドの引数には @Validated を付与した引数の直後に BindingResult が無いといけないっぽい。

これを調べて思ったが、以下のようにリクエストパラメータをバインドするオブジェクトを 2 つに分けて、それぞれのバリデーション結果を取得するようなコードも実現できる。
User のバリデーション結果は result1 に、OtherObj のバリデーション結果は result2 に格納される。

@Controller
public class HelloController {
    @GetMapping("/")
    public String index(@Validated User user, BindingResult result1, @Validated OtherObj other, BindingResult result2) {

        return "index";
    }
}

パラメータありのコンストラクタを利用する場合

パラメータありのコンストラクタを用いる場合は、オブジェクトを生成するタイミングで必要に応じて型変換が行われる。
その際、型変換ができないフィールドが含まれていた場合はその結果を BindingResult として保持するが、それ以降のバインド処理、バリデーションが実施されない。
つまり、setter によるリクエストパラメータのバインド処理や、Bean Validation によるフィールドチェックが実施されない。

public class User {
    @NotEmpty
    private String name;
    private Integer age;

    public User(String name, Integer age) {
        this.name = name;
        this.age = age;
    }

    // getter 省略
}

@Controller
public class HelloController {
    @GetMapping("/")
    public String index(@Validated User user, BindingResult result) {
        return "index";
    }
}

上記のようなクラスを作成した場合に、以下のようなリクエストを送る。

curl http://localhost:8080?age=hogehoge

本来期待しているバリデーション結果は、name が空であること、ageInteger に変換できないことだが、後者しか検出することができない。
パラメータありのコンストラクタを削除し、デフォルトコンストラクタと setter を利用するように変更すると、どちらも検出できる。

Model へ格納

上記までの処理で生成したオブジェクトと BindingResultModel に格納する。
@ModelAttribute で属性名が指定されている場合は、その値を key として利用する。

さいごに

なるほどー。
パラメータありのコンストラクタを利用する場合に、微妙に動作が異なることがあるので注意しないといけない。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SpringBootのログをjson形式で出力する

参考

JSON Logging for Spring Boot
以降、上記の記事に沿って実装した内容を載せています
上記の記事の方が情報量が多いので英語が苦でなければそちらを参照することをお勧めします

実行環境

  • Java12
  • SpirngBoot 2.1.7.RELEASE
  • Gradle 5.6

準備

依存関係にlogstashを追加

build.gradle
dependencies {
    implementation 'net.logstash.logback:logstash-logback-encoder:6.1'
}

logbackの設定を追加

src/main/resources/logback-spring.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <property resource="application.yml" />
    <contextName>${spring.application.name}</contextName>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
            <providers>
                <timestamp>
                    <fieldName>timeStamp</fieldName>
                    <timeZone>UTC</timeZone>
                </timestamp>
                <loggerName>
                    <fieldName>logger</fieldName>
                </loggerName>
                <logLevel>
                    <fieldName>level</fieldName>
                </logLevel>
                <threadName>
                    <fieldName>thread</fieldName>
                </threadName>
                <mdc />
                <message>
                    <fieldName>message</fieldName>
                </message>
                <arguments>
                    <includeNonStructuredArguments>false</includeNonStructuredArguments>
                </arguments>
                <stackTrace>
                    <fieldName>stack</fieldName>
                </stackTrace>
            </providers>
        </encoder>
    </appender>
    <root level="INFO">
        <appender-ref ref="CONSOLE" />
    </root>
</configuration>

実装

ControllerクラスでINFOログを出力する例
※Loggerの取得はlombokの@Slf4jアノテーションにおまかせ

TestController.java
package com.exsample.controller;

import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import static net.logstash.logback.argument.StructuredArguments.v;

@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {
    @GetMapping
    public void get() {
        MDC.put("MDC key","MDC value");
        log.info("JSON Log",v("arg key","arg value"));
    }
}

結果

以下のログが出力される

{"timeStamp":"2019-08-28T08:52:22.173Z","logger":"com.exsample.controller.TestController","level":"INFO","thread":"main","MDC key":"MDC value","message":"JSON Log","arg key":"arg value"}

補足

  • logback-spring.xml<encoder>タグ内で設定した内容が上から順に出力される
(抜粋)logback-spring.xml
<providers>
  <timestamp>
    <fieldName>timeStamp</fieldName>
    <timeZone>UTC</timeZone>
  </timestamp>
  <loggerName>
    <fieldName>logger</fieldName>
  </loggerName>
  <logLevel>
    <fieldName>level</fieldName>
  </logLevel>
  <threadName>
    <fieldName>thread</fieldName>
  </threadName>
  <mdc />
  <message>
    <fieldName>message</fieldName>
  </message>
  <arguments>
    <includeNonStructuredArguments>false</includeNonStructuredArguments>
  </arguments>
  <stackTrace>
    <fieldName>stack</fieldName>
  </stackTrace>
</providers>
ログ内容
{
  "timeStamp": "2019-08-28T08:52:22.173Z",
  "logger": "com.exsample.controller.TestController",
  "level": "INFO",
  "thread": "main",
  "MDC key": "MDC value",
  "message": "JSON Log",
  "arg key": "arg value"
}
  • MDC.put()した内容は<mdc />タグの位置に出力される
(抜粋)TestController.java
MDC.put("MDC key","MDC value");
ログ抜粋
"MDC key": "MDC value"
  • log.info()の第一引数に渡した内容は<message>タグの位置に出力される
(抜粋)TestController.java
log.info("JSON Log",v("arg key","arg value"));
ログ抜粋
"message": "JSON Log"
  • log.info()の第二引数以降にStructuredArguments.v(key,value)を渡すと、<arguments>タグの位置にその内容が出力される
(抜粋)TestController.java
log.info("JSON Log",v("arg key","arg value"));
ログ抜粋
"arg key": "arg value"
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

型がある言語で DDD してたらテストはいらないの?

前にとある DDD の座談会で「静的言語で DDD をしているならテストはいらない?」という質問が出て、思ったより多くの人が「そうだ / そうかも」の空気になって驚いたので、ちょっと整理してみることにした。

結論から言うと、僕の持論は「もちろんコンパイルの恩恵はとてもあるんだけど、思考停止で全てが解決するわけではないし、学びも多いので、書いた方が良い」です。

条件分岐は型があってもバグになりやすい

例えばelse ifが増えるような修正は、型があるだけだと割と危険。

Plan.java
public enum Plan {
    PLAN_1,
    PLAN_2,
    PLAN_3;
}
Price.java
public class Price {
    private final int value;

    public static Price of(Plan plan) {
        if (plan == Plan.PLAN_1)
            return new Price(100);

        else if (plan == Plan.PLAN_2)
            return new Price(200);

        else
            return new Price(300);
    }
}

これにPLAN_4を足した。

値段は 400 円にしたかったけど、else ifを書き足し忘れた。

public enum Plan {
    PLAN_1,
    PLAN_2,
    PLAN_3,
+   PLAN_4;
}

当然コンパイルは通り、PLAN_4は 300 円になる。

怖い。

とても怖い。

余談

くだらない例だけど、冗談ではないよ。

Planに応じて値段明細品目メールの文面システムコード等々、else ifを忘れずに書き足さないといけないプロダクトなんて珍しくないはず。

大規模でこの手の漏れを策なく防ぐのは割と無謀。

じゃあどうすんの

テストを書けば少しはマシになるはず。

enum の全要素 ( Plan.values() ) とかをテストコードにうまく絡めていると、検知できたりする。

PriceTest.groovy
// テストコードは groovy と spock を用いた

class PriceTest extends Specification {
    def "#plan -> #exp"() {
        expect:
        Price.of(plan) == new Price(exp)

        where:
        plan        || exp
        Plan.PLAN_1 || 100
        Plan.PLAN_2 || 200
        Plan.PLAN_3 || 300
    }

    def guard() {
        expect:
        Plan.values().length == 3
    }
}

シンプルだけど、Planに依存してるテストに== 3が入っていると、Planが増減した時に気づける。

elseを値段判定に使わない、とかもアリ。

Price.java
        else if (plan == Plan.PLAN_3)
            return new Price(300);

        else
            throw new RuntimeException("match error");

テストコードがあればこれでも気づける。

整理: ここが怖いよ

  • 例えば条件分岐の書き方によってはコンパイルが通ってもバグる可能性があるよ
  • 一番最初に書くときは簡単なので「まさかバグんないだろ〜」と思っても、保守と追記で地獄を見るよ

引数不正なんかは恩恵が得られる、かも

逆に、しっかりやればこんなバグはバッチリ防げる

例1

MailService.java
public class MailService {
    public void send(String itemName, String userName) {
        String mailBody = MailBodyFactory.create(itemName, userName);
        ...
    }
}

例2

FooService.java
public class FooService {
    FooRepository fooRepository;

    public void replace(Foo usingOne) {
        Foo newOne = Foo.newOne();

        fooRepository.replace(newOne, usingOne);
    }
}

どこが間違ってるかというと...

例1

MailFactory.java
public class MailBodyFactory {
    public static String create(String userName, String itemName) {
        return String.format("%s 様 %s のお買い上げありがとうございます", userName, itemName);
    }
}

例2

FooRepository.java
public interface FooRepository {
    void replace(Foo usingOne, Foo newOne);
}

引数の順番が逆でした。

また余談

「知るかよ!」って思った?

でも大体の場合プロダクトコードの引数順なんて覚えてないし、気をつけながら実装するのは案外とても疲れる。

さらに、こういうのは下手に動くだけにとてもタチが悪い。

じゃあどうすんの

Stringは乱用しないでUserNameItemNameのクラスを作ろう。

そしたら絶対に逆にできない。

Fooの方はドメインクラスを作ってみたみたいだけど、例えば状態ごとにわけてみよう。

public interface FooRepository {
-   void replace(Foo usingOne, Foo newOne);
+   void replace(UsingFoo usingFoo, NewFoo newFoo);
}

これで絶対に逆にならない。

(状態別クラスについては勢いで一緒にこんな記事を書いて見ました → 全部のステータスを1クラスで表現する神エンティティをやめよう!

大体の目安だけど、変数名で説明を頑張ってる箇所は、型にしてしまった方が良いと思う。

赤線になってくれた方がよっぽど楽だし、エディタも補完候補とかを賢く考えてくれる。

ちなみに、似た様な話は以前も書いたので興味があればそちらも見てみてください。
同じテーブルの値でも違うクラスを用意すると良い感じ

整理: ここが怖いよ

  • 型があると言っても、全部Stringじゃあ恩恵を全然享受できない
  • ドメインクラスを作ってみても、それがちゃんと設計されていなければ恩恵は大きくならない

テストがあると使いづらさに気づく、かも

例えばこんなコード

CampaignCode.java
public enum CampaignCode {
    CODE_A, CODE_B;

    public static CampaignCode create() {
        if (LocalDateTime.now().getDayOfMonth() < 15) {
            return CODE_A;
        } else {
            return CODE_B;
        }
    }
}

テストコードを書こうとするとnowが邪魔で「あれ、テスト書けないぞ...??」ってなるはずなんだ。

これはつまり作ったプロダクトもnowでしか動かないので、他の部分のテストを邪魔したり、モッキングや結合試験とかで融通が効かなかったりする。

テストコードは最初にそのメソッドを使うので、こういう「あれ?このメソッド使いづらいな?」みたいなのに気づくチャンスに、結構なる。

(この例はnowは引数で渡した方が良い)

まとめ

  • 静的言語で DDD をやっているからと言って、全てのバグが抜けるわけではないよ
    • 条件分岐の例とか
    • 他にすぐ思いつくのだと配列操作とか(前のどこかの処理が間違ってて index out of range が出るとか、よくあるよね)
    • 同じく前処理のどっかがおかしくてOptional.getしたらemptyだった、とか
    • イテレータのカーソルが思った位置にないとか
    • java でたまに見るのはStreamの終端操作を2回しちゃうとか
  • ドメインクラスを作ってるからと言って、思考停止で型の恩恵が得られるわけではないよ
    • Foo usingの例とか
  • テストコードは作った処理の使いづらさとかに気づく大事なチャンス
    • 品質面では当然
    • すぐ学習と経験のフィードバックが得られるという意味でも、とても大事
  • (忘れてたのでさらっと追記)リファクタリングを促進するためにも、テストはあった方が良い
    • モデルのアップデートをかけてドメイン層をどんどん直せるためにも、テストコードは大事
    • ただし不必要に書きすぎると逆にリファクタリングを阻害することにもなる

おわりに

例えば enum の件なんて言語によっては警告やコンパイルエラーが出るんだけど、会場の大きな流れとしては「コンパイルはテストコードを不要にするか」って感じだと思ったので、思い立ってアンチテーゼとして書いて見ました。

本記事はあくまで僕個人の持論です。

最近聞いたある人の言葉を真似るなら「テストいらないって聞いたんだけどマジ?」って思った人は、まずは素直にテストを書きなさい。
と僕は思います。おしまい。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

全部のステータスを1クラスで表現する神エンティティをやめよう!

勢いだけで書いたよ!

大体タイトルの通り!

いきなりだけど、勉強会で話を聞いたり twitter を見たりしてると、とりあえず見つけた名詞をクラスにして周辺のデータっぽいやつを全部そこに放り込んでいるエンティティがよくある気がするよ。  
僕も初学者の頃書いたw

早速コード

UserStatus.java
public enum UserStatus {
    Applying,    // 申請中
    Using,       // 利用中
    Leaved,      // 退会済
    BlackListed, // ブラックリスト
    Erased,      // 抹消済
}

(たった)これくらいの状態を持つUserってクラスを1つで表現してしまった!

「DDD だから User ってクラスを作って、処理はそこに書くんだ〜」って段階によくあると思う。

きっと最初は状態が少なかったんだけど、分析だかサービス成長だかがしていく間にヤベェことになってしまったんだね、わかるよ。ウンウン

User.java
public class User {
    private UserId userId;
    private UserCourse userCourse;
    private UserStatus userStatus;
    private ApplyDate applyDate;
    private Optional<UseStartDate> useStartDate;
    private Optional<LeaveDate> leaveDate;
    private Optional<EraseDate> eraseDate;

    public User apply(UserCourse userCourse) {
        return new User(
                new UserId(), userCourse, Applying, new ApplyDate(), Optional.empty(), Optional.empty(), Optional.empty()
        );
    }

    public User useStart() {
        if (userStatus != Applying)
            throw new RuntimeException("not applying");

        return new User(
                userId, userCourse, Using, applyDate, Optional.of(new UseStartDate()), leaveDate, eraseDate
        );
    }

    public User courseChange(UserCourse userCourse) {
        if (userStatus != Using)
            throw new RuntimeException("not using");

        return new User(
                userId, userCourse, userStatus, applyDate, useStartDate, leaveDate, eraseDate
        );
    }

    public User leave() {
        if (userStatus != Using)
            throw new RuntimeException("not using");

        return new User(
                userId, userCourse, Leaved, applyDate, useStartDate, Optional.of(new LeaveDate()), eraseDate
        );
    }

    public User blackListIn() {
        if (userStatus != Using)
            throw new RuntimeException("not using");

        return new User(
                userId, userCourse, BlackListed, applyDate, useStartDate, leaveDate, eraseDate
        );
    }

    public User blackListOut() {
        if (userStatus != BlackListed)
            throw new RuntimeException("not black listed");

        return new User(
                userId, userCourse, Using, applyDate, useStartDate, leaveDate, eraseDate
        );
    }

    public User erase() {
        if (userStatus != BlackListed)
            throw new RuntimeException("not black listed");

        return new User(
                userId, userCourse, Erased, applyDate, useStartDate, leaveDate, Optional.of(new EraseDate())
        );
    }
}

使うコードはこんな

UserService.java
public class UserService {
    public void leave(UserId userId) {
        User usingUser = userRepository.find(userId);
        User leavedUser = usingUser.leave();

        userRepository.leave(leavedUser);

        billing.stop(leavedUser.getLeaveDate().get());

        ...
    }
}

うへぇ〜辛ェ〜

コードが長い!
コンパイル通っても実行例外が超出そう!
ってかドメインロジックってナニ!

これほとんど DTO なんじゃあないのォ〜?

状態ごとにクラスを分けよう!

実装は割愛するけど、状態ごとにクラスを分けよう。

image.png

ポイントは2つ!

  • ステータスで頑張らずにもっと細かく分ける
  • 特定の状態でしか持たない値は極力持たない、持つときは必ず持つようにする
    • Optionalを極力排除するということ

主なメリットは!

  • ステータス不正で実行してしまう実行例外がなくなる
  • leavedUser.getLeaveDate().get()みたいな実行例外の可能性がなくなる
  • 特定の状態でできる処理が明瞭になる
  • 何から何に遷移するかが明瞭になる

簡単だけど超効果あるからやってみるべし!

もちろんテク先行ではなくて、モデリングありきだよ

例で状態を用いたけど、何で分けるかはモデリング次第。

ユースケースとかビジネス影響度とか、きっとまちまちのはず。

それから、この分割は「これが DDD だぞ!」ではなくて「ただの起点」だよ!
こうやってモデルを小さくして、そこから分析をしてロジックを選り分けて集めるんだ!

例えば今回の例だと、LeavedUserを取っ掛かりに請求停止(billing.stop(...)の箇所)のモデリングをする、とか。

ちょっと気をぬくとエンティティを作ったつもりでもただの DTO になってしまうので、気をつけよう!

ちなみに、2年弱前に似た様な記事を書いてるよ、そちらもよければどうぞ
同じテーブルの値でも違うクラスを用意すると良い感じ#ケース2-1つのクラスの状態更新をする

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Kotlin の後置・前置インクリメントと演算子オーバーロード (C, Java, C++ との比較)

概要

  • a++ のような後置インクリメント演算子: 値が関数に渡された後にインクリメントされる
  • ++b のような前置インクリメント演算子: 値が関数に渡される前にインクリメントされる

インクリメント - Wikipedia

C言語、C++、Java、JavaScriptなどでは、インクリメント演算子(増量子)「++」が用意されている。前置インクリメントと後置インクリメントの2種類がある。字句は同じ「++」だが前置演算子として使うか(例: ++x)後置演算子として使うか(例: x++)で意味が違う。オペランドの値が整数の場合は1、ポインタの場合は指し示す対象1個ぶん変わるのはどちらも同じだが、式としての値が、前置の場合はインクリメントした後の値になる(この意味は += 1 と同じ)、後置の場合はインクリメントする前の値になる。

環境

  • kotlinc-jvm 1.3.10 (JRE 11.0.1+13-LTS)

Kotlin で後置・前置インクリメント

Kotlin のソースコード。

var a = 1
var b = 1
println(a++)
println(++b)

実行結果。
a++ の場合、値が +1 される前に関数に渡されるため 1 が出力される。
++b の場合、値が +1 された後に関数に渡されるため 2 が出力される。

$ kotlinc -script hello.kts
1
2

Kotlin のインクリメント演算子は C や Java と同様の挙動をしている。

C のソースコード。

#include <stdio.h>

int main(int argc, char *args[]) {
  int a = 1;
  int b = 1;
  printf("%d\n", a++);
  printf("%d\n", ++b);
  return 0;
}

実行結果。

$ gcc hello.c -o hello

$ ./hello 
1
2

Java のソースコード。

public class Hello {
  public static void main(String[] args) {
    int a = 1;
    int b = 1;
    System.out.println(a++);
    System.out.println(++b);
  }
}

実行結果。

$ javac Hello.java 

$ java Hello
1
2

Kotlin で演算子オーバーロードしてインクリメント

Kotlin のインクリメント演算子オーバーロードは手軽に書ける。
operator fun inc でインクリメント演算子をオーバーロードできる。

data class Counter(val num: Int) {
  operator fun inc(): Counter {
    return Counter(num + 1)
  }
}

var a = Counter(1) 
var b = Counter(1)
println(a++)
println(++b)

実行結果。
a++ の場合、num の値が +1 される前に関数に渡されるため、num=1 の状態が出力される。
++b の場合、num の値が +1 された後に関数に渡されるため、num=2 の状態が出力される。

$ kotlinc -script counter.kts
Counter(num=1)
Counter(num=2)

同じようなインクリメント演算子の挙動を C++ で書いてみる。
C++ では後置と前置で別の関数を書く必要がある。

#include <iostream>

class Counter {

private:
  int num;

public:
  Counter(int num) : num(num) {}

  // Prefix increment operator
  Counter& operator ++() {
    this->num++;
    return *this;
  }

  // Postfix increment operator
  Counter operator ++(int) {
    Counter c = *this;
    this->num++;
    return c;
  }

  friend std::ostream& operator<<(std::ostream& os, const Counter& c) {
    os << c.num;
    return os;
  }
};

int main(int argc, char *argv[]) {
  Counter a(1);
  Counter b(1);
  std::cout << a++ << std::endl;
  std::cout << ++b << std::endl;
  return 0;
}

実行結果。

$ g++ hello.cpp -o hellocpp
$ ./hellocpp 
1
2

参考資料

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

JavaでNotification Hubsを使用してpush通知を送信する

azureのNotification Hubsを使用してpush通知を送信することになったので備忘録。
サーバ側の開発です。(スマホアプリはわかりません。)

 Java SDK入手

ここからJava SDKを入手
一応公式サイトとのことです。

 コンパイルとビルド

mavenコマンドを使用するので、mavenインストールしていない場合はここを参照
pom.xmlがあるフォルダでmavenコマンドを実行するだけ

cd NotificationHubs
mvn package

そうするとtarget配下にNotification-Hubs-java-sdk-0.1.0.jarできます。
これをパスが通ったフォルダに置いてください。

使い方

タグを指定して送信することを想定しています。
事前にトークンとタグをNotification Hubsに登録していることが前提です。

トークンはメールアドレスみたいなイメージで
タグはメールアドレスみたいなイメージです。

タグを指定してpush通知を送信すると、そのタグに紐づいているトークンすべてにpush通知を送信してくれます。

push通知送信(即時送信)

String connectionString = "Endpoint=sb://xxxxx.servicebus.windows.net/;SharedAccessKeyName=DefaultFullSharedAccessSignature;SharedAccessKey=~~~~~";
String hubPath = "Notification hubsの名前";

NotificationHub hub = new NotificationHub(connectionString, hubPath);
String message = "push通知";

String body = String.format("{\"data\":{\"message\":\"%s\"}}", message);

// android端末用
Notification notifiation = Notification.createFcmNotifiation(body);

hub.sendNotification(notifiation, "タグ名");

push通知送信(日時指定送信)

String connectionString = "Endpoint=sb://xxxxx.servicebus.windows.net/;SharedAccessKeyName=DefaultFullSharedAccessSignature;SharedAccessKey=~~~~~";
String hubPath = "Notification hubsの名前";

NotificationHub hub = new NotificationHub(connectionString, hubPath);
String message = "push通知";

String body = String.format("{\"data\":{\"message\":\"%s\"}}", message);

// android端末用
Notification notifiation = Notification.createFcmNotifiation(body);

SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date date = dateFormat.parse("2020-01-01 00:00:00");

hub.scheduleNotification(notifiation, "タグ名", date);

トークンとタグの登録

事前にトークンとタグをNotification Hubsに登録していることが前提ですと記載しましたが、
サーバ側でもトークンとタグを登録できます。(トークンとタグがわかれば)

// Android端末用
Registration reg = new FcmRegistration("トークン");
reg.getTags().add("タグ名");

NotificationHub hub = new NotificationHub(connectionString, hubPath);
Registration registreg = hub.createRegistration(reg);

トークンとタグの削除

トークンとタグを削除する場合はタグを指定するか、RegistrationIdを指定します。

・タグで削除する場合は意図しないトークンも削除されるため、注意が必要です。
・RegistrationIdはトークンとタグの登録で登録結果(上のソースではregistreg )の中に詰まっています。
 この値でトークンとタグを一意に識別することができます。

NotificationHub hub = new NotificationHub(connectionString, hubPath);

// タグを指定して削除
CollectionResult collectionResult = hub.getRegistrationsByTag(tag);
for (Registration registration:collectionResult.getRegistrations()) {
    hub.deleteRegistration(registration.getRegistrationId());
}

// RegistrationIdを指定して削除
hub.deleteRegistration(registreg.getRegistrationId());

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

私は如何にして JMockit の returns(Object) を安全に置換したか?

テストで使っているJMockitというモックライブラリが古いバージョンだったので、最新バージョンにアップデートする作業をしたのですが、その際にちょっとしたハックをしたので紹介します。

なお、JMockit自体の変更点などについては別記事にしているので、JMockitを使っている方はそちらもご覧ください(→JMockit と共に生きる者ためのメモ - Qiita

どんな問題があったのか?

JMockit(の旧バージョン)では以下のように Expectations のインスタンスイニシャライザでメソッドを差し替えます。戻り値は result または returnsで指定します。

new Expectations() {{
  intList.get(0); result = 1;     // intList.get(0) が常に1を返すようになる

  intList.get(0); returns(1);     // ↑ の別記法(1.30で廃止)

  intList.get(1); returns(-1, 1); // intList.get(1) が、-1 と 1 を交互に返すようになる
}};

ここで、1引数版returns(v)は JMockit 1.30 で廃止されてしまいました。
そのため、テストコード中の returns(v)result = v に書き換えなくてはなりません。

まず、sed で置換しようとしましたが、returnsには1引数版と2引数以上版があるので単純に置換することはできません。

sed -e 's/returns/result =' // 2引数版も置換してしまう。-e 以外の引数は省略

正規表現を工夫したりもしましたが「returnsの引数として別のメソッド呼び出しを指定する」コードがある等して、上手くいきませんでした。

次に IntelliJ のリファクタリング機能を探しましたが、

  • インライン化
  • メソッド名変更

などはあるのですが「メソッド呼び出しを代入文に変更」という変更はできませんでした1

どうやって解決したか?

まず、returns の呼び出しを自作クラスのメソッドの呼び出しに置換しました。

sed -e 's/returns(/test.MyDummy.returns(/g'

テストコードはこんな形になります。

new Expectations() {{
  intList.get(0); test.MyDummy.returns(1);     // returns が自作メソッドのものに置換されている
  intList.get(1); test.MyDummy.returns(-1, 1); // 2引数版も置換される
}};

さらに、自作クラスに以下のようにメソッドを定義します。この時点で、JavaコードとしてはValidになるので、IntellJのリファクタリング機能を使えるようになります。

class MyDummy {
    public static void returns(Object o) {
        result = o;
    }

    public static void returns(Object o, Object o2, Object... args) {
    }
}

IntelliJ で、MyDummy.returnsにカーソルを合わせ Refactor This → inline でインライン化します。

すると、IntelliJ は、メソッドオーバーロードを理解しているので、1引数版の returns のみをインライン化してくれます。

new Expectations() {{
  intList.get(0); result = 1;
  intList.get(1); test.MyDummy.returns(-1, 1);
}};

これで「1引数のreturnsresult」は完了です。残った test.MyDummy.returns は、再びsed で置換して、returnsに戻します。

sed -e 's/test.MyDummy.returns(/returns(g'

これで、1引数のreturnsのみが書き換えられた状態になりました。

new Expectations() {{
  intList.get(0); result = 1;
  intList.get(1); returns(-1, 1);
}};

かかった時間は、考える時間も含めて1・2時間ほどでした(測ってないけど多分)。returns は何千箇所もあったので、手作業では何日もかかったことでしょう。

また、使った変換は全て機械的なものだったので、バグは生じませんでした(本当にバグっていないかは、実際にテストをコンパイル・実行して確認できました)。


  1. 商用版では出来たりするかもしれませんが、私が使っていたIntelliJ IDEAでは出来ませんでした。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

IT業界転職活動し始めて自分の目指す方向と違う方向に行ってしまっていた事に気づき始め少し絶望感

業界・職種未経験で転職活動をし始めた話。現在進行形です。

まず僕のスペックから

概要

住所 : 都内在住
年齢 : 29歳
学歴 : 高卒
業種 : 未経験
職種 : 未経験
前職 : 弱電設備の設置施工、サービスエンジニア
前々職 : 電気工事・電気通信工事のエンジニア

来歴

高校卒業後
  ↓
アルバイトしながら生活
  ↓
上京
  ↓
またアルバイトしながら生活
  ↓
とりあえず元々自分で持っている資格があったため就職
  ↓
少し興味を抱いた仕事があったため転職
  ↓
かなり前から興味があったが本格的に仕事してみたいと思い退職
  ↓
無職

ITスキル

学習方法 : 某オンラインスクールにて
学習言語 : Java課程修了レベル

目指したい所

自社開発・受託開発を行っているWeb系のエンジニアになりたい

オンラインスクールでJavaを選んだ理由

転職サポートのついているコースがJavaだったため

→この時点でもう少し調べておくべきでした。

転職活動をはじめて

※詳しい社名は伏せます。

某転職エージェント業界最王手へ登録

とりあえず誰もが知る転職エージェントへの登録し担当のエージェントに会いに行きました。会ってみた感想としては確かに業界No.1の求人数を誇るようでしたがIT業界への転職に対してあまり強くなさそうな所に加えて未経験だと紹介しづらいとの事。にも関わらず担当のエージェントの方がSESの会社を何件か紹介してもらいました。

IT転職業界No.1のエージェント登録

次にIT業界に特化した転職エージェントへ登録しエージェントに会いに行きました。こちらは担当者がかなり若く私と話している時もそわそわしている状況。おそらく会社の指示だとは思うが自分のための転職相談に来ているのに「お友達紹介キャンペーンやってまして...」との話が始まりちょっとそれはないんじゃないかと思いました。
その話の流れで無料のスクールやっているとのこと。話を聞く流れだととりあえず気持ち勉強した後→スクールのスポンサーになっている企業への紹介→面接→就職という流れでした。とりあえず参加。が、ここに落とし穴が...。(後ほど記載)

●無料のスクールに入って感じたこと

当たり前かもしれませんがまずそもそも他の有料オンラインスクールを受講している人間だと必要がない。スクールで作った成果物やポートフォリオを提げて転職活動し始めた方がいい。そしてここが本題。紹介してくる企業がSESの会社しかない。転職活動し始めてサイトに登録するとSESメインでやっている会社からかなりオファーがくる。ってことは転職エージェント経由で紹介してもらう必要がない。スクールにも個々に担当者がつく。担当者曰く「未経験だとSESしかいけない。しかも年齢的にも29歳だときつい」と事あるごとに言われる。実際どうなのかなと疑念を抱き始める。

とりあえず面接に行って感じた事

●SES事業/受託開発など色々やっている結構大きい会社

こちらは通常の面接。
採用担当者との挨拶が済み個人情報取り扱いについての紙を記入している途中おそらく社長らしき人も入室。
挨拶するも反応なし。採用担当者が一般的な流れの面接開始。その途中社長の開口一番「この年齢だとね〜」から始まり「ITパスポートとかないと話にならないんだよね〜」とのこと。求人情報やホームページをかなり見てから来たがITパスポートの記載は一切なし。というかそもそも29歳業界完全未経験という情報を公開しての応募しているわけであって書類選考の時点で落とすべき。その後も「あなたの今までの経験だとインフラの方が向いてるよ」「なんで今更プログラマーなの?」と上げていくとキリがない。今思い返してみるとかなりの圧迫面接。
こちらの企業まだ合否の通知は来ていないが仮に採用の通知もらってもいくつもりはありません。おそらく入社しても一ヶ月もたないかも。笑

●自社開発/受託開発を行っているWeb系のベンチャー

採用面談という形での面接とのこと。
流れとして担当者からの企業概要の紹介(30分ほど)から面談者から自分に対しての質問(30分)その他質問(10分)。前述の会社から180°代わり担当者と色々な話ができました。

結果不採用でしたが、面談担当者から添えられたメッセージがとてもためになりました。

内容は、

ITの勉強をはじめてスクールでも大変だと思いますが、もう少し、ITに対する他のアクションが欲しかったです。 たとえば、ブログに勉強したことを公開するとか、アプリ作ってストアにあげてみるとか、 そういった、経験がエンジニアの成長には不可欠となってきます。 まだ、勉強を開始して間もないと思いますが、いろいろ自分から動いて、いろいろなことを吸収してみてください。 きっと素晴らしいエンジニアになれると思います。 また、年齢のことを気にしていましたが、あまり気にしなくて良いと思います。 〇〇様より年上で未経験の方もたくさん会ってきました。 重要なのは行動と情熱、それを維持することだと思います。

実は会う前のメッセージで技術的な興味や行動を見たいとの話をいただいていましたが自分の理解が足らずそのまま面談の場へ行ってしまいました。おそらく担当者も話しているうちに完全未経験で取るべき行動がわかってないと悟ったのだと思います。こちらこそご期待に添えられない状態でお伺いしたこと大変申し訳ありませんでした。

今現時点で思うこと(反省点)

そもそもJavaだとWeb系がきつい?

今更だけど市場で需要のある言語はRuby、Python、PHPなのかなと最近思う。
スキルコレクターみたいになるつもりはないけど言語学び直そうかな...。

SESだと逆に年齢が重要?

最悪SESでも問題ないかなとは思っていたけどSESやっている会社からしてみたら完全未経験で29歳はきついのかな。

言語の勉強をすればエンジニアになれると思っていたが違った

実際は勉強中、勉強後の行動が重要

今現時点で思うこと(よかった点)

退職

退職したおかげでかなり時間ができて今まで見えてこなかったことが見えてくるようになった。
おそらく仕事しながら勉強して、転職活動してだとろくな結果をうまない。
お金に困りそうだったらウーバーイーツの配達でなんとかなる。

Webベンチャーの面談担当の人と話ができた

今現時点で一番の収穫。かなり勉強になりました。

今後の行動

アウトプットを頑張っていく

・とりあえずQiitaで投稿

ポートフォリオの作り直し

・持っていくのに恥ずかしいくらいのはりぼてポートフォリオなため最初から作り直し
・なんらかの形でQiitaに投稿しながら、Gitにもプッシュしながら
・Web上で公開

言語の学び直し(検討中)

・実際どうなのか要検討

とりあえず転職活動は休止

最後に

同じような境遇のかたに情報共有になればと思います。
質問があるかた気軽にどうぞ。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

JMockit と共に生きる者ためのメモ

先日、某Java製プロダクトの、ユニットテストで使っている、JMockitのバージョンを上げる作業をしました(1.13 → 1.46)。その際に分かったことをメモします。

主に以下の2種類の人に役立つことを期待します。

  • 今まで古いJMockitを使っていたが、最新版を使う人(つまり私と私の同僚)
  • これからJMockitをアップデートする人

JMockit とは

JMockit は Java用のモックライブラリです。ユニットテストの中で、既存のメソッドの動作を差し替える(モックする)ことができます。

JMockitの公式ページ → The JMockit testing toolkit

差し替えは、インスタンスイニシャライザと特殊なインスタンス変数 resulttimes を使って定義します。

new Expectations() {{
  obj.method(arg1, arg2, arg3);
  result = value;
  times = 2;
}};

かなり強力な操作もサポートしており、以下のような差し替えもできます(詳しくは公式ページ参照)。

  • staticメソッドの差し替え
  • オブジェクトの特定のメソッドのみ差し替え(Partial Mocking)
  • new が返すオブジェクトの差し替え

JMockit のバージョンアップの特徴

2・3か月ごとにマイナーバージョンが上がります。過去のリリースはこちらのページから確認できます:

JMockit - Development history

履歴からわかるように、JMockitは古い機能はどんどん切り捨てるポリシーです。例えばDeencapsulation.setField というメソッドは2ヶ月・2バージョンで非推奨指定→廃止されています。

Version 1.45 (Jan 27, 2019):
Removed the Deencapsulation.setField methods, which were deprecated in version 1.44.

Version 1.44 (Nov 25, 2018):
Deprecated the Deencapsulation.setField methods, in favor of @Tested and @Injectable.

したがって、一気にバージョンを上げようとすると、大量の変更が必要になり、作業が辛くなります

  • JMockitのバージョンを上げる(常に最新版に追従する)
  • JMockit以外のライブラリに移行する

の、どちらかをお勧めします。

JMockit より Mockito の方がオススメ!

JMockitのライバルにはMockito があります。Mockito の方がユーザー数が多く、記法も自然です(特にRubyのRSpecに慣れた身としては)。

また、Mockito単体では通常のメソッドの差し替えしかできませんが、Powermock と組み合わせれば、JMockitと同様の強力な「汚い」変更もできるようになります。

// mockito の記法のイメージ
when(obj.method(arg1, arg2, arg3)).thenReturn(value);

したがって、可能ならJMockitからMockitoに移行することをお勧めします

なお、今回のプロダクトでは、JMockitを使ったテストが大量にあったためMockitoに移行するのは諦めました。

バージョンアップ作業の流れ

以下のような作業でバージョンアップできます。

  1. JMockitのバージョンを上げる
  2. バージョンアップで廃止された機能などを書き換える
  3. コンパイルする
  4. コンパイルが通るまでコードを修正する
  5. テスト実行
  6. テストが通るまでコードを修正する

ただし「最新版では避けるべき書き方」で説明するように、コンパイルは通るが、意図した通りに動作しなくなるケースがあります。テストが失敗する場合は、そこを疑ってください。

なお、古いバージョンから一足飛びに最新版に上げるのは、変更点が多くなりすぎ破綻するので、オススメしません。

また、

  • バージョン 1.x で廃止された機能が 1.y で復活しているので、1.xを飛ばして 1.yに上げるべき
  • バージョン 1.(x+1)には後方互換のための機能があるので、1.xを飛ばして1.(x+1)に上げるべき

といったショートカットパスも無いと思います。地道に1バージョンずつ上げていくことをオススメします。

最新版のチュートリアルを読む

バージョンアップしたりテストを書いたりする前に最新版のチュートリアルを読みましょう。

JMockitはそもそもクセが強いライブラリです。「書き方が違うだけでMockitoと同じだろ」と思うと痛い目に会います。例えば @Mocked であるクラスのモックオブジェクトを取得すると、そのクラスの全インスタンスがモックになります

また、上述のように機能が大胆に改廃されおり、書き方がだいぶ変わっています。JMockitの経験者もコードに触る前にに目を通してください。

JMockit - Tutorial

主な廃止機能と書き換え方法

最新版のチュートリアルに目を通しましたか?

OK!

過去のバージョンの変更点のうち、影響が大きそうなものを説明します(細かい機能は申し訳ないが、リリースノートなどで調べてください)。

テストの実行方法

従来は JUnitのテストクラスに @RunWith(JMockit.class) アノテーションをつけるとJMockitが読み込まれていました。

現在は方法が変わり、ユニットテストの実行時に、VMのオプションとして以下のように javaagent として JMockit の jar ファイルを指定すると、JMockitが読み込まれます。

-javaagent:/path/to/jmockit-1.46.jar

IntelliJ なら以下の場所で設定します。

「ツールバーの実行ボタン横のモジュール名」 → Edit Configurations → Templates → JUnit → Configurations → VM options

@RunWith(JMockit.class) アノテーション

上述のように廃止されたので単に削除してください。

1引数の returns

1引数の returnsは、代わりに result = を使ってください。2引数以上の returns は引き続き使えます。

なおreturnsを1個1個書き換えるのは大変ですが、それについては別記事に書いたので参考にしてください(→ 私は如何にして JMockit の returns(Object) を安全に置換したか?)。

// Old
new Expectations() {{
  obj.method(arg1, arg2, arg3); returns(value);
}};

// New
new Expectations() {{
  obj.method(arg1, arg2, arg3); result = value;
}};

NonStrictExpectations

NonStrictExpectations は、Expectationsの「差し替えたメソッドが呼び出されなくてもエラーにならないバージョン」でした。

以下のように Expectations に置き換えた上で minTimes = 0 で最小呼び出し回数を0回に設定してください。

new Expectations() {{
    mock.get(0); result = "1"; minTimes = 0;
}};

なお、「メソッドを差し替えたのに、それが呼び出されていない」のは、

  • 実はそのメソッドを差し替える必要が無かった
  • 意図した動作をしていない

のどちらかの場合が多いので、テストケースを見直してみてください。

StrictExpectations

単に Expectations に書き換えてください。

Deencapsulation

Deencapsulation は private なフィールドを参照したり変更したりする機能でした。

1. フィールドを package-private ないし public に変更する

Deencapsulation で変更していたフィールドのアクセス制限を緩め、テストからは.でアクセスするようにします。フィールドには Guava の @VisibleForTesting アノテーションを付けます。

本体側コードに変更を加えることになりますが、アクセス修飾子の変更がバグに繋がることは普通は無いはずです1

// Old
// 本体コード
class Spam {
 // ...
  private Foo field;
 // ...
}

// テストコード
x = Deencapsulation.getField(obj, "field");

// New
// Old
// 本体コード
class Spam {
 // ...
  @VisibleForTesting
  Foo field;
 // ...
}

// テストコード
x = obj.field;

2. @Tested@Injectable を使う

JMockitにはテスト用のDI機構があります。

Deencapsulation.setField をテスト対象オブジェクトにモックをセットするために使っている場合は、DIに置き換えられます。

4 Instantiation and injection of tested classes

.newMockInstance()

モックインスタンスを @Mocked で取得するようにします。単純な書き換えはできません。

System などのメソッドはモック

古いバージョンでは JMockit はどんなメソッドでも差し替えられましたが、
現在のバージョンでは System.currentTimeMillis のようなネイティブメソッドは差し替えられません。

テストを再設計し、差し替え自体を不要にするか、ネイティブではないメソッドを差し替える形にしましょう。

今回バージョンアップしたプロダクトの、System.currentTimeMillis を使っている部分は、代わりに new Dateを差し替える形に変えました。

最新版では避けるべき書き方

ドキュメントには明示されていませんが、旧バージョンでは問題なく動いていたし、コンパイルも通るのに、新バージョンでは動かなくなるパターンがあります。

引数・戻り値に別のメソッド呼び出しを書く

Expectationsの中で、メソッドの引数や戻り値として別のメソッド呼び出しを書くと、うまく差し替わらない場合があります。引数・戻り値はExpectationsの外で、一旦一時変数に代入してください。可読性の観点からも分けた方がよいでしょう。

// NG
new Expectations() {{
  foo.method(getX(), getY()); result = getHogeList();
}};

// OK
X x = getX();
Y y = getY();
HogeList hogeList = getHogeList();
new Expectations() {{
  foo.method(x, y); result = hogeList;
}};

引数ありExpectationsの中に無関係なメソッドを書く

クラスの特定のメソッドだけを差し替えたい時や、特定のインスタンスのメソッドだけを差し替えたい時には、Expectationsに引数にクラスやインスタンスを指定します。

このとき、Expectationsの引数と無関係なメソッド呼び出しを混ぜてしまうと、うまくモックできないことがあります。無関係なメソッド呼び出しは、別のExpectationsに分けましょう。可読性の観点からも分けた方がよいでしょう。

// NG
new Expectations(foo) {{
  foo.method(x, y); result = v;
  other.othersMethod(); result = value; // このメソッドが刺し変わらない
}};

// OK
new Expectations(foo) {{
  foo.method(x, y); result = v;
}};
new Expectations() {{ // Expectations を分ける
  other.othersMethod(); result = value;
}};

  1. もちろん、テスト用に public にしているフィールド(本来はprivateにしたい)に本番コードでアクセスされると困りますが、@VisibleForTesting を使えばアクセスしてよいフィールドかどうかは区別できるはずです。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ガチ初心者がKubernetesでコンテナを立てる?まで・・・

「Java Küche」のイベントに参加した!

2019/08/23の実施されたイベント「Kubernetes ハッカソン~Docker と Kubernetes にどっぷり浸かる二日間! ~」を振り返る記事。

講師
Microsoft・アドボケイト: 寺田 佳央さん

ありがとうございました。m(_ _)m

流れ

今回は、踏み台用の環境としてのUbuntu、そしてDockerfileのpush先、さらにKubernetesを動かす環境の、計3つのリソースをAzureで作成する。

内容としては、
UbuntuでDockerイメージ作成。
Azure Container Registry (以下、ACR) にpushする。
Azure Kubernetes Service (以下、AKS) で、コンテナを起動する。
までを紹介する。

必要なファイルや情報は、寺田さんが大変綺麗にまとめてくださってます。
GitHubリンク: https://github.com/yoshioterada/k8s-Azure-Container-Service-AKS--on-Azure

よし!やっていこう!

Outlookアカウント作成

リンク: https://signup.live.com/signup
※この時に、hotmail.comでないとAzureアカウントの作成でエラーする??

Azureアカウント作って、クレジットカード登録する

Azureのログインページ: https://portal.azure.com/#home
※クレジット登録してないと、Ubuntuのリソースを作ろうとしたときに再足される。

Ubuntuのリソースを作成する

Azureのコンソール画面にて、「Create a resource」をクリック。
Ubuntu 18.04 LTS版を作る
Resource gropは新規で作成!仮想マシン名も同じ。
リージョンは、「東日本」
SSH公開キーを「パスワード」
sshの許可する

作ったUbuntu環境にsshでアクセスする。

※resourceの詳細画面で、sshするときのip(パブリックIP)を調べとく。
例) ssh userName@ipAdress

Dockerをインストール

参考記事: https://qiita.com/myyasuda/items/cb8e076f4dba5c41afbc

sudo apt-get update

sudo apt-get install apt-transport-https ca-certificates curl software-properties-common

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable test edge"

sudo apt-get update

sudo apt-get install docker-ce

docker --version

Azure CLIのインストール

この後、ACR, AKSと連携したい。ので、AzureCLIをインストールするぞ!
参考記事: http://aka.ms/az-cli-install-jp
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash

// Dockerfileの作成と、Dockerイメージの違いが・・・
※Dockerfileは、Dockerイメージの為のレシピ。作り方、材料が書いてある。
※docker buildでイメージが作れる。らしい。

Dockerfileを、Git Cloneする

※今回は寺田さんが、GitHubで公開してくれているやつを使わえてもらう。
めっちゃ感謝してます!!!!!
(>ω<)

移動
cd FrontService

Dockerfileを眺めてみる
cat 0-Dockerfile-for-Maven

※たくさん使うライブラリ郡を毎回ダウンロードするのは大変なので、それらを一度ダウンロードして、イメージとして作っておく。次からは、そのイメージを読み込むだけで良い。よく依存しそうなものは、イメージを作っておくのをオススメする。らしい。

docker build -t maven-include-localrepo:1.1 . -f 0-Dockerfile-for-Maven
※sudo su で入っておいた方が良いかも。

さっきのbuildできたか、確認。
docker images

Azure Container Registry の作成

// Kubernetesは、ACR経由でDockerイメージを取りに来るイメージ。だと思う。
// docker hubのプライベート版てきなやつかな?

作ったイメージを、さっき言ったAzure Continer Registryにpushしたいので、先に作っておく。

参考記事: https://github.com/yoshioterada/DEIS-k8s-ACS/blob/master/CreateAzureContainerRegistry.md
ブラウザにてリソースを作成
レジストリ名は、適当に決める。
リソースグループは新規で適当に決める。
管理ユーザーは有効にしといた。

続けて、ACRへログイン

※docker login する。コマンドの引数として、user名とACRのドメインを与える。
docker login -u [user名] [xxx.azurecr.io]
の後に、パスワードを入力する

※必要な情報は、詳細画面でアクセスキーで確認できる。
※詳細画面で得られる情報 {
user : 〇〇
login-server: 〇〇〇〇.azurecr.io
pass: 〇〇〇〇〇〇〇〇
}

Dockerイメージ作成とpushの準備

// ACRへの認証はやった。(docker loginで。)
※イメージを作ると、コンテナが作れる。動いてる実態はコンテナ。

Dockerイメージの作成と、ACRへのpushを、.shファイル(2-build-create.sh)に寺田さんがまとめてくれてる。ので、shをコマンドで起動させれば良いらしい。が、そのpushするイメージの指定と、push先のリポジトリの2箇所はviで編集してね!

※ viでの編集箇所
vi 2-build-create.sh
DOCKER_IMAGE=〇〇〇〇/front-spring-service
DOCKER_REPOSITORY=〇〇〇〇.azurecr.io

※ DOCKER_IMAGEには、ACRのuser名: fullund2。/の後には、imagesの名前(ここで付けた名前がイメージの名前になるっぽい。今回は、front-spring-serviceで行こう!)

ACRへpushする

※コマンドを打つときに、バージョン指定を引数で受け取れるので・・・もし、2回目とかなら、2.0とかにしてね!
./2-build-create.sh [x.x]

※ブラウザで、pushされているか確認してね!

今回は試しに少しソースを編集してv2としてイメージ作成してみよう!

vi src/main/java/com/yoshio3/frontspring/FrontController.java
の一番した、returnの文字列を少し変更してみよう!

よし、変更したソースを元に、作成しよう
./2-build-create.sh 2.0
※引数のバージョンは、2.0にしとく。

動作確認

イメージの起動
docker run -p 8080:8080 -it [REPOSITORY名]:[x.x(TAGの数字)]

別タブで新たに開いて、curlでアクセスして確認してみよう!
curl http://localhost:8080/sample/hello

次の目標: AKSでコンテナを立てたい。まずは、ACRにあるイメージを取りに行く!

Azureページで、Create Kubernetes cluster を作る。

またリソースグループは、新規で!クラスター名は一緒かな。
リージョンは、東日本
Node count 1にする。
仮想ノードは有効

HTTP application ・・・ Yes

Kubectl コマンドのインストール

az aks install-cli

az-loginしようぜ

az login
※ブラウザ経由でログインできる

AKS接続用の資格情報の取得

az aks get-credentials --resource-group [xxxx] --name [xxxx]
※これで、Kubernetesに繋がるようになった。

試しにコマンドが使えるか確認してみるか

kubectl cluster-info
とか
kubectl get node

ACRにあるイメージを、Kubernetesに持ってきたい

今のままだと、KubernetesがACRへアクセスできない。
なので、3-create-secret4reg.shをviで編集する。

※具体的には、ACRへAKSからpullの通信を送りたい。その認証を、3-create-secret4reg.shファイルに書いてある感じ。

kubectl create secret docker-registry docker-reg-credential --docker-server=〇〇〇〇.azurecr.io --docker-username=〇〇 --docker-password="〇〇〇〇" --docker-email=hoge@example.com

※うまくいったら、 kubectl get secret で確認してみよう!

よし、これでイメージを取りに行く事は出来そう!

取ってくるイメージ名を修正する

※作成したACRサーバー名、コンテナのイメージ名に変更する
vi 4-create-deployment-svc.yaml
※修正箇所 30行目。入力する内容としては、docker imagesでの長いやつ。/の後には、verを書けばよい。
image:
〇〇〇〇.azurecr.io/〇〇/front-spring-service:1.0

※少し下も修正する。cpuの0.6を100mに変更。2箇所

※1.0を取りにいくなら、上の
template:
metadata:
labels:
app: spring-front-service
version: [ここを、"v1"としておく]

よし!認証と取りに行くイメージは指定できた。

実行

※applyコマンドを使って、ACRからイメージを取ってきて、コンテナを立てる事が出来る。
kubectl apply -f 4-create-deployment-svc.yaml

起動しているか確認

kubectl get po -w

動作確認

※実態はKubernetes側で動いてるから、lcoalhostへフォワードするコマンド
kubectl port-forward [spring-front-service-〇〇〇〇] 8080:8080
※もし上手くいかなかったら、docker stopで動いてるの止める。

別タブで・・・
curl localhost:8080/sample/hello
# 結果が表示される

v2を作って、applyする

4-create-deployment-svc.yamlを複製して、4-create-deployment-svc-v2.yamlとする。
vi 4-create-deployment-svc-v2.yaml で編集
・4行目の name: spring-front-serviceも末尾に、"-v2"を付けとく。
・imagesをいじる。:2.0にすると、v2のイメージを見ることになる。
・versionを、v2にする。

kubectl apply -f 4-create-deployment-svc-v2.yaml
※なんか、sudo suに入らないと出来なかった。

v2の起動確認

kubectl get po

// 2つめも、port-forwardして確認すると良いかも。

これで、僕らはk8sで2つのコンテナを立てることに成功した!やった!!

フィルタリングしてみよう!

kubectl get po --selector app=[],version=[v1]
-> つまり、selectorを書いて、どのpodへアクセスするか制御できる!!!

次に、Kubernetesでv2のコンテナを起動させて、v1をv2にアップデートさせる方法をやる。

serviceについて

serviceってのを作って、それを起動させよう。
serviceがpodへのアクセスを仲介する感じ??

podを実行すると、固定でipが生成されるよね。それってどう管理します?手動って大変。
実はip以外にもpodにアクセスする方法がある。
ipが頻繁に変化することになるのは、良くないですよね。

デプロイの肝
=> serviceを作って、この一意のIPアドレスを覚えてさえいれば、あとは、serviceのselectorでpodを変更できる。
11-Service.yaml を確認してみよう!

serviceの起動

kubectl apply -f 11-Service.yaml

確認

kubectl get svc

メリット

正常に動いているpodを立てたまま、新たなpodに以降できる。
直ぐに戻す事も出来る!
これが、kubernetesの凄く良いところ!!!!
自分自身でやろうとすると、大変。
yaml編集するだけ。

ちなみに

通常は、serviceはtype: ClusterIP!!
まぁ一瞬だけでも、どうしても外部に公開したいなら、type: LoadBalancerにする。
でも、よくない。危険。注意してね。

--> serviceが複数ある時、Ingressでまとめて、そのIngressの設定で、pathを設定する事で、URLに応じて呼び出すserviceを分岐できる。

まとめ: Ingressを外部に公開すると良い。

IngressでURLを分けて!serviceでpodを分岐する。
イメージは、serviceのルーティング。受付嬢!!

マイクロサービスを運営する上で、便利な機能。

Ingressを修正して立ち上げてみる

vi 12-Ingress.yaml
修正箇所: - host: front-service.[〇〇〇〇.aksapp.io]
この修正の中身は、ブラウザの、リソースの「HTTP アプリケーションのルーティング ドメイン」から貼り付け
※場所的には、ホーム > リソース グループ > [k8s] > [k8s] かな?

立ち上げ

kubectl apply -f 12-Ingress.yaml

確認

kubectl get ing

アクセス

http://[front-service.〇〇〇〇.japaneast.aksapp.io]/sample/hello

一応、イベントの内容はここまで。

デザインオブフェアーという考え

壊れる事が前提で、物事を作っていくマインドがないと、kubernetesは触らない方が良い。
落ちる。影響範囲を小さくする。
落ちないようにする努力よりも、落ちる前提で考えた方が良い。
落ちた時の再構築の流れを考える。どこまでデザインオブフェアーをやり通すか。必ず落ちる。

最後に

本当に寺田さん、ありがとうございました!
正直、Dockerは30分は触ってたので何となく分かったけど、Kubernetesは無知でした。
モブプロだった事も幸いし、なんとか最後まで出来た!!

(´∀`)

後日、知り合いと3人で同じ内容をした。
本記事は、その時の成功例を元に作成した。

繰り返すって大事だなぁ。

Kubernetes、、、というかマイクロサービスをちょっと感じれて良かった。
またやりたい!(^・^)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

線形探索(番兵法)

番兵法とは

線形探索でコストを抑えるための方法。
探索するキーと同じ値を配列の最後に格納する。
このときに格納するデータを番兵(sentinel)と呼ぶ。

SenlinSearch.java
/*
    線形探索(番兵法)
*/

import java.util.Scanner;

public class SenlinSearch{
  static int linSearch(int[] arr,int n,int key){
    int i=0;
    arr[n]=key;
    while(true){
      if(arr[i]==key){
        break;
      }
      i++;
    }
    return i == n ? -1: i;
  }

  public static void main(String[]args){

    Scanner sc = new Scanner(System.in);
    System.out.print("要素数を入力: ");
    int n=sc.nextInt();
    int arr[]=new int[n+1];

    for(int i=0;i<n;i++){
      System.out.print(i+"個目: ");
      arr[i]=sc.nextInt();
    }

    System.out.print("探す値を入力: ");
    int key=sc.nextInt();
    arr[n]=key;

    int ans=linSearch(arr,n,key);

    if(ans==-1){
      System.out.println("見つかりませんでした");
    }else{
      System.out.println(ans+"つ目に見つかりました");
    }

  }
}

実行例

要素数を入力: 5
0個目: 2 
1個目: 4
2個目: 5
3個目: 6
4個目: 7
探す値を入力: 6
3つ目に見つかりました

要素数を入力: 5
0個目: 2
1個目: 4
2個目: 5
3個目: 6
4個目: 7
探す値を入力: 8
見つかりませんでした
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

springを勉強するうちに、Neither BindingResult nor plain target object for bean name 'command' available as request attribute エラー対処

springmvcのフォームredirectの練習をしたとき、jsp中にspringのformタグを使い時、こんなエラーが発生しました。

Stacktrace:] with root cause
java.lang.IllegalStateException: Neither BindingResult nor plain target object for bean name 'command' available as request attribute
at org.springframework.web.servlet.support.BindStatus.(BindStatus.java:141)
at org.springframework.web.servlet.tags.form.AbstractDataBoundFormElementTag.getBindStatus(AbstractDataBoundFormElementTag.java:178)

調べたところ、

エラーが発生した理由:

   spring:bindのソースの中身を見ると
   Object target = requestContext.getModelObject(beanName);
   if (target == null) {
    throw new IllegalStateException("Neither BindingResult nor plain target object for bean name '" +
      beanName + "' available as request attribute");
   }
ようは、ある画面にリクエストする時、リクエストにオブジェクトのcommandがないので、エラーが発生する

解決方法:
 JSPに、/jsp:useBeanを追加します

    

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[個人メモ] すでにJavaが入っているAmazonLinuxに、最新のJavaをインストールしてバージョンを切り替える

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Java】バイト数指定で文字列を半角スペースパディングする

import java.nio.charset.Charset;

public class Main {
    public static void main(String[] args) throws Exception {
        int size = 10;
        String[] charsetNames = {"utf-8", "sjis"};
        String[] targets = {"あ", "aiueo", "あいうえお", "?"};
        for (String charsetName: charsetNames) {
            System.out.println("--- " + charsetName + " ---");
            for (String target: targets) {
                System.out.println("[" + Main.padRight(target, size, charsetName) + "]");
            }
        }
        // 実行結果:
        // --- utf-8 ---
        // [あ       ]
        // [aiueo     ]
        // [あいうえお]
        // [?      ]
        // --- sjis ---
        // [あ        ]
        // [aiueo     ]
        // [あいうえお]
        // [?         ]
    }

    /**
     * 文字列の末尾を半角スペースでパディングする。
     * 文字列が指定バイト数を超過している場合、渡された文字列をそのまま返却する。
     * 
     * @param target パディング対象の文字列
     * @param size パディング後のバイト数
     * @param charsetName 文字コード名
     * @return パディング後の文字列
     */
    private static String padRight(String target, int size, String charsetName) {
        Charset charset = Charset.forName(charsetName);
        int blankSize = " ".getBytes(charset).length;
        int paddingLen = (size - target.getBytes(charset).length) / blankSize;

        StringBuilder sb = new StringBuilder(target);        
        for (int i = 0; i < paddingLen; i ++) {
            sb.append(" ");
        }
        return sb.toString();
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Java]バイト数指定で文字列を半角スペースパディングする

import java.nio.charset.Charset;

public class Main {
    public static void main(String[] args) throws Exception {
        int size = 10;
        String[] charsetNames = {"utf-8", "sjis"};
        String[] targets = {"あ", "aiueo", "あいうえお", "?"};
        for (String charsetName: charsetNames) {
            System.out.println("--- " + charsetName + " ---");
            for (String target: targets) {
                System.out.println("[" + Main.padRight(target, size, charsetName) + "]");
            }
        }
        // 実行結果:
        // --- utf-8 ---
        // [あ       ]
        // [aiueo     ]
        // [あいうえお]
        // [?      ]
        // --- sjis ---
        // [あ        ]
        // [aiueo     ]
        // [あいうえお]
        // [?         ]
    }

    /**
     * 文字列の末尾を半角スペースでパディングする。
     * 文字列が指定バイト数を超過している場合、渡された文字列をそのまま返却する。
     * 
     * @param target パディング対象の文字列
     * @param size パディング後のバイト数
     * @param charsetName 文字コード名
     * @return パディング後の文字列
     */
    private static String padRight(String target, int size, String charsetName) {
        Charset charset = Charset.forName(charsetName);
        int blankSize = " ".getBytes(charset).length;
        int paddingLen = (size - target.getBytes(charset).length) / blankSize;

        StringBuilder sb = new StringBuilder(target);        
        for (int i = 0; i < paddingLen; i ++) {
            sb.append(" ");
        }
        return sb.toString();
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む