О дизайне DAO, версия 2
Про разницу между
public Function<Connection, List<Entity>> selectMyEntities(List<UUID> ids) {
return conn -> conn.createQuery("SELECT * FROM tableName WHERE id in (:ids)")
.addParameter("ids", ids)
.executeAndFetch(getHandlerForEntity());
}
и
public Query selectMyEntities(List<UUID> ids) {
return conn.createQuery("SELECT * FROM tableName WHERE id in (:ids)")
.addParameter("ids", ids);
}
Побочный эффект, о котором я говорю, это поход в базу, который:
- требует правильного порядка вызова
- зависит от момента вызова
- может упасть.
Можно рассмотреть две простых функции:
int getStringLength(String str) { ... }
int getFileLength(String fileName) { ... }
Ты обе из них можешь завернуть в Function<String, Integer>
но они совершенно неравнозначны: первая чистая, а вторая имеет побочный эффект (работает только в нормальных условиях, результат отличается от вызова к вызову, может упасть). Здесь имеет место абстрагирование, которое вообще-то не должно пропускаться компилятором. Например, в случае Хаскеля, первая функция будет иметь сигнатуру String -> Int
, а вторая String -> IO Int
- и вот здесь попытка передать getFileLength
туда, где можно только String -> Int
закончится ошибкой (как и должно быть).
Так что возвращая функцию из selectMyEntities
ты возвращаешь не чистое значение.
А зачем нам вообще чистое значение? Всё равно ведь мы идём в базу данных? Query selectMyEntities(List<UUID> ids)
имеет ряд преимуществ.
- Вернувшийся query можно посмотреть в дебаггере или выполнить ещё в каком-то другом месте без риска сломать дебаг-сессию, например в окне по Alt+F8. И это можно делать независимо от того, насколько сложный запрос внутри Query, он может быть и select, и insert, и update, и alter/drop. Получив значение, можно его тщательно посмотреть и исследовать на правильность.
- Вполне возможен тест, когда мы получаем некий query и потом проверяем, что он содержит необходимые поля, или что он на самом деле апсерт или ещё чего-нибудь.
- Я вполне могу допустить тест, в котором query1 и query2 делаются двумя разными способами, а потом сравниваются на равенство.
Это всё затруднено, если возвращать Function<...>
(но это может быть и не нужно!).
Если всё же хочется возвращать функции, содержащую эффекты, я бы порекомендовал смотреть в сторону Try
из скалы, чтобы превратить исключения в возвращаемые значения. В этом случае побочный эффект "зависит от порядка вызова" остаётся неконтролируемый, и мы такую зависимость выражаем через flatMap
.
То есть наш сервис будет зависеть от интерфейса вроде
type Id = Int
trait Dao {
def store(e: Entity): Try[Entity]
def find(id: Id): Try[Option[Entity]]
}
или что то же самое (немного менее эффективно из-за того, что в JVM представление функций требует выделения памяти)
trait Dao {
val store: Entity => Try[Entity]
val find: Id => Try[Option[Entity]]
}
И сервис принимает некий инстанс Dao
(в случае реального окружения он ходит в базу, а в тестах можно и просто замыкание затолкать). Логика тогда будет выглядеть в виде:
def myLogic(dao: Dao, e: Entity): Unit = {
val e1 = e.copy(createdAt = Instant.now())
for {
e2 <- dao.store(e1)
f <- dao.find(e2.id)
} yield f
}
// Использование в реальном коде
def myLogicUsage(dao: Dao): Unit = {
val e = Entity(name = "Denis")
val result = myLogic(dao, e) // CALL HERE
match result {
case Success(Some(e)) => println(s"entity is found: $e")
case Success(None) => println(s"entity is not found")
case Failure(ex) => println(s"a failure happened during the call: ${ex.getMessage}")
}
}
// Тест может быть таким
def myLogicTest(): Unit = {
val now = Instant.now()
val storeFun: Entity => Try[Entity] = e =>
if (id == -1) Failure(new Exception("Database error")) // EMULATE DATABASE FAILURE
else Success(e.copy(id = 1, createdAt = now))
val findFun: Id => Try[Option[Entity]] = id =>
if (id == 1) Success(Some(Entity(id = 1, name = "Denis", createdAt = now))
else Success(None)
val dao = new Dao {
def store(e: Entity) = storeFun(e)
def find(id: Id) = findFun(id)
}
val result1 = myLogic(dao, Entity(id = -1, name = "Boom"))
val result2 = myLogic(dao, Entity(name = "Denis"))
result1.getType shouldBe typeOf[Failure]
result2 shouldBe Success(Some(Entity(id = 1, name = "Denis", createdAt = now)))
}
То есть Dao
- интерфейс, формализующий походы в БД и возможные ошибки, функции используются через for-компрехеншн или flatMap в джаве.
Про карринг. Это вещь безусловно очень полезная, позволяет иметь минимальный интерфейс у модуля/компонента/класса, и множество функций из кода вокруг можно адаптировать для этого минимального интерфейса. И как я понимаю, идея такая, что мы можем иметь 2 функции execute(F<...>)
и executeBatch(int n, F<...>)
которые мы где-то абстрактно реализуем, и потом будем туда толкать все остальные F<..>
с походами в базу (поправь меня если не прав).
На мой взгляд это сложнее, чем просто сделать selectMyEntities
и selectMyEntitiesBatch
, потому что нам далеко не всегда нужно батчить запросы, множественные селекты могут быть сделаны через WHERE x IN(..)
или WHERE x = ANY(..)
и далеко не все запросы поддерживают батчинг. Например, сделать батч из апсертов - та ещё дисциплина. Поэтому я бы воздержался от абстрагирования в данном случае.
Про Connection
в качестве поля. Несмотря на то, что (казалось бы) мапперы содержат вопиющий пример дублирования
class Mapper {
long countRows(Connection c, UUID uuid) { ... }
void insertRow(Connection c, Row row) { ... }
}
и хочется вынести в поле
class Mapper {
Connection c;
Mapper(Connection c) { this.c = c; }
long countRows(UUID uuid) { ... }
void insertRow(Row row) { ... }
}
я бы этого не делал, потому что второй вариант сразу принуждает нас заботиться о состоянии класса. Connection - мутабельный, и мы должны опасаться сделать несколько mapper-ов расшаривающих один коннекшн.
И чтобы случайно не сделать вызов с устаревшим коннекшном, мы вынуждены постоянно пересоздавать Mapper. Это вынуждает нас держать в памяти правильный паттерн использования и немного влияет на эффективность (хотя конечно new Mapper довольно дёшево в плане ресурсов).
Можно не передавать везде Connection
, а вынести вообще работу с коннекциями в некий _Session_
, который инкапсулирует в себе коннекшн пул. Этот приём я подчерпнул из scalikejdbc
, ты это мог видеть в другом проекте :-):
case class Correlation(id: Long,
parameterTypeId: Long,
malfunctionId: Long,
tnodeId: Long,
settings: CorrelationSettings)
object Correlation extends SQLSyntaxSupport[Correlation] {
// здесь implicit session: DBSession, к сожалению в Java нужно передавать явно
def find(id: Long)(implicit session: DBSession): Option[Correlation] = {
sql"""SELECT ${cf.result.*} FROM correlation cf WHERE id = ${id}"""
.map(Correlation(cf.resultName)).single().apply()
}
def countBy(where: SQLSyntax)(implicit session: DBSession): Long = {
sql"""SELECT count(1) FROM correlation WHERE ${where}"""
.map(_.long(1)).single().apply().get
}
def create(parameterTypeId: Long,
malfunctionId: Long,
tnodeId: Long,
settings: CorrelationSettings)(implicit session: DBSession): Correlation = {
val generatedKey = sql"""
INSERT INTO correlation ( ... )
""".updateAndReturnGeneratedKey().apply()
Correlation(
id = generatedKey,
parameterTypeId = parameterTypeId,
malfunctionId = malfunctionId,
tnodeId = tnodeId,
settings = settings)
}
}
Но конечно, такой лёгкости тестирования, как в случае trait Dao
выше, не будет - придётся в тестах поднимать реальную базу и ходить в неё (что впрочем имеет и свои плюсы).