hatenob

プログラムって分からないことだらけ

一人Web開発~第9夜 EJB

EJBを書きます。正しくは、EJBJPAです。
サービスとして独立した形を目指して、ロジックとデータアクセス部分をひとまとまりに切り出すことにしました。
画面はアプリケーションごとに、ロジックは共通に、というのは古くからあるパターンなんだろうけど、少なくとも自分の目では、これがうまくいっている例を見たことはありません。基本、アプリケーションごとに作られている感じ。共通ライブラリやアプリケーションフレームワークとしての共通化がはかられているのはよく見るけれども。一昔前の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メソッドしかないので、画面も登録するためだけのものをまずは作ることにします。