hakobera's blog

技術メモ。たまに雑談

Play の evolutions を Heroku で使おうとしてハマった

Database-Driven Web Apps with Play! | Heroku Dev Center

(we don’t recommend setting jpa.ddl to update for a real world production app. Use Play!’s database evolutions instead.)

Heroku の Play のチュートリアル記事に「jpa.ddl=update が許されるのは開発環境までだよねー。本番環境では Play の evolutions を使ってね (・ω<) テヘペロ」(意訳)って書いてあったので、Heroku の Shared Database (PostgreSQL) で試してみたら色々とハマったので原因と解決方法を書いていこうと思います。

お勧めするならやり方くらい書いておいて欲しい・・・

結論

先に結論だけ書いておきます。

  • エンティティは play.db.jpa.Model ではなく、play.db.jpa.GenericModel を継承するようにする
  • id プロパティに @GeneratedValue(strategy = GenerationType.IDENTITY) をつける

注意

上記のチュートリアルをベースにエンティティ名だけ User から Account に変更しています。(User は PostgreSQL予約語でテーブル名として利用できないため。)

エンティティ作成

@Entity
@Table(name = "account")
public class Account extends Model {

	public String email;
	public String fullname;
	public boolean isAdmin;
	
	public Account(String email, String fullname, boolean isAdmin) {
		this.email = email;
		this.fullname = fullname;
		this.isAdmin = isAdmin;
	}

}

evolutions ファイル作成

evolutions ファイルを db/evolutions/1.sql というファイル名で作ります。

# Accounts schema
 
# --- !Ups
 
CREATE TABLE Account (
  id bigserial NOT NULL,
  email varchar(255) NOT NULL,
  fullname varchar(255) NOT NULL,
  isAdmin boolean NOT NULL,
  CONSTRAINT pk_account PRIMARY KEY (id)
);
 
# --- !Downs
 
DROP TABLE Account;

Heroku 上で evolutions を実行

heroku run コマンドを利用して、play evolutions:apply コマンドを heroku 上で実行します。

$ heroku run play evolutions:apply
unning play evolutions:apply attached to terminal... up, run.1
~        _            _ 
~  _ __ | | __ _ _  _| |
~ | '_ \| |/ _' | || |_|
~ |  __/|_|\____|\__ (_)
~ |_|            |__/   
~
~ play! 1.2.4, http://www.playframework.org
~
~ Connected to jdbc:postgresql://xxx
~ Application revision is 1 [f9f2691] and Database revision is 0 [da39a3e]
~
~ Applying evolutions:

# ------------------------------------------------------------------------------

# --- Rev:1,Ups - f9f2691

CREATE TABLE Account (
  id bigserial NOT NULL,
  email varchar(255) NOT NULL,
  fullname varchar(255) NOT NULL,
  isAdmin boolean NOT NULL,
  CONSTRAINT pk_account PRIMARY KEY (id)
);

# ------------------------------------------------------------------------------

~
~ Evolutions script successfully applied!

アプリにアクセス

アプリを実行すると、Bootstrap の Fixtures.loadModels() で落ちます。

A javax.persistence.PersistenceException has been caught,
org.hibernate.exception.SQLGrammarException: could not get next sequence value

どうやら play.db.jpa.Model の id プロパティの @GeneratedValue の strategy が指定されていないため、デフォルトの AUTO が設定されるのですが、これだと PostgreSQL の bigserial から自動シーケンスを取得できない模様。


Confusion over @Id property - error with postgresql database -
play-framework |
Google Groups

Entity を GenericModel を継承するように変更

public class Account extends GenericModel {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long id;

// ... 以下は同じ
}

これで OK です。heroku restart して再起動すれば、アクセスできるようになります。

考察

じゃ、なんで jpa.ddl=update だと上記の問題は起こらないのかというと、これは Play が内部的に使っている Hibernate の実装のせい(おかげ?)です。

Hibernate はデータベース作成時に hibernate_sequence というシーケンスを作成し、@GeneratedValue アノテーションが付与されたプロパティの値は、そのシーケンスから取得するように実装されています。このため、jpa.ddl=none を指定して evolutions を利用すると、この機能が利用されないのでエラーになるわけです。

個人的にはシーケンスがエンティティ間で共有されて、データベース単位になるのがちょっとどうかなと思うので、本番環境ではお勧めされた通り evolutions を使っていこうと思います。

おまけ

実際のアプリを作成する際には id 以外の属性も共通でつけると思うので、次のようなクラスを作って、それを継承するようにすれば良いんじゃないかと思います。

作成、更新、(論理)削除日時、楽観ロック用のバージョン番号を共通列としてもつエンティティの例

@MappedSuperclass
public class MyModel extends GenericModel {
	
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	public Long id;

	@Column(updatable = false)
	@Temporal(TemporalType.TIMESTAMP)
	public Date createdAt;

	@Temporal(TemporalType.TIMESTAMP)
	public Date updatedAt;

	@Temporal(TemporalType.TIMESTAMP)
	public Date deletedAt;

	@Version
	public Long versionNo;
	
	@Override
        public Object _key() {
            return id;
        }
	
        // Fixtures.loadModels() が内部で Model#_save() を呼んでデータを投入している
        // 作成日時などは YML ファイルに書きたくないので、自動的に設定されるように
        // _save() メソッドを override しておくと便利
	@Override
	public void _save() {
		Date now = DateUtil.now();
		if (id == null) {
			createdAt = now;
		}
		updatedAt = now;
		super._save();
	}

}

おまけ2

デフォルトの Hibernate のデータベースの列名が区切り文字を入れてくれない( isAdmin -> isadmin となる)ので変更する設定。

conf/application.conf に以下を追記

hibernate.ejb.naming_strategy=org.hibernate.cfg.ImprovedNamingStrategy

これで isAdmin -> is_admin みたいに Camel Case を Under Score で区切ってくれるようになります。