NLinker

Вопрос о дизайне DAO, версия 2

Nov 27th, 2018
213
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Markdown 11.04 KB | None | 0 0

О дизайне 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 выше, не будет - придётся в тестах поднимать реальную базу и ходить в неё (что впрочем имеет и свои плюсы).

Add Comment
Please, Sign In to add comment