内容と想定する読者
Javaアドベントカレンダーの21日目の記事になります。質の高い記事が続いている中で恐縮ですが、よろしくお願いします。
play 2.4 をJava8 + Ebeanでアプリ開発した際に、気づいたことや身に着けたtipsを体系的にまとめたものです。
play javaをやってみたい、Ebeanやjava8の具体的な使用例を知りたい、という方が主な想定する読者です。端的に言ってHOW TOものです。
設計や開発方針を固めるのに思った以上に時間がかかって、webアプリ開発の枠組み的な話の比率が多くなってしまったかもです。僕自身は開発経験4年ほどの、駆け出しプログラマです。言語仕様につっこんだ話とかは、ご容赦いただければと・・・
きっかけ
自分のスキルを棚卸したいのと、play 2.4にチャレンジしたいということで、つくったものと、その過程で得たものをアウトプットしようということで、アドベントカレンダーに初参加しています。
プログラミング言語に扱いに慣れてきた初心者が、webアプリ開発をするにあたってどういうことを知っておくと良いかの、ひとつの指針になれれば幸いです。
どんなアプリ
自身のスキルや経歴の棚卸したいのと、仕事で自分の職歴をエクセルで書いて送付するというオペレーションがあったので、従業員の開発経験を管理するためのWebアプリをつくろう、となりました。
個々人が自分の経歴を入力し、管理者はそれらを閲覧できるという、ごくごくありふれたシステムです。残念なことにまだ作成中です・・・
ソースはこちらで公開しています。
https://github.com/JunNakamura/phm
構成
- Play 2.4.x
- Java8
- Ebean
- MySQL
- Flyway
- WebJars
- Bootstlap
- deadbolt2(予定)
開発
IntelliJ IDEAのコミュニティ版を使っています。
0. GitHubで一人Issue駆動、git flow
- GitHub上でissueを作成
- 対応するfeatureブランチ作成
- PR作成
- PRマージ
- issueクローズ
を、なるべく守るように開発。
内容が細かいissueは一つのPRにまとめたりなど、例外をちょこちょこやらかしていますが。
1. ルーティング
Railsのルーティング に準拠する方針でいきます。
注意するのは、/XXXs/new
と /XXXs/:id
では、前者は先に書かないと、/XXXs/new
が:idの方にルーティングされてしまい、エラーになります。
# User
GET /users/new controllers.UserController.displayNew()
POST /users/new/confirm controllers.UserController.confirmNew()
POST /users/create controllers.UserController.create()
GET /users controllers.UserController.index(page: Int ?= 1)
GET /users/:id controllers.UserController.view(id: Long)
# WorkExperience
GET /users/:userId/workExperience/new controllers.WorkExperienceController.displayNew(userId: Long)
POST /users/:userId/workExperience/new/confirm controllers.WorkExperienceController.confirmNew(userId: Long)
2. パッケージ構成(MVVCもどき?)
コントローラ <-> DTO <-> サービス <-> モデル という流れで、データのやりとりをするように、パッケージをきっていきます。モデル層のオブジェクトをそのままビューに持っていくと、ビュー上の話がまざってコードが肥大化したり、影響範囲が広がったりすることがあったので、DTOを経由するようにしました。
モデル層
DBのテーブルに対応するオブジェクトをここにまとめていきます。
基底クラス
主キーや、作成日時、更新日時など、各テーブルに共通する部分をつぎのクラスでまとめます。
@MappedSuperclass
public class BasicModel extends Model {
/**
* 主キー.
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long id;
/**
* 作成日.
*/
@CreatedTimestamp
public LocalDateTime createdAt;
/**
* 更新日.
*/
@UpdatedTimestamp
public LocalDateTime updatedAt;
}
エンティティ
エンティティは、先ほどのBasicModelを継承してつくります。
toString()はcommons.lang3のToStringBuilderをつかって、手っ取りばやくoverrideします。
(EbeanのModelがもつgetterメソッドを表示させないための引数を色々追加しています。もっといいやり方があれば是非。)
@Entity
public class User extends BasicModel {
/**
* 従業員番号.
*/
public String employeeNumber;
/**
* 姓.
*/
public String firstName;
/**
* 名.
*/
public String lastName;
/**
* 性別.
*/
public Sex sex;
/**
* 生年月日.
*/
public LocalDate birthday;
/**
* 入社日.
*/
public LocalDate hireDate;
@Override
public String toString() {
return ToStringBuilder.reflectionToString(this, ToStringStyle.SHORT_PREFIX_STYLE, fasle, User.class) ;
}
}
OneToManyとManyToOne
1:n の関係になるエンティティを、@OneToManyで持たせることができます。(逆はManyToOne)
UserとWorkExperienceの関係が1:nのとき、それぞれ次のようにします。
/** 中略 **/
/**
* 職歴.
*/
@OneToMany(cascade = CascadeType.ALL)
public List<WorkExperience> workExperiences;
@Entity
public class WorkExperience extends BasicModel {
/**
* 紐づくユーザ.
*/
@ManyToOne(optional = false)
public User user;
/* 以下略 */
constants
Enumによる定数クラスはconstantsというパッケージきって管理します。
@EnumValueで、データベース上の値を次のように指定できます。
public enum Sex {
/**
* 男性.
*/
@EnumValue("male") // Userテーブルのsexカラムではmaleで登録されます
MALE,
/**
* 女性.
*/
@EnumValue("female")
FEMALE;
}
サービス層
ビジネスロジックを扱うレイヤーです。
services直下にinterfaceを配置し、services.implementにその実装クラスを配置します。
これらをコントローラにinjectします。こうすると、コントローラのテストをするときに、モックをinjectすると、コントローラの部分のみのテストがしやすくなります。
実装クラスは、XXXServiceEbeanという命名規則で作成します。
@ImplementedBy
で、実装クラスとinterfaceを紐付けています。
@ImplementedBy(UserServiceEbean.class)
public interface UserService {
List<User> findAll();
Optional<User> findOne(long id);
Optional<User> findOne(String employeeNumber);
PagedList<User> find(int pageIndex, int pageSize);
void create(NewUserDto newUserDto);
public class UserServiceEbean implements UserService {
private Model.Find<Long, User> find = new Model.Find<Long, User>(){};
@Override
public List<User> findAll() {
return find.all();
}
}
ユニークキーによる検索
主キーやユニークキーを指定してselectした場合は、結果はあるかなしかの状態になるので、Optionalで表現すると良さそうです。EbeanのFinder#byId()やfindUnique()は、結果がない場合はnullを返すので、Optional.ofNullable()でくるむと、nullチェック漏れを防ぎやすくなります。
public Optional<User> findOne(long id) {
return Optional.ofNullable(find.byId(id));
}
public Optional<User> findOne(String employeeNumber) {
User user = find.where()
.eq("employeeNumber", employeeNumber)
.findUnique();
return Optional.ofNullable(user);
}
DTO層
入力フォームから送信されるデータを扱う型を、dtoパッケージでまとめます。
必要に応じて、その下にテーブル毎にパッケージをきって管理します。
play.data.validation.Constraintsにあるアノテーションで、必須項目、文字数の最小・最大などの基本的にバリデーションをフィールド毎に指定できます。
play.data.format.Formatsにあるアノテーションで、日付文字列のパターンなどを指定できます。
import play.data.format.Formats;
import play.data.validation.Constraints;
public class NewUserDto {
/**
* 従業員番号.
*/
@Constraints.Required
public String employeeNumber;
/**
* 姓.
*/
@Constraints.Required
public String firstName;
/**
* 名.
*/
@Constraints.Required
public String lastName;
/**
* 性別.
*/
@Constraints.Required
public Sex sex;
/**
* 生年月日.
*/
@Constraints.Required
@Formats.DateTime(pattern = "yyyy-MM-dd")
public LocalDate birthday;
/**
* 入社日.
*/
@Constraints.Required
@Formats.DateTime(pattern = "yyyy-MM-dd")
public LocalDate hireDate;
}
追加のバリデーション
上記のアノテーション以外(たとえば、データの一意性のチェックや、複数項目をまたぐもの)のバリデーションを追加したい場合は、public List<ValidationError> validate()
を実装する。バリデーションにひっかかるものがないときはnullを返すようにする。
DTOクラスへのinjectionがうまくいかなったので、調べてみたらやはり対象外らしい。
how-to-inject-something-into-a-form というstackoverflowに同じ質問がでていて、解決策がでていました。
エラーメッセージをFormオブジェクト使って制御するのを考えると、このクラスでバリデーションをまとめたいので、データベースにさわれるようにするには、サービスクラスのinjectionに相当するのをしたいです。
/**
* 追加のバリデーション.
* @return
*/
public List<ValidationError> validate() {
UserService service = Play.application().injector().instanceOf(UserService.class);
List<ValidationError> errors = new ArrayList<>();
if (service.findOne(employeeNumber).isPresent()) {
errors.add(new ValidationError("employeeNumber","This employee number is registered."));
}
return errors.isEmpty() ? null : errors;
}
コントローラ層
リクエストに対してどのような処理をするかをコントローラクラスで決めます。
play.mvc.Controllerを継承し、XXXControllerという命名規則で作成します。また、必要なサービスクラスを@Injectを使って注入します。
import play.mvc.Controller
public class UserController extends Controller {
@Inject
UserService service;
}
id指定での詳細画面
サービスクラスで表示するエンティティをOptional<T>
で取得します。
それをmapでOptional<Result>
に変換し、orElseで存在しないときはnotFoundのページを返すようにします。
/**
* ユーザ詳細画面.
* @param id ユーザID.
* @return
*/
public Result view(long id) {
Optional<User> userOptional = service.findOne(id);
Optional<Result> result = userOptional.map(user -> {
return ok(userDetailView.render(user));
});
return result.orElse(notFound());
}
作成・確認・結果画面
DTOをForm#form()でくるんで、ビューテンプレートに渡します。このフォームオブジェクトで、画面の表示に必要なものをまかないます。
作成画面を表示するときには空のフォームオブジェクトを渡します。
/**
* ユーザ新規作成画面.
* @return
*/
public Result displayNew() {
//入力画面の表示なので空のフォームオブジェクトを渡す
Form<NewUserDto> userDtoForm = Form.form(NewUserDto.class);
return ok(newUserView.render(userDtoForm));
}
確認画面の表示では、入力エラーがあれば受け取ったフォームオブジェクトを入力画面テンプレートに渡してそれを表示、エラーがなければ確認画面テンプレートに渡して表示します。
/**
* ユーザ新規作成の確認画面.
* @return
*/
public Result confirmNew() {
Form<NewUserDto> userDtoForm = Form.form(NewUserDto.class).bindFromRequest();
if (userDtoForm.hasErrors()) {
Logger.debug("userform has erros:" + userDtoForm.errors());
return ok(newUserView.render(userDtoForm));
}
return ok(newUserConfirmView.render(userDtoForm));
}
確認画面で作成ボタンを押されたときは、フォームオブジェクトからDTOを取り出し、それをサービスクラスに渡して登録処理を行います。
/**
* ユーザの新規作成.
* @return
*/
public Result create() {
Form<NewUserDto> userDtoForm = Form.form(NewUserDto.class).bindFromRequest();
NewUserDto dto = userDtoForm.get();
service.create(dto);
Optional<User> user = service.findOne(dto.employeeNumber);
Form<User> userForm = Form.form(User.class).fill(user.get());
return ok(newUserResultView.render(userForm));
}
3. スキーマ管理
flyway を使ってスキーマの変更管理をします。
db/migrateの下にV__X_zzz.sqlという命名規則で、DDLやマスターデータを投入するSQLを管理します。
activator flywayMigrate
でマイグレーションをします。
evolutionsは、ModelクラスからDDLを生成する仕組みで、これもありといえばありですが、細かく管理するにはちょっと不向きという印象。flywayを使うので、今回はこの機能は止めます。
play.evolutions.enabled=false
4. フロント(ビュー)
Bootstrap3とjQueryでつくっていきます。
原則、WebJarsで必要なものを入手する方針でいきます。
ない場合は、自分で追加していきます。
※ 余談ですが Play-Bootstrap3というライブラリもあるようです。これを使うのも良いかもです。
5. ページング
twbs-paginationというjQueryプラグインと、EbeanのPagedListを組み合わせます。
ビューテンプレートでは、下記のようにします。
@(users: com.avaje.ebean.PagedList[models.User])
<div class="text-left">
<ul id="pagination" class="pagination-sm"></ul>
</div>
<script>
$('#pagination').twbsPagination({
totalPages: @users.getTotalPageCount(),
startPage: @(users.getPageIndex() + 1),
visiblePages: 10,
href: '/users?page={{number}}'
});
</script>
コントローラでは、下記のようです。findPagedListは、0はじまりでページングをするので、クエリのページ番号を調整するのに注意します。
/**
* ユーザ一覧画面.
* @param pageNumber ページ数
* @return
*/
public Result index(int pageNumber) {
int pageIndex = pageNumber -1;
PagedList<User> users = service.find(pageIndex, pageSize);
return ok(userView.render(users));
}
@Override
public PagedList<User> find(int pageIndex, int pageSize) {
return find.findPagedList(pageIndex, pageSize);
}
6. データバインド
play.data.format.Formatters#registerを使って、新しいデータバインドを追加します。
SimpleFormatterを使えば、かなり簡単に追加できます。
今回は、日付をLocalDateで持ちたかったので、yyyy-MM-ddをLocalDateにバインドするようにします。LocalDateに必要なものが揃っているので、下記のようにラップする感じでいけます。
Formatters.register(LocalDate.class, new SimpleFormatter<LocalDate>() {
@Override
public LocalDate parse(String s, Locale locale) throws ParseException {
return LocalDate.parse(s);
}
@Override
public String print(LocalDate localDate, Locale locale) {
return localDate.toString();
}
});
7. GlobalSetting#onStart()の代替
基本的なやり方は次のとおりです。
- アプリ開始時にしたい処理をコンストラクタに記述したクラスを作成
- 上記をSingletonとしてバインドを指定するmoduleを追加
- 上記を任意のコントローラへinject
今回は、inject先をまとめるために専用のコントローラを用意する形をとりました。
@Singleton
public class CustomDataBinder {
protected CustomDataBinder() {
Formatters.register(LocalDate.class, new SimpleFormatter<LocalDate>() {
@Override
public LocalDate parse(String s, Locale locale) throws ParseException {
return LocalDate.parse(s);
}
@Override
public String print(LocalDate localDate, Locale locale) {
return localDate.toString();
}
});
}
}
public class StarterModule extends AbstractModule {
@Override
public void configure() {
bind(CustomDataBinder.class).asEagerSingleton();
}
}
play.modules.enable += "modules.StarterModule"
public class StarterController extends Controller {
@Inject
CustomDataBinder dataBinder;
public Result apply() {
return ok();
}
}
# Starter
GET /start controllers.StarterController.apply()
8. 認証(TBD)
deadbolt-2 を使う予定
9. エラーページ(TBD)
HttpErrorHandler を実装。
10. エラーメッセージの国際化(TBD)
11. アクセスログ(TBD)
webサーバをおいて、そこからプロキシさせるのが本番運用のよくある形なので、webサーバ側でアクセスログをとればよいのですが、アプリ側でもリクエスト毎にログをとっておくと何かと便利。
java版でも HttpFilter が使えるようになったので、これで実装する。
12. テストコード (TBD)
単体テスト
TBD
機能テスト
2.2.xのときのやり方を見直す予定。
13. CI (TBD)
2.2.xのときに、findbugs + jacocoを組み合わせる のができたので、それを見直す予定。
14. CentOSのサービスに登録するための起動スクリプト (TBD)
デプロイは、activator dist
で生成されたものを使います。サーバの再起動時に自動起動できるようにするのと、パフォーマンスを監視するための仕掛けをいれるために、起動スクリプトを用意します。使い慣れているのでCentOSにしています。
heapstats を組み合わせる予定。
この記事 に切り出して整理中です。Javaアプリなので、ヒープメモリ、GCログなどの設定も盛り込むと、手堅いです。
まとめ
webアプリを業務で使えるレベルで作るには、それなりの範囲の知識をカバーしていなければいけないと、改めて気づかされました。学ぶことが増えている分、効率的に習得できる道筋をつくることと、そのために獣道を切り開いていくことの両方が必要なんでしょう。
play自体は自由度の高さがあってメリットでもあるのですが、ある程度の設計方針を自分で持っていないと、好き勝手につくった結果として初歩的なアンチパターンを踏みそうです。その点では、初心者にはもっとレールが敷かれたフレームワークの方がよいかもしれません。
開発続けていって、ある程度まとまれば、TBDのところは埋めていきます。
参考サイト
蛇足
僕自身の所感などの、ガチンコの蛇足です。
感想1
フロント周りのスキルを持っていないので、そこで躓くことが多くてしんどいです・・・
サーバサイドはREST APIにして、フロントは別のフレームワークでやるなど切り離した方が、もっと柔軟にできそうな気がしますが、それでは勉強することが増えすぎていつになったらできるのだと・・・
感想2
流行的にはscalaの方が受けが良さそうかなと思ってますが、あまり他の人がやらなさそうなところで戦うという、生存戦略です。仕事においてもscalaかそれに相当するものが今後はもっと使われていくとは思いますが、それでもまずはjava力をもっとつけたいのもあったり。
挫折したこと
オブジェクトのリストをフォーム送信
hoge[i].propertyA というnameで送信すれば、scalaではhogeに対応する型で受け取れるようだが、試したところなぜかうまくいかず・・・