Шаблоны проектирования при разработке под Android. Часть 4 — Сохранение данных. Domain Model, Repository, Singleton и BDD

    Сразу хочу сказать, что в статье я не буду описывать как надо работать с Data Provider-ом. Это можно найти и в документации и в многочисленных статьях в интернете.
    Здесь я расскажу про шаблоны проектирования Domain Model, Singleton, Repository, про подход Behavior Driven Development (BDD) и как я их использовал в своей программе.

    Шаблон Domain Model используется в тех случаях когда разработка ведется от предметной области, в таких случаях есть понятная предметная область и ее термины просто воплощаются в байтах.

    Например, в моей программе предметная область состоит из данных расписания, заданного в виде нескольких будильников, для будильника можно задать дни недели и время, а также признак «будильник включен». Так же есть несколько алгоритмов, например получить будильник, который сработает следующим и дату и время его срабатывания. Поскольку будильник может дремать, получается что у одного будильника есть несколько срабатываний с разными действиями: первое срабатывание, дремание, и последнее дремание, когда кнопка дремать уже не доступна. Поэтому есть еще алгоритм поучения ближайшего абсолютного времени и действия.
    Так же есть алгоритмы для создания нового будильника и редактирования/удаления существующих.

    То есть в моей предметной области есть данные в виде нескольких будильников и несколько алгоритмов, которые реализуют логику предметной области.

    Почему я вообще решил использовать этот шаблон проектирования. В альтернативу я бы мог сделать отдельный класс, который создает/редактирует будильники и сохраняет их в БД, а алгоритмы вычисления ближайшего будильника можно было сделать в другом классе.

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

    Во-вторых, в таком случае, когда я тестирую модель я могу протестировать обычное поведение пользователя. Например создать будильник, задать ему расписание, проверить что модель выдает правильное следующее время, потом я добавляю новый будильник, а старый отключаю и проверяют что и на этот раз время следующего срабатывания правильное.

    Такой подход называется Behavior Driven Development. Его достоинство в том, что я могу тестировать модель в терминах предметной области, то есть в модульных тестах я имитирую обычное поведение пользователя. Благодаря тому, что это реализовано через механизм модульных тестов, я перед каждым релизом могу эти тесты прогнать и быть уверенным, что моя программа программа нормально отрабатывает основные действия пользователя.

    Если бы я использовал отдельный класс для редактирования/сохранения будильников и отдельный класс для вычисления ближайшего будильника, то я конечно бы протестировал их по отдельности, но не смог бы проверить их вместе и не смог бы проверить основные действия пользователя.


    Модель и ее интерфейсы


    При реализации предметной области я реализовал модель в виде класса AlarmListModel, которая хранит будильники и реализует эти алгоритмы. Хочу подчеркнуть что модель ничего не знает о сохранении в БД. Сохранением модели занимается репозиторй, о котором я расскажу ниже.

    У модели есть список будильников, который используется при расчете следующего будильника или при редактировании. То есть модель хранит и данные и реализует алгоритмы. Очевидно, что в классе «будильник» нельзя реализовать эти алгоритмы, потому что тот, кто рассчитывает время следующего срабатывания должен знать про все включенные будильники, а объект «будильник» знает только об одном себе. Поэтому у меня будильник это простая структура с данными и без всяких алгоритмов.

    Модель реализует два интерфейса.
    IAlarmListModel — реализует методы связанные с предметной областью, например, создать новый будильник и узнать время следующего срабатывания.
    IListModelData — интерфейс который используется репозиторием для того чтобы сохранить модель в БД или наоборот прочитать из БД.

    Хочу отметить, что репозиторий у меня целиком читает все данные, читай, расписания всех будильников, из БД. Это связано с тем что будильников не так много чтобы за каждым лазить в БД, а так же с тем, что для вычисления следующего будильника мне нужны все включенные будильники, а для отображения в списке мне нужны вообще все будильники.

    Ниже основные исходники модели и интерфейсов:

    public interface IAlarmListModel
    {
    	IDisplayAlarm	getNextDisplayAlarm(Date curTime);
    	
    	IDisplayAlarm	createAlarm();
    	void 			updateAlarm(IDisplayAlarm iDisplayAlarm);
    	void 			addAlarm(IDisplayAlarm iDisplayAlarm);
    	void 			deleteAlarm(IDisplayAlarm item);
    	
    	void 			takeAlarmForEdit(int alarmID);
    	IDisplayAlarm	getEditingAlarm();
    	void 			saveEditingAlarm(boolean stayEditing);
    }


    Методы takeAlarmForEdit() и saveEditingAlarm() нужны чтобы клонировать будильник для редактирования и иметь возможность отбросить внесенные пользователем изменения, если тот нажал «Отмена». В базу данных эти методы ничего не сохраняют, только во внутренний список будильников.

    public interface IListModelData<T>
    {
    	ArrayList<T> getItemList();
    	ArrayList<T> getDeletedItemList();
    }


    Этот интерфейс использует репозиторий, чтобы иметь доступ к внутренним спискам будильников в модели. Как видно, здесь есть список удаленных будильников. Сюда попадают будильники, которые пользователь решил удалить, потом репозиторий просматривает этот список и удаляет будильники из базы.

    public class AlarmListModel
    				implements IAlarmListModel,
    							IListModelData<AlarmItem>
    {
    	private ArrayList<AlarmItem> alarmArray = new ArrayList<AlarmItem>();
    	private ArrayList<AlarmItem> alarmArrayDeleted = new ArrayList<AlarmItem>();
    
    	@Override
    	public synchronized IDisplayAlarm getNextDisplayAlarm(Date curTime)
    	{
    		//Мудреный алгоритм, который не важен в этой статье.
    	}
    
    	@Override
    	public synchronized AlarmItem createAlarm()
    	{
    		AlarmItem alarm = new AlarmItem();
    		alarm.setId(0);
    
    		//Признак для репозитория, что объект надо создать в БД,
    		//а его ID заменить сгенереным из базы
    		alarm.setState(EntityState.ADDED);
    		
    		alarm.setName("New alarm");//Название
    		alarm.setIssue(8, 0, false);//8 утра для срабатывания
    		alarm.setEnable(true);//будильник включен
    
    		//Звонить по будним дням
    		//Второй false означает, что будильник не изменен однократно
    		alarm.setDay(Calendar.MONDAY,	true, false);
    		alarm.setDay(Calendar.TUESDAY,	true, false);
    		alarm.setDay(Calendar.WEDNESDAY,true, false);
    		alarm.setDay(Calendar.THURSDAY,	true, false);
    		alarm.setDay(Calendar.FRIDAY,	true, false);
    		alarm.setDay(Calendar.SATURDAY,	false, false);
    		alarm.setDay(Calendar.SUNDAY,	false, false);
    		
    		return alarm;
    	}
    
    	@Override
    	public synchronized void addAlarm(IDisplayAlarm item)
    	{
    		AlarmItem alarm = (AlarmItem)item;
    		
    		if(item.getId() > 0)
    		{
    			ex = new Exception("Попытка добавить запись с ключем");
    		}
    		
    		alarm.setState(EntityState.ADDED);
    		alarmArray.add(alarm);
    	}
    
    	@Override
    	public synchronized void updateAlarm(IDisplayAlarm item)
    	{
    		AlarmItem alarm = (AlarmItem)item;
    		alarmArray.set(getPositionByID(item.getId()), alarm);
    	}
    
    	@Override
    	public synchronized void deleteAlarm(IDisplayAlarm item)
    	{
    		AlarmItem alarm = (AlarmItem)item;
    		
    		alarmArray.remove(alarm);
    
    		alarm.setState(EntityState.DELETED);
    		alarmArrayDeleted.add(alarm);
    	}
    
    	public synchronized void takeAlarmForEdit(int alarmID)
    	{
    		if(alarmID > 0)
    			editAlarm = ((AlarmItem)getDisplayAlarmByID(alarmID)).clone();
    		else
    			editAlarm = createAlarm();
    	}
    
    	@Override
    	public synchronized AlarmItem getEditingAlarm()
    	{
    		return editAlarm;
    	}
    	
    	@Override
    	public synchronized void saveEditingAlarm(boolean stayEditing)
    	{
    		if(editAlarm == null)return;
    
    		if(editAlarm.getId() > 0)
    			updateAlarm(editAlarm);
    		else
    			addAlarm(editAlarm);
    		
    		if(!stayEditing)editAlarm = null;
    	}
    
    	@Override
    	public synchronized ArrayList<AlarmItem> getItemList()
    	{
    		return alarmArray;
    	}
    
    	@Override
    	public synchronized ArrayList<AlarmItem> getDeletedItemList()
    	{
    		return alarmArrayDeleted;
    	}
    }


    Итак, посмотрим что внутрях нашей модели. Почему все методы помечены как synchronized читайте в разделе про репозиторий.

    Метод createAlarm(), нужен, чтобы модель была фабрикой будильников. Хочу отметить, что фабрика не добавляет созданный объект во внутренний список будильиков для этого есть метод addAlarm()

    Методы addAlarm(), updateAlarm(), deleteAlarm() нужны для работы с внутренним списком будильников. Как вы уже заметили у класса будильника AlarmItem есть методы для отслеживания состояния, эти состояния показывают состояния записи относительно базы данных:

    public enum EntityState
    {
    	ADDED,
    	NOT_CHANGED,
    	CHANGED,
    	DELETED
    }


    Это стандартный прием используемый в ORM движках, чтобы при синхронизации с БД, чтобы уменьшить количество обращений к базе и вносить изменения только для изменившихся записей.
    Как вы понимаете класс AlarmItem во всех своих setter-ах проставляет статус, что запись изменилась. Репозиторий анализирует эти состояния, чтобы решить что надо обновлять в БД, а что нет.

    Хочу заметить, что в методе addAlarm я использую одну хитрость. Если у меня возникла исключительная ситуация, когда в коллекцию добавляется неправильный будильник, то я не выбрасываю исключение, а тихо сохраняю это исключение в поле:

    ex = new Exception("Попытка добавить запись с ключем");


    Это поле проверяется у меня в тестах. После выполнения теста в этом поле должно быть null, в противном случае тест не пройден. Конечно здесь можно было бы использовать Assert или полноценно выбрасывать исключение, но бывают ситуации, когда исключение выбрасывать нельзя. Например, если ваш класс реализует интерфейс и в методе у вас происходит исключение, а по интерфейсу исключение из этого метода выбрасывать нельзя. Чтобы все таки отловить это исключение, я помещаю его в поле тестируемого класса и проверяю в конце теста.
    Полагаю есть более изящно решение этой проблемы, но я его еще не нашел.

    Методы takeAlarmForEdit(), getEditingAlarm(), saveEditingAlarm(), в основном, нужны для формы редактирования будильника. Как я уже говорил они нужны чтобы клонировать будильник, изменить его и, при необходимости, отбросить изменения.

    Встречайте репозиторий


    Репозиторий имеет всего два метода, сохранить и прочитать модель:

    public interface IAlarmRepository 
    {
    	public abstract void load();
    	public abstract void save();
    }


    Как видите здесь нет упоминания модели. Модель у меня реализована в виде шаблона Singleton, то есть во всей программе есть только один экземпляр модели и если одна часть программы, например, UI, что то меняет в модели, то другая часть, например сервер или репозиторий, читая данные из того же экземпляра модели сразу получат последние изменения. Очень удобно когда надо передавать единственно существующий объект между частями приложения.

    У шаблона Singleton есть ряд недостатков, за которые его даже предали анафеме и называют антипаттерном. Обычно Singleton реализуют через статический класс что приводит к тому, что его нельзя имитировать в тестах а также к тому, что если какой-то класс использует Singleton, то это не видно из объявления самого класса.
    Так же есть проблема в том, что надо быть уверенным, что Singleton всегда находится в правильном состоянии, если для перевода Singleton-а из одного состояния в другое требует два вызова то между ними может вклинится другой вызов Singleton-а из другого компонента и застать Singleton в неправильном состоянии, что может привести к неправильному результату.

    Так вот проблему со статическим классом я решил благодаря тому, что использую RoboGuice, который в данном случае является фабрикой модели. Так же использование RoboGuice позволяет показать, что класс использует Singleton и через RoboGuice я могу заменить Singleton имитацией в тестах.

    Проблема правильного состояния решается тем, что все изменения с моделью производятся за один вызов метода и все методы помечены как synchronized, что бы к модели не было двух вызовов одновременно.

    В таком виде Singleton очень полезен :). Модель у меня загружается из базы один раз и поэтому при работе программы не надо тратить ресурсы на загрузку модели в каждой активности будь то форма или сервис. Так же не надо думать как передать данные из одной активности в другую. Например, в активность редактирования я передаю только ID, по которому в модели нахожу нужный мне будильник.

    Так что учитесь правильно готовить Singleton.

    Вот код репозитория:

    public class AlarmRepository implements IAlarmRepository
    {
    	//подтягиваем Singleton модели.
    	//То, что это Singleton сконфигурировано в RoboGuice
    	@Inject private IAlarmListModel		alarmListModel;
    	@Inject Context context;
    
    	@Inject
    	public AlarmRepository()
    	{
    		db = (new DBHelper(context)).getWritableDatabase();
    	}
    
    	@Override
    	public synchronized void load()
    	{
    		IListModelData<AlarmItem> res = (IListModelData<AlarmItem>)alarmListModel;
    		res.getItemList().clear();
    		res.getDeletedItemList().clear();
    		
    		Cursor c = db.query(DBHelper.TABLE_NAME, projection, null, null, null, null, DBHelper.A_ID);
    		c.moveToNext();
    		
    		AlarmItem alarm = null;
    		while(!c.isAfterLast())
    		{
    			alarm = cvToAlarm(c);
    			res.getItemList().add(alarm);
    				
    			c.moveToNext();
    		}
    		
    		c.close();
    		
    		alarmListModel.setLoaded(true);
    	}
    	
    	@Override
    	public synchronized void save()
    	{
    		IListModelData<AlarmItem> model = (IListModelData<AlarmItem>)alarmListModel;
    		
    		ContentValues v = null;
    		
    		for(AlarmItem item : model.getItemList())
    		{
    			switch(item.getState())
    			{
    			case CHANGED:
    				v = alarmToCV(item);
    				int retVal = db.update(DBHelper.TABLE_NAME, v, DBHelper.A_ID + "=" + item.getId(), null);
    				break;
    			case ADDED:
    				v = alarmToCV(item);
    				int id = (int)db.insert(DBHelper.TABLE_NAME, null, v);
    				item.setId(id);
    				break;
    			case DELETED:
    				ex = new Exception("Не должно быть сущности со статусом DELETED в основной коллекции");
    				break;
    			}
    			
    			item.setState(EntityState.NOT_CHANGED);
    		}
    		
    		for(AlarmItem item : model.getDeletedItemList())
    			switch(item.getState())
    			{
    			case CHANGED:
    				ex = new Exception("Не должно быть сущности со статусом CHANGED в удаляемой коллекции");
    				break;
    			case ADDED:
    				ex = new Exception("Не должно быть сущности со статусом ADDED в удаляемой коллекции");
    				break;
    			case DELETED:
    				int retVal = db.delete(DBHelper.TABLE_NAME, DBHelper.A_ID + "=" + item.getId(), null);
    				break;
    			}
    		
    		model.getDeletedItemList().clear();
    	}
    }


    Я не стал показывать методы cvToAlarm() и alarmToCV() в них просто много строк с маппингом полей будильника в поля таблицы БД и обратно, которыми я не хочу загромождать статью.

    Еще раз скажу, что не буду здесь рассматривать как работать с Conten provider-ами. Поэтому, что значат всякие db.update() и db.insert() смотрите в SDK.

    Здесь я расскажу про шаблон проектирования «Repository». Как вы поняли по вторичным признакам в виде объявления методов synchronized, репозиторий у меня тоже Singleton.

    Репозиторий нужен только для того чтобы сохранить модель в хранилище. Хранилищем может быть файл, БД или сервис в интернете. В результате способ хранения можно отделить от модели и даже заменить, если понадобится.
    Поскольку репозиторий использует интерфейс, то в тестах его можно легко заменить имитацией. В результате можно делать не только простые модульные тесты, когда тестируется только один класс, а все зависимые части заменяются имитациями, но и провести функциональные тесты. В функциональных тестах тестируется сразу несколько классов. Как правило берут Presenter, модель и другие вспомогательные классы которые нужны, а все что ниже Presenter-а подменяют имитацией. Получается что можно полприложения протестировать по технике BDD, чтобы удостоверится что основные действия пользователя обрабатываются нормально.

    Как видно метод load() читает всю таблицу будильников и перекладывает их в модель. А метод save() смотрит что надо сохранить в модели и сохраняет только изменившиеся данные.

    P.S.
    Поскольку статья и так получилось большой я не стал показывать тесты для модели и репозитория. Ничего принципиально нового по сравнению с тестирванием Presenter-а в них нет. Так же делаем в тесте экземпляр тестируемого класса, подменяем зависимости имитациями через RoboGuice и тестируем полученную тушку.

    Читайте в других статьях


    Введение
    MVP и Unit tests. Путь Джедая
    Пользовательский интерфейс, тестирование, AndroidMock
    — Сохранение данных. Domain Model, Repository, Singleton и BDD
    — Реализация серверной части, RoboGuice, тестирование
    — Небольшие задачи, настройки, логирование, ProGuard
    Метки:
    • +19
    • 11,6k
    • 2
    Поделиться публикацией
    Похожие публикации
    Комментарии 2
    • 0
      Например, если ваш класс реализует интерфейс и в методе у вас происходит исключение, а по интерфейсу исключение из этого метода выбрасывать нельзя.


      Скажите пожалуйста, а про unchecked exceptions вы знаете? А про принцип fail fast?
      • 0
        Вот я как чувствовал, что есть нормальное решение. Теперь я это точно знаю.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.