Статьи

Серия уроков по калитке: проектирование бэкенда

Как уже упоминалось во второй день нашей серии, у нас есть основной интерфейс сервиса, который появился из первоначального обсуждения того, что, по нашему мнению, должен делать pastebin. Что может быть проще, чем создание сущностной модели для приложения-пастина, верно? Это просто большая текстовая область, которая содержит фрагмент текста (контент), которым кто-то хочет поделиться с остальным миром или, по крайней мере, с кем-то еще, кто может быть заинтересован в его просмотре. Тем не менее, есть некоторые конкретные вещи, которые мы хотели бы достичь в нашем приложении, которые были получены из ряда идей различных членов команды. Конечно, хотя это и не было обязательным требованием, мы хотели создать это приложение с использованием Apache Wicket .

Требования

Вот список идей (или требований), которые мы хотели для нашей пасты, без определенного порядка:

  • Элемент вставки должен быть идентифицирован уникальным идентификатором и / или отметкой времени, и он должен содержать текст и, необязательно, идентификатор языка (полезно для подсветки синтаксиса).
  • Элемент вставки может быть ответом на другой элемент вставки (может иметь родительский элемент).
  • Элемент Вставить может иметь один или несколько ответов (дочерние элементы).
  • Элемент вставки может быть закрытым. Частные предметы будут идентифицироваться специальным случайным строковым токеном определенной длины.
  • Элемент вставки может быть связан с конкретным пользователем (автором).
  • Определенный автор, который был идентифицирован в системе, сможет видеть все элементы вставки, которые он / она создали.

Модели / Entities

Из набора требований мы можем получить представление о том, как будут создаваться объекты и как они будут связаны друг с другом, как показано на следующей диаграмме:

Диаграмма классов

Это выглядит как очень простая сущностная модель, но она соответствует тому, что мы хотим достичь для этого конкретного проекта. Во время мозгового штурма обсуждались многие функции, такие как возможность редактировать и / или удалять принадлежащий вам элемент (идентифицированный по некоторому токену сеанса, сохраненному в cookie), возможность загрузки изображений, API для внешних клиентов, которым нужен доступ Это может быть реализовано в будущем, но было опущено для первой итерации проекта. Тем не менее, существует некоторая основа для функциональности, например, наличие клиентского токена для идентификации различных клиентов, обращающихся к серверу, и возможности их отображения в будущем.

Итак, теперь, когда у нас есть база, давайте приступим к некоторому фактическому кодированию. Как упоминалось ранее, мы решили использовать Hibernate в качестве нашего ORM-отображения. Вот как мы определяем класс PasteItem (методы получения и установки были исключены из класса, чтобы его было легче читать):

import javax.persistence.Basic;
import javax.persistence.Column;
import javax.persistence.Entity;
import static javax.persistence.EnumType.STRING;
import javax.persistence.Enumerated;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.Lob;
import javax.persistence.ManyToOne;
import javax.persistence.NamedQueries;
import javax.persistence.NamedQuery;
import javax.persistence.OneToMany;
import javax.persistence.Table;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@Entity
@NamedQueries({@NamedQuery(name = "item.getById", query = "from PasteItem item where item.id = :id"),
		@NamedQuery(name = "item.find",
				query = "from PasteItem item where item.isPrivate != true order by item.timestamp desc"),
		@NamedQuery(name = "item.findThreaded",
				query = "from PasteItem item where item.isPrivate != true and item.parent is null order by item.timestamp desc"),
		@NamedQuery(name = "item.findByLanguage",
				query = "from PasteItem item where item.isPrivate != true and item.type = :type order by item.timestamp desc"),
		@NamedQuery(name = "item.findByLanguageThreaded",
				query = "from PasteItem item where item.isPrivate != true and item.parent is null and item.type = :type order by item.timestamp desc"),
		@NamedQuery(name = "item.findByToken", query = "from PasteItem item where item.privateToken = :token"),
		@NamedQuery(name = "item.findByUser",
				query = "from PasteItem item where item.isPrivate != true and item.userToken = :token"),
		@NamedQuery(name = "item.count", query = "select count(*) from PasteItem item where item.isPrivate != true")})
public class PasteItem implements Serializable {
	@Id @GeneratedValue(strategy = GenerationType.AUTO)
	protected long id;
	@Lob
	protected String content;
	@Enumerated(STRING)
	protected LanguageType type;
	@Temporal(TemporalType.TIMESTAMP)
	protected Date timestamp;
	@Basic
	protected String userToken;
	@Basic
	protected String clientToken;
	@Basic
	protected boolean isPrivate;
	@Basic
	@Column(name = "PRIVATE_TOKEN", unique = true, updatable = false)
	protected String privateToken;
	@ManyToOne(fetch = FetchType.LAZY, optional = true)
	@JoinColumn(name = "PARENT_ITEM_ID", nullable = true)
	protected PasteItem parent;
	@OneToMany(fetch = FetchType.LAZY, mappedBy = "parent")
	protected List<PasteItem> children;
	...
}

Мы используем аннотации для определения нашей модели сущностей. Интересно, что мы не использовали аннотации , специфичные для Hibernate , а вместо этого использовали стандартные из JPA API . Hibernate понимает эти аннотации, поэтому кажется логичным использовать их, потому что тогда мы можем сделать приложение более переносимым, как мы могли бы заменить Hibernate для любой библиотеки с поддержкой JPA. Кроме того, еще одна замечательная особенность JPA заключается в том, что мы можем определить набор именованных запросов, которые могут использовать наши реализации Dao. Это позволяет вам определить почти все, что связано с постоянным слоем в одном классе, например, сущность, ее свойства и различные способы доступа к сущности с помощью запросов в одном месте, которое затем становится вашим основным ссылочным классом.

Сервисный уровень

Мы определили основной интерфейс службы, который интерфейс Wicket будет использовать для взаимодействия между веб-страницами и остальной частью приложения. Классы уровня обслуживания обычно отвечают за реализацию требуемых бизнес-правил приложения, и они могут быть настолько сложными или настолько простыми, насколько это требуется, в зависимости от того, каковы фактические требования приложения. Википедия определяет бизнес-правило как:

Бизнес-правило — это утверждение, которое определяет или ограничивает некоторые аспекты бизнеса. Он предназначен для утверждения структуры бизнеса или для контроля или влияния на поведение бизнеса. Отдельные бизнес-правила, описывающие один и тот же аспект предприятия, обычно объединяются в  наборы бизнес-правил . Бизнес-правила описывают операции, определения и ограничения, которые применяются к организации при достижении ее целей.

С точки зрения непрофессионала, бизнес-правило — это все, что влияет на то, как мы поступаем с нашими данными. Вот так выглядит реализация нашего сервиса:

import com.mysticcoders.mysticpaste.model.LanguageType;
import com.mysticcoders.mysticpaste.model.PasteItem;
import com.mysticcoders.mysticpaste.persistence.PasteItemDao;
import com.mysticcoders.mysticpaste.utils.TokenGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
public class PasteServiceImpl implements PasteService {
	private final Logger logger = LoggerFactory.getLogger(getClass());
	public static final int DEFAULT_TOKEN_LENGTH = 10;
	private PasteItemDao itemDao;
	private int tokenLength;
	public PasteServiceImpl() {
		this.tokenLength = DEFAULT_TOKEN_LENGTH;
	}
	public PasteServiceImpl(PasteItemDao itemDao, int tokenLength) {
		this.itemDao = itemDao;
		this.tokenLength = tokenLength;
	}
	@Transactional(readOnly = true)
	public List<PasteItem> getLatestItems(String clientToken, int count, int startIndex, boolean threaded)
			throws InvalidClientException {
		logger.trace("Service: getLatestItems. clientToken = {}, count = {}, startIndex = {}, threaded = {}",
				new Object[]{clientToken, count, startIndex, threaded});
		List<PasteItem> results = null;
		if (threaded) {
			results = itemDao.findThreaded(count, startIndex);
		} else {
			results = itemDao.find(count, startIndex);
		}
		if (null == results) {
			logger.warn("Found no items in database.");
			results = new ArrayList<PasteItem>();
		}
		return results;
	}
	@Transactional(readOnly = true)
	public PasteItem getItem(String clientToken, long id) throws InvalidClientException {
		return itemDao.get(id);
	}
	@Transactional(readOnly = true)
	public PasteItem findPrivateItem(String clientToken, String privateToken) throws InvalidClientException {
		return itemDao.findByToken(privateToken);
	}
	@Transactional(readOnly = true)
	public List<PasteItem> findItemsByLanguage(String clientToken, LanguageType languageType, int count,
											   int startIndex, boolean threaded)
			throws InvalidClientException {
		List<PasteItem> results = null;
		if (threaded) {
			results = itemDao.findByLanguageThreaded(languageType, count, startIndex);
		} else {
			results = itemDao.findByLanguage(languageType, count, startIndex);
		}
		if (null == results) {
			results = new ArrayList<PasteItem>();
		}
		return results;
	}
	@Transactional(rollbackFor = Exception.class)
	public long createItem(String clientToken, PasteItem item) throws InvalidClientException {
		if (null != item && item.isPrivate()) {
			item.setPrivateToken(TokenGenerator.generateToken(getTokenLength()));
		}
		// set created Timestamp
		item.setTimestamp(new Date(System.currentTimeMillis()));
		return itemDao.create(item);
	}
	public long getLatestItemsCount(String clientToken) throws InvalidClientException {
		return itemDao.getPasteCount();
	}
	public PasteItemDao getItemDao() {
		return itemDao;
	}
	public void setItemDao(PasteItemDao itemDao) {
		this.itemDao = itemDao;
	}
	public int getTokenLength() {
		return tokenLength;
	}
	public void setTokenLength(int tokenLength) {
		this.tokenLength = tokenLength;
	}
}

The business rules for the pastebin are very light in nature. One of the business rule we have from the requirements is to generate a random string token to use as the private paste identifier, so that instead of having a sequential id (which can be guessed), it is identified by this string and a special url.

We’re using slf4j as our logging mechanism. This allows us to statically map our logging to one of log4j, jdk, etc., and it also allows us to have very simple logging messages that will help us ‘debug’ the application in a sense. Nowadays it is considered bad practice to use System.out.println() messages as we don’t have control over them (i.e. they will always appear). Having a logging mechanism with separate message levels allows us to control what we want to show.

It is also interesting to note that in order to support a transactional set of methods, we used Spring’s @Transactional annotation. This annotation allows us to mark a method (and its underlying method calls) as part of one transaction, and optionally to mark said transaction as read-only. Also, take care that marking a method as transactional is often not enough, as we have to specify under which conditions the transaction needs to rollback (in our case, every time an exception is thrown). This is because by default, spring only rolls back transactions when runtime exceptions are thrown.

Persistence layer

Since we are using Hibernate, our persistence layer becomes a really easy, thin layer. Here’s our Dao implementation:

import com.mysticcoders.mysticpaste.model.LanguageType;
import com.mysticcoders.mysticpaste.model.PasteItem;
import com.mysticcoders.mysticpaste.persistence.PasteItemDao;
import java.util.List;
public class PasteItemDaoImpl extends AbstractDaoHibernate<PasteItem> implements PasteItemDao {
	protected PasteItemDaoImpl() {
		super(PasteItem.class);
	}
	public Long create(PasteItem item) {
		save(item);
		return item.getId();
	}
	public PasteItem get(long id) {
		PasteItem item = (PasteItem) getSession().getNamedQuery("item.getById")
				.setLong("id", id).setMaxResults(1)
				.uniqueResult();
		return item;
	}
	public List<PasteItem> findByLanguage(LanguageType languageType, int count, int startIndex) {
		return getSession()
				.getNamedQuery("item.findByLanguage")
				.setParameter("type", languageType)
				.setMaxResults(count).setFirstResult(startIndex).list();
	}
	public List<PasteItem> findByLanguageThreaded(LanguageType languageType, int count, int startIndex) {
		return getSession()
				.getNamedQuery("item.findByLanguageThreaded")
				.setParameter("type", languageType)
				.setMaxResults(count).setFirstResult(startIndex).list();
	}
	public List<PasteItem> find(int count, int startIndex) {
		return getSession().getNamedQuery("item.find")
				.setMaxResults(count).setFirstResult(startIndex).list();
	}
	public List<PasteItem> findThreaded(int count, int startIndex) {
		return getSession()
				.getNamedQuery("item.findThreaded")
				.setMaxResults(count).setFirstResult(startIndex).list();
	}
	public PasteItem findByToken(String privateToken) {
		return (PasteItem) getSession()
				.getNamedQuery("item.findByToken")
				.setParameter("token", privateToken)
				.uniqueResult();
	}
	public long getPasteCount() {
		Long count = (Long) getSession()
				.getNamedQuery("item.count")
				.setMaxResults(1).uniqueResult();
		return null == count ? 0 : count;
	}
}

We have taken advantage of JPA’s @NamedQuery annotation to greatly simplify the code in our Dao implementation. Since all the queries for accessing a PasteItem were already defined inside the entity (in our case, the PasteItem class), we only need to refer to those queries here, set the named parameters and get the results.

We also defined an abstract class to “generify” (term borrowed from IDEA’s inspection) Hibernate’s access:

import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.springframework.orm.hibernate3.support.HibernateDaoSupport;
import java.io.Serializable;
public class AbstractDaoHibernate<T> extends HibernateDaoSupport {
	private Class entityClass;
	private SessionFactory sessionFactory;
	protected AbstractDaoHibernate(Class dataClass) {
		super();
		this.entityClass = dataClass;
	}
	@SuppressWarnings("unchecked")
	private T load(Long id) {
		return (T) getSession().get(entityClass, id);
	}
	@SuppressWarnings("unchecked")
	private T loadChecked(Long id) throws EntityNotFoundException {
		T persistedObject = load(id);
		if (persistedObject == null) {
			throw new EntityNotFoundException(entityClass, id);
		}
		return persistedObject;
	}
	public void merge(T detachedObject) {
		getSession().merge(detachedObject);
	}
	public void save(T persistedObject) {
		getSession().saveOrUpdate(persistedObject);
	}
	private void delete(T persistedObject) {
		getSession().delete(persistedObject);
	}
	public void delete(Long id) {
		delete(loadChecked(id));
	}
}

This class takes advantage of Java 5 generics in order to implement the common persistence methods that we have. It also extends Spring’s HibernateDaoSupport to make it easier to integrate with Spring.

Wiring everything together

Once we have the service and the persistence layer, we need a way to put everything together in order for our application to work. Since we are using Spring, we only need to define our beans in the applicationContext.xml:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
	   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	   xmlns:tx="http://www.springframework.org/schema/tx"
	   xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.5.xsd http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-2.5.xsd">
	<!-- Services Beans -->
	<bean id="pasteService" class="com.mysticcoders.mysticpaste.services.PasteServiceImpl">
		<property name="itemDao" ref="pasteItemDao"/>
		<property name="tokenLength" value="${private.token.length}" />
	</bean>
	<!-- DAOs -->
	<bean id="pasteItemDao" class="com.mysticcoders.mysticpaste.persistence.hibernate.PasteItemDaoImpl">
		<property name="sessionFactory" ref="sessionFactory"/>
	</bean>
	<!--  Database Beans -->
	<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
		<property name="driverClassName" value="${jdbc.driver}"/>
		<property name="url" value="${jdbc.url}"/>
		<property name="username" value="${jdbc.username}"/>
		<property name="password" value="${jdbc.password}"/>
	</bean>
	<!-- Hibernate session factory -->
	<bean id="sessionFactory"
		  class="org.springframework.orm.hibernate3.annotation.AnnotationSessionFactoryBean">
		<property name="dataSource" ref="dataSource"/>
		<property name="hibernateProperties">
			<props>
				<prop key="hibernate.dialect">${hibernate.dialect}</prop>
				<prop key="hibernate.show_sql">${hibernate.show_sql}</prop>
				<prop key="use_outer_join">${hibernate.use_outer_join}</prop>
				<prop key="hibernate.cache.use_second_level_cache">${hibernate.cache.use_second_level_cache}</prop>
				<prop key="hibernate.cache.use_query_cache">${hibernate.cache.use_query_cache}</prop>
				<prop key="hibernate.cache.provider_class">${hibernate.cache.provider}</prop>
				<prop key="hibernate.connection.pool_size">10</prop>
				<prop key="hibernate.jdbc.batch_size">1000</prop>
				<prop key="hibernate.bytecode.use_reflection_optimizer">true</prop>
			</props>
		</property>
		<property name="annotatedClasses">
			<list>
				<value>com.mysticcoders.mysticpaste.model.PasteItem</value>
			</list>
		</property>
		<property name="schemaUpdate" value="${hibernate.schemaUpdate}"/>
	</bean>
	<!-- Tell Spring it should use @Transactional annotations -->
	<tx:annotation-driven/>
	<bean id="transactionManager"
		  class="org.springframework.orm.hibernate3.HibernateTransactionManager">
		<property name="sessionFactory" ref="sessionFactory"/>
	</bean>
</beans>

The Service is configured as a bean, with a reference to the Dao implementation and to a variable that will be replaced by maven when building for the appropriate platform, as mentioned in day 1. The Dao is configured with a reference to Hibernate’s sessionFactory bean, and the rest is the configuration for Hibernate (the data source, the transaction manager, etc.).
Since we’re using annotations, we need to set the property annotatedClasses with a list of the Hibernate (or JPA) configured entity classes, in this case the PasteItem class. In order for the @Transactional annotation to work we need to tell Spring that our transaction is driven by those annotations with the <tx:annotation-driven/> tag. I’ve been involved in previous projects where the other developers think they are doing transactions because they used the annotation, but forgot to add this piece to the configuration, thus resulting in database inconsistencies.

Conclusion

Designing the backend involves very little Wicket (or nothing at all as we saw here), but it’s very important to separate our different layers to make the application easier to maintain. Using Spring and Hibernate is win-win situation because we leave many configuration options to Spring, and we are able to provide an easy to use and easy to understand implementation of our service and persistence layer.

For more about Mystic, check us out at http://www.mysticcoders.com