Хочу поделиться очередной реализацией паттерна ActiveRecord на Objective-C, а конкретно для iOS.
Когда только начинал использовать CoreData в iOS разработке, то уже тогда появлялись мысли о том, что это взаимодействие можно как-то упростить. Спустя некоторое время я познакомился с ActiveRecord из RubyOnRails, и тогда я понял чего мне не хватает.
Немного поискав на гитхабе нашел массу реализаций, но по разным причинам они мне не понравились. Одни написаны для CoreData, а мне она не нравится, в других нужно создавать таблицы руками, или писать raw sql-запросы. А в каких-то код был до неприличия ужасен, я и сам порой пишу не очень чисто, но огромный забор из вложенных if/switch/if/switch это чересчур.
В конце концов решил написать свой велосипед, без CoreData и без SQL для пользователя.
Главной причиной этой разработки был, есть и, надеюсь, будет — интерес к разработке.
Вот что из этого всего вышло.
А под катом небольшое описание возможностей и реализации (на самом деле много текста и кусков кода, резюме в самом конце статьи).
Первой проблемой было создание таблиц.
В случае с CoreData ничего создавать не надо, нужно только описать сущности, а все остальное сделает CD.
Я долго думал над тем как бы это лучше оформить, и через время меня осенило.
Objective-C позволяет получить список всех подклассов для любого класса, и кроме того получить список всех его свойств. Таким образом описанием сущности будет являться простое описание класса, а все что мне останется сделать, так это собрать эту информацию и скомпоновать SQL-запрос на ее основе.
Описание сущности
Получение всех подклассов
Получение всех свойств вплоть до базового класса
Для большей гибкости пришлось отказаться от базовых типов данных (int, double etc.) и работать только с классами в качестве полей таблицы.
Таким образом в качестве поля таблицы можно использовать любой класс, единственное требование: он должен сам уметь сохранять и загружать себя.
Для этого он должен реализовывать ARRepresentationProtocol
Я реализовал эти методы для базовых типов Foundation фрэймворка при помощи категорий
— NSDecimalNumber — real
— NSNumber — integer
— NSString — text
— NSData — blob
— NSDate — date (real)
но набор этих классов может быть расширен в любой момент, без особого труда.
Того же позволяет добиться и CoreData с типом данных Transformable, но я до сих пор так и не осилил как с ним работать.
Процесс создания новой записи очень прост и прозрачен
Получение всех записей
Зачастую все записи не нужны, по-этому я добавил реализацию фильтров, но о них позже.
ActiveRecord следит за изменением всех свойств, и при обновлении создает запрос только на обновление измененных полей.
Все записи имеют свойство id(NSNumber), по которому производится удаление.
Как быть с полями которые нам не нужно сохранять в базу? Просто игнорировать их :)
Для этого в реализации класса нужно добавить следующую конструкцию, это простой сишный макрос.
Одним из тербований, которые я ставил перед собой в разработке, это поддержка валидаций.
На данный момент реализовано два типа валидации: на наличие и на уникальность.
Синтаксис прост, и также использует сишные макросы. Кроме того класс должен реализовывать ARValidatableProtocol, от пользователя ничего не требуется, это сделано для того, чтобы не запускать механизм валидации для классов, которые ее не используют.
Кроме того я реализовал поддержку кастомных валидаторов, которые может добавлять сам пользователь.
Для этого необходимо создать класс-валидатор, который должен реализовывать ARValidatorProtocol и описать его в валидируемом классе.
ARValidatorProtocol
Custom validator
Методы save, update и isValid возвращают булевые значения, в случае возврата false/NO можно получить список ошибок
после чего будет возвращен массив объектов класса ARError
Этот класс не содержит никаких детальных сообщений об ошибке, а только ключевые слова, на основе которых можно создать локализованное сообщение и отобразить его пользователю приложения.
Миграции реализованы на примитивном уровне: реагирует только на добавление новых полей к сущностям или на добавление новых сущностей.
Для использования миграций не нужно ничего нигде прописывать.
При первом запуске приложения создаются все таблицы, а при последующих запусках выполняется проверка на наличие новых полей ли таблиц, и если такие имеются, то производятся запросы alter table.
Для того чтобы не инстанциировать проверку на наличие изменения в структурах таблиц нужно перед всякими обращениями к ActiveRecord отправить следующее сообщение
Я также реализовал возможность использовать транзакции, для этого используются блоки
rollback — обыкновенный макрос который бросает исключение типа ARException.
Тарнзакции можно использовать не только для отката в случае неудачи, но и для увеличения скорости выполнения запросов при добавлении записей.
В одном из проектов был жуткий тормоз при попытке создать over9000 записей. Время выполнения дампа составляло около 180 секунд, после того как я обернул это в транзакцию BEGIN;… COMMIT; время снизилось до ~4-5 секунд. Так что советую всем, кто не в курсе.
Когда я познакомился с реализацией ActiveRecord в RoR, то я был восхищен простотой создания связанности между сущностями. По большому счету эта простота и послужила первой предпосылкой к созданию этого фрэймворка. И сейчас я считаю самой главной фичей в моем велосипеде как раз связи между сущностями, и их относительная простота.
Макросы belongs_to_dec belonsg_to_imp принимают три параметра: имя класса с которым мы «связываемся», имя getter'а и тип зависимости.
Типов зависимости реализовано два: ARDependencyNullify и ARDependencyDestroy, первый при удалении модели зануляет ее связи, а вторая удаляет все связанные сущности.
Поле для этой связи должно совпадать с именем модели и начинаться с буквы в нижнем регистре
Group <-> groupId
User <-> userId
ContentManager <-> contentManagerId
EMCategory <-> eMCategory // немного коряво, но так уж исторически сложилось
Обратная связь (HasMany)
Тоже самое что и в случае со связью типа BelongsTo.
Главное что нужно запомнить: перед созданием связи обе записи доолжны быть сохранены, иначе они не имеют id, а связи завязаны именно на нем.
Для создания этой связи нужно оздать еще одну модель, промежуточную.
Промежуточная, связующая модель
Эта связь имеет те же недостатки что и HasMany
Макросы *_dec/*_imp добавляют вспомогательные методы для добавления связей
Очень часто требуется как-то отфильтровать выборку из базы:
— поиск соответствующих какому-то шаблону записей (UISearchBar)
— вывод в таблицу только 5 записей из тысячи
— получение только текстовых полей записей, без доставания из базы кучи «тяжелых» картинок
— еще масса вариантов :)
Поначалу я также не представлял как реализовать все это в удобном виде, но потом снова вспомнил Ruby и присущую ему «ленивость», в итоге решил создать класс который будет доставать записи только по требованию, но принимать фильтры в любом порядке.
Вот что из этого получилось.
iActiveRecord подерживает базовые условия WHERE
Тоже самое можно описать в стиле привычных и удобных NSPredicate'ов
Сам этим почти никогда не пользовался, но решил что для полноты нужно реализовать.
Подерживаются разные типы join'ов
— ARJoinLeft
— ARJoinRight
— ARJoinInner
— ARJoinOuter
думаю названия говорят сами за себя.
С этой возможностю связан один небольшой костыль, потому для получения объединенных записей нужно вызывать
вместо
Этот метод возвращает массив из словарей, где ключами являются имена сущностей, а значениями — данные из базы.
Базу можно хранить как в Caches, так и в Documents, при этом в случае хранения в Documents к файлу добавляется атрибут отключающий бэкап
иначе приложение получит reject от Apple.
Проект на github — iActiveRecord.
Возможности
В качествеоправдания заключения хочу сказать, что проект начинался just for fun, и он продолжает развиваться, в планах в конце концов вычистить кучу «грязного» кода и добавить другие полезные возможности.
С удовольствием выслушаю адекватную критику.
P.S. сообщения об ошибках пишите в ЛС, пожалуйста.
Когда только начинал использовать CoreData в iOS разработке, то уже тогда появлялись мысли о том, что это взаимодействие можно как-то упростить. Спустя некоторое время я познакомился с ActiveRecord из RubyOnRails, и тогда я понял чего мне не хватает.
Немного поискав на гитхабе нашел массу реализаций, но по разным причинам они мне не понравились. Одни написаны для CoreData, а мне она не нравится, в других нужно создавать таблицы руками, или писать raw sql-запросы. А в каких-то код был до неприличия ужасен, я и сам порой пишу не очень чисто, но огромный забор из вложенных if/switch/if/switch это чересчур.
В конце концов решил написать свой велосипед, без CoreData и без SQL для пользователя.
Главной причиной этой разработки был, есть и, надеюсь, будет — интерес к разработке.
Вот что из этого всего вышло.
А под катом небольшое описание возможностей и реализации (на самом деле много текста и кусков кода, резюме в самом конце статьи).
Создание таблиц
Первой проблемой было создание таблиц.
В случае с CoreData ничего создавать не надо, нужно только описать сущности, а все остальное сделает CD.
Я долго думал над тем как бы это лучше оформить, и через время меня осенило.
Objective-C позволяет получить список всех подклассов для любого класса, и кроме того получить список всех его свойств. Таким образом описанием сущности будет являться простое описание класса, а все что мне останется сделать, так это собрать эту информацию и скомпоновать SQL-запрос на ее основе.
Описание сущности
@interface User : ActiveRecord
@property (nonatomic, retain) NSString *name;
@end
Получение всех подклассов
static NSArray *class_getSubclasses(Class parentClass) {
int numClasses = objc_getClassList(NULL, 0);
Class *classes = NULL;
classes = malloc(sizeof(Class) * numClasses);
numClasses = objc_getClassList(classes, numClasses);
NSMutableArray *result = [NSMutableArray array];
for (NSInteger i = 0; i < numClasses; i++) {
Class superClass = classes[i];
do{
superClass = class_getSuperclass(superClass);
} while(superClass && superClass != parentClass);
if (superClass == nil) {
continue;
}
[result addObject:classes[i]];
}
return result;
}
Получение всех свойств вплоть до базового класса
Class BaseClass = NSClassFromString(@"NSObject");
id CurrentClass = aRecordClass;
while(nil != CurrentClass && CurrentClass != BaseClass){
unsigned int outCount, i;
objc_property_t *properties = class_copyPropertyList(CurrentClass, &outCount);
for (i = 0; i < outCount; i++) {
// do something with concrete property => properties[i]
}
CurrentClass = class_getSuperclass(CurrentClass);
}
Типы данных
Для большей гибкости пришлось отказаться от базовых типов данных (int, double etc.) и работать только с классами в качестве полей таблицы.
Таким образом в качестве поля таблицы можно использовать любой класс, единственное требование: он должен сам уметь сохранять и загружать себя.
Для этого он должен реализовывать ARRepresentationProtocol
@protocol ARRepresentationProtocol
@required
+ (const char *)sqlType;
- (NSString *)toSql;
+ (id)fromSql:(NSString *)sqlData;
@end
Я реализовал эти методы для базовых типов Foundation фрэймворка при помощи категорий
— NSDecimalNumber — real
— NSNumber — integer
— NSString — text
— NSData — blob
— NSDate — date (real)
но набор этих классов может быть расширен в любой момент, без особого труда.
Того же позволяет добиться и CoreData с типом данных Transformable, но я до сих пор так и не осилил как с ним работать.
CRUD для записей
Create
Процесс создания новой записи очень прост и прозрачен
User *user = [User newRecord];
user.name = @"Alex";
[user save];
Read
Получение всех записей
NSArray *users = [User allRecords];
Зачастую все записи не нужны, по-этому я добавил реализацию фильтров, но о них позже.
Update
User *user = [User newRecord];
user.name = @"Alex";
[user save];
NSArray *users = [User allRecords];
User *userForUpdate = [users first];
userForUpdate.name = @"John";
[userForUpdate update]; // или [userForUpdate save];
ActiveRecord следит за изменением всех свойств, и при обновлении создает запрос только на обновление измененных полей.
Delete
NSArray *users = [User allRecords];
User *userForRemove = [users first];
[userForRemove dropRecord];
Все записи имеют свойство id(NSNumber), по которому производится удаление.
Ненужные поля
Как быть с полями которые нам не нужно сохранять в базу? Просто игнорировать их :)
Для этого в реализации класса нужно добавить следующую конструкцию, это простой сишный макрос.
@implementation User
...
@synthesize ignoredProperty;
...
ignore_fields_do(
ignore_field(ignoredProperty)
)
...
@end
Валидация
Одним из тербований, которые я ставил перед собой в разработке, это поддержка валидаций.
На данный момент реализовано два типа валидации: на наличие и на уникальность.
Синтаксис прост, и также использует сишные макросы. Кроме того класс должен реализовывать ARValidatableProtocol, от пользователя ничего не требуется, это сделано для того, чтобы не запускать механизм валидации для классов, которые ее не используют.
// User.h
@interface User : ActiveRecord
<ARValidatableProtocol>
...
@property (nonatomic, copy) NSString *name;
...
@end
// User.m
@implementation User
...
validation_do(
validate_uniqueness_of(name)
validate_presence_of(name)
)
...
@end
Кроме того я реализовал поддержку кастомных валидаторов, которые может добавлять сам пользователь.
Для этого необходимо создать класс-валидатор, который должен реализовывать ARValidatorProtocol и описать его в валидируемом классе.
ARValidatorProtocol
@protocol ARValidatorProtocol <NSObject>
@optional
- (NSString *)errorMessage;
@required
- (BOOL)validateField:(NSString *)aField ofRecord:(id)aRecord;
@end
Custom validator
// PrefixValidator.h
@interface PrefixValidator : NSObject
<ARValidatorProtocol>
@end
// PrefixValidator.m
@implementation PrefixValidator
- (NSString *)errorMessage {
return @"Invalid prefix";
}
- (BOOL)validateField:(NSString *)aField ofRecord:(id)aRecord {
NSString *aValue = [aRecord valueForKey:aField];
BOOL valid = [aValue hasPrefix:@"LOL"];
return valid;
}
@end
Обработка ошибок
Методы save, update и isValid возвращают булевые значения, в случае возврата false/NO можно получить список ошибок
[user errors];
после чего будет возвращен массив объектов класса ARError
@interface ARError : NSObject
@property (nonatomic, copy) NSString *modelName;
@property (nonatomic, copy) NSString *propertyName;
@property (nonatomic, copy) NSString *errorName;
- (id)initWithModel:(NSString *)aModel property:(NSString *)aProperty error:(NSString *)anError;
@end
Этот класс не содержит никаких детальных сообщений об ошибке, а только ключевые слова, на основе которых можно создать локализованное сообщение и отобразить его пользователю приложения.
Миграции
Миграции реализованы на примитивном уровне: реагирует только на добавление новых полей к сущностям или на добавление новых сущностей.
Для использования миграций не нужно ничего нигде прописывать.
При первом запуске приложения создаются все таблицы, а при последующих запусках выполняется проверка на наличие новых полей ли таблиц, и если такие имеются, то производятся запросы alter table.
Для того чтобы не инстанциировать проверку на наличие изменения в структурах таблиц нужно перед всякими обращениями к ActiveRecord отправить следующее сообщение
[ActiveRecord disableMigrations];
Транзакции
Я также реализовал возможность использовать транзакции, для этого используются блоки
[ActiveRecord transaction:^{
User *alex = [User newRecord];
alex.name = @"Alex";
[alex save];
rollback
}];
rollback — обыкновенный макрос который бросает исключение типа ARException.
Тарнзакции можно использовать не только для отката в случае неудачи, но и для увеличения скорости выполнения запросов при добавлении записей.
В одном из проектов был жуткий тормоз при попытке создать over9000 записей. Время выполнения дампа составляло около 180 секунд, после того как я обернул это в транзакцию BEGIN;… COMMIT; время снизилось до ~4-5 секунд. Так что советую всем, кто не в курсе.
Связи
Когда я познакомился с реализацией ActiveRecord в RoR, то я был восхищен простотой создания связанности между сущностями. По большому счету эта простота и послужила первой предпосылкой к созданию этого фрэймворка. И сейчас я считаю самой главной фичей в моем велосипеде как раз связи между сущностями, и их относительная простота.
HasMany <-> BelongsTo
// User.h
@interface User : ActiveRecord
...
@property (nonatomic, retain) NSNumber *groupId;
...
belongs_to_dec(Group, group, ARDependencyNullify)
...
@end
// User.m
@implementation User
...
@synthesize groupId;
...
belonsg_to_imp(Group, group, ARDependencyNullify)
...
@end
Макросы belongs_to_dec belonsg_to_imp принимают три параметра: имя класса с которым мы «связываемся», имя getter'а и тип зависимости.
Типов зависимости реализовано два: ARDependencyNullify и ARDependencyDestroy, первый при удалении модели зануляет ее связи, а вторая удаляет все связанные сущности.
Поле для этой связи должно совпадать с именем модели и начинаться с буквы в нижнем регистре
Group <-> groupId
User <-> userId
ContentManager <-> contentManagerId
EMCategory <-> eMCategory // немного коряво, но так уж исторически сложилось
Обратная связь (HasMany)
// Group.h
@interface Group : ActiveRecord
...
has_many_dec(User, users, ARDependencyDestroy)
...
@end
// Group.m
@implementation Group
...
has_many_imp(User, users, ARDependencyDestroy)
...
@end
Тоже самое что и в случае со связью типа BelongsTo.
Главное что нужно запомнить: перед созданием связи обе записи доолжны быть сохранены, иначе они не имеют id, а связи завязаны именно на нем.
HasManyThrough
Для создания этой связи нужно оздать еще одну модель, промежуточную.
// User.h
@interface User : ActiveRecord
...
has_many_through_dec(Project, UserProjectRelationship, projects, ARDependencyNullify)
...
@end
// User.m
@implementation User
...
has_many_through_imp(Project, UserProjectRelationship, projects, ARDependencyNullify)
...
@end
// Project.h
@interface Project : ActiveRecord
...
has_many_through_dec(User, UserProjectRelationship, users, ARDependencyDestroy)
...
@end
// Project.m
@implementation Project
...
has_many_through_imp(User, UserProjectRelationship, users, ARDependencyDestroy)
...
@end
Промежуточная, связующая модель
// UserProjectRelationship.h
@interface UserProjectRelationship : ActiveRecord
@property (nonatomic, retain) NSNumber *userId;
@property (nonatomic, retain) NSNumber *projectId;
@end
// UserProjectRelationship.m
@implementation UserProjectRelationship
@synthesize userId;
@synthesize projectId;
@end
Эта связь имеет те же недостатки что и HasMany
Макросы *_dec/*_imp добавляют вспомогательные методы для добавления связей
set#ModelName:(ActiveRecord *)aRecord; // BelongsTo
add##ModelName:(ActiveRecord *)aRecord; // HasMany, HasManyThrough
remove##ModelName:(ActiveRecord *)aRecord; // HasMany, HasManyThrough
Фильтры для запросов
Очень часто требуется как-то отфильтровать выборку из базы:
— поиск соответствующих какому-то шаблону записей (UISearchBar)
— вывод в таблицу только 5 записей из тысячи
— получение только текстовых полей записей, без доставания из базы кучи «тяжелых» картинок
— еще масса вариантов :)
Поначалу я также не представлял как реализовать все это в удобном виде, но потом снова вспомнил Ruby и присущую ему «ленивость», в итоге решил создать класс который будет доставать записи только по требованию, но принимать фильтры в любом порядке.
Вот что из этого получилось.
Limit/Offset
NSArray *users = [[[User lazyFetcher] limit:5] fetchRecords];
NSArray *users = [[[User lazyFetcher] offset:5] fetchRecords];
NSArray *users = [[[[User lazyFetcher] offset:5] limit:2] fetchRecords];
Only/Except
ARLazyFetcher *fetcher = [[User lazyFetcher] only:@"name", @"id", nil];
ARLazyFetcher *fetcher = [[User lazyFetcher] except:@"veryBigImage", nil];
Where
iActiveRecord подерживает базовые условия WHERE
- (ARLazyFetcher *)whereField:(NSString *)aField equalToValue:(id)aValue;
- (ARLazyFetcher *)whereField:(NSString *)aField notEqualToValue:(id)aValue;
- (ARLazyFetcher *)whereField:(NSString *)aField in:(NSArray *)aValues;
- (ARLazyFetcher *)whereField:(NSString *)aField notIn:(NSArray *)aValues;
- (ARLazyFetcher *)whereField:(NSString *)aField like:(NSString *)aPattern;
- (ARLazyFetcher *)whereField:(NSString *)aField notLike:(NSString *)aPattern;
- (ARLazyFetcher *)whereField:(NSString *)aField between:(id)startValue and:(id)endValue;
- (ARLazyFetcher *)where:(NSString *)aFormat, ...;
Тоже самое можно описать в стиле привычных и удобных NSPredicate'ов
NSArray *ids = [NSArray arrayWithObjects:
[NSNumber numberWithInt:1],
[NSNumber numberWithInt:15],
nil];
NSString *username = @"john";
ARLazyFetcher *fetcher = [User lazyFetcher];
[fetcher where:@"'user'.'name' = %@ or 'user'.'id' in %@",
username, ids, nil];
NSArray *records = [fetcher fetchRecords];
Объединения (join)
Сам этим почти никогда не пользовался, но решил что для полноты нужно реализовать.
- (ARLazyFetcher *)join:(Class)aJoinRecord
useJoin:(ARJoinType)aJoinType
onField:(NSString *)aFirstField
andField:(NSString *)aSecondField;
Подерживаются разные типы join'ов
— ARJoinLeft
— ARJoinRight
— ARJoinInner
— ARJoinOuter
думаю названия говорят сами за себя.
С этой возможностю связан один небольшой костыль, потому для получения объединенных записей нужно вызывать
- (NSArray *)fetchJoinedRecords;
вместо
- (NSArray *)fetchRecords;
Этот метод возвращает массив из словарей, где ключами являются имена сущностей, а значениями — данные из базы.
Сортировка
- (ARLazyFetcher *)orderBy:(NSString *)aField ascending:(BOOL)isAscending;
- (ARLazyFetcher *)orderBy:(NSString *)aField;// ASC по умолчанию
ARLazyFetcher *fetcher = [[[User lazyFetcher] offset:2] limit:10];
[[fetcher whereField:@"name"
equalToValue:@"Alex"] orderBy:@"name"];
NSArray *users = [fetcher fetchRecords];
Хранилище
Базу можно хранить как в Caches, так и в Documents, при этом в случае хранения в Documents к файлу добавляется атрибут отключающий бэкап
u_int8_t b = 1;
setxattr([[url path] fileSystemRepresentation], "com.apple.MobileBackup", &b, 1, 0, 0);
иначе приложение получит reject от Apple.
Резюме
Проект на github — iActiveRecord.
Возможности
- поддержка ARC
- поддержка unicode
- миграции
- валидации, с подержкой кастомных валидаторов
- транзакции
- кастомные тип данных
- связи (BelongsTo, HasMany, HasManyThrough)
- сортировка
- фильтры (where =, !=, IN, NOT IN и другие)
- объединения
- поддержка CocoaPods
Заключение
В качестве
С удовольствием выслушаю адекватную критику.
P.S. сообщения об ошибках пишите в ЛС, пожалуйста.