Недавно я занимался разработкой приложения для Android, которому необходимо было делать запросы к базе данных через JDBC- драйвер. Тогда мне пришла идея создать нечто подобное Retrofit только для запросов к базе данных. Так появился RetroBase, о котором я Вам сейчас и расскажу.
This article in English. Эта статья на английском.
Для того, чтобы интерфейс и аннотации превратились в рабочий код, потребуется 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<*класс модели*>. При этом, название класса модели можно и не определять. В этом случае сгенерированный метод будет возвращать io.reactivex.Completable, что удобно для SQL-запросов INSERT, DELETE или UPDATE, для которых не требуется возвращение результата.Например, для методов интерфейса
ResultSet getAllRecords(); и void deleteRecord(int id) throws SQLException; из примера выше будет сгенерированы следующие методы-обертки:public io.reactivex.Observable<com.qwert2603.retrobase_example.DataBaseRecord> getAllRecords() { return Observable.generate(() -> mDB.getAllRecords(), (resultSet, objectEmitter) -> { if (resultSet.next()) { objectEmitter.onNext(new com.qwert2603.retrobase_example.DataBaseRecord(resultSet)); } else { objectEmitter.onComplete(); } } , ResultSet::close); } public Completable deleteRecord(int id) { return Completable.fromAction(() -> mDB.deleteRecord(id)); }
Здесь
mDB представляет собой объект интерфейса, аннотированного DBInterfaceRx, который был передан в конструктор.Как видно из сгенерированного метода, нам потребуется создание объектов класса модели из
ResultSet, поэтому у класса модели должен быть открытый конструктор, который принимает ResultSet.Естественно, что параметры сгенерированного метода будут точно соответствовать параметрам метода, вызов которого происходит:
public Completable 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 Single<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)
UPD. 2: если Вам необходимо выполнить запрос INSERT и получить id созданной записи, получить количество строк, измененных в результате выполнения запроса UPDATE или узнать id записей, которые были удалены запросом DELETE, Вы можете добавить конструкцию
returning id в конец SQL запроса и получить ResultSet c искомыми id. @DBMakeRx(modelClassName = "com.qwert2603.spenddemo.model.Id") @DBQuery("UPDATE spend_test SET kind=?, value=?, date=? WHERE id=? returning id") ResultSet updateRecord(String kind, int value, Date date, int id) throws SQLException;
Как видно из кода, возможно также использовать аннотацию
DBMakeRx, чтобы получать Observable<*id*>. Для нужно создать класс модели, получающий Id из ResultSet и передать его параметру modelClassName аннотации DBMakeRx. Класс модели, содержащий Id может выглядеть следующим образом:public class Id { private int mId; public Id(ResultSet resultSet) throws SQLException { mId = resultSet.getInt(1); } public int getId() { return mId; } public void setId(int id) { mId = id; } }
UPD. 3: в версии RertoBase 1.1 добавлена проверка
java.sql.Connection#isValid(0) для автоматического создания нового подключения к БД в случае ошибки (например, потеря соединения).UPD. 4: в версии RertoBase 1.2 весь Rx код генерируется для версии RxJava 2.x. Пример сгенерированных методов Вы видели выше. Также таким образом была решена проблема backpressure, о которой было указано в комментариях.

