Следить за обновлениями блога можно в моём канале: Эргономичный код

Введение

Почти 30 лет назад в классической книге по шаблонам проектирования Design Patterns: Elements of Reusable Object-Oriented Software, авторы сформулировали один из самых известных, но недопонятых принципов в истории программирования:

Program to an interface, not an implementation.

Программируйте в соответствии с интерфейсом, а не с реализацией.

— Erich Gamma et. al, Design Patterns: Elements of Reusable Object-Oriented Software

Зачем "программировать в интерфейсы"? Для того чтобы реализацию этого интерфейса можно было менять без изменений клиентского кода.

Далее авторы объясняют как следовать этому совету:

Don’t declare variables to be instances of particular concrete classes. Instead, commit only to an interface defined by an abstract class.

Не объявляйте переменные как экземпляры конкретных классов. Вместо этого придерживайтесь интерфейса, определенного абстрактным классом.

— Erich Gamma et. al, Design Patterns: Elements of Reusable Object-Oriented Software

Многие воспринимают это буквально и просто вносят в программу дополнительную сущность (интерфейс или абстрактный класс), которая дублирует список и сигнатуры методов одного конкретного класса.

Проблема в том, что использование ключевого слова abstract или interface само по себе не создаёт абстракцию и не защищает клиента от изменения реализации. Зато эти "заголовочные интерфейсы" по капельке, но каждый день подъедают человеческие, машинные и временные ресурсы.

Поэтому в Эргономичном подходе я отказался от повсеместного использования интерфейсов и применения принципа инверсии зависимостей по умолчанию.

Давайте рассмотрим на примере вымышленной истории, почему использование ключевого слова interface не является ни достаточным, ни необходимым для защиты клиентского кода от смены реализации интерфейса.

А потом увидим, насколько легко превратить конкретный класс в интерфейс и добавить новую реализацию, если он описывает абстракцию.

Наконец, рассмотрим несколько эвристик, которые помогают создавать классы описывающие абстракции, а не реализации.

Эпизод первый: Скрытая угроза. Интерфейсы.

Представим, что некий молодой архитектор Артемий начинает новый проект. Так как бизнес требует от него минимизировать "time to market", он решает, что собрать на коленке прототип будет быстрее всего на базе Spring Data JPA. Но он будет "программировать в интерфейсы", чтобы быстро всё переписать на более поддерживаемую технологию, если проект стрельнет.

Артемий пишет примерно такой код:

package pro.azhidkov.programtointerfaces.v1

import org.springframework.data.repository.CrudRepository
import javax.persistence.Entity
// ...

@Entity
class User(
    @Id var id: Long,
    var login: String,
    var password: String
)

@Repository
interface UsersRepo : CrudRepository<User, Long>

@Service
class UsersService( // тут по идее тоже должен быть интерфейс, но сократим его ради экономии места
    private val usersRepo: UsersRepo
) {

    @Transactional
    fun updatePassword(id: Long, newPass: String) {
        val user = usersRepo.findByIdOrNull(id)
        user.password = newPass
    }

}

Казалось бы, всё восхитительно - мы не завязываемся ни на какие детали реализации и в любой момент сможем сменить технологию работы с БД, да и сам тип БД.

И когда приложение перерастает штанишки прототипа, Артемий, решив перейти на Spring Data JDBC, просто меняет зависимость в скрипте сборки, делает замену по проекту "@Entity" на "@Table" и…​ О чудо! Всё собирается!

Однако, после поспешного релиза в прод выясняется, что ничего не работает. Точнее в режиме чтения приложение работает, а вот никакие модификации не сохраняются. После судорожного отката релиза и суток дебага, Артемий в документации к Spring Data JDBC выясняет, что она не реализует такую "небольшую деталь" как Dirty Checking и автомагически ничего не сохраняет. Тогда Артемий везде добавляет *Repo.save() и всё, кризис преодолён.

package pro.azhidkov.programtointerfaces.v2

import org.springframework.data.relational.core.mapping.Table
import org.springframework.data.repository.CrudRepository
// ...


@Table
class User(
    var id: Long,
    var login: String,
    var password: String
)

@Repository
interface UsersRepo : CrudRepository<User, Long>

@Service
class UsersService(
    private val usersRepo: UsersRepo
) {

    @Transactional
    fun updatePassword(id: Long, newPass: String) {
        val user = usersRepo.findByIdOrNull(id)
        user.password = newPass
        usersRepo.save(user)
    }

}

Правда эта правка превратит код Артемия в анти-паттерн с точки зрения JPA????.

Так на своём горьком опыте Артемий узнал, что одно только использование интерфейсов не гарантирует изменение реализации, без изменения клиентов.

На самом деле в этой истории есть пара нестыковок, но они лишь подтверждают основной тезис поста.

Артемий вряд ли смог бы скомпилировать и запустить проект, просто заменив аннотации. Наверняка ему пришлось бы нарезать модель на агрегаты и что-то сделать с двунаправленными связями.

Это обусловлено второй нестыковкой - пруф сейчас найти не могу, но уверен, что где-то читал, как сами пацаны из Spring Data писали, что интерфейсы репозиториев не являются абстракциями.

Когда проект ещё подрос, и возникла потребность в реактивном подходе, Артемий уже понимал, что переход на Spring Data R2DBC будет долгим и тяжёлым. Осознав, насколько кодовая база заточена на синхронную работу, вместо миграции проекта на Spring Data R2DBC, Артемий решил сам мигрировать на новый проект.

Эпизод второй: Пробуждение силы. Абстракции.

Наученный горьким опытом, Артемий понял, что "программирование в интерфейсы" само по себе ничего не даёт с точки зрения гибкости. Зато интерфейсы занимают место на экране, диске и в голове Артемия. А также увеличивают время компиляции проекта. И усложняют навигацию по коду и его рефакторинг.

Поэтому в новом проекте Артемий решил программировать без лишних церемоний, зато с учётом всего своего опыта. На этот раз Артемий отложил выбор технологии для работы с БД и начал с тривиальных suspend-репозиториев неизменяемых сущностей на базе ассоциативных массивов:

package pro.azhidkov.programtointerfaces.v3

import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional


class User(
    val id: Long,
    val login: String,
    val password: String
)

@Repository
class UsersRepo {

    private val data = HashMap<Long, User>()

    suspend fun findByIdOrNull(id: Long): User? = data[id]

    suspend fun save(user: User) {
        data[user.id] = user
    }

}

@Service
class UsersService(
    private val usersRepo: UsersRepo
) {

    @Transactional
    suspend fun updatePassword(id: Long, newPass: String) {
        val user = usersRepo.findByIdOrNull(id)
        val updatedUser = user.copy(password = newPass)
        usersRepo.save(updatedUser)
    }

}

Начав работать в таком стиле, Артемий каждый день радовался как ребёнок тому, что теперь не приходится постоянно возиться с чёртовыми прицепами в виде интерфейсов.

Однако, когда пришёл день Д - день выбора технологии работы с БД - Артемий по старой памяти напрягся. У нового проекта не ожидалось большого количества пользователей, поэтому Артемий снова решил использовать Spring Data JDBC. Кроме того, имеющиеся in-memory репозитории решили сохранить для использования в демо-версии продукта.

"Вот бы у нас сервисы зависели от интерфейсов репозиториев, чтобы мы могли во время исполнения выбирать реализацию" - злорадно говорили адепты карго культа "program to interface" из команды Артемия.

Однако, Артемий хорошо владел своими инструментами и знал, что класс, описывающий хорошую абстракцию, с помощью рефакторинга extract interface превращается в тот самый интерфейс лёгким движением руки.

Правда для Kotlin, в отличие от Java, у этого рефакторинга ещё нет галки "use interface where possible"????‍♂️. Но всё равно можно вытащить интерфейс, а потом без рефакторинга просто поменять местами имена интерфейса и класса:

  1. С помощью рефакторинга из класса UsersRepo вытащить интерфейс IUsersRepo;

  2. Без рефакторинга в файле IUsersRepo.kt заменить текст "IUsersRepo" на "UsersRepo";

  3. Без рефакторинга в файле UsersRepo.kt заменить текст "UsersRepo" на "InMemUsersRepo";

  4. Без рефакторинга переименовать файл UsersRepo.kt в InMemUsersRepo.kt;

  5. Без рефакторинга переименовать файл IUsersRepo.kt в UsersRepo.kt.

Тут опытный читатель может сказать "А если я программирую библиотеку или фреймворк и не могу зарефакторить код 100500 неизвестных клиентов?". На что я ему отвечу: "Вот тогда вам нужны интерфейсы сразу". Но это должны быть тщательно спроектированные интерфейсы, а клиенты этих интерфейсов должны изо всех сил стараться не завязываться на реализацию по умолчанию.

Следующая проблема. Артемий перестраховался и везде добавил suspend, который стал лишним, т.к. Spring Data JDBC работает в блокирующем режиме. Хорошо, что ломать не строить. Можно воспользоваться структурной заменой для того, чтобы найти и удалить все модификаторы suspend у методов классов заканчивающихся на "Repo":

Потом то же самое надо проделать для интерфейсов. Поэтому лучше загодя подумать об этом и сначала убрать suspend, а потом выделить интерфейс. В итоге у Артемия получился такой код:

package pro.azhidkov.programtointerfaces.v4

import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional


class User(
    var id: Long,
    var login: String,
    var password: String
)

interface UsersRepo {
    fun findByIdOrNull(id: Long): User?

    fun save(user: User)
}

@Repository
class InMemUsersRepo : UsersRepo {

    private val data = HashMap<Long, User>()

    override fun findByIdOrNull(id: Long): User? = data[id]

    override fun save(user: User) {
        data[user.id] = user
    }

}

@Service
class UsersService(
    private val usersRepo: UsersRepo
) {

    @Transactional
    suspend fun updatePassword(id: Long, newPass: String) {
        val user = usersRepo.findByIdOrNull(id)
        val updatedUser = user.copy(password = newPass)
        usersRepo.save(updatedUser)
    }

}

Теперь Артемий может спокойно добавить "реализации" с помощью Spring Data JDBC и у него всё будет работать.

На этом история Артемия благополучно заканчивается. А нам ещё надо сделать орг. выводы.

Эпизод третий: Последние джедаи. Эргономичный подход к абстракции.

Именно потому что сами по себе интерфейсы ничего не гарантируют, а тщательно спроектированные классы превращаются в интерфейсы одним движением руки, в Эргономичном подходе я отказался от повсеместной инверсии зависимостей и внедрения интерфейсов.

Вместо этого я слежу за утечками абстракций в интерфейсах классов (имени класса и сигнатурах методов) и применяю функциональную архитектуру.

Отслеживания требуют два основных вида утечек - явные и неявные.

Явные утечки

Явные утечки в свою очередь тоже бывают двух видов - в именовании и типах.

Для того чтобы класс описывал абстракцию, внезапно надо, чтобы имена самого класса, методов и параметров были максимально абстрактными. Например, в True Story Project за отправку фида в 2Гис по Email у меня отвечает такой класс:

class DGisFeedSender {

    // Поля и конструктор

    public void sendFeedTo2Gis(String recipient, String subject, InputStreamSource inputStreamSource) {
        // ..
    }

}

Как видно, в этом коде никак не упоминается Email, и я могу переделать его на отправку в телеграм, например, не трогая интерфейс класса или его клиентов. Или лёгким движением руки (и т.к. в этом проекте у меня Java - это будет действительное лёгкое движение) выделить интерфейс и сделать механизм отправки конфигурируемым.

Бывают ситуации, когда я предвижу смену или появление новой реализации и сразу завожу интерфейс. Например, я бы так поступил на месте Артемия во втором проекте.

В этом случае повысить качество абстракции мне помогает другое правило - я не пользуюсь префиксами/суффиксами I/Impl/Abstract/Default и им подобным. Интерфейсы я называю абстрактно, а в классы реализации добавляю что-то (прилагательное, название технологии и т.п.), характеризующее суть реализации. Так в примере Артемия у меня был бы интерфейс UsersRepo, который реализуется (в кавычках для Spring Data) интерфейсом SpringDataUsersRepo и классом InMemUsersRepo.

И если у меня появляются проблемы с выбором имени класса или интерфейса - для меня это красный флаг, указывающий на проблемы в дизайне.

Что касается типов - я слежу за тем, чтобы через параметры и результаты методов не утекали типы, использованные в реализации. Например, в Проекте Л мне среди прочего надо было реализовать "подглядывающие" проксирование HTTP-запросов. Метод проксирования у меня очевидным образом получал HTTP-запрос и возвращал HTTP-ответ. И хотя я мог взять эти классы из библиотеки реализации (ktor) я их обернул в собственные типы:

data class HttpRequest(
    val method: String,
    val path: String,
    val query: Map<String, List<String>>,
    val headers: Map<String, List<String>>,
    val body: String?
)

data class HttpResponse(
    val status: Int,
    val headers: Map<String, List<String>>,
    val bodyBytes: ByteArray
)

suspend fun ApiClient.proxy(token: String, request: HttpRequest): HttpResponse {
    // ...
}

Это позволило мне при разборе одной из ошибок быстро попробовать подменить реализацию на Spring WebClient, чтобы попытаться её обойти (в итоге остался на ktor). Если бы я завёл для класса заголовочный интерфейс, но вытащил туда типы из ktor-а - этот фокус у меня не удался. Поэтому между генераций "лишних" интерфейсов и "лишних" типов параметров я голосую за вторые.

Тут важно не перегнуть палку. Например, Spring Data даёт много чудесной автомагии, если использовать класс Pageable. Если же вместо него использовать собственный класс, то придётся написать гору ручного кода для реализации пагинации. А миграцию своих проектов со Spring на что-то другое я считаю практически невероятной, поэтому использую Pageable в интерфейсах классов без зазрения совести.

Неявные утечки

По моему опыту, наиболее проблемные неявные утечки связаны с одним предположением, проявляющемся в двух аспектах. Само предположение - "сервер" (реализация зависимости) находится в одном адресном пространстве/процессе с "клиентом".

С одним из аспектов этого предположения - достаточностью простого присвоения нового значения полю изменяемого объекта на клиенте для того, чтобы оно изменилось на сервере - мы уже столкнулись в истории Артемия. Ровно ту же проблему Артемий бы получил, если бы по каким-то причинам решил заменить реализацию репозиториев на работу через REST API, например.

С этим аспектом отлично борются неизменяемые структуры данных, которые вообще несут много светлого и доброго в дизайн. Поэтому я сам повсеместно и по умолчанию использую неизменяемые структуры данных, требую этого от своей команды и всячески продвигаю их в
интернете.

Ко второму аспекту Артемий тоже подошёл, но решил его избежать. Суть этого аспекта, заключается в полагании на простоту внутрипроцессного взаимодействия. Внутрипроцессное взаимодействие является моментальным (на фоне межпроцессного) и сам вызов наверняка дойдёт до адресата, а ответ наверняка вернётся. Если же адресат окажется в другом процессе (или того хуже на другой машине), то у нас тут же возникают все проблемы, свойственные распределённому программированию, которые никак не отражаются в упрощённом интерфейсе.

Для того чтобы обеспечить себе возможность безболезненного перехода с внутрипроцессного на межпроцессное взаимодействие, интерфейс надо существенно усложнить. Как минимум стоит рассмотреть вариант suspend/reactive интерфейса. В зависимости от контекста может быть смысл вытащить в интерфейс и потенциальные инфраструктурные ошибки.

Это всё довольно сильно усложняет код, поэтому к абстрагированию от местонахождения зависимости я прибегаю только в том случае, если считаю вероятность отъезда зависимости в другой процесс "достаточно высокой".

Вообще, самое лучшее практическое руководство по созданию нетекущих абстракций, которое я читал, содержится в книге Practical API Design: Confessions of a Java Framework Architect. Это 400 страниц квинтэссенции боли и страданий от последствий ошибок, допущенных её автором (главным архитектором NetBeans) при проектировании "ядерных" абстракций IDE.

Функциональная архитектура

Проектирование хороших абстракций решает проблему гибкости системы, однако остаётся проблема статической привязки домена к инфраструктуре. Для решения этой проблемы Эргономичный подход полагается на функциональную архитектуру - выделение домена в чистое ядро, которое на самом деле не зависит ни от чего ни во время компиляции, ни во время исполнения.

В качестве иллюстрации возьму пример из проекта хранения информации о торговле на бирже крипто-валют.

Там был такой пример плохой реализации (немного подправил под контекст этого поста):

fun updateCustomerSymbols(customerId: Long, activeSymbols: List<ActiveSymbol>) {
    val customerSymbols = customerSymbolsRepo.fetchCustomerSymbols(customerId)

    // Доменная логика суть которой не так важна в этом посте и описана в посте про агрегаты
    activeSymbols.map { activeSymbol ->
        val trading = customerSymbols.tradings.find { it.symbol == activeSymbol.symbol }
        if (trading != null) {
            trading.activeGrid = trading.grids.find { it.name == activeSymbol.gridName } ?: Grid(activeSymbol.gridName, BigDecimal(0))
        } else {
            val activeGrid = Grid(activeSymbol.gridName, BigDecimal(0))
            customerSymbols.tradings.add(
                SymbolTrading(activeSymbol.symbol, mutableListOf(activeGrid), activeGrid)
            )
        }
    }

    customerSymbolsRepo.save(customerSymbols)
}

Даже если customerSymbolsRepo - интерфейс, доменная логика всё равно сильно сцеплена с вводом-выводом и её сложно переиспользовать в другом контексте. Примером "другого контекста", который всегда актуален для доменной логики, являются тесты.

И хотя интерфейс существенно упрощает тестирование бизнес-логики, за счёт того, что позволяет в тестах использовать фейковый репозиторий, мы можем намного лучше.

Если вынести логику в отдельные чистые функции:

data class SymbolTrading private constructor(
    val symbol: Symbol,
    val grids: Map<GridName, Grid>,
    val activeGrid: GridName
) {

    fun activateGrid(gridName: String): SymbolTrading =
        if (gridName in grids) SymbolTrading(symbol, grids, gridName)
        else SymbolTrading(symbol, grids + (gridName to Grid(gridName)), gridName)

}

data class CustomerSymbols(
    val customerId: Long,
    val tradings: Map<Symbol, SymbolTrading>
) {

    fun activateSymbols(activeSymbols: List<ActiveSymbol>): CustomerSymbols {
        val updatedTradings = activeSymbols.map {
            tradings[it.symbol]?.activateGrid(it.gridName)
                ?: SymbolTrading.new(it.symbol, it.gridName)
        }

        return CustomerSymbols(customerId, tradings + updatedTradings.associateBy { it.symbol })
    }

}

fun updateCustomerSymbols(customerId: Long, activeSymbols: List<ActiveSymbol>) {
    val customerSymbols = customerSymbolsRepo.fetchCustomerSymbols(customerId)
    val updatedCustomerSymbols = customerSymbols.activateSymbols(activeSymbols)
    customerSymbolsRepo.save(updatedCustomerSymbols)
}

то тестирование бизнес-логики становится просто вызовом функции и проверкой результата. А изолированно тестировать оркестрацию (метод updateCustomerSymbols) особого смысла нет - ошибки в нём могут быть только на границах модулей, он будет исчерпывающе протестирован любым сценарным/интеграционным тестом, а любой юнит-тест с моками будет тавтологией.

Функциональная архитектура помогает расцепить только бизнес-логику и инфраструктуру, и если не предпринимать дополнительных усилий, то слой приложения останется сцепленным с инфраструктурой. Однако, я считаю, очень часто этого вполне достаточно. А решение расцепить слой приложения и инфраструктуру должно быть обоснованным требованиями конкретного приложения, а не способом реализации по умолчанию.

Заключение

"Program to interface" - хороший совет, за которым скрывается огромный опыт банды четырёх. Однако, если интерпретировать его буквально, то следование ему повысит сложность и стоимость поддержки кодовой базы, ничего не дав взамен. Кроме того, этот совет наиболее актуален при разработке библиотек, фреймворков и платформ с динамической загрузкой кода (плагинами).

При разработке же прикладных программ, намного более простой и поддерживаемый код дают усилия по минимизации протечек абстракций и применение функциональной архитектуры.

Комментарии (14)


  1. Olegun
    06.07.2022 08:26
    +1

    Стоило бы цитаты из/про шаблоны проектирования перевести на русский.


    1. jdev Автор
      06.07.2022 08:42

      Добавил переводы, спасибо


  1. Ares_ekb
    06.07.2022 08:55
    +2

    В этом случае повысить качество абстракции мне помогает другое правило - я не пользуюсь префиксами/суффиксами I/Impl/Abstract/Default и им подобным. Интерфейсы я называю абстрактно, а в классы реализации добавляю что-то (прилагательное, название технологии и т.п.), характеризующее суть реализации.

    Полностью поддерживаю, на мой взгляд это прямо лютый анти-паттерн, когда люди просто на автомате создают ISomething и SomethingImpl. У нас на одном проекте была часть унаследованного кода и схема там была примерно такая. 4 отдельных Java-проекта:

    1) Слой хранения. В нём, например, класс UserEntity (1) и интерфейс IUsersRepository (2) для работы с пользователями в БД. Плюс много других сущностей, сделанных по той же схеме.

    2) API бизнес-логики. Интерфейс IUsersService (3), который чуть менее чем полностью повторяет IUsersRepository. Дефолтная реализация этого интерфейса NoOpUsersService (4). DTO-класс User (5), который содержательно практически идентичен UserEntity. UserMapper (6) между UserEntity и User.

    3) Реализация бизнес-логики. Класс UsersService (7).

    4) Контроллеры. Класс UsersController (8), интерфейс которого практически полностью повторяет IUsersRepository и IUsersService.

    Итого 8 классов и интерфейсов в 4 разных Java-проектах, если я ещё чего-то не забыл. Это для каждой сущности, понятно что сущностей больше. Конечно такой подход имеет право на существование. Но, блин, вероятность того, что пользователи и другие сущности будут храниться не в реляционной БД, а в текстовых файлах, мировом эфире или где-то ещё конечно не нулевая, но очень близка к этому.

    Я конечно понимаю смысл DTO-классов и сам их использую в некоторых проектах. Но тут мы просто нафиг всё это выкинули, оставили Entity-классы и репозитории. А те же контроллеры генерятся Spring'ом. Безусловно теряется какая-то гибкость, но как минимум в 4 раза сокращается количество кода и сокращается время на написание, чтение и сопровождение всего этого. Если в будущем дефолтной Spring'овой реализации будет недостаточно, то в принципе можно вернуться к исходному подходу, но весь этот код совершенно точно не должен писаться руками. Мы для такого обычно используем кодогенераторы, но это уже холиворная тема.


    1. MentalBlood
      06.07.2022 10:05
      +1

      как минимум в 4 раза сокращается количество кода

      Это главное. Лучший код — тот, который не написан: его не надо читать, поддерживать, тестировать


    1. qw1
      06.07.2022 11:51

      Считаю наоборот, строгие паттерны в наименовании сущностей — это хорошо.

      У инструментов JetBrains есть универсальный переход по имени, и там очень удобно видеть, что тебе нужно открыть: IService или ServiceImpl, или UserDTO, или UserVM, сразу видно, к какому слою проекта относится символ.

      Некоторые миддлы на проекте вообще не знают английский, и чтобы что-то назвать, идут в переводчик, и рождается SendAccount вместо BillSender. Другая проблема: начинающие (некоторые всю жизнь не могут перерасти этот этап) разработчики, когда выдумывают наименование, видят очень узкий контекст и не думают, как это наименование встроится в проект. То есть, сервис рассылки могут назвать просто SendService, не уточняя — рассылки чего именно.
      И если тут не будет стандартных префиксов/суффиксов, можно сразу застрелиться, выбирая между наименованиями, порожденными сумеречными разумами, что они хотели этим сказать: Send — это сервис, DTO, или интерфейс?

      Понятно, когда ты один на проекте, все названия твои родные и ты в них не запутаешься, там уже можно называть с точки зрения краткости и эстетики.


      1. jdev Автор
        06.07.2022 12:34

        Я с вами частично согласен, частично нет.

        С одной стороны, против *Controller, *Repo, *DTO я ничего не имею.

        С другой стороны *Service - ну как-то так... Не ООП-но и в итоге это часто превращается ПП-подход с процедурами внутри сервисов и структурами данных в сущностях.

        С другой стороны, этот суффикс действительно удобен. В результате я для себя пришёл к такой схеме - я нарезаю проект на подсистемы, интерфейс каждой подсистемы определяется классом *Service, а имена вспомогательных классов для реализации этой подсистемы разработчик определяет сам.


        1. Reposlav
          06.07.2022 16:25

          *Service - плохая практика, так как мотивирует напихать туда всего, так же как и *Manager и подобное. Если не получается подобрать подходящее имя класса (например PostCreator, ProductFinder и т.д.), то сразу возникает мысль, не нарушается ли здесь SRP?

          Из суффиксов Controller и Repository по крайней мере можно понять домен использования, поэтому оно больше полезно, чем вредно.


      1. Ares_ekb
        06.07.2022 14:50

        Это холиварная тема, как и другие аспекты оформления кода. И не только кода, из той же области именование таблиц и столбцов в базах данных, именование объектов и отношений в разных типах моделей (описывающих данные, процессы). Я много этим занимался, писал методики моделирования, руководства по оценке качества моделей, внутренние рекомендации по написанию кода - там везде затрагивается вопрос именования.

        Лично я для себя определил следующие пункты:

        1) Совершенно точно в рамках одного проекта должна использоваться одна схема именования, в этом все сходятся.

        2) Названия классов, полей, методов, таблиц, столбцов, объектов, отношений и т.п. на английском языке должны соответствовать правилам английского языка. В английском главное/определяемое слово ставится в конце, а дополнительные/описывающие слова ставятся в начале. Например, DocumentChangeEventHandler. В русском языке порядок слов часто обратный: обработчик событий изменения документов, поэтому если используется калька с русского языка, то может начаться какой-то хаос. А если к этому добавить ещё технические суффиксы и префиксы, то хаос усиливается ещё сильнее. Гораздо проще запомнить одно правило, что английские названия пишем по-английски и всё.

        3) Иногда есть смысл вести словарь каких-то основных терминов. Например, сервисные классы иногда называют SomethingHelper, SomethingService, SomethingUtil, SomethingExtension, ..., плюс то же самое, но во множественном числе. Можно зафиксировать, что обычные сервисы (которые подключаются через DI, у которых может быть несколько реализаций) мы называем Service, а просто классы с какой-то вспомогательной функциональностью (например, для строк, для потоков, ..., классы у которых точно не будет нескольких реализаций, которые не будут подключаться через DI, которые в C# обычно реализуются через расширения) - называем, например, Helper.

        Пункт 2 и 3 как-раз и дают стандартные суффиксы и префиксы. Например, всё что относится к документам скорее всего будет начинаться со слова Document (или содержать это слово где-то близко к началу - например, AbstractDocumentEventHandler), а все обработчики событий скорее всего будут заканчиваться на EventHandler. Но это не какие-то искусственные, технические суффиксы и префиксы, которые на каждом проекте будут свои и будут выносить мозг не русскоязычным разработчикам, а вроде достаточно естественные, универсальные и понятные.

        Причем, префикс I у интерфейсов просто не имеет смысла. Почему бы тогда у классов не делать префикс C, у таблиц - T, у полей и столбцов - префикс в зависимости от типа данных. На мой взгляд, это какие-то технические детали реализации в названии, которые а) не приближают людей к пониманию назначения этого объекта б) вытаскивают на верхний уровень детали реализации. Например, сейчас эта штука реализована как класс, а потом мне захочется превратить её в интерфейс. И что, теперь всё переименовывать? Или какая мне разница, например, значение в переменной "расстояние до объекта" хранится в виде целого числа, или с одинарной, с двойной точностью. А что если я в будущем захочу изменить этот тип?

        К тому же, IDE обычно и так показывает тип объекта, зачем это тянуть ещё и в название.

        Насчет Impl - ну, это масло масляное. Все классы что-то реализуют. Или какое-нибудь слово Default - хотелось бы больше деталей. Например, мы реализуем какой-нибудь сервис получения настроек приложения. Название EnvironmentSettingsService звучит понятнее, чем DefaultSettingsService. Оно отражает, что настройки берутся из переменных окружения, в будущем могут появиться альтернативные реализации, например, DatabaseSettingsService. К тому же название DefaultSettingsService не очень устойчивое к изменениям. А что если в будущем дефолтной будет другая реализация сервиса?

        Я немного ушел в сторону. Если снова вернуться к пунктам 2 и 3 выше, то они позволяют выбирать наиболее естественные и устойчивые названия. Возможно они также косвенно помогают четко сформулировать назначение класса и не тянуть в него лишнее. Если не получается нормально назвать класс или видно что реализация явно не соответствует названию, то это повод что-то улучшить в коде.

        Другие варианты на мой взгляд усиливают хаос. Могут приводить к тому, что класс рассматривается не как какая-то осмысленная сущность, а что-то техническое, куда добавляются случайные поля и методы.

        4) Мы стараемся не использовать сокращения и аббревиатуры. Если только какие-то общепринятые или добавленные в словарь (всё тот же пункт 3 выше).

        5) В аббревиатурах только первую букву делаем заглавной, остальные строчными. Это обычно более удобный вариант для IDE. В них заглавная буква считается началом слова, можно перемещаться по словам, можно искать по словам. И для людей это более читаемо, иначе может получиться какое-нибудь EAEUCDXML - сложно понять что это значит, где какое слово начинается, нужно совершать усилия при чтении.

        Плюс у нас есть ещё куча разных рекомендаций по именованию CRUD методов и т.п.

        Насчет того, что джунам сложно называть классы:

        1) Если есть четкие рекомендации типа пункта 2 выше, то всё становится на много проще. Даже если человек не знает английского, это правило легко усвоить.

        2) Часть вещей может проверяться Checkstyle и аналогичными инструментами.

        3) Более сложные вещи проверяются в рамках код-ревью.

        Я возможно слишком сильно упарываюсь в правила именования :) Просто у меня были проекты, где англоязычные названия объектов распечатываются в бумажных документах, отдаются на подпись министру, после чего в них даже буковку просто так не изменишь, всё это раздаётся во множество компаний и используется десятилетиями. Поэтому нужно было придумывать сразу правильные названия, чтобы даже мысли не возникло изменить их в будущем, например, при расширении или сужении назначения объекта, при появлении похожих по смыслу объектов.


        1. qw1
          06.07.2022 16:04

          Причем, префикс I у интерфейсов просто не имеет смысла
          Смотря с чем работаешь. С одной стороны, если в метод приходит Dog, с точки зрения использования, должно быть без разницы, класс это или интерфейс, лишь бы у него был метод Bark(). С другой стороны, на типичном энтерпрайзе, мы в основном только и делаем, что гоняем данные туда-сюда, и тут очень важно, финальный класс это или абстрактный класс, или интерфейс, потому что как только я принимаю решение метод вынести в REST (или любой другой RPC) сервис, становится важно, как я могут это сериализовать/десериализовать, нужны ли мне обёртки для этого, или я могу сразу Dog кидать. Также становится важно, Dog — это класс репозитория или модели, чтобы я его не передал выше области действия транзакции.
          Почему бы тогда у классов не делать префикс C, у таблиц — T, у полей и столбцов — префикс в зависимости от типа данных
          Как по мне, прекрасная практика в SQL все вьюшки называть на букву v, уже не попытаешься их апдейтить. Суффиксы с типом данных тоже очень неплохо выглядят
          create table Cities (CityId int, CityName nvarchar(max), CountryId int);
          Если заканчивается на Id — это код какой-то сущности, заканчивается на Name — имя сущности.
          какая мне разница, например, значение в переменной «расстояние до объекта» хранится в виде целого числа, или с одинарной, с двойной точностью. А что если я в будущем захочу изменить этот тип?
          Это да, Венгерскую нотацию тоже считаю вредной.


          1. Ares_ekb
            06.07.2022 16:52

            Суффиксы с типом данных тоже очень неплохо выглядятcreate table Cities (CityId int, CityName nvarchar(max), CountryId int);

            Эти названия вполне соответствуют пунктам 2 и 3 из моего предыдущего комментария. Если добавить пробелы, то это будут обычные названия в соответствии с правилами английского языка: city identifier (id - это общепринятое сокращение), city name, country identifier. Т.е. это не просто слова с техническими суффиксами/префиксами (city_char, cCity, iCountry, intCountry, idCountry, fk_country), а осмысленные названия.

            Есть стандарты ISO/IEC 11179, ISO 7372 (UNTDED), UN/CEFACT CCTS, ISO 20022, OASIS UBL, NIEM и многие другие. Практически везде там используется примерно такая схема именования. Названия свойств обычно заканчиваются на Id, Name, Date, Amount, ...

            Также становится важно, Dog — это класс репозитория или модели, чтобы я его не передал выше области действия транзакции.

            Да, здесь как минимум два класса, которые можно было бы назвать Dog. Это JPA-сущность и DTO-класс. Скорее всего они будут лежать в разных пакетах, по которым можно понять что это за класс, но с другой стороны, конечно лучше им дать разные названия, например, DogEntity и DogDto. Но опять-таки это ничему не противоречит. DogEntity - это сокращение от dog persistence entity. DogDto - сокращение от dog data transfer object. Т.е. это осмысленные названия на английском языке. Вот, если бы были DtoDog или DogJPA, то у меня возникли бы вопросы.


            1. qw1
              06.07.2022 17:10

              Скорее всего они будут лежать в разных пакетах, но с другой стороны, конечно лучше им дать разные названия, например, DogEntity и DogDto
              Комбинируя это с п.1 ваших правил, получается что все entity в проекте должны заканчиваться суффиксом *Entity.
              Так и до Service недалеко )))
              Лучше всё единообразно LoginService, чем каждый раз удивлятся изобретениям типа краткого Login (это сервис или dto, или вообще интерфейс?) или Gatekeeper — для сервиса входа юзера в приложение.


  1. dmitryvolochaev
    06.07.2022 09:54
    +1

    Принцип Барбары Лисков не был бы сформулирован, если бы не было технической возможности его нарушить. Не придуман еще тот язык, на котором можно выразить необходимость явно сохранять изменения в базу или, наоборот, нежелательность при том, что метод есть.

    Зато придуманы юнит-тесты. Нужен тест, который пишет в базу через один контекст и затем читает через другой. Тогда в сценарии из первого эпизода ошибка не попадет в продакшен


    1. jdev Автор
      06.07.2022 10:02

      Не придуман еще тот язык, на котором можно выразить необходимость явно
      сохранять изменения в базу или, наоборот, нежелательность при том, что
      метод есть.

      Не уверен, что правильно вас понял, но возможно есть такой язык - Haskell. Если дали IO-монаду - надо сохранять, не дали - не надо:)

      Зато придуманы юнит-тесты

      У меня в черновике была заметка про тесты, но я решил убрать её, чтобы не размывать фокус поста:)

      Проблема Артемия была в том, что он был сторонником лондонской школы тестирования, тестировал отдельные методы, а в тестах сервиса репозитории замокал.

      После этого случая он стал сторонником детроицкой школы тестирования и больше у него таких проблем не будет:)


      1. dmitryvolochaev
        06.07.2022 10:04
        +1

        есть такой язык

        Спасибо, буду знать