PlayFramework2.1.3(Java)+JPA 使ってみる #1
Play 2.x RoadmapによるとPlayFramework2.3(Java)では、EBeanでなくJPAが推奨されるようです。職場ではplay1を使っているが、play2でJPAを使えるなら移行しやすいかもしれない。2.3を待たずPlayFramework2.1.3(Java)+JPAでどれだけやれるか試そう。と思った。
以下は、play2.1(Java)でJPAを使ってみた時のメモです。
アプリケーション作成
Play のインストールのつぎにアプリケーション作成する。
$play2 new jpaprac1
コマンドも結構変わってるみたいね。playコマンドでplayコンソールに入り、実行するコマンドもあるようだ。
ディレクトリ構成はplay1と似てる。projectディレクトリとtargetディレクトリが増えたな。
JNDI を経由してデータソースを公開する
play2でJPAが使えるかどうかを検証することが目的なので、コントローラー、ビューは飛ばしてモデル周りを見ていく。
ドキュメント通りにやる。
conf/application.conf
db.default.driver=org.h2.Driver db.default.url="jdbc:h2:mem:play" db.default.jndiName=DefaultDS jpa.default=defaultPersistenceUnit
[2013.8.30] 追記 ドキュメントに書いてないけど `jpa.default=defaultPersistenceUnit` も追加する。
play1から見慣れた application.conf な感じ。play2はマルチDBがサポートされたので default は接続先名なんだろう。
フレームワークIDははくなった、といつかどこかで聞いた気がする。
JPA の実装をプロジェクトに追加する
play2.1はJPA関連に依存していないので、使用ライブラリに追加する必要があると。
ドキュメントに書いて無くてちょっと躓いたが、これは project/Build.scala を編集する。
samples/java/computer-database-jpa の project/Build.scala
import sbt._ import Keys._ import play.Project._ object ApplicationBuild extends Build { val appName = "computer-database-jpa" val appVersion = "1.0" val appDependencies = Seq( javaCore, javaJdbc, javaJpa, "org.hibernate" % "hibernate-entitymanager" % "3.6.9.Final" ) val main = play.Project(appName, appVersion, appDependencies).settings( ebeanEnabled := false ) }
依存ライブラリは conf/dependencies.yml でなくここに書くことになったのか。conf/application.conf に書いてた name, version もここに書くと。
Build.scala を編集したらplay reload (または playコンソールでreloadコマンド)する。と、次回testしたとき依存ライブラリが解決されるようだ。
runのときは「auto-reloading」してくれる。とりあえず reload コマンドは覚えておこう。ライブラリを追加したら play eclipse もしておくとよさそうだ。
永続性ユニットを作成する
ドキュメント通りにやる。
samples/java/computer-database-jpa の persistence.xml をコピペでもいい。
<persistence xmlns="http://java.sun.com/xml/ns/persistence" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd" version="2.0"> <persistence-unit name="defaultPersistenceUnit" transaction-type="RESOURCE_LOCAL"> <provider>org.hibernate.ejb.HibernatePersistence</provider> <non-jta-data-source>DefaultDS</non-jta-data-source> <properties> <property name="hibernate.dialect" value="org.hibernate.dialect.H2Dialect"/> </properties> </persistence-unit> </persistence>
play.db.jpa.JPA ヘルパーを使う
ドキュメント通りにやる。あるいは samples/java/computer-database-jpa の Company.java を参考にする。
@Entity public class Company { @Id public Long id; @Constraints.Required public String name; public static Company findById(Long id) { return JPA.em().find(Company.class, id); } public void save() { JPA.em().persist(this); } }
play2.1.3 にはJPA用のModelクラスがないので、そのうち自作する必要がありそうだ。
ユニットテスト(できなかった)
DDLも作っていないが、ここまでをテストしてみる。DBに接続できない、テーブルがないくらいは出るだろうか。
samples/java/computer-database-jpa の ModelTest.java を参考にした。
public class ModelTest { @Test public void findById() { running(fakeApplication(), new Runnable() { public void run() { JPA.withTransaction(new play.libs.F.Callback0() { public void invoke() { Company asdf = Company.findById(1L); assertThat(asdf.name).isEqualTo("asdf"); } }); } }); } }
これが噂の fakeApplication か。これはうざい。
play2のテスト実行はplayコンソールで test。しかし、テストされずスルーされてしまった。
[info] ModelTest [info] [info] [info] Total for test ModelTest [info] Finished in 0.001 seconds [info] 0 tests, 0 failures, 0 errors [info] Passed: : Total 0, Failed 0, Errors 0, Passed 0, Skipped 0
テストクラスは拾われたが、メソッドがスルーされてる。@Testアノテートしているのになぜだろう。
Eclipseからテストしたらテストできたようだ。これはあとで調査しよう。
Yiiでも中間テーブルにアクセスしたい
またこのネタです。
YiiFrameworkのCManyManyRelationは中間テーブルへのアクセスをサポートしていません。ならばオレオレリレーションによってそれを可能にしてみます。前エントリのまとめと反するのですが、まあ、あそびばってことで。
実現のためにCJoinElementを改造します。まだまだ中途半端な状態ですが載せておきます。気がむいたら更新するかもしれません。
2012.10.25 実験段階 とりあえずレイジーローディング
リレーション
まずオレオレリレーションモデルです。
protected/components/ManyManyPivotRelation.php
<?php class ManyManyPivotRelation extends CManyManyRelation { public $pivotModel = null; public $pivotWith = array(); }
モデル
続いてモデル
<?php class Viewer extends CActiveRecord { public static function model($className=__CLASS__) { return parent::model($className); } public function tableName() { return 'viewer'; } public function relations() { return array( 'movies_with_pivot' => array("ManyManyPivotRelation", 'Movie', 'viewer_watched_movie(viewer_id, movie_id)', 'pivotModel' => 'ViewerWatchedMovie', // <- 中間テーブルモデル 'pivotWith' => array('id','viewer_id', 'movie_id', 'liked',), // <-中間デーブルのカラム ), ); } }
viewer_watched_movie テーブルのPKはIDです。
<?php class ViewerWatchedMovie extends CActiveRecord { public static function model($className=__CLASS__) { return parent::model($className); } public function tableName() { return 'viewer_watched_movie'; } }
<?php class Movie extends CActiveRecord { public $pivot; // <- ここに中間テーブルのモデルが入る public static function model($className=__CLASS__) { return parent::model($className); } public function tableName() { return 'movie'; } }
CJoinElement
次はCJoinElementに手を入れます。hack begin - hack end を挿入してください。
リレーションがManyManyPivotRelationならば、selectするカラムを追加し、select結果からpivotインスタンスを生成し、子モデルの $record->pivot にセットしています。
YiiFramework/framework/db/ar/CActiveFinder.php
<?php class CJoinElement { private function applyLazyCondition($query,$record) { $schema=$this->_builder->getSchema(); $parent=$this->_parent; if($this->relation instanceof CManyManyRelation) { .... if($parentCondition!==array() && $childCondition!==array()) { $join='INNER JOIN '.$joinTable->rawName.' '.$joinAlias.' ON '; $join.='('.implode(') AND (',$parentCondition).') AND ('.implode(') AND (',$childCondition).')'; if(!empty($this->relation->on)) $join.=' AND ('.$this->relation->on.')'; $query->joins[]=$join; foreach($params as $name=>$value) $query->params[$name]=$value; } else throw new CDbException(Yii::t('yii','The relation "{relation}" in active record class "{class}" is specified with an incomplete foreign key. The foreign key must consist of columns referencing both joining tables.', array('{class}'=>get_class($parent->model), '{relation}'=>$this->relation->name))); // hack begin ---------- if($this->relation instanceof ManyManyPivotRelation) { // add pivot columns // TODO table.column -> t0_c0 foreach ($this->relation->pivotWith as $col) { $query->selects[]=$joinAlias.'.'.$schema->quoteColumnName($col); } } // hack end ---------- } else { .... } } private function populateRecord($query,$row) { .... if(isset($this->records[$pk])) $record=$this->records[$pk]; else { $attributes=array(); $aliases=array_flip($this->_columnAliases); foreach($row as $alias=>$value) { if(isset($aliases[$alias])) $attributes[$aliases[$alias]]=$value; } $record=$this->model->populateRecord($attributes,false); foreach($this->children as $child) { if(!empty($child->relation->select)) { $record->addRelatedRecord($child->relation->name,null,$child->relation instanceof CHasManyRelation); } } $this->records[$pk]=$record; // hack begin ---------- if($this->relation instanceof ManyManyPivotRelation) { // make pivot instance // TODO t0_c0 -> column_name $p = new $this->relation->pivotModel; $record->pivot = $p->populateRecord($row,false); } // hack end ---------- } .... } }
ビュー
<?php $viewers = Viewer::model()->findAll(); foreach($viewers as $viewer) { foreach ($viewer->movies_with_pivot as $movie) { echo $viewer->name . ($movie->pivot->liked ? ' liked ' : ' didn’t like ') . $movie->title; } }
結果
hoge didn’t like foo hoge liked bar piyo liked foo piyo didn’t like bar
Yiiで中間テーブルに属性を持たせたいときはHasMany, BelongsToを使う
YiiFrameworkのCManyManyRelationは中間テーブルへのアクセス手段を用意してくれません。なので、中間テーブルに属性を持たせたいときはHasManyとBelongsToを使うのが、今のところエレガントな解決方法のようです。
http://www.yiiframework.com/wiki/285/accessing-data-in-a-join-table-with-the-related-models/#c6007
http://www.yiiframework.com/wiki/285/accessing-data-in-a-join-table-with-the-related-models/#c7483
この問題のサンプルコードにはtypoがたくさんあるので、検証コードを載せておきます。
テーブル
CREATE TABLE IF NOT EXISTS `viewer` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(45) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE IF NOT EXISTS `movie` ( `id` int(11) NOT NULL AUTO_INCREMENT, `title` varchar(45) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE IF NOT EXISTS `viewer_watched_movie` ( `id` int(11) NOT NULL AUTO_INCREMENT, `viewer_id` int(11) NOT NULL, `movie_id` int(11) NOT NULL, `liked` tinyint(1) DEFAULT NULL, PRIMARY KEY (`id`), KEY `fk_viewer_watched` (`movie_id`), KEY `fk_movie_watched_by` (`viewer_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
データ
INSERT INTO `viewer` (`id`, `name`) VALUES (1, 'v1'), (2, 'v2'); INSERT INTO `movie` (`id`, `title`) VALUES (1, 'm1'), (2, 'm2'); INSERT INTO `viewer_watched_movie` (`id`, `viewer_id`, `movie_id`, `liked`) VALUES (1, 1, 1, 1), (2, 1, 2, 0), (3, 2, 1, 1);
モデル
<?php class Viewer extends CActiveRecord { public static function model($className=__CLASS__) { return parent::model($className); } public function tableName() { return 'viewer'; } public function relations() { return array( 'watched_movies' => array(self::HAS_MANY, 'ViewerWatchedMovie', 'viewer_id'), ); } }
<?php class Movie extends CActiveRecord { public static function model($className=__CLASS__) { return parent::model($className); } public function tableName() { return 'movie'; } }
<?php class ViewerWatchedMovie extends CActiveRecord { public static function model($className=__CLASS__) { return parent::model($className); } public function tableName() { return 'viewer_watched_movie'; } public function relations() { return array( 'movie' => array(self::BELONGS_TO, 'Movie', 'movie_id'), ); } }
レイジーローディング
<?php $viewer = Viewer::model()->findByPk(1); foreach ($viewer->watched_movies as $watched) { echo $viewer->name . ($watched->liked ? ' liked ' : ' didn’t like ') . $watched->movie->title; }
イーガーローディング
<?php $viewers = Viewer::model()->with(array( 'watched_movies'=>array( 'condition'=>'watched_movies.liked=:liked', 'params'=>array(':liked'=>true), ), ))->findAll(); foreach ($viewers as $viewer) { foreach ($viewer->watched_movies as $watched) { echo $viewer->name . ($watched->liked ? ' liked ' : ' didn’t like ') . $watched->movie->title; } }
まとめのような
CActiveRecordの MANY_MANY, HAS_MANY...は単なるクラス名なので、次のようにオレオレリレーションのクラス名「OreOreRelation」でもいいんです。
<?php class Viewer extends CActiveRecord { .... public function relations() { return array( 'movies' => array('OreOreRelation', 'Movie', 'viewer_watched_movie(viewer_id, movie_id)', 'pivot_model'=>'ViewerWatchedMovie', ), ); } }
で、オレオレリレーションでSQL書き換えればいいかと思ったらさにあらず、SELECT..JOIN...生成はリレーションクラスでなく、CActiveFinderというかCJoinElementがやっています。
中間テーブルを取得するリレーションを作ってみたのですが、CJoinElementの 「if (relation instanseof CManyManyRelation) 」している辺りを探りながらパッチ当てることで、レイジーローディングだけどうにかできました。書くまでもなく茨の道です。
そんなわけで、いまのところエレガントな解決方法はHasMany, BelongsToだな、というところに落ち着きました。まえに弱点と書きましたが、今回のような設計で使ってくれと言うことなんだと思います。
laravelのpivotいいなと思ったの巻
yii ActiveRecord の弱点
yii ActiveRecord は、many-manyの中間テーブルに属性を付けにくい弱点があります。たとえばこんな問題とか。
Accessing data in a join table with the related models
いまだにキレイな解決方法がない。と思う。ほんとにないの?
laravel は pivot で解決
一方、laravelならこう書く。
テーブル
CREATE TABLE viewer ( id INT NOT NULL PRIMARY KEY, name VARCHAR(45)); CREATE TABLE movie ( id INT NOT NULL PRIMARY KEY, title VARCHAR(45)); CREATE TABLE viewer_watched_movie ( id INT NOT NULL, -- <--- ここ見逃さない viewer_id INT NOT NULL, movie_id INT NOT NULL, liked TINYINT(1), PRIMARY KEY (id), CONSTRAINT fk_viewer_watched FOREIGN KEY (movie_id) REFERENCES movie (id), CONSTRAINT fk_movie_watched_by FOREIGN KEY (viewer_id) REFERENCES viewer (id));
viewerモデル
<?php class Viewer extends Eloquent { public static $table = 'viewer'; public function movies() { return $this->has_many_and_belongs_to('Movie', 'viewer_movies') ->with('liked'); // <--- この with が大事 } }
movieモデル
<?php class Movie extends Eloquent { public static $table = 'movie'; }
コントローラー
<?php $viewer = Viewer::find(1); foreach ($viewer->movies()->get() as $movie) echo $viewer->name . ($movie->pivot->liked ? ' liked ' : ' didn’t like ') // <--- pivot で with したカラムを参照できる . $movie->title ."<br>" ;
うん、素直に書けた。しかも冒頭挙げたページのproblemコードと酷似したコードで書けてる。
注意
pivot テーブルに created_at, updated_at がないと、こんなエラーがでる。
SQLSTATE[42S22]: Column not found: 1054 Unknown column 'viewer_movies.created_at' in 'field list'
そんなときは、 application/start.php にこれを書いておく。
Laravel\Database\Eloquent\Pivot::$timestamps = false;
pivot テーブルに created_at, updated_at を要求するのはお節介なような気が。
参考
Eloquent ORM - laravel 日本語ドキュメント
Disable timestamps in pivot table ? - github/laravel/issues/543
laravelを使おうかと思った理由の一つがコレ
laravel はじめました
公式ドキュメントを購入したのは先月だったか。ようやくはじめた。
はじめて書いたコード
見よう見まねで書いたログインフォームがこれ。 action_login と action_dologin で同じものをviewに渡そうとゴニョゴニョしていますね。
controllers/auth.php
<?php class Auth_Controller extends Base_Controller { public function action_login() { $input = array('username' => ''); return View::make('auth.login') ->with('input', $input) ->with('errors', new Laravel\Messages()); } public function action_dologin() { $input = Input::all(); $rules = array( 'username' => 'required', 'password' => 'required', ); $validation = Validator::make($input, $rules); if ($validation->fails()) { return View::make('auth.login') ->with('input', $input) ->with('errors', $validation->errors); } return "authed!"; } }
views/auth/login.blade.php
{{ Form::open('auth', 'post') }} {{ Form::text('username', $input['username']) }} @if ($errors->has('username')) {{ $errors->first('username') }} @endif {{ Form::password('password') }} @if ( $errors->has('password')) {{ $errors->first('password') }} @endif {{ Form::submit() }} {{ Form::close() }}
書き直したコード
ドキュメントとかソースとか読み直して、書き直したのがこれ。action_login からバリデーション失敗時のコードがなくなりすっきりしました。
controllers/auth.php
<?php class Auth_Controller extends Base_Controller { public function action_login() { return View::make('auth.login'); } public function action_dologin() { $input = Input::all(); $rules = array( 'username' => 'required', 'password' => 'required', ); $validation = Validator::make($input, $rules); if ($validation->fails()) { return Redirect::back() ->with_input() ->with_errors($validation); } return "authed!"; } }
views/auth/login.blade.php
{{ Form::open('auth', 'post') }} {{ Form::text('username', Input::old('username')) }} {{ $errors->first('username') }} {{ Form::password('password') }} {{ $errors->first('password') }} {{ Form::submit() }} {{ Form::close() }}
わかったこと
バリデーションエラーで前のページに戻るときは、Redirect::back()
あわせて with_input(), with_errors() も使う
Input::old() で前回入力した値を得られる
そのために with_input() しておく
view に空の errors を渡す必要はない
Viewコンストラクタ で $this->date['errors'] = new Messages() している。
エラーメッセージのキーは errors とするのが laravel の流儀っぽい
バリデーションメッセージ表示は {{$errors->first('fieldname')}}
エラーがなければ何もしないから if いらない
play1.2をJavassistで改造してみた
先日の第3回PlayFramework勉強会でこんなことを考えた。
play1系はメンテナンスモードか、今後の更新はあまり期待できないな、じゃあJavassistで改造すればいいか。
普通に考えると、自分でPlayFrameworkをビルドしろってことでしょうが、当日は道に迷ったせいかおかしくなってました。
今回は実際Javassistで改造できるものなのか試してみました。
ここをこうしたい
play1系はGsonでJSONエンコードしますが、GsonでなくJSONICとかJackson使いたいんだよ、ってケースだとします。ControllerをOverrideするとか、JSONエンコード後のString渡せばいいじゃんとかありますが、これはまあ例ってことで。
JSONエンコードはRenderJsonのコンストラクタでやってます。これを
package play.mvc.results; public class RenderJson extends Result { public RenderJson(Object o) { json = new Gson().toJson(o); } }
こうしたい。
public RenderJson(Object o) {
json = net.arnx.jsonic.JSON.encode(o);
}
RenderJsonを書き換える
前回作ったMyPluginを利用してonApplicationStartで書き換えてみます。
public class MyPlugin extends PlayPlugin { public void onApplicationStart() { play.Logger.info("@@@ MyPlugin.onApplicationStart"); try { enhanceRenderJson(); } catch (Exception e) { play.Logger.error(e, ""); } } protected void enhanceRenderJson() throws NotFoundException, CannotCompileException, IOException { // クラスプールから RenderJson を得る ClassPool classPool = ClassPool.getDefault(); CtClass cc = classPool.get("play.mvc.results.RenderJson"); // コンストラクタのSignatureを得る // CtConstructor[] cnsts = cc.getConstructors(); // for (CtConstructor cnst : cnsts) { // play.Logger.info("@@@ CtConstructor:%s", cnst.getSignature()); // } // コンストラクタ書き換え cc.defrost(); CtConstructor m = cc.getConstructor("(Ljava/lang/Object;)V"); m.setBody("{" + " play.Logger.info(\"@@@ jsonic encode!\", null);" + " json = net.arnx.jsonic.JSON.encode($1);" // $1:1つ目のメソッド引数のこと + "}" ); cc.writeFile(); // クラスローダーに登録 Class thisClass = cc.getClass(); ClassLoader loader = thisClass.getClassLoader(); ProtectionDomain domain = thisClass.getProtectionDomain(); cc.toClass(loader, domain); } }
onApplicationStart ならプラグインでなくても、JobをExtendsしてOnApplicationStartアノテーション付けてもいいですよね。
動作確認
モデル
public class Hoge { public Long id; public String name; @net.arnx.jsonic.JSONHint(format="yyyy-MM-dd'T'HH:mm:ssZZ") public Date createdAt; }
コントローラー
public class MyController extends Application { public static void index() { Hoge h = new Hoge(); h.id = 1000L; h.name = "hogehoge"; h.createdAt = new Date(); renderJSON(h); } }
改造前:Gsonでレンダリング
{ id: 1000, name: "hogehoge", createdAt: "Jul 26, 2012 6:47:41 AM" }
改造後:JSONICでレンダリング
JSONHintアノテーションによってcreatedAtがフォーマットされている
{ createdAt: "2012-07-26T06:48:33+09:00", id: 1000, name: "hogehoge" }
まとめ
play1.2をJavassistで改造してみました。
自前ビルドしろよとか改造後のテストはどうすんのとか思いますが、とりあえず置いておきます...
参考
Javassist - ひしだま's ホームページ
Javassistチュートリアル - Acroquest Technology
書き換えたクラスのクラスローダー登録を忘れてはまりました。いやー感謝感謝。
play1.2系の自作プラグイン
PlayFrameworkといえばトレンドはplay2.0なのでしょうが、うちはまだplay1.2系です。play1.2系で自作プラグインを作ってみました。
plugins.MyPlugin 作成
- play.PlayPluginをextends
- onなんたらをOverride
- パッケージは適当に
package plugins; import play.PlayPlugin; public class MyPlugin extends PlayPlugin { public void onLoad() { play.Logger.info("@@@ MyPlugin.onLoad"); } public void onApplicationReady() { play.Logger.info("@@@ MyPlugin.onApplicationReady"); } public void onApplicationStart() { play.Logger.info("@@@ MyPlugin.onApplicationStart"); } public void enhance(ApplicationClass applicationClass) throws Exception { play.Logger.info("@@@ MyPlugin.enhance : %s", applicationClass.name); } }
conf/play.plugins 作成
- プライオリティ:プラグインクラス
- プライオリティはデフォルトのものを参考にしつつ決める
こんな感じに。
100001:plugins.MyPlugin
デフォルトプラグインのプライオリティはここ参照
動作確認
アプリケーションを起動するとこうなり、play.Logger.infoできていることがわかります。
>play test ~ _ _ ~ _ __ | | __ _ _ _| | ~ | '_ \| |/ _' | || |_| ~ | __/|_|\____|\__ (_) ~ |_| |__/ ~ ~ play! 1.2.5, http://www.playframework.org ~ framework ID is test ~ ~ Running in test mode ~ Ctrl+C to stop ~ INFO ~ Starting INFO ~ @@@ MyPlugin.onLoad WARN ~ You're running Play! in DEV mode ~ ~ Go to http://localhost:9000/@tests to run the tests ~ INFO ~ @@@ MyPlugin.onApplicationReady INFO ~ Listening for HTTP on port 9000 (Waiting a first request to start) ... INFO ~ @@@ MyPlugin.onLoad INFO ~ Connected to jdbc:mysql://localhost/plugintest?useUnicode=yes&characterEncoding=UTF-8&connectionCollation=utf8_general_ci INFO ~ @@@ MyPlugin.onApplicationStart INFO ~ Application 'plugintest' is now started !
参考
How to extend the playframework? - stackoverflow
Play framework 2.0 での自作プラグイン - Scala版 - なんとなくな Developer のメモ
プラグインの作り方は1.2系も2.0系も同じですね。感謝