一人Web開発~第9夜 EJB
EJBを書きます。正しくは、EJB+JPAです。
サービスとして独立した形を目指して、ロジックとデータアクセス部分をひとまとまりに切り出すことにしました。
画面はアプリケーションごとに、ロジックは共通に、というのは古くからあるパターンなんだろうけど、少なくとも自分の目では、これがうまくいっている例を見たことはありません。基本、アプリケーションごとに作られている感じ。共通ライブラリやアプリケーションフレームワークとしての共通化がはかられているのはよく見るけれども。一昔前のHW基準で処理負荷のかかるアプリケーションロジックをスケールできるようにということなのだろうけれど、使いにくさもあいまってHTTPリクエスト単位でスケールできたのでいいよということで落ち着いた感じ。結局、「煩わしいトランザクション管理をアプリケーションで制御しなくてよい」という程度に残っている印象です。それは多分僕がEJBを正しく使ってないからなのかもしれないけれど。
想定アプリケーション仕様
ユーティリティじゃなく、アプリケーション本体のコードを書く上で、簡単なアプリケーションの仕様を決めておこうと思います。例として一番よく出てきそうなECサイトっぽいのを作ることにします。
ユーザがいて、商品があって、商品をカートに入れて、発注、みたいなことができればよいのかな、と。
で、何度も言いますが今回の目的はアプリケーション本体にはありません。なので、まずは対象をユーザに限定して進めます。
エンティティの作成
ユーザはCustomerというクラスで扱うことにします。永続化先はtCustomer。先頭のtはテーブルのtの意味。一般的な単語を使うと予約語とか気にしないといけないので、頭にtを付けてそれを回避することにしました。
import java.sql.Timestamp; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; import javax.persistence.Table; import javax.persistence.Version; import javax.validation.constraints.NotNull; import javax.validation.constraints.Size; @Entity @Table(name = "tCustomer") public class Customer { @Id @GeneratedValue private long id; @NotNull @Size(max = 30) private String name; @NotNull private String password; @NotNull private Timestamp createdAt; @Version private Timestamp updatedAt; (アクセッサメソッドは省略) }
実は、ValidationとColumnアノテーションとの使い分けがちゃんとできてなかったりしますが、そこは今後の改善として、ひとまずidという自動生成のキーを用意し、更新日付を楽観ロックに使うことにします。あとは名前とかパスワードとか。
これのテストはどうしようか迷ったけれど、ひとまず「ネイティブのSQLは書かない」という方針でRDBMSは問わない実装を目指し、ユニットテストではDerbyを使うことにしました。
import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.assertThat; import static org.junit.Assert.fail; import java.sql.Timestamp; import javax.persistence.EntityManager; import javax.persistence.EntityManagerFactory; import javax.persistence.EntityTransaction; import javax.persistence.Persistence; import javax.validation.ConstraintViolation; import javax.validation.ConstraintViolationException; import javax.validation.Path.Node; import org.junit.After; import org.junit.AfterClass; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; public class CustomerTest { private static EntityManagerFactory emf; private static EntityManager em; private static EntityTransaction tx; @BeforeClass public static void setUp() { emf = Persistence.createEntityManagerFactory("test-pu"); em = emf.createEntityManager(); } @AfterClass public static void tearDown() { if (em != null) { em.close(); } if (emf != null) { emf.close(); } } @Before public void begin() { tx = em.getTransaction(); tx.begin(); } @After public void end() { if (tx != null && tx.isActive()) { tx.rollback(); tx = null; } } private Customer newCustomer() { Customer cust = new Customer(); cust.setName("name1name2name3name4name5name6"); cust.setPassword("pass"); Timestamp now = new Timestamp(System.currentTimeMillis()); cust.setCreatedAt(now); cust.setUpdatedAt(now); return cust; } @Test public void should_create_a_customer() { Customer cust = newCustomer(); Timestamp now = cust.getUpdatedAt(); em.persist(cust); assertThat(1L, is(cust.getId())); assertThat(cust.getUpdatedAt(), is(now)); } private void validationProperty(Customer cust, String propName, String message) { try { em.persist(cust); fail("not raise exception"); } catch (ConstraintViolationException e) { assertThat(e.getConstraintViolations().size(), is(1)); for (ConstraintViolation<?> cv : e.getConstraintViolations()) { for (Node node : cv.getPropertyPath()) { assertThat(node.getName(), is(propName)); } assertThat(cv.getMessage(), is(message)); } } } private static String MESSAGE_NOTNULL = "may not be null"; @Test public void validation_error_name_notNull() { Customer cust = newCustomer(); cust.setName(null); validationProperty(cust, "name", MESSAGE_NOTNULL); } @Test public void validation_error_password_notNull() { Customer cust = newCustomer(); cust.setPassword(null); validationProperty(cust, "password", MESSAGE_NOTNULL); } @Test public void validation_error_createdAt_notNull() { Customer cust = newCustomer(); cust.setCreatedAt(null); validationProperty(cust, "createdAt", MESSAGE_NOTNULL); } }
テスト用persistence.xml。
<?xml version="1.0" encoding="UTF-8"?> <persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd" version="2.1"> <persistence-unit name="test-pu" transaction-type="RESOURCE_LOCAL"> <class>org.oneman.entity.Customer</class> <exclude-unlisted-classes>false</exclude-unlisted-classes> <properties> <property name="javax.persistence.jdbc.driver" value="org.apache.derby.jdbc.EmbeddedDriver" /> <property name="javax.persistence.jdbc.url" value="jdbc:derby:memory:testdb;create=true" /> <property name="javax.persistence.jdbc.user" value="APP" /> <property name="javax.persistence.jdbc.password" value="APP" /> <property name="hibernate.dialect" value="org.hibernate.dialect.DerbyDialect" /> <property name="hibernate.hbm2ddl.auto" value="create" /> </properties> </persistence-unit> </persistence>
これでひとまず
$ mvn test
で通るはずです。
pomはGithubを参照ください。
oneman/application/projects/oneman-ejb at master · nobrooklyn/oneman · GitHub
サービス
サービスもユーザを扱うCustomerServiceだけ作ります。
しかも、登録メソッドだけです。
ユーザ名とパスワードをもらい、パスワードはSHA-256でハッシュ化して保存します。
import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.sql.Timestamp; import javax.ejb.LocalBean; import javax.ejb.Stateless; import javax.inject.Inject; import javax.persistence.EntityManager; import org.oneman.entity.Customer; import org.slf4j.Logger; @Stateless @LocalBean public class CustomerService { @Inject private Logger log; @Inject private EntityManager em; public void register(String name, String password) { String hashPassword = null; try { MessageDigest md = MessageDigest.getInstance("SHA-256"); hashPassword = new String(md.digest(password.getBytes())); } catch (NoSuchAlgorithmException e) { log.error(e.getMessage()); throw new ServiceRuntimeException(e); } Customer cust = new Customer(); cust.setName(name); cust.setPassword(hashPassword); Timestamp time = new Timestamp(System.currentTimeMillis()); cust.setCreatedAt(time); cust.setUpdatedAt(time); em.persist(cust); log.info("registered customer id={} name={}", cust.getId(), cust.getName()); } }
例外が起きたら、ServiceRuntimerExcpetionを投げることにします。
これは今のところ単なるRuntimeExceptionです。
import javax.ejb.ApplicationException; @ApplicationException public class ServiceRuntimeException extends RuntimeException { private static final long serialVersionUID = 1L; public ServiceRuntimeException(String message) { super(message); } public ServiceRuntimeException(Throwable t) { super(t); } public ServiceRuntimeException(String message, Throwable t) { super(message, t); } }
サービスのテストです。
登録メソッドの正常系と、例外発生パターンをテストしています。
ここではJPAコンポーネントとの結合はしないことにしてjMockitを使うことにしました。
もちろん、最初からある程度結合できていたほうがよいですし、組込コンテナを使ったテストも書けるのですが、ひとまずここでは単体メソッドのテストのを行うことにし、今後の拡充ポイントにしておきます。
import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.assertThat; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import javax.persistence.EntityManager; import mockit.Deencapsulation; import mockit.Delegate; import mockit.Expectations; import mockit.Mocked; import org.junit.Test; import org.oneman.entity.Customer; import org.slf4j.Logger; public class CustomerServiceTest { @Mocked private EntityManager em; @Mocked private Logger log; private CustomerService service = new CustomerService(); @SuppressWarnings("rawtypes") @Test public void should_registered_customer() throws Exception { final String name = "test"; String password = "pass"; final String hashPassword = new String(MessageDigest.getInstance( "SHA-256").digest(password.getBytes())); final Customer cust = new Customer(); Deencapsulation.setField(service, em); Deencapsulation.setField(service, log); new Expectations() { { em.persist(withAny(cust)); result = new Delegate() { @SuppressWarnings("unused") void persist(Customer cust) { assertThat(cust.getName(), is(name)); assertThat(cust.getPassword(), is(hashPassword)); cust.setId(1L); } }; log.info(anyString, anyLong, anyString); result = new Delegate() { @SuppressWarnings("unused") void info(String format, long p1, String p2) { assertThat(format, is("registered customer id={} name={}")); assertThat(p1, is(1L)); assertThat(p2, is(name)); } }; } }; service.register(name, password); } @Test(expected = ServiceRuntimeException.class) public void should_throw_exception_register( @Mocked MessageDigest messageDigest) throws Exception { Deencapsulation.setField(service, log); new Expectations() { { MessageDigest.getInstance("SHA-256"); result = new NoSuchAlgorithmException(); log.error(anyString); times = 1; } }; service.register("test", "pass"); } }
これでEJBの完成です。
次はWeb層のJSF。もちろん、CustomerServiceのregisterメソッドしかないので、画面も登録するためだけのものをまずは作ることにします。