playframework1 – こみなのメモ帳 / 趣味と実益のネタ帳 Tue, 16 Dec 2025 16:04:28 +0000 ja hourly 1 https://wordpress.org/?v=6.1.1 [play1]evolutions機能 /archives/1820/ Tue, 16 Dec 2025 16:04:28 +0000 /?p=1820 概要

playframework1に当初から内包されているDBスキーマを管理する機能の名前。普段、DB周りはJPAに任せてしまうことが多いのですが、先日初めて evolutionを使ってみたのでそのとき得た知見をまとめておこうと思います。

何ができるのか

JPA(Hibernate)は自動でデータベースのスキーマ更新を行うことができます。小規模開発ならJPAの自動更新(インデックスの削除など不可なケースもある)に任せてもいいのですが、ある程度のプロジェクトであればスキーマの変更を追跡、管理する必要が出てきます。

スキーマの変更をメンバー全員に周知したり、本番サーバのスキーマ変更を明示的に行ったり、いくつかの開発環境DBを同期させたり、などの場面で役立ちます。

準備

  1. Evolution機能を利用する際はまず、ディレクトリ db/evolutions を作成する。
  2. そのディレクトリの中に、1.sql, 2.sql, とDDLを作成する。
    スキーマ変更を行うたびに新しい番号でDDLを記述したファイルを配置する。

SQLスクリプトファイル

  • db/evolutions に配置するSQLスクリプトは、UpsとDownsの2つのパートから構成される。
  • Upsは変更するためのスクリプト。(例えば、 create table XXX...
  • Downsは元に戻すためのスクリプト。(例えば、drop table XXX...

マルチDB対応

マルチDBにも対応しています。マルチDBでは各DBに識別子を設定しますが、その識別子をスクリプトファイルの先頭に記述します。

1.sql, 2.sql, ... となるところが、DB-1.1.sql, DB-1.2.sql, ...,DB-2.1.sql, DB-2.2.sql,... といった形になります。

設定

application.conf による動作の制御方法

evolutions.enabled=false

Evolutions機能を利用しないときに false を設定する。

modules.evolutions.enabled=false

Evolutionsモジュールを利用しないときに設定する。

dbname.evolutions.enabled=false

マルチDBでDB指定してEvolutionsモジュールを利用しないときに設定する。

evolutions.autocommit=false

autocommitをしないときに指定する。

evolution.PLAY_EVOLUTIONS.textType=varchar(32768)

Evolutions機能で作成する管理テーブル play_evolutionsapply_script , revert_script , last_problem 列の型を指定する。未指定の時、oracleなら clob型、他は text型 となる。
デフォルトの hd2b を使う場合は varchar(32768) を指定する必要があった。

db/evolutions ディレクトリの存在でモジュールはONとなるので、上記設定で個別にOFFにしていく感じになります。

動作

DEVモード

DEVモードの時はリクエストが発生するたびにスキーマ状態の確認が割り込みます。そこで未適用のスクリプトが見つかった場合は適用を要求するページが表示されます。(例外オブジェクトの getMoreHTML にて適用するための <form/> を含む html を返すことで適用要求ページを実現している)

詳しくは調べていないのですが、UnexpectedException にラップされて <form /> が表示されない事象が発生したので以下のように views/errors/500.html を修正して対応しました。

適用要求ページが出ないで UnexpectedException が表示される
      <body>
        #{if play.mode.name() == 'DEV'}
          #{if exception?.cause instanceof play.exceptions.PlayException }
            #{500 exception.cause /}
          #{/if}#{else}
            #{500 exception /}
          #{/else}
        #{/if}
        #{else}
            <h1>Oops, an error occurred</h1>

db=mem で中身が空の場合は問い合わせなく勝手にスクリプトが適用される。

PRODモード

本番モードなら最初の起動時にスキーマ状態の検査が行われます。未適用のスクリプトが見つかった場合はエラーとなって起動中断となります。

アプリケーションが要求するDBスキーマとなっていないと起動しないようになっています。

コマンドライン

コマンドラインよりスクリプトの適用を制御します。

$ play evolutions

未適用のスクリプトを表示する。

$ play evolutions:apply

未適用のスクリプトを適用する。

$ play evolutions:markApplied

未適用と認識されているスクリプトを Evolutions機能を経由しないで適用したときに使用する。(未適用スクリプトを強制的に適用済ステータスにする)

$ play evolutions:resolve

スクリプトの実行中にエラーが発生すると問題が発生したスクリプトとして記録され、以降の適用が保留となる。そのときこのコマンドで問題が解決済とする。(問題発生ステータスをクリアする)

play evolutionsplay ev と省略することが可能です。

Tips

DDL作成

自分で一からSQLスクリプトファイルを書くのは大変なのでJPA(Hibernate)を利用すると楽ができます。

jpa.ddl=update
jpa.ddl=create

この設定にしておけば起動時にDDL変更を自動で行ってくれる。

jpa.debugSQL=true
hibernate.show_sql=true

DDL変更のSQLがログに出力されるのでこれをスクリプトファイルへ転記すればよい。

戻しについてはJPAからの出力は期待できないので自分でスクリプトを用意する必要があります。

module_key カラムについて

管理テーブル play_evolutions には、module_key というカラムが有、application.name の値が格納されていて、抽出条件にもなっています。

複数の playframeworkアプリが同一DBに対して Evolutionsを利用することを想定しているようです。

なので、application.name の扱いには注意しましょう。途中で変更したり、開発時だけ名前を変えていたりすると、予期しない挙動を招く可能性があります。

途中からEvolutionを利用するとき

既に開発が進んでいるプロジェクトで Evolutionsを利用するときの手順を示します。

  1. アプリケーション停止する。
  2. db/evolutions ディレクトリを作成する。
  3. 1.sql」 を設置(現時点のDBのDDLを取得して作成)
  4. play ev」コマンドを実行する。(管理テーブルが作成されて、1.sql が未適用として表示される)
  5. play ev:markedApplied」コマンドを実行して適用済とマークする。
  6. アプリケーション起動する。

以上になります。以降のDDL変更は、#.sql へ記述します。

h2dbで現時点のスキーマを出力する方法

sql> script nodata to 'C:\work\ddl.sql

データやスキーマをSQLとして書き出すことができるコマンド。今回はデータなしなので nodata を指定。

参考)https://h2database.com/html/commands.html#script

本番リリースするとき

新しいバージョンのアプリケーションに入れ替えるときは、停止中にDDL適用を行います。

  1. play ev:apply」コマンドを実行し、未適用のスクリプトを適用する。
  2. apply中にエラーが出てしまったらステータスがエラーになってしまうので、「play ev:resolve」でエラー状態を解消する。
    問題を解決して再びスクリプト適用を行う。

evolution.PLAY_EVOLUTIONS.textTypeの必要性

h2dbでは下図のような管理テーブル play_evolutions が作られます。

デフォルト設定の場合

ここで apply_scriptrevert_script 列のデータ型が clob型となっている点に注目してください。Evolutionsではこれらを resultSetからgetString(n) で取得しているのですが、Clob型のtoStringではテキストの中身ではなく `java.sql.Clob@123′ のようなオブジェクトID文字列になってしまいます。

そのオブジェクトID文字列とスクリプトファイルの中身を比較することになるために常にNGとなってしまうのです。

そこでDBデータ型を明示的に指定することでこの問題を回避することができるのです。

evolution.PLAY_EVOLUTIONS.textType=varchar(32768)

※ この設定だけ evolutionが単数形なので注意。

org.h2.jdbc.JdbcSQLSyntaxErrorException: 列名 “MODULE_KEY” が重複しています

想像ですが。昔の Evolutionsでは module_key による管理がなく、ある時から管理できるようになった。そのため昔に作られた管理テーブル play_evolutions に自動でカラムを追加してくれるマイグレーション機能が存在するみたいです。

で、module_keyが存在しているのにこのマイグレーション機能が誤作動してしまうと題名のようなエラーが発生することがあります。

具体的例としては、h2dbの DATABASE_TO_UPPERtrueになっていると大文字でテーブルやカラムが作成されてしまって存在チェックで誤検知が発生してしまうことになります。

play-1.5.3 + h2db@1.4.196 では小文字で play_evolutions が作成
play-1.7.2 + h2db@1.4.200 では大文字で作成

組み合わせでデフォルト動作が異なるので JDBC_URL で明示的に指定した方が良いです。

]]>
[play1] play1.7系でついにpython3へ /archives/1040/ /archives/1040/#respond Wed, 02 Nov 2022 02:18:31 +0000 https://www.komina.info/?p=1040 Pythonが同梱されなくなる

playframework1ではplayアプリケーションを管理するために沢山のスクリプトがpythonで書かれています。JavaVMの起動の遅さ、OSに関わる処理のやりにくさなどの理由でJavaではなくPythonを採用したそうです。

PythonはOS依存せず高速動作し書きやすいですが、残念ながらWindowsには標準でインストールされていません。そこでplayframeworkではpythonも一緒に同梱するアプローチを採っています。

で、今まで同梱されてたpythonのバージョンは2系でした。(現在のpythonは3系に主流が移っていますが、playframework1が誕生したのは2007年頃ですのでこれは仕方ないことですね。)

play1.7.0からはpythonで書かれたスクリプトがすべてpython3対応されるとともに、python2の同梱も廃止となりました。

Windows環境ではPython3のインストールが必要

pythonがバンドルされなくなったので windows環境で使用する場合はまずpython3をインストールしなくてはなりません。

Python Releases for Windows | Python.org
https://www.python.org/downloads/windows/

このあたりから自分に合った版を選んでインストールすればいいです。pipでモジュール管理するのであれば素直にインストーラを用いるのがいいと思います。

今まで同梱Pythonがあったフォルダには代わりに requirements.txt というファイルが置かれています。これは依存しているモジュールを記したファイルなので、これを引数にして pip経由でモジュールを追加しましょう。

C:\> pip install -r play-1.7.1\python\requirements.txt

また、これとは別に windows環境の場合は pywin32 というモジュールも必要なようです。インストールされていないと下記のようなエラーが出ます。

No module named 'win32pdh'
  File "C:\play1\bin\play-1.7.1\framework\pym\play\commands\daemon.py", line 14, in <module>
    import win32pdh, win32pdhutil
ModuleNotFoundError: No module named 'win32pdh'

直接 pywin32 を指定してインストールします。

C:\> pip install pywin32

プロキシ経由するとき

pip.ini経由でプロキシ設定したりする方法もありますが、私は環境変数を用いた方法を使いました。

SET HTTP_PROXY=http://user:pass@192.168.0.10:8080
SET HTTPS_PROXY=http://user:pass@192.168.0.10:8080

最後に

python3が正しくインストールされていればお馴染みの画面が表示されるはずです。

C:\play1\workspace>play
~        _            _
~  _ __ | | __ _ _  _| |
~ | '_ \| |/ _' | || |_|
~ |  __/|_|\____|\__ (_)
~ |_|            |__/
~
~ play! 1.7.1, https://www.playframework.com
~
~ Usage: play cmd [app_path] [--options]
~
~ with,  new      Create a new application
~        run      Run the application in the current shell
~        help     Show play help
~

import imp 問題

impライブラリは python3.4から非推奨になっていて、python3.12で削除されたそうです。
The imp module was removed from python 3.12 · Issue #1457 · playframework/play1 (github.com)

/opt/hostedtoolcache/play-1.7.1/play deps --verbose --sync --forProd 
Traceback (most recent call last):
  File "/opt/hostedtoolcache/play-1.7.1/play", line 13, in <module>
    from play.cmdloader import CommandLoader
  File "/opt/hostedtoolcache/play-1.7.1/framework/pym/play/cmdloader.py", line 2, in <module>
    import imp
ModuleNotFoundError: No module named 'imp'
make: *** [Makefile:68: deps] Error 1

修正は 1.8.0 に対して行われているようなので、1.7系で対処したい場合は以下の差分を参考にcmdloader.py, modulesrepo.py を自前で修正しましょう。
#1457 python imp module by xael-fry · Pull Request #1465 · playframework/play1 (github.com)

おまけ

モジュールにも command.py というスクリプトがあります。そのため play-1.7系ではそのままでは動かなくなっています。play deps 直後にエラーを吐いちゃったり。。

~ !! Error while loading C:\play1\workspace\aaaa\modules\launcher-0.1.1\commands.py: Missing parentheses in call to 'print'. Did you mean print(…)? (commands.py, line 23)
~

モジュール側でのPython3対応が期待できない場合は、Python に付属していある 2to3.py というスクリプトを用いてPython3対応が簡単に行えます。

]]>
/archives/1040/feed/ 0
[play1-test] Async-http-clientのエンコード問題 /archives/947/ /archives/947/#respond Mon, 13 Dec 2021 06:38:31 +0000 https://www.komina.info/?p=947 play1で小さなシステムを組むときは効率優先でテストまで作らないことが多いのですが、ちょっとシステムが複雑になってきたのでテストを書くことにしました。

playframework1にはテスト機能もセットに含まれています。テストの種類としては

  • 単体テスト (Unit test)
  • 機能テスト (Functional test)
  • Seleniumテスト (Selenium test)

の3つのテスト機能が提供されていて、フレームワーク側のフォーマットに従うと簡単に実装できます。

機能テストはControllerに対して疑似パラメータを送ってテストすることができます。ブラウザ経由ではないので、画面からは入力できないようなパラメータも試すことができます。

Seleniumテストはブラウザ経由のテストなのですが、ここで使われるブラウザは同梱されているHtmlUnitというヘッドレスブラウザ。単純なhtmlだけなら不足はないのですけど、JavaScriptエンジンが弱いのが欠点です。最近のSPAフレームワークとかは無理と考えた方がいいです。

で、ひとまず全画面が表示できるか、みたいな簡易なテストであれば機能テストで実装できます。controllerやroutesをいじっているといつの間にかリンク切れとか起きちゃうことがありますからね。大事なことです。

問題発生

ことろで最近、効率優先でフィールド名に使うことが多くなりました。そうするとフォーム入力でも

<form method="POST">
  <input type="text" name="data.パラメータ" />

のような name値が日本語のタグが出てくるようになります。

これは問題なく動作します。

一方、この画面の機能テストを作成する場合は下記のようになるわけですが

    Map<String, String> param = new HashMap<String, String>() {
        {
            put("data.パラメータ", "編集テスト");
        }
    };
    Response response = POST("/", param);

これがどうやらうまくいきません。よくよく追いかけてみますとPOSTデータをエンコードする際に文字化けしているようです。

value値はRequest.encodingに合わせてUTF-8でエンコードしているのですが、name値はUS_ASCII固定でのエンコードとなっていました。

原因箇所

テストでのPOSTリクエストデータの生成を担っているのが async-http-client というライブラリです。play-1.5.3に同梱されているのは async-http-client-1.9.40.jar という少し前の版です。

play.test.FunctionalTest では下記のように com.ning.http.client.multipart.StringPartオブジェクトを生成してパラメータを処理しています。

    public static Response POST(Request request, Object url, Map<String, String> parameters, Map<String, File> files) {
        List<Part> parts = new ArrayList<>();

        for (String key : parameters.keySet()) {
            StringPart stringPart = new StringPart(key, parameters.get(key), request.contentType, Charset.forName(request.encoding));
            parts.add(stringPart);
        }

StringPart PartBase を継承しており name値のエンコードはこの PartBase に実装されている箇所で行っています。最新の version 2.12.3 では org.asynchttpclient.request.body.multipart.part.MultipartPart.java に同じコードが残っています。

  protected void visitDispositionHeader(PartVisitor visitor) {
    visitor.withBytes(CRLF_BYTES);
    visitor.withBytes(CONTENT_DISPOSITION_BYTES);
    visitor.withBytes(part.getDispositionType() != null ? part.getDispositionType().getBytes(US_ASCII) : FORM_DATA_DISPOSITION_TYPE_BYTES);
    if (part.getName() != null) {
      visitor.withBytes(NAME_BYTES);
      visitor.withByte(QUOTE_BYTE);
      visitor.withBytes(part.getName().getBytes(US_ASCII));
      visitor.withByte(QUOTE_BYTE);
    }
  }

part.getName().getBytes(US_ASCII) とUS_ASCIIに強制的にエンコードされている箇所がそれです。

対策について

play1のテストで使うだけであれば該当の FuncionalTest.POST() をベースに自前のPOSTを作成し、StringPart の代わりに独自実装の MyStringPart を用意すればOKです。

    public static Response POST(Request request, Object url, Map<String, String> parameters) {
        List<Part> parts = new ArrayList<>();

        for (String key : parameters.keySet()) {
            MyStringPart stringPart = new MyStringPart(key, parameters.get(key), request.contentType, Charset.forName(request.encoding));
            parts.add(stringPart);
        }
public class MyStringPart extends com.ning.http.client.multipart.StringPart {

    public MyStringPart(String name, String value, String contentType, Charset charset) {
        super(name, value, contentType, charset, (String)null);
    }

    @Override
    public void visitDispositionHeader(PartVisitor visitor) throws IOException {
        visitor.withBytes(CRLF_BYTES);
        visitor.withBytes(CONTENT_DISPOSITION_BYTES);
        visitor.withBytes(this.getDispositionType() != null ? this.getDispositionType().getBytes(StandardCharsets.US_ASCII) : FORM_DATA_DISPOSITION_TYPE_BYTES);
        if (this.getName() != null) {
           visitor.withBytes(NAME_BYTES);
           visitor.withByte((byte)34);
           visitor.withBytes(this.getName().getBytes(StandardCharsets.UTF_8));
           visitor.withByte((byte)34);
        }
     }
}

とりあえずこれでplay1の機能テストは通るようになりました。

終わりに

本来は githubの方へプルリクエストを投げた方がいいんでしょうね。

ちょっとやり方を勉強してみますか・・。

]]>
/archives/947/feed/ 0
[play1-controller] formの受け取り方法について /archives/918/ /archives/918/#respond Mon, 08 Nov 2021 09:10:53 +0000 https://www.komina.info/?p=918 playframeworkではフォームから値を受け取るのが非常に簡単です。inputタグのnameとアクションメソッドの引数名を一致させるだけで値を受け取ることができます。

あまり使わないテクニックは自分でも忘れてしまうので備忘も兼ねて書いておきます。

型ごとの書き方

数値型や文字列型

<input type="text" name="id" />
<input type="text" name="name" />
public static void action(Long id, String name) { ... }

プリミティブ型のときは何も考えずに受け取れる。受け取り側の型にパース出来ないときは null になる。

Date型

<input type="text" name="postAt" />
public static void action(
    @As("yyyy-MM-dd HH:mm:ss") Date postAt) { ... }

@play.data.binding.Asアノテーションで気軽に日付文字列をDate型として受け取ることができる。カレンダーピックアップなどのコントロールと組み合わせるとなお便利。

Enum型

列挙型の受け取りもフレームワークが行ってくれる。その際、Enum.valueOf() でパースを試みるので、valueの値もそれに見合った値にしておく。

<input type="radio" name="status" value="${MyEnum.TO_DO}" />
<input type="radio" name="status" value="${MyEnum.IN_PROGRESS}" />
<input type="radio" name="status" value="${MyEnum.DONE}" />
public enum MyEnum { TO_DO, IN_PROGRESS, DONE }
public static void action(MyEnum status) { ... }

Map型

Map<String, String>型の key と value として値を受け取ることもできる。パラメータが多かったり変化が大きいときなど、POJOクラスを用意するのが困難な時は有用。

<input type="text" name="test.name" />
<input type="text" name="test.address />
<input type="text" name="test.age" />
public static void action(Map test) { ... }
[ name: '...', address: '...', age: 'nn' ]

同じ name が使われていた場合はカンマ区切りで解決される。

POJOクラス

Mapの時と同様にドットで区切る。ネスト構造でも再帰的にバインドしてくれる。

<input type="text" name="user.name />
<input type="text" name="user.address" />
<input type="text" name="user.age" />
<input type="text" name="user.birthDay" />
public class User {
    public String name;
    public String address;
    public Long age;
    @As("yyyy-MM-dd")
    public Date birthDay;
}
public static void action(User user) { ... }

JPAオブジェクトへのバインド

idが含まれるとfindByIdで自動的にSELECTされ、そのオブジェクトに対してフォームから渡される値が設定される。そのまま save() することもできる。

<input type="text" name="id" value="123"/>
<input type="text" name="user.name />
<input type="text" name="user.address" />
<input type="text" name="user.age" />
<input type="text" name="user.birthDay" />
@Entity
public class User extends Model {
    public String name;
    public String address;
    public Long age;
    @As("yyyy-MM-dd")
    public Date birthDay;
}
public static void action(User user) {
   user.save();
}

余談だが、@javax.persistence.Version を使った楽観的なバージョン管理をしている場合は、SELECTした時のVersion値をプロパティ値とは別で保持しており、故意にプロパティ値を変えてバージョンエラーを起こそうとしても起きない。これと同様の事象がバインドでも発生するので、画面からバージョン値は渡す場合は別変数として渡す必要がある。

JPAのリレーション

JPAオブジェクトをバインドする際、@javax.persistence.ManyToOne などのリレーション系のアノテーションについても処理してくれる。name値の末尾に ".id" を付けるのがポイント。

<input type="text" name="user.name" />
<select name="user.age.id" >
 #{list models.Age.findAll() }
  <option value="${_.id}">${_.generation}</option>
 #{/list}
</select>
@Entity
public class Age extends Model {
    public String generation;
}
@Entity
public class User extends Model {
    public String name;
    @ManyToOne
    public Age age;
}
public static void action(User user) { ... }

カスタムバインダー

アクションメソッドの引数や、オブジェクトのプロパティに対して@play.data.binding.As によって解決するためのタイプバインダーを指定することができる。

タイプバインダーは play.data.binding.TypeBinder インタフェースを実装し、文字列からオブジェクトへどのように変換すればよいかを記述する。

作成した タイプバインダーに @play.data.binding.Global を付与すると @As で指定することなく等価的にタイプバインダーが適用される。

任意のオブジェクトにバインド

アクションメソッドの引数として書かない方法。任意のタイミングで任意の変数にバインドすることもできる。

<input type="text" name="user.name" />
<input type="text" name="user.address" />
User u = new User();
Binder.bindBean(params.getRootParamNode(), "user", u);
// JPAオブジェクトの場合
User u = User.findById(id);
u.edit("user", params.all());
u.save();

タグの種類ごとの書き方

Radio Button

ラジオボタンの受け取りも用意。nameタグの値を統一するのがポイント。

初期選択状態を制御する場合はcheckedキーワードを任意で差し込む。

<input type="radio" name="reportType" value="1"
 />
<input type="radio" name="reportType" value="2" />
<input type="radio" name="reportType" value="3" />
public static void action(String reportType) { ... }

Checkbox

チェックボックスは配列型やコレクション型で受け取ることができる。nameタグの値を統一するのがポイント。

初期選択状態を制御する場合はcheckedキーワードを任意で差し込む。

<input type="checkbox" name="reportType" value="1" />
<input type="checkbox" name="reportType" value="2" />
<input type="checkbox" name="reportType" value="3" />
public static void action(Set<String> reportType) { ... }

public static void action(String[] reportType) { ... }

Select

セレクトはラジオボタンやチェックボックスと同様です。単一選択ならラジオボタン方式、複数選択ならチェックボックス方式で受け取る。

<select name="ans1" >
  <option value="1">the flog</option>
  <option value="2">the rabbit</option>
  <option value="3">the bird</option>
</select>
public static void action(String ans1) { ... }
<select name="ans2" multiple >
  <option value="1">Bike</option>
  <option value="2">Car</option>
  <option value="3">Train</option>
</select>
public static void action(Set ans2) { ... }

public static void action(String[] ans2) { ... }

Input type=”FILE”

form の enctype multipart/form-data にして、input type="file" にする。アップロードすると 一時フォルダ内にFileオブジェクトとして受け取ることができるが、リクエスト処理の終了と共に消えてしまう。必要ならコピーなど行う必要がある。

一時格納場所は ${play.tmp}/uploads/配下。

<form method="POST" action="upload" enctype="multipart/form-data" >

  <input type="file" name="heno"/>

</form
public static void upload(File heno) {
  heno.renameTo(new File("abc.bin");
}

JPAオブジェクトにplay.db.jpa.Blobプロパティを置くと、エンティティに紐づいたファイルとして受け取ることができる。格納先は apllication.conf で設定された ${attachments.path} ディレクトリ。

<form method="POST" action="upload" enctype="multipart/form-data" >

  <input type="text" name="profile.name" />
  <input type="file" name="profile.photo"/>

</form
@Entity
public class Profile extends Model {
  public String name;
  public Blob photo;
}
public static void upload(Profile profile) { ... }

${attachments.path}へのファイル保存は永続化とは同期しておらず、保存ポリシーはユーザに任されている(save()せず破棄したり、ロールバックしたり、あるいはdelete()しても削除されない)。アップロードされたファイルを勝手に消さない方向になっているのだろう。

なので、参照されていないファイルを削除するような非同期ジョブを定期的に動かすなどした方がいいかもしれない。

ついでにNettyで稼働している場合にマルチパートでアップロードすると一時ファイルが${play.tmp}配下に作られる。こちらも自動で削除されないようなので適宜掃除することを検討されたし。

]]>
/archives/918/feed/ 0
[play1-template]Bootstrap5でfieldタグ活用(バリデーション) /archives/912/ /archives/912/#respond Thu, 04 Nov 2021 03:07:30 +0000 https://www.komina.info/?p=912 Bootstrapって見栄えのいいページが簡単に作れて便利ですよね。play1でツールを作成する際に活用するとグレードが数段上がった気になれます。ありがたいです。

ところでplay1にはフォーム周りの記述を楽にするために fieldタグというものがあります。

この記事でも解説していますが fieldタグではValidation機能で検出したエラーを、${field.error} および ${field.errorClass} で得ることができます。

${field.error} はエラーの有無およびエラーメッセージ。${field.errorClass} はエラー発生時に "hasError" という文字列を返します。あらかじめstylesheetで、hasErrorクラスに対して装飾を定義しておくことを前提にしています。

一方、Bootstrap5ではフォームのエラー状態の装飾が is-validクラスで定義されていいます。class="" の中に#{if}タグでfield.errorの値の有無を判断してis-validクラスを追加するようにしてもいいのですが、あまり可読性が良くありません。

そこで ${field.errorClass} はそのままに、JavaScriptで "hasError" を目印にして "is-invalid" をクラスに追加する方法を思いつきました。

#{field 'user.name'}
<div class="mb-3 row">
  <label for="${field.id}" class="col-sm-2 col-form-label" >&{field.name}</label>
  <div class="col-sm-10">
    <input type="text" class="form-control ${field.errorClass}" id="data_実行予定日時" value="${field.value}"  name="${field.name}" />
    #{if field.error}<div class="invalid-feedback">${field.error}</div>#{/if}
  </div>
</div>
#{/field}

で、最後にJavaScriptでイベント登録。コンテンツがロード完了したタイミングで “hasError”クラスを持つ全ての要素に対して “is-invalid”クラスを追加しています。

document.addEventListener("DOMContentLoaded", function(){
    var elements = document.getElementsByClassName('hasError');
    Array.from(elements).forEach(function(e) {
        e.classList.add('is-invalid');
    });
});

Bootstrap5からは jQuery不採用となったらしいので、標準JavaScriptでの記述にしました。逆に新鮮な感じです。

]]>
/archives/912/feed/ 0
[play1] 単発実行用のlauncherモジュールを作りました /archives/879/ /archives/879/#respond Thu, 29 Jul 2021 09:41:43 +0000 https://www.komina.info/?p=879 以前から自分で使っていたモジュールをgithubに公開することにしました。
play1-projects/launcher at main · komina77/play1-projects (github.com)

mavenへ登録するまでは手が回っていません。launcher/dist配下のzipをコピーしてきてローカルレポジトリに置いて、依存性解決してください。設定の書き方は /samples/say-hello/conf/dependencies.yml を参考に。

何ができるか

Webアプリケーションフレームワークとして作られているplayframework1を、Webサーバを起動しないで任意のクラスを実行することができます。

$ play launcher:run yourAppDir --class=info.komina.MyJob 

play1のJPA拡張やpojo拡張などの便利な機能を享受したJavaアプリケーションを作ることができるわけです。

リソースが相互に干渉しないように設定すれば、Webアプリケーションとして稼働しつつ、コマンドラインからジョブ実行。JenkinsやJP1のようなツールにジョブの実行を任せるような運用も可能です。

作るきっかけ

playframework1のジョブ機能を利用していて運用に不満を持ったのがきっかけです。ジョブ機能は正常に動いているときは確かに便利なのですが、実行状況を把握したり、任意のジョブをリカバリのためにリランさせたりといったことは、あらかじめプログラミングされていないとできません。

そこでジョブ類をWebアプリケーションから分離できたらな、と考えコマンドラインからの実行を思い立ちました。

Groovyも使えます

最近はGroovyも良く使うので、Groovyスクリプトも利用できるようにしています。もともとplay1ではテンプレート機能のためにGroovyの実行環境が同梱されているので、それを呼び出す部分から流用しています。

そのため、テンプレートと同様にコンパイル後キャッシュされます。

ジョブの動作テスト用に使う

モジュールのドキュメントにも書きましたが、もともとあるジョブ(play.jobs.Jobを継承)にmainメソッドを追加する方法がおすすめです。mainメソッドにはジョブを起動するだけのコードを書き、メインの処理はdoJob()内に記述します。

コマンドラインからジョブだけを直接実行できるので、試行錯誤が効率的に行えます。

]]>
/archives/879/feed/ 0
[play1-deps] ui4jを使いたいのですが /archives/854/ /archives/854/#respond Wed, 16 Jun 2021 08:32:13 +0000 https://www.komina.info/?p=854 うだうだ

とあるサイトをJsoupでスクレイピングして情報を収集していたのですが、ある日を境に収集に失敗するように。調べてみるとreact.jsというのを使って動的にページを作成するように変わったようです。

こうなると単なるhtmlをダウンロードするjsoupではお手上げ、ブラウザのように振舞いJavascriptを実行してページを読み込まないといけません。

playframework1ではテスト機能をtestrunnerというモジュールとして内蔵しています。その中でhtmlテスト用にhtmlUnitというライブラリを標準で使用するようになっています。いわゆるheadlessブラウザで簡単なWebページやJavascriptであれば表示(画面はないのでDOMとして読み込み)が可能です。

htmlUnitは確かにJavaScriptを実行できるのですが、JavaScriptエンジンにMozilla Rhihoというオープンソースのエンジンを採用していて、その仕様に制限を受けることが間々あります。Yahoo!JAPANのような皆が見るようなサイトでもJavaScriptエラーが出まくり例外スローを抑える設定をしないとページ読み込みさえ完遂できないことがあります。(WebClientを生成するときのバージョン指定で古いブラウザであるINTERNET_EXPLORERを指定すると相手方で手加減してくれるワンチャンあるようです)

自分でサイトを一から作成するときのテストツールとしては使えないこともないですが、一般に公開されているサイトのスクレイピング用には不向きと言わざるを得ません。

次なる候補としてui4jというのを使ってみることにしました。JavaFXに含まれるWebKitのラッパーライブラリということで使いやすそうだったからです。

導入…できない?

ui4jのgithubレポジトリ(webfolderio/ui4j: Web Automation for Java (github.com))を見ると、mavenレポジトリから取得できるよ、と書いてあります。headlessモードに対応させるためのMonocleも同じように取得できるので conf/dependencies.yml にて解決しそうです。

<dependency>
    <groupId>io.webfolder</groupId>
    <artifactId>ui4j-webkit</artifactId>
    <version>4.0.0</version>
</dependency>
<dependency>
    <groupId>org.testfx</groupId>
    <artifactId>openjfx-monocle</artifactId>
    <version>jdk-11+26</version>
    <scope>test</scope>
</dependency>
require:
    - play
    - play -> docviewer
    - io.webfolder -> ui4j-webkit 4.0.0
    - org.testfx -> openjfx-monocle jdk-11+26

こんな感じに記述して、play deps もエラーなく成功。でも実際に実行してみると、javafx.application.Platform というクラスが見つからないと例外が出てしまいました。

java.lang.reflect.InvocationTargetException
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at play.modules.launcher.Launcher.main(Launcher.java:57)
Caused by: java.lang.NoClassDefFoundError: javafx/application/Platform
	at io.webfolder.ui4j.webkit.WebKitBrowser.<init>(WebKitBrowser.java:59)
	at io.webfolder.ui4j.webkit.WebKitBrowserProvider.create(WebKitBrowserProvider.java:27)
	at io.webfolder.ui4j.api.browser.BrowserFactory.getBrowser(BrowserFactory.java:115)
	at io.webfolder.ui4j.api.browser.BrowserFactory.getWebKit(BrowserFactory.java:151)
	at io.webfolder.ui4j.api.browser.BrowserFactory$getWebKit.call(Unknown Source)
	at org.codehaus.groovy.runtime.callsite.CallSiteArray.defaultCall(CallSiteArray.java:47)
	at org.codehaus.groovy.runtime.callsite.AbstractCallSite.call(AbstractCallSite.java:115)
	at org.codehaus.groovy.runtime.callsite.AbstractCallSite.call(AbstractCallSite.java:119)
	at test.run(test.groovy:13)
	at play.modules.launcher.GroovyLauncher.executeScript(GroovyLauncher.java:90)
	at jobs.LaunchScript.main(LaunchScript.java:33)
	... 4 more
Caused by: java.lang.ClassNotFoundException: javafx.application.Platform
	at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:581)
	at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178)
	at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:522)
	... 15 more

な、なにゆえに。と調べてみるとui4jに依存するライブラリとしてダウンロードされた、javafx-base.jarjavafx-graphics.jarjavafx-web.jarjavafx-controls.jar の中身が空っぽでした。

さらに調べを進めると、そもそもそれらのライブラリは実行環境によって、win/linux/macを指定するものらしいです。(例:<classifier>win</classifier>

解決できた

で、どうやって解決したかというと、下記のようにui4jの定義の前に javafx関連の定義を書くことで解決できました。先勝ちのようですね。

require:
    - play
    - play -> docviewer
    - org.openjfx -> javafx-base 11.0.2 win
    - org.openjfx -> javafx-graphics 11.0.2 win
    - org.openjfx -> javafx-web 11.0.2 win
    - org.openjfx -> javafx-controls 11.0.2 win
    - io.webfolder -> ui4j-webkit 4.0.0 win
    - org.testfx -> openjfx-monocle jdk-11+26

ちなみに、いったん実行環境(プラットフォーム)の指定なし版をダウンロードしまうと、実行環境を指定した版を取得してくれないことがありました。そんなときは強制キャッシュクリアのオプション(--clearcache)を付けましょう。

BrowserEngine browser = BrowserFactory.getWebKit()
Page page = browser.navigate('about:blank') 
page.show()
page.getDocument().getBody().append('<h1>Hello, world!</h1>')

Thread.sleep(5000)

page.close()
browser.shutdown()

ひとまずブラウザの表示までの動きが確認できました。

しかし?

ブラウザが起動できることは確認できたので一般のサイトを表示させてみよう、ということでお馴染みの Yahoo! JAPAN を開いてみました。ところが、java.lang.NoClassDefFoundError: com/sun/media/jfxmedia/MediaManager という例外が発生して止まってしまいました。まだ何か足りないようです。

[main] INFO io.webfolder.ui4j.api.browser.BrowserFactory - Initializing WebKit
[JavaFX Application Thread] INFO io.webfolder.ui4j.webkit.WebKitBrowser - Loading https://www.yahoo.co.jp/ [30%]
Exception in thread "JavaFX Application Thread" java.lang.NoClassDefFoundError: com/sun/media/jfxmedia/MediaManager
	at com.sun.javafx.webkit.prism.PrismGraphicsManager.getSupportedMediaTypes(PrismGraphicsManager.java:155)
	at com.sun.webkit.MainThread.twkScheduleDispatchFunctions(Native Method)
	at com.sun.webkit.MainThread.lambda$fwkScheduleDispatchFunctions$0(MainThread.java:35)
	at com.sun.javafx.application.PlatformImpl.lambda$runLater$10(PlatformImpl.java:428)
	at java.base/java.security.AccessController.doPrivileged(Native Method)
	at com.sun.javafx.application.PlatformImpl.lambda$runLater$11(PlatformImpl.java:427)
	at com.sun.glass.ui.InvokeLaterDispatcher$Future.run(InvokeLaterDispatcher.java:96)
	at com.sun.glass.ui.win.WinApplication._runLoop(Native Method)
	at com.sun.glass.ui.win.WinApplication.lambda$runLoop$3(WinApplication.java:174)
	at java.base/java.lang.Thread.run(Thread.java:834)
Caused by: java.lang.ClassNotFoundException: com.sun.media.jfxmedia.MediaManager
	at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:581)
	at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178)
	at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:522)
	... 10 more

その後、さらにJavaFXについて調べた結果、OpenJDK11を利用している場合、jarの取得だけでは不十分ということが分かりました。OpenJDKではJavaFX部分が分離されており、実行時にJavaFXのモジュールを利用する場合はVM引数で明示しなければならないそうです。

具体的には下記のようなVM引数を付けることで、ブラウザで Yahoo! JAPAN を表示するところまで到達しました。(headlessの場合はjavafx.graphicsも?)

--module-path="C:\Tools\bin\javafx-sdk-11.0.2\lib"
--add-modules=javafx.controls,javafx.web
--add-exports javafx.web/com.sun.webkit=ALL-UNNAMED

OpenJDK 11にJavaFXを導入する
JDK に JavaFX が同梱されなくなったため、 JavaFX アプリケーションの開発には別途 OpenJFX の導入が必要になりました。

https://blogs.osdn.jp/2018/11/12/merge-openjfx.html

JavaFXライブラリのインストール – ソフトウェアエンジニアリング – Torutk
本ページは、Java SE 11以降でJavaFXライブラリを利用した開発をするためのインストール作業を記述します。

https://www.torutk.com/projects/swe/wiki/JavaFX%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA%E3%81%AE%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB

java – Package ‘com.sun.webkit.dom’ is declared in module ‘javafx.web’, which does not export it to module – Stack Overflow

https://stackoverflow.com/questions/47684470/package-com-sun-webkit-dom-is-declared-in-module-javafx-web-which-does-not

というわけでめでたくウェブページを表示するところまでたどり着きました。(まだ下記のようなエラーは出ますが)

[main] INFO io.webfolder.ui4j.api.browser.BrowserFactory - Initializing WebKit
[JavaFX Application Thread] INFO io.webfolder.ui4j.webkit.WebKitBrowser - Loading https://www.yahoo.co.jp/ [30%]
[JavaFX Application Thread] INFO io.webfolder.ui4j.webkit.WebKitBrowser - Loading https://www.yahoo.co.jp/ [50%]
[JavaFX Application Thread] INFO io.webfolder.ui4j.webkit.WebKitBrowser - Loading https://www.yahoo.co.jp/ [55%]
[JavaFX Application Thread] INFO io.webfolder.ui4j.webkit.WebKitBrowser - Loading https://www.yahoo.co.jp/ [100%]
Exception in thread "JavaFX Application Thread" java.lang.IllegalAccessError: class io.webfolder.ui4j.webkit.browser.WebKitPageContext (in unnamed module @0x30c93896) cannot access class com.sun.webkit.dom.DocumentImpl (in module javafx.web) because module javafx.web does not export com.sun.webkit.dom to unnamed module @0x30c93896
	at io.webfolder.ui4j.webkit.browser.WebKitPageContext.createDocument(WebKitPageContext.java:122)
	at io.webfolder.ui4j.webkit.WebKitBrowser$WorkerLoadListener.changed(WebKitBrowser.java:161)
	at io.webfolder.ui4j.webkit.WebKitBrowser$WorkerLoadListener.changed(WebKitBrowser.java:1)
	at javafx.base/com.sun.javafx.binding.ExpressionHelper$SingleChange.fireValueChangedEvent(ExpressionHelper.java:181)
	at javafx.base/com.sun.javafx.binding.ExpressionHelper.fireValueChangedEvent(ExpressionHelper.java:80)
	at javafx.base/javafx.beans.property.ReadOnlyObjectPropertyBase.fireValueChangedEvent(ReadOnlyObjectPropertyBase.java:74)
	at javafx.base/javafx.beans.property.ReadOnlyObjectWrapper.fireValueChangedEvent(ReadOnlyObjectWrapper.java:102)
	at javafx.base/javafx.beans.property.ObjectPropertyBase.markInvalid(ObjectPropertyBase.java:113)
	at javafx.base/javafx.beans.property.ObjectPropertyBase.set(ObjectPropertyBase.java:147)
	at javafx.web/javafx.scene.web.WebEngine$LoadWorker.updateState(WebEngine.java:1251)
	at javafx.web/javafx.scene.web.WebEngine$LoadWorker.dispatchLoadEvent(WebEngine.java:1366)
	at javafx.web/javafx.scene.web.WebEngine$LoadWorker.access$1200(WebEngine.java:1244)
	at javafx.web/javafx.scene.web.WebEngine$PageLoadListener.dispatchLoadEvent(WebEngine.java:1231)
	at javafx.web/com.sun.webkit.WebPage.fireLoadEvent(WebPage.java:2513)
	at javafx.web/com.sun.webkit.WebPage.fwkFireLoadEvent(WebPage.java:2358)
	at javafx.web/com.sun.webkit.network.URLLoader.twkDidFinishLoading(Native Method)
	at javafx.web/com.sun.webkit.network.URLLoader.notifyDidFinishLoading(URLLoader.java:871)
	at javafx.web/com.sun.webkit.network.URLLoader.lambda$didFinishLoading$5(URLLoader.java:862)
	at javafx.graphics/com.sun.javafx.application.PlatformImpl.lambda$runLater$10(PlatformImpl.java:428)
	at java.base/java.security.AccessController.doPrivileged(Native Method)
	at javafx.graphics/com.sun.javafx.application.PlatformImpl.lambda$runLater$11(PlatformImpl.java:427)
	at javafx.graphics/com.sun.glass.ui.InvokeLaterDispatcher$Future.run(InvokeLaterDispatcher.java:96)
	at javafx.graphics/com.sun.glass.ui.win.WinApplication._runLoop(Native Method)
	at javafx.graphics/com.sun.glass.ui.win.WinApplication.lambda$runLoop$3(WinApplication.java:174)
	at java.base/java.lang.Thread.run(Thread.java:834)

そしてもう一つ問題が。思ったより表示にかかる時間が長く、気軽に使える感じじゃありません。もしかすると設定値とかを工夫すると何とかなるのかもしれませんが、ちょっとモチベーションがそこまで続きそうもないので、ui4jは一旦ここまでとしたいと思います。

唐突に終わってすみません。

]]>
/archives/854/feed/ 0
[play1-jpa]JPQLの結果をList<Map>で受け取る /archives/810/ /archives/810/#respond Wed, 12 May 2021 21:00:00 +0000 https://www.komina.info/?p=810 サンプルアプリのYABEの models/Tag.java のL27~あたりより抜粋。

public static List<Map> getCloud() {
    List<Map> result = Tag.find(
        "select new map(t.name as tag, count(p.id) as pound) from Post p join p.tags as t group by t.name"
    ).fetch();
    return result;
}

受取専用のbeanクラスを用意する方が、おそらくメモリ消費が少なくて済む。(Mapオブジェクトだと管理部分が必要なので)だが、少量の簡単なデータ取得なんかは、こちらの記述方法の方が向いていると思う。

]]>
/archives/810/feed/ 0
[play1-jpa]テーブル間の関係記述 /archives/804/ /archives/804/#respond Tue, 11 May 2021 21:00:00 +0000 https://www.komina.info/?p=804 jpaの機能ですが、playframework1の場合はgetter/setter不要なこともあり一般的なサンプルと少し記述が違います。

1:n

自分1レコードに対し、子nレコードの場合は、@OneToManyアノテーションを使う。
子エンティティを参照するためのコレクション型のプロパティを設け、このプロパティへのアクセスが子エンティティへのアクセスとなる。

public class Parent {
    @OneToMany(mappedBy = "parent")
    public List<Child> children;
}

mappedByを省略すると、ParentテーブルとChildテーブルの関係を保持するための中間テーブルが自動で作成される。
mappedByで子クラスの親参照プロパティを指定すると、中間テーブルは作られない。
(中間テーブルは、通常 n:n の場合に必要に迫られて作る感覚だったので、省略すると中間テーブルが作られるのはちょっと違和感あり。)

逆に子クラスが親クラスを参照する時は、@ManyToOneアノテーションを使う。
親エンティティを参照するためのプロパティを設け、このプロパティへのアクセスが親エンティティへのアクセスとなる。

public class Child {
    @ManyToOne
    public Parent parent;
}

順序

関係性を記述してコレクション型のプロパティを設けたとき、@OrderByアノテーションによりその要素の順序を指定することができる。

    @ManyToMany
    @OrderBy("lastname ASC", "seniority DESC")
    public List<Employee> getEmployees() {
        ...
    };

応用してソート条件ごとにコレクション型プロパティを設けることもできる。(名前降順で取得したいときのプロパティ、年齢昇順で取得したいときのプロパティ、のように)

orphanRemoval

親子関係のあるエンティティに対して、JPA2の新機能であるorphanRemovalの機能を使うことで単に親のコレクションから削除するだけで、関連付けが削除されると同時に子エンティティ自身もDELETEされる。
子エンティティの数だけDELETEが発行されるようなので、パフォーマンスを求めるなら自前で条件を書いてDELETEした方が良いかもしれない。

CascadeType.REMOVE, orphanRemoval=true にして関連エンティティの削除を自動で行わせようとするとき、参照整合性制約エラーが発生することがある。このようなときは関連エンティティを先に削除する処理を自前で記述しなければいけなさそう。検証が必要。

子を全て削除

子Objのdelete()メソッドを呼びまくる。⇒DELETEが子Objの数だけ発行される。良くない。

子Objを親IDで絞り込んで Child.delete("parent = ?", parent); で削除すると DELETEは1回で済む。
親Objへ削除を反映させる為に、親Obj.reflesh()しておきたい。

参考

今からでも遅くない JPAを学ぼう!(後編) オブジェクト間の関連を理解し、JPQLを使用する (1/6):CodeZine(コードジン)

https://codezine.jp/article/detail/5061
]]>
/archives/804/feed/ 0
[play1-jpa]データベース設定 /archives/801/ /archives/801/#respond Mon, 10 May 2021 21:00:00 +0000 https://www.komina.info/?p=801 特に指定しない場合、次の設定が適用される。

acquireRetryAttempts=10

新コレクションを取得するのに失敗したときのリトライ回数

checkoutTimeout=5000 or (db.pool.timeout)

接続プールが使い尽くされている場合のコレクションの最大待ち時間。
タイムアウトになるとSQLExceptionが発生する。
単位はミリ秒。0にすると無限に待つ。

breakAfterAcquireFailure=false

コレクションの取得ができなかった場合、すべてのスレッドにExceptionを投げる。ただし、DataSourceは有効のままで保持し続ける。
次回のgetConnection()の時も、新たなコレクションの取得を試みる。

maxPoolSize=30 or (db.pool.maxSize)

接続プールの最大コレクション数。

minPoolSize=1 or (db.pool.minSize)

接続プールの最小コレクション数。

maxIdleTimeExcessConnections=0 or (db.pool.maxIdleTimeExcessConnections)
idleConnectionTestPeriod=10

接続プール内のアイドル状態のコレクションを指定の時間間隔でチェックする。単位は秒。

testConnectionOnCheckin=true

trueの場合、コネクション利用開始時にコネクションの有効性を確認する。

参考:
play.db.DBPlugin.onApplicationStart()のあたり。

]]>
/archives/801/feed/ 0