Комментарии 58
В результате у вас должны получаться чёрные ящики, о устройстве которых можно забыть после того как код класса дописан. Этот подход позволяет существенно снизить сложность разрабатываемой системы.
Этого определенно пытались добиться авторы упомянутых концепций, но на практике такого не происходит. Почему? Потому что объявленный публичный метод в классе (или интерфейсе) не накладывает никаких ограничений на реализацию, следовательно не дает никаких гарантий тому, кто будет его использовать.
Поясню на реальном примере: допустим мы разрабатываем систему, которая получает сообщения из кафки и что-то с ними делает. Сообщения хранятся в определенном формате (avro) и чтобы начать с ними работать — необходимо их предварительно десериализовать (для этого нужно обработать массив байт по какой-либо схеме). Описываем интерфейс:
public interface Deserializer<T> {
T deserialize(String topic, byte[] data);
}
И вот примерно так выглядит наша функция получения сообщения:
<T> T receiveMessage(byte[] data) {
final Deserializer<T> deserializer = myDeserializerFactory.getDeserializer(
getClassName<T>()
);
final T message = deserializer.deserialize(data);
return message;
}
Что-ж, казалось бы, что цель достигнута — сложная логика десериализации «спрятана в черную коробку» с помощью интерфейса и сложность системы существенно снижена. Если бы не одно НО:
— мы понятия не имеем, что происходит внутри вызванного метода.Вот два рабочих кейса:
1. У нас есть класс, который выполняет десериализацию по заранее заданной схеме:
class SimpleAvroDeserializer<T> extends Deserializer<T> {
Schema getSchema(String topic) {}
@Override
public T deserialize(String topic, byte[] bytes) {
Schema schema = getSchema(topic);
// do some deserialization stuff
final T message;
return message;
}
}
2. В какой-то другой части проекта мы стали использовать внешнее хранилище для схем (SchemaRegistry. Представляет собой отдельное хранилище, куда попадают схемы всех сущностей проекта) и написали свой десериалайзер:
class SimpleAvroDeserializer<T> extends Deserializer<T> {
Map<String, Schema> schemaCache;
Schema getSchema(String topic) {}
@Override
public T deserialize(String topic, byte[] bytes) {
// get schema from cache
Schema schema = getSchema(topic);
// if schema is not there -- load it via HTTP call
if (schema == null) {
schema = loadSchemaFromRegistry(topic);
}
// do some deserialization stuff
final T message;
return message;
}
}
И теперь у нас следующая ситуация — есть две валидные реализации интерфейса, поведение которых значительно отличается. Хочу напомнить, что это реальный (или близкий к нему) пример (библиотеки для работы с Kafka).
Далее нелегкая переносит нас в шкуру разработчика, которому дали задачу то-ли пофиксить баг, то-ли отрефакторить и добавить новый функционал в функцию receiveMessage. Открываем ее код и видим следующий вызов:
final Deserializer<T> deserializer;
final T message = deserializer.deserialize(data);
И с ужасом осознаем, что для того, чтобы понять, что же делает эта строчка (для того, чтобы далее провести безопасный рефакторинг) с вызовом метода интерфейса нам нужно будет изучить исходники всех его имплементаций и молится, чтобы не появились новые. Потому что сегодня там может быть обычная чистая функция (pure function), завтра — блокирующий запрос к API (наверное автор метода receiveMessage не предполагал, что основной поток получения сообщений из кафки будет заблокирован на такое долгое время), а что будет послезавтра? Никто сказать не сможет.
Вывод какой — на бумаге мы получаем «существенно сниженную сложность разрабатываемой системы», тк. принцип
наследование должно использоваться только как реализация отношения «является»
— выполняется. Полиморфизм и инкапсуляция — сохраняется в лучшем виде. Интерфейс все еще предоставляет согласованную абстракцию, да вот только на деле это привело к тому, что система стала монструозно сложной. Если попробовать применить закон индукции и попытаться повторить приведенные выше рассуждения к другим частям нашей ООП-программы — можно прийти к неутешительным выводам: в худшем случае, чтобы понять как работает система — необходимо понять как работают все ее составные части.
Подводя итоги оставляю открытый вопрос — оправданы ли проиллюстрированные абстракции с точки зрения желаемого результата?
Безусловно то, о чем говорит автор имеет место быть, но для достижения заявленного в эпилоге статьи эффекта — подходит очень слабо.
Но это потому что в вашем примере нарушается принцип единственной ответственности. Не надо лезть во внешнее хранилище в функции, которая занимается десерелизацией.
Так то можно вообще код Doom2 в эту функцию запихать, а потом дать программисту там баг найти.
Не нарушать SOLID и будет всё нормально.
нарушается принцип единственной ответственности.
Строго говоря — нет. Потому что ответственность компонента — десериализовать авро данные схемой из реджистри, а для этого процесса необходима схема.
Не надо лезть во внешнее хранилище в функции, которая занимается десерелизацие
Начну с контрпримера:
У вас есть функция-коллбэк (или аналог), которая обрабатывает ответ на какой-то HTTP-запрос. По различным причинам вам необходимо, чтобы метаинформация ответа логгировалась (упростим — выводилась в консоль).
Integer handleResponse(Response response) {
System.out.println(response.meta.stringify);
// do stuff
return 42;
}
Выходит, что мы нарушили принцип единой ответственности, так как функция, отвечающая за обработку ответа содержала в себе сайд эффект в виде вывода в stdout. Само по себе это не имеет отношения к обработке ответа (если удалить логгирование — логика работы не пострадает) и именно по этому твердо можно сказать, что у компонента «два вида ответственности».
Как же нам сделать, чтобы этот принцип не нарушался? Может быть сделать функцию с названием logAndHandle? Тоже нет, потому что это те же два действия — двойная ответственность. И даже если мы сделаем `Logger` и делегируем ему вывод в консоль (вы же понимаете, что я упростил свой пример с десериалайзером и на деле обращением в реджистри занимается отдельный компонент, но вызов все равно происходит в этом месте):
Logger log;
Integer handleResponse(Response response) {
log.info(response.meta);
// do stuff
return 42;
}
То это тоже не изменит ситуацию — мы по прежнему делаем два дела. Так получается, что этот принцип соблюсти невозможно? Вовсе нет. Единственная ответственность != одно действие. Вопрос только в том, что мы понимаем под этой ответственностью.
Таким образом, возвращаясь к моему изначальному сообщению, ответственность компонента — десериализовать данные схемой из реджистри. И он прекрасно справляется с этим. И определять что такое «ответственность компонента» — задача разработчика, где-то получится точнее это сделать, где-то — нет.
Но самое интересное, что это не имеет особого отношения к той мысли, которую я пытался высказать — определение метода в интерфейсе не накладывает никаких ограничений на реализацию и чтобы понять, что действительно произойдет — надо изучить все реализации.
Строго говоря — нет. Потому что ответственность компонента — десериализовать авро данные схемой из реджистри, а для этого процесса необходима схема.
Вообще-то нарушается. Ответственность компонента – десериализовать данные схемой. Откуда эта схема пришла – не его дело. Для него это просто зависимость. Вот в таком случае принцип не нарушится, а десериализатор можно будет легко покрыть юнит тестами.
Выходит, что мы нарушили принцип единой ответственности, так как функция, отвечающая за обработку ответа содержала в себе сайд эффект в виде вывода в stdout.
Здесь скорее нарушается принцип CQS, поскольку функция-запрос, возвращающая результат, еще и занимается всякой побочной ерундой. Варианты, как сделать чисто и красиво, существуют – можно посмотреть подходы в ФП.
Но самое интересное, что это не имеет особого отношения к той мысли, которую я пытался высказать — определение метода в интерфейсе не накладывает никаких ограничений на реализацию и чтобы понять, что действительно произойдет — надо изучить все реализации.
Так в этом-то и смысл принципов. Конечно, они не заложены в компилятор. Это же просто договоренность. И если все в команде будут эту договоренность соблюдать и поднимать вопрос на код ревью, то жить станет проще, потому что с высокой степенью достоверности вы можете полагать, что "завтра" у вас в проекте не появится блокирующий запрос к АПИ там, где делать ему абсолютно нечего.
Уже ответили, но добавлю
Схему, если она нужна десериализации, нужно как то передавать. Можно либо сразу в метод, либо отдельный класс делать, который будет хранить схему, которая туда передастся. А откуда её взять — это не ответственность десериализации.
определение метода в интерфейсе не накладывает никаких ограничений на реализацию и чтобы понять, что действительно произойдет
Интерфейс не накладывает, но вообще для этого есть там контракты, юнит тесты. Которые и должны проверять, что функция не делает ничего лишнего. Они конечно не избавят вас того, что кто-то добавит код, который делает ерунду, но как минимум минимизируют этот риск.
Ну и плюсом ревью кода, ревью архитектуры, когда другие люди, незамыленным взглядом сразу укажут на возможные проблемы, тоже очень помогает.
В общем, мое мнение — архитектура не очень, отсюда и проблемы.
Можно либо сразу в метод, либо отдельный класс делать, который будет хранить схему, которая туда передастся.
Я сознательно упростил исходный пример, но давайте тогда вернем детали. У нас есть SchemaRegistyClient, который занимается непосредственно загрузкой схем. Он передается как зависимость в наш десериалайзер (скажем — через конструктор). Чтобы быть еще ближе к реальности — схема загружается по id, который содержится в первых 4х байтах сообщения.
interface SchemaRegistryClient {
Schema getSchemaById(int id);
}
class AvroDeserializer<T> extends Deserializer<T> {
SchemaRegistry registryClient;
@Override
public T deserialize(String topic, byte[] bytes) {
int id = readSchemaId(bytes);
Schema schema = registryClient.getSchemaById(id);
// do some deserialization stuff using provided schema
final T message = ...;
return message;
}
}
На ваш взгляд в такой реализации формальности соблюдены? Десериалайзер занимается десериализацией, клиент — загрузкой схем.
Думаю, что все равно не соблюдены. Я не очень в Java и тонкостях вашей задачи, поэтому, например, не понимаю, что такое массив байт byte[] bytes и почему в нем хранится id. Я бы отдельно выделили сущность id из массива байт для загрузки схемы и не передавал бы массив байт в котором этот id существует, потому что сегодня он в первых 4 байтах, завтра он станет 8 байтным и надо будет переделывать класс AvroDeserializer. Я бы сделал два отдельных класса — один отвечает за загрузку схемы, второй за десериализацию.
Вот если будет так (не знаю синтаксис Java, поэтому возможны ошибки):
class AvroDeserializer<T> extends Deserializer<T> {
Schema schema;
public AvroDeserializer(Schema schema)
{
this.schema = schema ; // схема вычитывается клиентским кодом и передается уже сюда. В таком случае, класс не зависит от того, где лежит схема. А зависит только от самой схемы.
}
@Override
public T deserialize(String topic, byte[] bytes) {
// do some deserialization stuff using provided schema
final T message = ...;
return message;
}
}
Ну либо просто передавать её в метод deserialize, раз вы topic и массив байт туда передаете.
class AvroDeserializer<T> extends Deserializer<T> {
@Override
public T deserialize(Schema schema, String topic, byte[] bytes) {
// do some deserialization stuff using provided schema
final T message = ...;
return message;
}
}
Я не очень в Java и тонкостях вашей задачи, поэтому, например, не понимаю, что такое массив байт byte[] bytes и почему в нем хранится id.
Схема для каждого сообщения может быть разная. Плюс за время, пока программа запущена, схема может изменится (сохранив обратную совместимость), и мы не можем знать, какой вариант схемы нужен для десериализации сообщения, до того как его получим. Потому id схемы приходит в первых 4 байтах (int32). Это не моя придумка — такая реализована офф. библиотека для работы с кафкой+авро. Поэтому дальнейший ваш вариант отпадает — схему надо грузить динамически.
Ну либо просто передавать её в метод deserialize, раз вы topic и массив байт туда передаете.
Тут вы завязались на авро на уровне интерфейса модуля получения данных из кафки, хотя никто не форсит этот формат хранения данных. Вам приходят просто наборы байт — это может быть все, что угодно. Авро — самая популярная опция.
Вот я и говорю, что по моему должно быть так:
1 шаг. Получили сообщение
2 шаг. Распарсили сообщение, вытащили оттуда данные и id
3 шаг. Загрузили по id схему
4 шаг. Отдали десирелезатору данные и схему для десирелизации.
За каждый шаг отвечает отдельный класс.
Тогда, если что то меняется в загрузке схемы, меняете только класс шага 3. Если формат сообщения только класс шага 2.
Ну или я не понял вашу задачу...
Ну или я не понял вашу задачу...
Это не моя задача, строго говоря. За пример был взят код, который дебажил, в тот день когда написал комментарий. Библиотеки на которые я ссылаюсь — опенсорсные, их писали умные, талантливые люди и у меня нету к ним претензий. Я более чем уверен, что они реализованы с соблюдением всех солидов и прочего. Я просто ставлю под сомнение изначальные парадигмы (ООП подход) и искренне считаю, что он был выбран не потому, что он лучше всего подходит для решения данной задачи, а потому что целевая аудитория больше.
В итоге как мы реализуем абстрацию для модуля получения данных из кафки? Напомню, что мы имеем:
public interface Deserializer<T> {
T deserialize(String topic, byte[] data);
}
<T> T receiveMessage(byte[] data) {
final Deserializer<T> deserializer = myDeserializerFactory.getDeserializer(
getClassName<T>()
);
final T message = deserializer.deserialize(data);
return message;
}
Если привязать десериалазйзер к какой-то конкретной схеме — возникает много противоречий. В данных может быть банальный джейсон, который не требует схемы. Или же просто строка. Если следовать предложенной Вами логике — мы делаем общий интерфейс для частного случая, а это некорректно.
Да и в целом — мы уже ушли в сторону. Любой интерфейс подразумевает, что возможны несколько валидных имплементаций, которые удовлетворяют всем формальностям (KISS, SOLID, DRY, ...) и при этом их логика будет значительно отличаться. Соглашусь, что абстракции предложенные автором статьи позволяют уменьшить сложность разработки (разработчик в какой-то момент декларирует, что тут — черный ящик), но вот все, что касается дальнейшего жизненного цикла системы — значительно усложняется. Я имею ввиду рефакторинг, добавление новых фич и прочее и прочее.
Если мы говорим, про простые системы, которые могут быть реализованы одним-двумя разработчиками — то все это обсуждение, конечно не так актуально.
Но на моем опыте хоть сколько нибудь сложные и интересные проекты разрабатываются командами, при этом требования к проекту меняются и дополняются со временем. Поэтому очень важно следить за тем, насколько с нашим кодом «легко» работать. И вот тот факт, что мне каким-то чудом надо изучить все реализации интерфейса, чтобы понять, что конкретно может произойти в одной единственной строчке кода — это ни разу не «легко».
Библиотеки на которые я ссылаю — опенсорсные, их писали умные, талантливые люди и у меня нету к ним претензий, я уверен, что они реализованы с соблюдением всех солидов и прочего.
Ну люди ошибаются всегда. Солид тут точно не соблюден, потому класс Deserializer в вашем примере не зависит от класса Schema к слову совсем. Он его использует внутри своей функции — но это не зависимость, потому что — это чисто такая реализация, сел бы другой программист и реализовал бы по другому.
Грубо говоря, когда делали архитектуру — не предполагали, что кто-то будет туда передавать схему. И не показали это на UML диаграмме, а потом кто-то из программистов сел, и решил, а почему бы тут схему не считатать, ну точно также он бы мог решить, а почему бы тут не сделать вывод лога?
Этот класс изначально не предполагался ни для схемы, ни для лога. Скорее всего, просто об этом не подумали тогда, а когда решили, уже было много легаси кода, для которого интерфейс было менять не с руки.
Но могли бы просто добавить еще один такой же метод, куда бы передавалась эта схема. Снова — это проблема Архитектуры. Она не сделана по принципам ООП.
Чтобы была зависимость, её нужно показать на архитектуре, скажем, на UML диаграмме классов, — где будет указано что нужно передавать объект или ссылку на объект этого класса. Тогда вы сразу бы ограничили полет мысли человека, который будет это реализовывать.
А как сейчас показать на архитектуре, что есть зависимость от Schem, а никак — она только в реализации метода встречается, которая к архитектуре отношения не имеет?
Сейчас класс Deserializer зависит только от буфера данных (заметьте от id он тоже не зависит), от строки и SchemaRegistry. Больше ни от чего, все остальное — программист может реализовывать и делать все что ему в голову придет. Это просто костыль.
Я просто ставлю под сомнение изначальные парадигмы (ООП подход)
Их всего три, они основные, но как и в любой другой отрасли — это лишь основа, а есть еще уточнения. Уточнения не противоречат основе. Это как Конституция и другие законы.
Поэтому ООП это не только три кита, но и как вы верно сказали, СОЛИД, КИСС и так далее...
Если мы говорим, про простые системы, которые могут быть реализованы одним-двумя разработчиками — то все это обсуждение, конечно не так актуально.
Наоборот, для больших систем это более актуально. Если много людей, то классы должны быть маленькие, более независимые, задачки небольшие. А когда один или два — там можно сразу весь код дать писать им… без архитектуры вообще :)
Но на моем опыте хоть сколько нибудь сложные и интересные проекты разрабатываются командами, при этом требования к проекту меняются и дополняются со временем
Именно поэтому принцип единственной ответственности важен. Делаешь класс, который отвечает чисто за одну вещь, потом уже определяешь, нужен он будет или нет, если вдруг требования поменяются. А так сделали класс, который делает Всё по всем требованиям, а они бах и поменялись, и пошел этот огромный класс менять...
Я не знаю как другие, но мы тратим довольно много усилий на архитектуру, чтобы потом можно было переиспользовать её. Разбиваем на небольшие задачи и потом реализуем. Долго вначале, но зато меньше проблем потом, но у нас специфика немного другая, ко всем применять конечно её нельзя.
но вот все, что касается дальнейшего жизненного цикла системы — значительно усложняется. Я имею ввиду рефакторинг, добавление новых фич и прочее и прочее.
Да есть такая проблема, иногда проще все переписать :) Но с другой стороны рефакторинг тоже можно с умом сделать. Например, в вашем случае, почему отдельно метод не добавить с передачей схемы? Или вообще еще один интерфейс прилепить Десериализация со схемой....?
Грубо говоря, когда делали архитектуру — не предполагали, что кто-то будет туда передавать схему.
Нет нет, дело совсем не в этом. Дело в том, что конкретно этому коду все равно есть ли там схема и какая она. Он работает с данными. С массивом байт. А на выходе он хочет получить объект типа T (при этом — любого. На тип не наложено никаких ограничений. В частом случае метод десериализации может вернуть массив байт без изменений и это будет считаться валидной реализацией).
Сейчас класс Deserializer зависит только от буфера данных (заметьте от id он тоже не зависит), от строки и SchemaRegistry. Больше ни от чего, все остальное — программист может реализовывать и делать все что ему в голову придет. Это просто костыль.
Не корректно. Возьмите любой интерфейс любого фреймворка, который позволяет создавать REST-API. Все, о чем там упоминается — есть запрос, нужен ответ. Там есть упоминания про MySql? про Rabbit? Нет? Значит неверная архитектура? Если наш класс зависит от двух других — это неверная архитектура?
Ответ — все в порядке с архитектурой. То, что вы называете костылем — суть абстракции. Поскольку для получения сообщения нам не важно, какие конкретно данные нам пришли — наш интерфейс и не содержит никаких деталей об этом. Мы лишь декларируем то, что нам необходимо десериализовать данные перед тем как их использовать. Напомню, что это все еще отдельный модуль. Это, если хотите — «черная коробка», внутри которой лежит другая «черная коробка» под названием десериализатор. Которая уже используется (другими людьми) для реализации конкретных пайплайнов.
Если мы, как разработчики, не можем абстрагироваться (возвращаясь в наш контекст) от того, какие именно данные нам приходят, то тогда вообще не стоит упоминать про ООП, СОЛИД и прочее, потому что все эти понятия требуют определенного уровня абстракции.
А вы пытаетесь избавиться от всего этого, заменив деталями реализации, потому что я вам указал на две из них. Это создает иллюзию, что их всего две. А их все так же — неисчислимо много.
Завтра я могу сказать, что мы решили данные зашифровать сообщения, которые хранятся в кафке. И вот уже десериализация зависит не только от схемы, но еще и от ключа. И что же мы? Пойдем допиливать public interface Deserializer? Не думаю, что такой подход имеет место быть.
Отдельно отмечу, что если почему-то есть ощущение, что солид нарушается — то это только потому, что потому что я привел две части совершенно независимых модулей, как будто они были сделаны одним разработчиком (да еще и упаси боже — мной) и лежат в виде 3х (4х\5ти) файлов в одной папке. Все не так. В 100й раз подчеркну, есть отдельный модуль\пакет\библиотека для получения данных из кафки. Есть отдельный модуль\пакет\библиотека для работы с авро. Есть отдельный модуль\пакет\библиотека для работы с реджистри. Есть уже конкретный модуль\пакет который пишет конечный разработчик, который создает продукт (часть продукта) и он использует вышеперечисленные кусочки кода (опенсорс) в своей программе. В каждом таком модуле соблюдены принципы SOLID (и др.) на достаточном уровне. Единственное, почему Вам может показаться, что собранная из этих кусочков система не соблюдает какие-либо принципы (придуманные умными людьми), так это потому, что ООП подход по определению подразумевает, что части программы (классы, интерфейсы или целые модули) не композициональны (тут очень сложно перевести на русский язык. В оригинале это «composable != compositional»). То есть их можно скомпоновать, чтобы использовать вместе, но их нельзя рассматривать как части композиции. Но это еще один камень в огород ООП и совсем другая история.
Например, в вашем случае, почему отдельно метод не добавить с передачей схемы? Или вообще еще один интерфейс прилепить Десериализация со схемой....?
Потому что в кафке хранятся данные и работаем мы с данными (массив байт). Схема и авро формат — это уже детали реализации, которые невозможно знать внутри модуля для работы с кафкой.
А если уж мы возвращаемся к сути дискуссии (абстракциям), то позволю себе процитировать мой изначальный комментарий:
Подводя итоги оставляю открытый вопрос — оправданы ли проиллюстрированные абстракции с точки зрения желаемого результата?
Безусловно то, о чем говорит автор имеет место быть, но для достижения заявленного в эпилоге статьи эффекта — подходит очень слабо.
Дело в том, что конкретно этому коду все равно есть ли там схема и какая она. Он работает с данными.
Тогда зачем он использует схему?
Не корректно. Возьмите любой интерфейс любого фреймворка, который позволяет создавать REST-API. Там есть упоминания про MySql? про Rabbit? Нет? Значит неверная архитектура? Если наш класс зависит от двух других — это неверная архитектура?
Корректно. Зависит — это не тоже самое, что и Использует без показа этой зависимости. В вашем случае, вы просто в имплементации стали Использовать схему, вопрос почему? в Архитектуре её нет. Это просто прихоть программиста.
Вот зависимость от данных показана — и это не приходь, это необходимость. А схемы нет, нигде, кроме кода… который кто-то сам придумал, так ему захотелось — это не абстракция, это вредительство :)
Потому что в кафке хранятся данные и работаем мы с данными (массив байт). Схема и авро формат — это уже детали реализации, которые невозможно знать внутри модуля для работы с кафкой.
Я тогда не понимаю, если с данными работаете, зачем схема? Пусть она и работает с данными, а вы сделайте над ней обертку, где будет и схема и id и выбирайте в ней все что вам надо, а потом отдавайте только данные. Отдельный класс со схемой.
Пойдем допиливать public interface Deserializer?
Есть множество вариантов — допиливать инетрфейс (если он еще не расползся сильно), добавить новый. Можно сделать свою обертку над Deserializer, и добавить туда зависимость от схемы… Да куча вариантов, чтобы не трогать базу.
Мое мнение такое :) Что классы, которые кто-то собирается переиспользовать, должны быть как можно меньше, делать одну очень узкоспециализированную вещь. Понятно, что в бизнеслогике, когда там какой-нить Медиатор знает про всех, его простым не получиться сделать, но его никто переиспользовать и не будет, потому что он пишется конкретно под задачу. А все остальное, особенно — универсальные классы всяких фреймворков должны быть маленькие и отвечать за одну конкретную вещь.
Но этого не всегда бывает легко добиться на практике.
Тогда зачем он использует схему?
Вы перемешиваете понятия интерфейс и реализация интерфейса (класс). Модуль получения данных из кафки не содержит ни одной реализации данного интерфейса (Dsererializer).
Корректно. Зависит — это не тоже самое, что и Использует без показа этой зависимости. В вашем случае, вы просто в имплементации стали Использовать схему, вопрос почему? в Архитектуре её нет. Это просто прихоть программиста.
Интерфейс — не зависит от схемы и уж точно не может ее использовать, т.к. не может содержать ни реализацию, ни данных — по определению.
Код получения данных не зависит от реализации, а зависит от интерфейса и использует любой класс, реализующий интерфейс (постулаты ООП, см. статью автора). От чего там зависит этот класс — нам все равно, потому что мы никак не можем на это повлиять (тут и запрятан главный предмет моей изначальной критики). Этот код соблюдает SOLID. Интерефейс соблюдает SOLID.
Я тогда не понимаю, если с данными работаете, зачем схема? Пусть она и работает с данными, а вы сделайте над ней обертку, где будет и схема и id и выбирайте в ней все что вам надо, а потом отдавайте только данные. Отдельный класс со схемой.
Со схемой, реджистри и прочим работает реализация интерфейса. Она зависит (в публичном конструкторе передается клиент реджистри как зависимость) и использует схему. Эта реализация соблюдает SOLID (см предыдущий пункт).
interface SchemaRegistryClient {
Schema getSchemaById(int id);
}
class AvroDeserializer<T> extends Deserializer<T> {
SchemaRegistry registryClient;
public AvroDeserializer(SchemaRegistryClient registryClient) {
this.registryClient = registryClient;
}
@Override
public T deserialize(String topic, byte[] bytes) {
int id = readSchemaId(bytes);
Schema schema = registryClient.getSchemaById(id);
// do some deserialization stuff using provided schema
final T message = ...;
return message;
}
}
Это не прихоть программиста. Это две разные подсистемы — реализация общего (инфрастуктурного) модуля получения (любых) данных из хранилища (кафки) и реализация конкретного пайплайна по получению данных из хранилища.
Вы же не предлагаете под каждую схему данных делать отдельную очередь сообщений (KafkaForAvro, KafkaForJson)? Надеюсь, что нет.
Итак, поскольку теперь все стало очевидно, предлагаю вернуться к изначальному комментарию — все, что я там указал по прежнему имеет место быть. Солид (и прочее) тут ни на что не повлияли.
Ну не совсем очевидно :)
Я уже слегка запутался, но вот мое видение по разделению всего этого дела выглядит вот так:
Эта реализация соблюдает SOLID (см предыдущий пункт).
Нет не соблюдает — нарушен принцип единственной ответственности, см выше и мой предыдущий ответ, как разрулить это.
А у вас как раз хороший пример того, как делать не надо и даже проблему привели правильную, когда программист плевался из-за несоблюдения принципов ООП… Программисту пришлось лезть в реализацию метода, в который он по идее лезть не должен.
а) У нас известны все возможные кейсы реализации, чтобы учесть все возможные сценарии в изначальном интерфейсе
б) Это дело пишется внутри одной команды и люди могут оперативно вносить изменения если понадобилось внести какую-то новую функциональность
в) Это все никак не отменяет поинта с интерфейсом. Просто попробуйте в Вашей интерпретации СОЛИДА сделать функционал, который получает какие-то абстрактные данные (кафка, http-запрос), обрабатывает их и логгирует, чтобы при этом каждый из компонентов делал только одно.
а) как раз не все, все развязано через интерфейс, если хотите можете за схемой ходить хоть в SQL, хоть просто на ходу её генерить, хоть получать её по Http, просто нужно сделать реализацию интерфейса
б) Не совсем понял, но наверное да :)
в) Отменяет, теперь в интерфейсе мы реализуем только десериализацию, используя схему и абстрактные данные, которые не важно откуда были получены (и схема и данные) и ничего больше. Если хотите логировать, пожалуйста, делайте интерфейс для логирования и реализуйте его и также агрегируйте объект этого интерфейса
просто нужно сделать реализацию интерфейса
Я до сих пор не могу понять, что я упускаю… давайте на секунду переключимся на более простой пример:
Если хотите логировать, пожалуйста, делайте интерфейс для логирования и реализуйте его и также агрегируйте объект этого интерфейса
Модуль получает запрос, производит с ним какие-то вычисления, возвращает ответ. Задача — сделать так, чтобы запросы логгировались, не нарушая принципы солид.
public interface Request {}
public interface Responce {}
public interface HttpHandler {
public Response handle(req: Request)
}
HttpHandler — это интерфейс, который предоставляет нам наш (любой) фреймворк. Как будет выглядеть валидная (на ваш взгляд) имплементация?
Мне в голову приходит такой вариант, но он решительно нарушает СОЛИД (как я понял с ваших слов):
public class MyHttpEndpoint extends HttpHandler {
// внешняя зависимость
Logger log;
public Response handle(req: Request) {
// делегируем логгеру грязную работу
log.info("Some logging stuff here");
Integer result = doJob(...); // 42
Response response = new JsonHttpResponse(...);
return response;
}
}
- С моей точки зрения, тут все почти хорошо. Тут показана зависимость от лога. Он агрегируется в класс MyHttpEndpoint.
Единственное, наверное его надо было бы сделать через интерфейс и передавать ссылку в конструктор, а не создавать объект класса Logger прямо в MyHttpEndpoint, чтобы в случае чего подсунуть другую реализацию лога
. Отличие от деселиризатора тут в том, что зависимости показаны, вы даёте понять, что класс зависит от лога. Единственная ответственность класса в таком случае — , сформировать респонс и залогировать это. В данном случае, вы показываете это имплементатору(тому, кто будет кодировать эту архитектуру), и поясняете что для этого класса является единственной ответственностью.
В случае же с деселиризатором, вы объявляете всем, что единственная ответсвенность это деселиризация, зависящая только от данных и не показываете, что будет ещё схема, которая берется черти откуда и зависит от странного id, который черти как определяется и поэтому нарушаете это принцип. Имплементатор не знает об этом и не может сам додумать (что нужна схема, да которая к тому же зависит от id, который берется из первых 4 байт массива данных… )
Вообще чтобы понять это нужно, чтобы архитектуру делал один человек, а имплементировал другой. Вот, если я буду реализовывать вашу архитектуру, как мне понять, что там со схемой что то надо делать?
P.S. хотя наверное и лог можно выкинуть из класса, например, подписать его на событие от HttpHandler через шаблон подписчик. Тогда вообще все будет по SOLID, просто по приходу реквеста нужно будет оповестить подписчиков, неважно кого… логгер или ещё кого то, что вот был запрос, вот вам с него данные, делайте с ним, что хотите. В таком случае вообще все будет развязано и каждый класс отвечает только за свое...
Не успел код добавить в предыдущее сообщение, как то вот так может быть:
public class MyHttpEndpoint extends HttpHandler, Observable {
// внешняя зависимость не известная нам
private List<ISubscriber> observers; // Список подписчиков
private IResponse response ;
public MyHttpEndpoint (IResponse res) {
this.response = res ;
}
public Response handle(Request req) {
// делегируем логгеру и другим, кому нужен запрос грязную работу
observers.Notify(Request);
Response response = new JsonHttpResponse(...); // это на самом деле тоже плохо... Его тоже надо развязать, чтобы создавать Response в отдельном классе. Будет что-то похожее на это:
Response = response.OnRequest(Request) ;
Integer result = doJob(...); // 42
return response;
}
private void Notify(Request req){
for (Observer observer : observers) {
observer.OnRequest(Request) ;
}
}
@Override
void Subscribe(ISubscriber observer) {
observers.add(observer);
}
}
public class Logger implements ISubscriber {
@Override
public void OnRequest(Request req) {
// Делаем в логгере грязную работу
log.info("Some logging stuff here");
}
}
// и где-то в коде
Request myRequest = new Request() ;
MyHttpEndpoint myHttpEndpoint= new MyHttpEndpoint(myRequest);
ISubscriber mylogger= new Logger();
myHttpEndpoint.Subscribe(mylogger) ;
Синтаксиса Java не знаю толком, так что возможны ошибки, но принцип надеюсь понятен.
P.S. на самом деле у вас Response response = new JsonHttpResponse(...);
тоже как бы плохо выглядит… :)
И я бы его отдельно сделал классом Response c подпиской на Request и своим обработчиком
В таком варианте скорее нарушает, чем нет. Вот если что-то вроде:
public class MyHttpEndpoint extends HttpHandler {
// внешняя зависимость
Logger log;
JobHttpService jobHttpService;
public Response handle(req: Request) {
log.info("job starting for " + req.url);
Response response = jobHttpService.doJob(req);
log.info("job finished for" + req.url);
return response;
}
}
Вот тут SRP точно не нарушается: ответственность класса в диспетчеризации, в оркестрации. Он ничего не делает сам.
Он ничего не делает сам.
Не очень понял. Он как минимум:
- Логирует
- Инициирует работу внешнего сервиса
- Обрабатывает запрос (из этого вытекает п.2). Имеется ввиду цикл req -> res
Так что в любом случае — отвечает более, чем за одну вещь.
Он не логирует, он делегирует логирование логгеру.
Он не обрабатывает запрос, он делегирует обработку сервису.
Сам ничего не делает, кроме определения когда, кому и что делегировать.
Если же вы работаете с методом интерфейса, то для вас не очевидно что произойдет (в плане сайд-эффектов, да и в целом).
public interface HttpHandler {
public Response handle(req: Request)
}
Только подебажив (или запустив тесты), вы увидите, что он и логирует и обрабатывает.
Вы согласны с этим?
Мое мнение такое :) Что классы, которые кто-то собирается переиспользовать, должны быть как можно меньше, делать одну очень узкоспециализированную вещь.
Да, но все это время мы говорим про две очень маленьких имплементации одного интерфейса и все мои аргументы были именно парадигмам ООП которые реализуются с помощью механизма декларирования и дальнейшей имплементации интерфейса.
Их всего три, они основные, но как и в любой другой отрасли — это лишь основа, а есть еще уточнения. Уточнения не противоречат основе. Это как Конституция и другие законы.
Поэтому ООП это не только три кита, но и как вы верно сказали, СОЛИД, КИСС и так далее...
Я не критикую и ничего не писал про СОЛИД, КИСС и другие. Мой комментарий был про то, что принципы, которые декларирует ООП (которые лежат в базе ООП подхода) — значительно сужает предметную область программ, которые могут быть реализованы на их основе. И проиллюстрировал почему.
Тот факт, что вы ограничили рамки и тем самым отсеяли некоторые неэффективные реализации никак не может повлиять на изначальные парадигмы, т.к. Вы сами декларировали, что они не противоречат основе. А для того чтобы нивелировать обозначенный мной казус необходимо сделать такие уточнения, которые противоречат основе.
PS наследование считается плохой практикой и его применения необходимо максимально избегать (это следует из тех уточнений, про которые вы говорили), заменяя на композицию. А как я писал в другом комменте — ООП не подразумевает композиционности, только композируемость. Не противоречие ли это?
значительно сужает предметную область программ, которые могут быть реализованы на их основе.
Я думаю сужает полет фантазии и границы реализации, но реализовать можно что угодно, не учитывая там специфические вещи, типа переключения контекста задач, который только на ассемблере и можно написать.
PS наследование считается плохой практикой и его применения необходимо максимально избегать (это следует из тех уточнений, про которые вы говорили). Не это ли противоречие?
Нет это не противоречия. Под наследованием понимается наследование интерфейсов, а не реализации. Поэтому и говорят, что нужно избегать наследование реализаций, вместо этого просто агрегируйте или делайте композицию объектов. Т.е. Кит — наследование, но оно имеет настолько широкое определение, что его уточнили другими постулатами.
Я думаю сужает полет фантазии и границы реализации, но реализовать можно что угодно, не учитывая там специфические вещи, типа переключения контекста задач, который только на ассемблере и можно написать.
Реализовать можно что угодно, но имелось ввиду, что сужается круг задач, которые эффективно можно реализовать в данной парадигме. Неэффективно можно реализовать что угодно, на это я и указал в своем изначальном комментарии.
Да согласен, но любая парадигма сужает круг задач, которые можно эффективно реализовать, потому что накладывают некие ограничения. Самое эффективное это на ассемблере писать, но нужно очень много думать :) это не просто эффективно на нем писать, но точно можно. А парадигмы облегчают решение задач, но при этом да падает эффективность.
Тут ещё надо договориться, что считать эффективностью.
Подавляюще большинство заказчиков сочтут более эффективным сайт, который будет отвечать юзеру за 1000мс, потреблять 100Мб ОЗУ на запрос и т. п., но будет сделан за 1 человеко-месяц на PHP, чем в 100 раз быстрее и т. д., но сделанный за сотню человеко-лет на ассемблере.
Процедурно тоже можно писать эффективно, сложнее, но можно.
А ООП и ФП это одни и те же грабли только с разных сторон. Одно другому не мешает, одно лучше в одном случае, другое в другом — но в целом никаких преимуществ одного перед другим нет. ООП, например, хорошо ложиться на микроконтроллеры, а ФП не очень из-за активного использования стека и рекурсий.
https://habr.com/ru/company/oleg-bunin/blog/462505/
https://habr.com/ru/post/479238/
Перед этим приводит в качестве аргументов, что в не ФП языках есть ФП фичи… хм.
это одни и те же грабли только с разных сторон
Конечно, после такого чтива можно придти к этому заключению, но к счастью, механизмы языка — это способ выразить функциональные программы. А вот про концепции, которые лежат в основе ФП, говорится вскользь да и то, с точки зрения обывателя.
То же с монадами и функторами. Функторы и монады облегчают описание последовательности шагов вычисления в контексте.
Создатели Swift не стали пугать программистов монадой. Они знают, что такое монада, и ввели в язык синтаксис, который выполняет монадические вычисления: последовательность шагов в контексте с отсутствием значения.
Вызывает только ироничную улыбку. Т.е. по мнению автора — монады, это примерно эквивалентно синтаксическому сахару.
Хочу лишь сказать, что если Вы составляли свое мнение о ФП на основе этих материалов — искренне рекомендую обратиться к более компетентным источникам (Вы будете удивлены).
Потому что главное — это доказуемая теоретическая база, которая нам, как разработчикам, предоставляет некие гарантии.
Поэтому и говорят, что нужно избегать наследование реализаций, вместо этого просто агрегируйте или делайте композицию объектов.
Говорить про наследование интерфейса не совсем корректно. Интерфейс имплементируют, наследуют один класс от другого класса. (Ну, разве что у вас есть интерфейс, который расширяет другой интерфейс, но это к делу не относится).
Противоречие номер один — в том, что в ООП есть именно наследование (один класс расширяет другой класс, не интерфейс!), а использовать этот механизм — чуть ли не антипатерн.
вместо этого просто агрегируйте или делайте композицию объектов.
Как я уже указал выше, композиция в ООП в широком смысле не эффективна. Композиция объектов — возможно и эффективна, но я сомневаюсь, что вы сможете построить программу вкладывая один объект в другой. Рано или поздно вам придется делать композицию функций (методов), а тут-то и весь нюанс. Вы могли заметить, что наша дискуссия касается именно неэффективной композиции функций (методов), точнее их вызовов. Все ваши доводы, по поводу композиции объектов (солида и прочего) я с удовольствием принял.
Отсюда второе противоречие — используйте композицию, но она неэффективна (в широком смысле) в ООП.
на бумаге мы получаем «существенно сниженную сложность разрабатываемой системы»
система стала монструозно сложной
Мне кажется, вы немного не уловили суть, для чего все это делается. «Черный ящик» — это не то, куда смотреть нельзя-нельзя. Это то, что «должно самодостаточно работать так, как задумано, без необходимости смотреть вовнутрь».
Собственно, суммарную сложность разрабатываемой системы никто снижать и не обещал. Комплексность задачи — это штука более-менее константная. Мы ее можем только декомпозировать и распределить. Т.е. «портянка, делающая все-все-все» vs «некоторое количество кусков кода, которые можно отлаживать, тестировать и разрабатывать независимо друг от друга».
Прямо в ващем примере — есть интерфейс Deserializer, есть его реализации. При отладке/дебаге и т.д. вы достаточно быстро можете прийти к пониманию, что бага всплывает именно при десериализации, найти косячную реализацию и отлаживать уже именно ее, а не всю портянку кода сразу. Т.е. вы просто «переключаете контекст», который и является «бутылочным горлышком» работоспособности кожаного программиста. Т.е. весь этот black-boxing придуман не для того, чтобы скрыть подробности реализации от программиста, а для того, чтобы определить функциональный согласованный кусок кода, который может разрабатываться/дебажиться отдельно от других частей программы.
Это то, что «должно самодостаточно работать так, как задумано, без необходимости смотреть вовнутрь».
Я лишь указываю на то, что это не так. Точнее, что это так лишь на момент создания интерфейса, а как только появляется хоть одна реализация — мы получаем обратный эффект.
Собственно, суммарную сложность разрабатываемой системы никто снижать и не обещал. Комплексность задачи — это штука более-менее константная.
Не могу комментировать комплексность задачи — т.к. не очень понимаю что это такое. Зато могу комментировать такие вещи как:
- комплексность реализации задачи — напрямую зависит от выбранных инструментов. В текущем контексте — это абстракции и механизмы их обеспечения (ООП).
- сложность разрабатываемой системы — это, условно говоря, можно оценить как степень трудозатрат при дальнейшей работе с системой (в контексте моего изначального комментария)
Сложность разрабатываемой системы многократно возрастает при использовании предложенных автором статьи решений (сам автор заявляет обратное). В некоторых случаях этот подход оправдан, в других — нет, при чем вероятней всего он именно не оправдан. И тот факт, что ООП зачастую выбирается не взвешенным решением, а просто «потому что» — и порождает подобные заблуждения (нужно же как-то себя оправдать).
Прямо в ващем примере — есть интерфейс Deserializer, есть его реализации. При отладке/дебаге и т.д. вы достаточно быстро можете прийти к пониманию, что бага всплывает именно при десериализации, найти косячную реализацию и отлаживать уже именно ее, а не всю портянку кода сразу.
При отладке и дебаге можно распутать даже спаггети-код. Не думаю, что это может служить контраргументом. Тем более, что я как раз и говорю, что очень печально, если вам надо деббагером изучить все реализации интерфейса, чтобы понять, что происходит в момент его вызова.
И да, если систему разрабатывает не один человек, а, скажем, несколько команд — то эти модули могут храниться вообще в разных репозиториях и прочее и прочее. Просто прикиньте, как разработчик из команды, которая занимается инфраструктурными модулями (работа с кафкой) — бегает по всем остальным командам в попытках разобраться как они используют его интерфейс :)
Была бы карма — плюсанул бы!
Шел 2020 год… программисты все ещё пересказывали прописные истины ООП...
Однако, если же вы создадите потомка Bird, называющегося Penguin (пингвин) и его метод fly, например, будет бросать исключение (потому что пингвины не летают), это будет нарушать принцип Liskov Substitution
Здесь надо определиться: или же не считать пингвина птицем — или же с Liskov Substitution что-то не то.
Кто-то где-то когда-то, мучаясь от скуки в самоизоляции, предложил пачку сферических принципов в вакууме. И теперь кто не исповедует — тот немец.
Неужели так уж обязательно натягивать каждую практическую сову на академический глобус?
Здесь надо определиться: или же не считать пингвина птицем — или же с Liskov Substitution что-то не то.
Ни то, ни другое. Пингвин – птица, только птица не обязательно реализует интерфейс Flyable. Вообще же, о чем всё время умалчивают новоиспеченные апостолы ООП, в тысячный раз бубня про инкапсуляцию-наследование-полиморфизм (которые, к слову сказать, к ООП относятся чуть больше чем никак, никоим образом не являясь его столпами) – ваша иерархическая модель в коде совершенно не обязана повторять иерархию реального мира. Иерархия в ООП – это не отображение иерархии реального мира, это один из способов (не очень удобный) переиспользования кода.
Тем не менее, никто не мешает создать эту иерархию, не нарушая LSP (а лишь вызвав нервный тик у биологов) как-нибудь так:
Bird Flyable
| |
| |
----------- ----------------
| | | |
Penguin FlyableBird Bat
|
Crow
Опять путаница с инкапсуляцией и сокрытие. Инкапсуляция — это помещение в один объект данных и функций/процедур, их обрабатывающих, свойств и методов. Инкапсуляция нарушается, например, когда в объекте у нас есть свойства, которые ни одним методом этого объекта не используются, а не когда свойства публичные.
Есть ООП языки, которые не имеют механизмов сокрытия, но они не перестают быть ООП. Сокрытие помогает соблюдать инкапсуляция, но не является необходимым для неё.
Так а в чем проблема? Сделайте отдельно объекты с данными, отдельно функции для работы с ними. Это можно сделать в любом ООП языке. Просто это будет не ООП подход.
Просто это будет не ООП подход.Это тоже будет ООП подход, но с облегченными классами/объектами. Ведь все равно главным актором остается объект, который вызывает нужные ему функции. Это как-бы следующий уровень развития ООП подхода: от универсальных и тяжелых классов/объектов — к объектам поведенченским. Вот здесь и ФП хорошо ляжет (на вызываемые функции).
Это будет, так скажем, не true OOP. ООП как раз и говорит о том, чтобы держать в одном объекте данные и методы для работы с ними. Но ведь никто четкую границу не проводит – вот здесь ООП, а вот тут одну строчку поменяй, и сразу станет не-ООП.
Это как-бы следующий уровень развития ООП подхода: от универсальных и тяжелых классов/объектов — к объектам поведенченским.
Это может быть шагом в сторону Anemic Domain Model, что в некоторых случаях считается анти-паттерном, но уж никак не следующим уровнем развития.
Только никто не говорит, что объекты должны быть универсальными и тяжелыми. Наоборот, многие корифеи ООП ратуют за большое количество маленьких объектов.
Речь про то, чтобы объект хранил только данные и условия, проверяющие что и когда надо с ними делать. А все методы (по изменению этих данных) лучше выносить наружу в виде отдельных процедур.
(то есть в объекте остаются только if-ы, дергающие внешние функции)
Когда в ООП придумали принцип композиции данных и функций, то сделали очевидный первый шаг — объединили их в одном куске кода. Теперь, когда есть проблемы раздувания классов, очевидным шагом будет эти классы декомпозировать: высушенные «характерные» объекты вызывают нужные им методы. Это лучше для тестирования, применения ФП и т.д.
Нет, помещение в один объект данных и функций, это собственно само понятие объекта из ООП: concept of "objects", which can contain data, in the form of fields (often known as attributes or properties), and code, in the form of procedures (often known as methods).
А вот инкапсуляция – это про сохранение инвариантов и сокрытие реализации – и опять же, ООП тут ни при чем, C обеспечивает прекрасную инкапсуляцию.
Инкапсуляция нарушается, например, когда в объекте у нас есть свойства, которые ни одним методом этого объекта не используются, а не когда свойства публичные.
А зачем тогда они вообще нужны, если они не используются ни одним методом класса? Это тогда просто данные… и к ним уже нельзя инкапсуляцию вообще применять.
Я бы по другому сказал, что инкапсуляция — это защита инварианта. Т.е. если у вашего класса есть данные, которые друг от друга не зависят и они должны изменяться из вне — то собственно и не нужно их прятать — это не нарушает инкапсуляцию. Прямо так их и задавайте или читайте их.
А вот если данные друг от друга зависят, т.е. меняем скажем единицы измерения, должен пересчитаться диапазон в эти единицы, оба параметра являются данными, но диапазон зависит от единиц. В таком случае их нужно прятать, и делать отдельные методы для их установки, чтобы инвариант не нарушался. Иначе единицы и диапазон будут жить отдельной жизнью.
P.S Пока писал, уже тоже самое практически написали :)
Если вспомнить классиков, принципы ООП придумывались для обьектов действительно сложных, но не таких сложных, чтобы ООП ограничивал понимание влияния деталей на целое.
Попытка применять ООП там где это нужно и где не нужео-приводят к плачевным результатам.
В танке Т-55, например, в некоторых системах прохождение тока через катушку вызывает появление магнитной силы, которая срабатывает как перекючающее реле для запуска нового процесса. Гениально придумано, работает надежно. Попытка инкапсуляции здесь бы провалилась, потому что знание деталей принципиально важно для работы системы в целом.
ПО ракеты Ариан-5 было сделано по принципам ООП, но смена датчика с другой функцией измерения высоты привела г гибели системы в целом.
Или другой пример, делается ПО для Верховного суда с нерушимым ядром. И потом программисты жестко извращаются, сидя ночами, чтобы не менять ядро и все изменения внести в наследумые функции.
ПО ракеты Ариан-5 было сделано по принципам ООП, но смена датчика с другой функцией измерения высоты привела г гибели системы в целом.
Что-то вы напридумывали тут. С чего это ПО Ариан-5 по принципам ООП сделано? Там все на Ада было сделано — чисто структурное программирование.
И вообще не из-за смены датчика. Там ошибка была в том, что ракета была тяжелее и горизонтальная скорость была такова, что при расчете не хватило разрядности, так как предыдущие расчеты были сделаны для более легкой ракеты, которая имела меньшую горизонтальную скорость и точности хватало. В итоге перевод из 64-разрядного формата с плавающей точкой в 16-разрядное целое со знаком привел к переполнению. Туда просто расчетное число не влезло.
Об объектно-ориентированном программировании