Недавно я занимался разработкой приложения для Android, которому необходимо было делать запросы к базе данных через JDBC- драйвер. Тогда мне пришла идея создать нечто подобное Retrofit только для запросов к базе данных. Так появился RetroBase, о котором я Вам сейчас и расскажу.
Для того, чтобы интерфейс и аннотации превратились в рабочий код, потребуется Annotation Processing, который открывает поистине огромные возможности для автоматизации написания однотипного кода. А в сочетании с JavaPoet процесс генерации java-кода становится удобным и простым.
На хабре, как и на просторах интернета, имеется несколько хороших статей по этой теме, поэтому разобраться с Annotation Processing не составляет труда, а необходимый мануал библиотеки JavaPoet умещается в ее README.md.
Основу RetroBase составляют две аннотации
DBInterface
и DBQuery
вместе с DBAnnotationProcessor
, который и выполняет всю работу. С помощью DBInterface
отмечается интерфейс с методами-запросами к БД, а DBQuery
отмечает сами методы. Методы могут иметь параметры, которые будут использованы в SQL-запросе. Например:@DBInterface(url = SpendDB.URL, login = SpendDB.USER_NAME, password = SpendDB.PASSWORD)
@DBInterfaceRx
public interface SpendDB {
String USER_NAME = "postgres";
String PASSWORD = "1234";
String URL = "jdbc:postgresql://192.168.1.26:5432/spend";
@DBMakeRx(modelClassName = "com.qwert2603.retrobase_example.DataBaseRecord")
@DBQuery("SELECT * from spend_test")
ResultSet getAllRecords();
@DBMakeRx
@DBQuery("DELETE FROM spend_test WHERE id = ?")
void deleteRecord(int id) throws SQLException;
}
Самое интересное происходит в
DBAnnotationProcessor
, где осуществляется генерация класса, реализующего интерфейс, сгенерированный класс будет иметь имя *название_интерфейса* + Impl
:TypeSpec.Builder newTypeBuilder = TypeSpec.classBuilder(dbInterfaceClass.getSimpleName() + GENERATED_FILENAME_SUFFIX)
.addSuperinterface(TypeName.get(dbInterfaceClass.asType()))
.addField(mConnection)
.addMethod(waitInit)
.addModifiers(Modifier.PUBLIC, Modifier.FINAL);
После этого создается соединение с БД:
FieldSpec mConnection = FieldSpec.builder(Connection.class, "mConnection", Modifier.PRIVATE)
.initializer("null")
.build();
Также создается
PreparedStatement
для каждого запроса:FieldSpec fieldSpec = FieldSpec
.builder(PreparedStatement.class, executableElement.getSimpleName().toString() + PREPARED_STATEMENT_SUFFIX)
.addModifiers(Modifier.PRIVATE)
.initializer("null")
.build();
… и реализация метода для этого запроса:
MethodSpec.Builder methodBuilder = MethodSpec.methodBuilder(executableElement.getSimpleName().toString())
.addAnnotation(Override.class)
.addModifiers(Modifier.PUBLIC)
.returns(returnTypeName);
При этом учитывается тип возвращаемого значения метода. Он может быть либо
void
, если SQL-запрос представляет собой INSERT, DELETE или UPDATE. Либо ResultSet
, если SQL-запрос представляет собой SELECT.Также выполняется проверка на то, может ли метод выбрасывать
SQLException
. Если может, они будут выброшены и из реализации метода. А если нет — пойманы и выведены в stderr
.Все параметры аннотированного метода добавляются в переопределяющий метод, а также для каждого параметра генерируется выражение позволяющее передать значение параметра в
PreparedStatement
:insertRecord_PreparedStatement.setString(1, kind);
Конечно же, количество и типы параметров метода должны соответствовать параметрам запроса, переданного с помощью аннотации
DBQuery
.После того, как файл был сгенерирован, он записывается средствами Annotation Processing:
JavaFileObject sourceFile = processingEnv.getFiler().createSourceFile(filename);
Writer writer = sourceFile.openWriter();
writer.write(javaFile.toString());
Rx it!
Конечно, удобно получать
ResultSet
, определяя лишь интерфейс. А еще удобнее было бы воспользоваться популярной RxJava и получать Observable
. К тому же, это позволит легко решить проблему с выполнением запросов в другом потоке.Для этого был создан
DBMakeRxAnnotationProcessor
вместе с DBInterfaceRx
и DBMakeRx
, которые позволяют создать класс с методами-обертками. Применение этих аннотаций Вы уже могли увидеть в примере выше. Созданный класс будет иметь имя *название_интерфейса* + Rx
, а также будет иметь открытый конструктор, принимающий объект интерфейса, аннотированного DBInterfaceRx
, которому он будет перенаправлять запросы, возвращая результаты в реактивном стиле.Все, что нужно — это добавить к методу аннотацию
DBMakeRx
и передать ей название класса модели. Сгенерированный метод-обертка будет возвращать Observable<*класс модели*>
. При этом, название класса модели можно и не определять. В этом случае сгенерированный метод будет возвращать Observable<java.lang.Object>
, что удобно для SQL-запросов INSERT, DELETE или UPDATE, для которых не требуется возвращение результата.Например, для метода интерфейса
ResultSet getAllRecords();
из примера выше будет сгенерирован следующий метод-обертка:public rx.Observable<com.qwert2603.spenddemo.model.DataBaseRecord> getAllRecords() {
return Observable.create(subscriber -> {
try {
ResultSet resultSet = mDB.getAllRecords();
while (resultSet.next() && !subscriber.isUnsubscribed()) {
subscriber.onNext(new com.qwert2603.spenddemo.model.DataBaseRecord(resultSet));
}
if (!subscriber.isUnsubscribed()) {
subscriber.onCompleted();
}
}
catch (Exception e) {
if (!subscriber.isUnsubscribed()) {
subscriber.onError(e);
}
}
} );
}
Здесь
mDB
представляет собой объект интерфейса, аннотированного DBInterfaceRx
, который был передан в конструктор.Как видно из сгенерированного метода, нам потребуется создание объектов класса модели из
ResultSet
, поэтому у класса модели должен быть открытый конструктор, который принимает ResultSet
.Естественно, что параметры сгенерированного метода будут точно соответствовать параметрам метода, вызов которого происходит:
public rx.Observable<Object> insertRecord(String kind, int value, Date date) {
...
mDB.insertRecord(kind, value, date);
...
}
Все исключения, которые происходят при выполнении запроса, передаются Subscriber'у как и положено в Rx.
Пример использования всего описанного выше может выглядеть следующим образом:
private SpendDB mSpendDB = new SpendDBImpl();
private SpendDBRx mSpendDBRx = new SpendDBRx(mSpendDB);
public Observable<List<DataBaseRecord>> getAllRecords() {
return mSpendDBRx.getAllRecords()
.toList()
.compose(applySchedulers());
}
А если нужно подменить
new SpendDBImpl();
или new SpendDBRx(mSpendDB);
для выполнения тестов, можно воспользоваться популярным Dagger.На github Вы можете найти исходники с комментариями, а также рабочий пример этой небольшой библиотеки.
Целью этой статьи было показать насколько полезным может быть Annotation Processing, позволяющий избавиться от написания однотипного кода. И, надеюсь, у Вас могут появиться новые идее по использованию этого инструмента в своих проектах.
UPD. 1: благодаря замечаниям в комментариях была добавлена проверка отписки подписчика в RX-методах-обертках. (Версия RetroBase 1.0.4)
Комментарии (9)
MzMz
05.10.2016 10:41+2Есть даже без аннотаций: http://projects.spring.io/spring-data/#quick-start
omickron
05.10.2016 11:04Spring Data генерирует классы по интерфейсам в момент старта приложения (контекста), а автор генерирует код перед компиляцией.
Иногда в spring приходится повозиться, чтобы понять, в чём ошибка наименования. С генерацией кода проверить проще.
Artem_zin
05.10.2016 10:58+2Немного пройдусь по поддержке Rx, если вы не против :)
Например, для метода интерфейса ResultSet getAllRecords(); из примера выше будет сгенерирован следующий метод-обертка:
public rx.Observable<com.qwert2603.retrobase_example.DataBaseRecord> getAllRecords() { return Observable.create(subscriber -> { try { ResultSet resultSet = mDB.getAllRecords(); while (resultSet.next()) { subscriber.onNext(new com.qwert2603.retrobase_example.DataBaseRecord(resultSet)); } subscriber.onCompleted(); } catch (Exception e) { subscriber.onError(e); } } ); }
Тут 3 проблемы:
- Не надо использовать
Observable.create()
в RxJava v1, а если используете, будьте очень осторожны. В user-space коде используйтеObservable.fromEmitter()
, в библиотеках используйте его после того, как он выйдет из@Experimental
. В RxJava v2Observable.create()
норм. - Вообще не проверяется
subscriber.isUnsubscribed()
— соответсвенно,Observable
будет работать до конца, даже после отписки подписчика. Лучше добавить проверку прямо в циклwhile(!subscriber.isUnsubscribed() && …)
и перед каждым вызовомonNext/onCompleted/onError
. - Нет обработки backpressure, а в IO операциях, таких как работа с БД — это неизбежно.
Вообще судя по статье, Rx здесь прикручен постольку-поскольку и реактивную БД оно здесь явно не делает, тк на изменения, произошедшие в БД, новых данных просто так подписчикам не придёт, что, конечно, не сводит смысл от поддержки Rx в ноль, но всё же убирает важную часть реактивности.
qwert2603
05.10.2016 11:36По поводу 2 проблемы — исправить несложно. Сегодня вечером будет лучше. 3 проблему тоже можно исправить.
Реактивность была и правда добавлена для возможности выполнять запросы в фоне и применять rx-операторы к результатам.
andreich
05.10.2016 13:17Мне кажется проблему backpressure библиотека не должна решать. Это должен делать пользователь библиотеки. Потому что как минимум не один вариант можно выбрать.
Artem_zin
05.10.2016 13:20Проблема в том, что код выше просто не поддерживает backpressure и, соответственно, не даст пользователю библиотеки правильно её обработать.
- Не надо использовать
jehy
05.10.2016 15:54Неужели есть Android приложения, которые работают напрямую с базой данных? По-моему, это какой-то уникальный и из ряда вон выходящий случай.
qwert2603
05.10.2016 16:03Действительно, таких очень немного. Одно я делал для себя для домашнего использования, поэтому и появилась идея для этой библиотеки. При этом сам инструмент Annotation Processing может быть полезен во многих других областях.
omickron
Спасибо за идею!