1 Мотивация
Я знаю как сократить код ваших Dao в 50-90%.
2 Вступление
Зачастую при написании приложения на платформу Android для сохранения данных используются популярная библиотека Room. В целом видимых проблем с ней не возникает когда у нас используется например 3 таблицы, но по мере роста бд становится заметным постоянное дублирование кода базовых CRUD операций. Для базовых Insert, Update и Delete операций зачастую просто копируется код.
@Dao interface UserDao { @Insert fun insert(user: User) @Update fun update(user: User) } @Dao interface CarDao { @Insert fun insert(car: Car) @Update fun update(car: Car) }
Решить это можно с помощью наследования Dao, как указано здесь. Решение выглядит так:
interface InsertDao<T> { @Insert fun insert(item: T) } interface UpdateDao<T> { @Update fun update(item: T) } interface SaveDao<T> : InsertDao<T>, UpdateDao<T> @Dao interface UserDao : SaveDao<User> @Dao interface CarDao : SaveDao<Car>
Данный подход вполне себе обеспечивает Interface Segregation Principle из SOLID. Это правильно обеспечивать только нужной частью dao, если нам нужно выполнить только Insert без Update, мы просто обеспечиваем необходимый объект сущностью. Пример с InsertDao<User>:
class SomeClass<T>(private val insertDao: InsertDao<T>) { fun saveSomething(item: T) { insertDao.insert(item) } }
Но весь Interface Segregation Principle рушится как только добавляются функции с аннотацией Query:
@Dao interface UserDao : SaveDao<User> { @Query("SELECT * FROM USER") fun getAllUsers(): List<User> } @Dao interface CarDao : SaveDao<Car> { @Query("SELECT * FROM CAR") fun getAllCars(): List<Car> }
В таком случае мы можем воспользоваться решением предложенным там же в комментариях. И переработать код следующим образом:
interface GetAllDao<T> { @JvmSuppressWildcards fun getAll(): List<T> } @Dao interface UserDao : SaveDao<User>, GetAllDao<User> { @Query("SELECT * FROM USER") @JvmSuppressWildcards override fun getAll(): List<User> } @Dao interface CarDao : SaveDao<Car>, GetAllDao<Car> { @Query("SELECT * FROM CAR") @JvmSuppressWildcards override fun getAll(): List<Car> }
Таким образом нам все еще удается придерживаться Interface Segregation Principle ценой добавления аннотации JvmSuppressWildcards. Но от повторяющегося кода мы так далеко и не ушли, ведь все Read операции из базы данных будут копироваться в каждой Dao да еще и базовую для них создавать надо.
Главная проблема состоит в том что мы не можем использовать Query в интерфейсе GetAllDao. Причина проста, у нас нет имени таблицы, над которой проводится операция. Рефлексия нам тут тоже не помощник, попробовав ее использовать:
interface GetAllDao<T> { @Query("SELECT * FROM T::class.java.simpleName) fun getAll(): List<T> }
Получаем сразу две ошибки от компилятора:
-
An annotation argument must be a compile-time constant
-
Cannot use ‘T’ as reified type parameter. Use a class instead.
3 RawQuery в действии
Решить вышеописанную проблему можно используя аннотацию RawQuery. Поскольку большинство разработчиков не знакомы с ней ввиду 0% использования в большинстве проектов вкратце изложу суть.
RawQuery — одна из базовых аннотаций для функций внутри тела Dao с помощью которой можно создавать query к бд на Runtime.
Использование:
interface SomeDao { @RawQuery suspend fun getAll(query: SimpleSQLiteQuery): List<SomeEntity> }
Где SimpleSqliteQuery — уже готовый запрос к бд.
Но это еще не все, ведь SimpleSqliteQuery нужно еще создать. Логика использования очень проста, объект создается следующим образом для простого запроса:
val sqliteRawQuery = SimpleSqliteQuery("SELECT * FROM TABLE_NAME")
И для запроса с аргументами:
val query = "SELECT * FROM TABLE_NAME WHERE id = ?" val args = listOf(13) val sqliteRawQuery = SimpleSqliteQuery(query, args)
Таким образом разобравшись как работает SimpleSqliteQuery можем приступить к конечной реализации.
Сперва переработаем наши Dao заменив Query на RawQuery.
interface GetAllDao<T> { @RawQuery fun getAll(): List<T> } @Dao interface UserDao : SaveDao<User>, GetAllDao<User> @Dao interface CarDao : SaveDao<Car>, GetAllDao<Car>
Повторяющегося кода в Dao стало значительно меньше и это учитывая что у нас была только одна такая функция только в двух Dao.
Далее я предлагаю использовать Helper для работы с Dao поскольку он инкапсулирует в себе логику создания запроса. Создадим базовый класс Helper.
abstract class Helper<T>(private val classType: Class<T>) { protected val table: String get() = classType.simpleName protected abstract val query: String protected val sqliteQuery: SimpleSQLiteQuery get() = SimpleSQLiteQuery(query, getBindArgs()) protected open fun getBindArgs(): Array<Any> = arrayOf() }
Дальше станет понятно зачем он нужен и как работает).
Следующим шагом создадим GetAllDaoHelper для получения данных из бд.
class GetAllDaoHelper<T> @Inject constructor( private val itemDao: GetAllDao<T>, classType: Class<T>, ) : Helper<T>(classType) { override val query = "SELECT * FROM $table" suspend fun getAll() : List<T> { return itemDao.getAll(sqliteQuery) } }
Теперь разберем по порядку, что происходит. Переменная query инкапсулирует в себе запрос к базе данных. И при этом использует поле table.
override val query = "SELECT * FROM $table"
Поле table получает свое значение из переменной конструктора в базовом классе
abstract class Helper<T>(private val classType: Class<T>) { //..... protected val table: String get() = classType.simpleName //...... }
Здесь simpleName представляет простое имя класса T, которое было указано при объявлении, то есть если класс называется Car, то и его simpleName будет Car.
Переменная classType же приходит к нам в конструтор, об этом позже.
Вернувшись к GetAllItemsDaoHelper мы еще видим метод getAll()
class GetAllItemsDaoHelper<T> @Inject constructor( private val itemDao: GetAllDao<T>, //.. ) : Helper<T>(..) { //.. suspend fun getAll() : List<T> { return itemDao.getAll(sqliteQuery) } }
Как мы видим он использует уже известный GetAllDao c аргументом sqliteQuery.
abstract class Helper<T>(private val classType: Class<T>) { protected abstract val query: String //.. protected val sqliteQuery: SimpleSQLiteQuery get() = SimpleSQLiteQuery(query, getBindArgs()) protected open fun getBindArgs(): Array<Any> = arrayOf() }
Тут SimpleSqliteQuery создается уже по известной нам схеме, а метод getBindArgs не обязателен для переопределения, но может использоваться в случае использования аргументов в query.
Таким образом взглянув на класс GetAllDaoHelper остается лишь один вопрос, откуда мы берем переменную classType.
class GetAllItemsDaoHelper<T> @Inject constructor( //.. classType: Class<T>, ) : Helper<T>(classType) {
Ответ: из Dependency Injection. В данном примере я использую Hilt DI, ввиду простоты его работы с Generics в отличие от Koin. Таким образом когда мы добавляем сущность в базу данных необходимо внести в граф так же ее Class<Entity>, а именно по нашему примеру:
@Module @InstallIn(SingletonComponent::class) object EntityModule { @Provides fun provideCarClass(): Class<Car> = Car::class.java @Provides fun provideUserClass(): Class<User> = User::class.java //И так далее }
Уж не говорю что для работы программы GetAllDao<Car> и GetAllDao<User> тоже должны быть добавлены в граф.
4. Итоги
Данный подход имеет как плюсы так и минусы, не все были ранее описаны.
«Простейшая форма дублирования — куски одинакового кода. Программа выглядит так, словно у программиста дрожат руки, и он снова и снова вставляет один и тот же фрагмент.» — Роберт Мартин.
Начнем с очевидных и наиболее значительных плюсов.
-
Используя выше описанные рекомендации значительно уменьшается дублирование кода Dao. Последствия дублирования кода вы сами знаете.
-
Простота добавления новых Dao сведена к минимуму и при наличии повторяющихся действий нужно просто наследоваться от необходимых интерфейсов. А это означает прямое следование принципам Interface Segregation Principle и Open-Closed Principle.
-
Упрощенное тестирование(Если интересно что конкретно это значит, я могу рассказать это в следующей статье).
Теперь к минусам в порядке важности:
-
Ошибки на runtime. Главная проблема такого подхода в том, что ошибки возникают не на стадии компиляции, а на стадии работы программы. Как по мне этот минус нивелируется при качественном покрытии тестами;
-
При использовании Observable запросов (c возвращаемым типом Flow, PagingSource и т.д.) необходимо напрямую указывать за какими таблицами надо наблюдать;
interface GetPagingSourceDao<T : Any> { @RawQuery fun getPagingSource(query: SimpleSQLiteQuery): PagingSource<Int, T> } @Dao interface CharacterDao : GetPagingSourceDao<Character> { @RawQuery(observedEntities = [Character::class]) override fun getPagingSource( query: SimpleSQLiteQuery ): PagingSource<Int, Charater> }
-
Непонимание в глазах нового человека. Поскольку с RawQuery знакомо мало людей, новый человек взглянув на этот код не сразу поймет к чему;
-
Необходимо добавлять в DI Class<Entity> для каждой сущности которая использует RawQuery через предложенный мной Helper;
-
Дополнительный уровень абстракции над Dao(как по мне не является проблемой, но в больших программах может усложнить понимание);
-
RawQuery функция обязательно должна что-то возвращать. (Особо не проблема, но при кастомном Update или Delete надо указывать хотя он и не нужен);
-
TableName в Entity менять нельзя ставить кастомный, иначе логика simpleName не будет работать(не является проблемой как по мне но кому-то может не понравиться).
Мой playground где я использую этот подход https://github.com/HalfAPum/Playground
ссылка на оригинал статьи https://habr.com/ru/post/668564/
Добавить комментарий