В большой команде разработчики часто сталкиваются с ситуацией, что ранее сильно похожие сущности, которые даже способны пройти «утиный тест», со временем начинают расходиться по смыслу и реализации, но все ещё продолжают хранить в себе похожие признаки. В одномодульном приложении это неприятно, а вот в многомодульном… В общем, если вы думали, что многомодульность лишь упрощает жизнь, то к концу этой статьи я постараюсь убедить вас, что как минимум в одном аспекте многомодульность её усложнит.

Конвергенция

Что такое конвергенция?

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

Так что же такое конвергенция? Это термин из биологии, который означает, что два биологических вида приобрели схожие свойства независимо друг от друга. Сделать это они могли по разным причинам и разными способами. Для большего понимания давайте взглянем на парочку максимально отбитых примеров.

Примеры конвергенции

Посмотрите на это прекрасное, но уже вымершее животное — Тетраподофис.

Длинный как канат, четыре коротенькие лапки, змеиная морда. Кто же это? Первое, что приходит на ум — это какой-то предок змеи. Увы, но нет! На деле филогенетически он ближе не к змеям, а к мозазаврам. Которые, между прочим, выглядят вот так:

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

Или вот второй пример — Прионозух.

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

Я привёл эти примеры для того, чтобы у вас в головах осталась мысль, что «да, на определённом этапе эволюции это могли быть очень похожие существа, но ни до, ни после они таковыми уже не являлись, просто в определённый момент — так совпало, что две разные группы животных имели схожие свойства».

Но давайте перейдём к коду, у нас же тут как-никак программисты собрались.

Конвергенция в коде

В программировании под конвергенцией обычно понимается — когда несколько программистов приходят к схожим решениям в коде независимо друг от друга. Довольно яркий пример — когда программист пилит свой велосипед, не зная о том, что в SDK уже есть решение этой задачи и в итоге этот велосипед получается очень похожим на то, что находится в SDK, но под капотом реализации могут отличаться и в некоторых ситуациях вести себя по-разному. Я думаю, все, так или иначе, с этим сталкивались. Сегодня, я бы хотел рассмотреть немного другой аспект конвергенции. А именно: когда у нас многомодульное приложение и за разные модули отвечают разные разработчики. В таком случае конвергенция может стать проблемой.

Проблема

Возьмём стандартный пример с животными, в данном случае это весьма уместно. Берём птицу и, допустим, стрекозу. Это абсолютно разные существа, но у них есть одно очень важное общее свойство, они летают (да, я знаю, что это, не то чтобы свойство, а скорее умение, но зато пример понятный). Лёгким движением руки мы создаём два класса: соответственно, для птицы — Bird, и для стрекозы — Dragonfly.

class Bird {
}

class Dragonfly {
}

Теперь нам как-то надо задать им общее свойство — полёт.

Одномодульное приложение

В детском саду учат нас, что в таком случае лучшим решением будет создание интерфейса, который каждый из классов будет реализовывать. Но если призадуматься… Действительно ли это правильное решение? Конечно правильное! Не стоит спорить с воспитателем!

Поэтому и мы создаем и отдельный интерфейс, который будет у нас отвечать за полёт — Flight.

class Bird : Flight {
}

class Dragonfly : Flight {
}

interface Flight {
}

С точки зрения одномодульного приложение всё хорошо — можно разложить классы по пакетам, где каждый из них будет потихоньку «обрастать мясом».

Многомодульное приложение

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

Допустим, логика стрекозы усложнилась настолько, что ей понадобился отдельный модуль, то же самое произошло и с птицей. Теперь нам надо куда-то положить наш интерфейс Flight, чтобы оба этих модуля имели к нему доступ. 

Можно создать отдельный модуль для полёта.

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

Как же выйти из этого положения? Можно создать отдельный модуль для всех интерфейсов свойств. В итоге у нас получится один огромный модуль со всеми свойствами. И это во многих смыслах даже хуже чем множество отдельных модулей, так как у нас получилось то, что хочется назвать — God Module (типа есть God Object, который антипаттерн иии… ну вы поняли).

Проблема в том, что например модуль с птицей знает о свойствах, которые ему вообще не нужны, и о которых он вообще никогда не должен был узнать, например об умении отращивать шесть ног. Вы когда-нибудь видели шестиногих птиц? Вот и я нет, а для стрекоз это норма. То есть мы ему дали доступ к модулю ради интерфейса Flight, а на деле модуль птицы получил доступ ко всем доступным свойствам. А ведь одна из главных фишек многомодульности — возможность давать доступ к классам только по «запросу». Для доступа нужно явно прописать связь между модулями.

В качестве аналогии — это то же самое, как если бы мы захотели дать кому-либо доступ к какой-то из фотографий, например, с корпоратива, но присутствовала бы только возможность дать доступ ко всем вашим фото. Звучит не очень безопасно.

Так... А что с этим можно сделать? Можно, например, сгрупировать все свойства, связанные с «небесными» созданиями, в том числе и полёт, в один модуль, а все свойства, связанные насекомыми, в другой модуль.

И на самом деле, вроде ничего такое решение, например у нас в Android приложении Циан — именно так. С одной стороны, у нас нет сотен полупустых модулей, а с другой, получить доступ к совсем уж случайным вещам просто так нельзя. Возвращаясь к аналогии с фото, мы теперь даем доступ не ко всем нашим фото, а например, к конкретному альбому — «Пасха».

Но и у этого решения тоже есть проблема. По сути, нет чёткого правила деления на модули. Всё завязано на решениях конкретного разработчика, а каждый мыслит по-своему. Он может захотеть сделать не sky-module и insect-module, а сгруппировать свойства по другому признаку, например, fly-module и walk-module. Если разработчиков много, то таких несостыковок в видении может накопиться приличное количество.

Давайте посмотрим на пример из жизни.

Пример из жизни

Допустим, у нас есть абстрактное приложение для подачи объявлений о недвижимости. У нас есть сам объект объявления — Offer и объект пользователя, подавшего это объявление, — User.

У объявления есть изображение. У этого изображения есть три версии: полная, чтобы можно было приближать и рассматривать, есть версия среднего размера и есть совсем малого для использования в превью. Мы как люди искушенные в архитектуре приложений создадим для этого изображения целый отдельный класс, ведь мы смотрим в будущее, а в будущем у объявления может быть несколько изображений. Класс назовём Image и под каждую версию будет отдельное property со ссылкой.

data class Image(
   val bigUrl: String,
   val mediumUrl: String,
   val previewUrl: String,
)

Ну а дальше просто начинаем использовать Image в объекте нашего объявления.

data class Offer(
   val title: String,
   val image: Image,
)

Вроде бы все круто и логично, но где-то на совершенно другом конце приложения, совершенно другой разработчик начинает делать раздел со списком пользователей, где у каждого пользователя тоже есть изображение, которое может быть трёх размеров: большое, среднее и малое…

Одномодульное приложение

Будь у нас классическое одномодульное приложение, то всё было бы довольно просто — берём и переиспользуем класс Image и для пользователя, и для объявления. 

data class User(
   val name: String,
   val image: Image,
)

Разве что стоит положить Image в отдельный пакет. В целом схема получается следующая:

Многомодульное приложение

В многомодульном приложении у нас имеется отдельный модуль для объявлений и отдельный модуль для пользователя. Получается, Image надо тоже положить в какой-то отдельный модуль? Ситуация очень схожа с интерфейсом Flight.

Поэтому мы выносим Image в какой-то третий модуль. Разработчик модуля пользователя, сам решает в какой именно.

И вроде бы всё хорошо: есть модули фичей, они смотрят на какой-то общий модуль и используют класс Image из него. Но… Через три месяца выясняется, что для объекта объявления нам нужен ещё один размер картинки — оригинальный. Он понадобится для фичи скачивания фотографии. Backend-разработчик фичи объявлений в ответ метода, который возвращает объявления, в объект image добавляет еще одно property —  originalUrl.

Мобильный разработчик фичи объявлений смотрит на все это и понимает, что в целом это задача на полчасика. Сделать кнопку скачивания, да новое property в класс Image добавить. Правда его придётся сделать nullable, ведь image для пользователя никто не менял. Ну и ничего страшного, не менять же всю эту логику с модулями ради одного поля.

data class Image(
   val originalUrl: String?,
   val fullUrl: String,
   val mediumUrl: String,
   val previewUrl: String,
)

Ещё через месяц у нас в фиче пользователя решают добавить ещё один формат для картинки — круглый. А делать круглой решили на сервере, чтобы не нагружать клиент. Разработчики фичи пользователя руководствуются той же логикой, что и разработчики фичи объявлений, и в итоге мы получаем новое nullable property в классе Image.

data class Image(
   val originalUrl: String?,
   val circleUrl: String?,
   val fullUrl: String,
   val mediumUrl: String,
   val previewUrl: String,
)

В итоге у нас в Image есть два nullable property. Стороннему разработчику, не знакомому ранее ни с фичей объявлений, ни с фичей пользователя, вообще будет непонятно, в каком случае будет null, а в каком нет. Придётся разбираться или добавлять кучу комментариев к коду.

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

В одномодульном же приложении всё не так уж ужасно, нужно просто продублировать класс Image, что не займёт много времени. А вот в многомодульном это может стать проблемой, так как обычной копией всё не ограничится — придется разруливать иерархию модулей. В реальности она может быть гораздо сложнее прямого подключения. Например модуль объявлений и модуль пользователя могут подключать модуль содержащий Image не напрямую, а через модуль-посредник. Вдобавок к этому с экрана объявления можно открыть экран галереи, в который нужно передать Image. И вот тогда начнётся жара.

Суть

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

Как так получается то?

Всё дело в том, что на каком-то этапе своей «эволюции» объекты картинок для пользователя и для объявления выглядели одинаково. Ну а дальше? Дальше их пути разошлись, так как на самом деле в своей сути это разные объекты.

У нас в приложении похожая ситуация сложилась с объектом объявления — Offer. Один и тот же класс использовался как для экрана отображения объявления, так и для экрана создания объявления. Затем ещё в некоторых местах, и ещё, и ещё и ещё. В итоге даже id у него nullable, не говоря уже о том, что занимает он почти 3 000 строк (правда на Java). Количество использований этого класса в проекте более 1 500, при том что в последнее время мы довольно активно уходим от его использования.

Всё это сильно затрудняет работу с ним, добавить новое поле — проблема, переименовать что-то проблема, понять «в данном месте это поле будет null или нет» — проблема и даже перестать его использовать — проблема. Всё это ломает иерархию и структуру модулей. И все из-за того, что в определённый момент объекты объявления для экрана отображения объявления и для экрана создания объявления были максимально похожи и разработчики решили использовать один объект. Но ни до этого, ни после они уже не были так похожи. Если вдуматься, то они и не должны были быть одинаковыми, просто так совпало. Точно так же как с животными, про которых я говорил в начале.

Как мне кажется это всё из-за сидящего в подкорке каждого программиста желания максимально всё переиспользовать, так еще и давит пресловутый принцип Don’t repeat yourself (DRY), из-за которого «молодой разработчик» не захочет создавать два одинаковых объекта. 

Что же делать?

Подумать. А потом ещё раз подумать. Не звучит как решение, не правда ли? Но сколько я не размышляю над этим, всё время прихожу к тому, что прежде чем пытаться переиспользовать какой-то класс, стоит задать себе вопросы — «А это точно один и тот же объект по сути? Или просто на данный момент они похожи?».

С объектами которые мы используем для общения с сервером, есть приятное исключение. Можно просто спросить бэкенд-разработчика. Если на сервере это разные таблицы, то вам определённо стоит сделать разные объекты.

В этой статье я хотел продемонстрировать достаточно частую проблему, у которой нет идеального решения. Которая, к тому же, сильно усугубляется при использовании многомодульности. Я уверен, что все разработчики с ней, так или иначе, сталкиваются. Она выражается не только в использовании моделей данных. Что-то похожее можно встретить и с переиспользованием утилит, обогащением классов, методов и т.д. Сформулировав для себя проблему и придумав ей название, мы смогли избавиться от многословности в дискуссиях и CodeReview, просто задавая вопрос: «А не конвергенция ли у тебя тут получается?». Что думаете? Встречалась ли вам такая проблема? Как вы её решали?

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


  1. Kriptman
    25.04.2022 18:39

    Открыть для себя наследование может? В том же примере для картинок просто напрашиваются UserImage и OfferImage отнаследованные от базового Image.


    1. princeparadoxes Автор
      25.04.2022 19:02
      +4

      В том-то и дело, что наследование тут не поможет, так как надо положить базовый Image в еще какой-то модуль. Просто в отдельном модуле будет лежать уже базовый класс Image вместо конкретного класса.

      В моем примере property только добавлялись, но они могут и удалятся. В таком случае придется удалять или делать nullable property в базовом классе. В итоге объекты вообще могут разойтись и у них не будет ни одного общего property. И это нормально. Так как это изначально по логике - абсолютно разные объекты, предназначенные для разных фичей. Просто в определенный момент они были одинаковы. В итоге базовый Image может быть вообще удален, а вот связи между модулями останутся и с ними придется разбираться. Поэтому проблема и бъёт, по большей части, по многомодульным проектам.

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


  1. Antavu
    25.04.2022 19:03
    +1

    Похоже на вариацию принципа Лисковой, не наследовать прямоугольник от квадрата. Задавать вопрос : эти объекты действительно являются одной сущностью или они просто похожи ?


    1. Neki
      26.04.2022 00:25
      +1

      Забавно, джунов на собеседовании обычно такое спрашиваем. Очень надеясь услышать, что наследование нужно «когда есть общая абстракция», а не «когда есть общий код».

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

      Но я бы, в таком случае, усомнился в корректности разбиения приложения на модули. Если же архитектура корректна, то предложение автора, выделять некоторую абстракции в отдельный модуль («группировка по общему признаку»), звучит вполне здраво. Такие высокоуровневые абстракции выделять сложно. И заниматься этим должен человек, имеющий соответсвующий уровень, тот кто отвечает за архитектуру проекта, а не просто случайный разработчик. Когда абстракция выделена грамотно, то у других разработчиков не будет сильного желания поломать ее.


  1. XaBoK
    26.04.2022 09:44
    +3

    Самое простое правило - правило 3. Выносим класс Image в общий, только если у на есть 3 одинаковых требования (2 мало из-за шанса конвергенции). Тут Interface segregation principle из солид как раз подталкивает "ориентироваться в тысяче полупустых модулей". Но это работает только с хорошей автоматизацией процессов.

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

    Я не совсем понимаю, что у вас включает в себя модульность, но сам процесс переиспользования (code reuse) кода и абстракций можно выстроить по разному. Иногда это просто оффлайн копия (притворимся, что это не наш код и возьмем к себе копию, пусть даже она не будет дальше обновляться - тупо copy/paste), иногда вызов API (нам очень важно, чтоб всё похожее всегда работало одинаково и на одной версии, а время вызова не критично).


  1. Pan_brigadir
    26.04.2022 23:26

    Отличная статья. Сам сталкивался с такой проблемой. Но пока не нашёл хорошего решения. Такая проблема возникает уже на большом проекте - у меня пока небольшой опыт работы с большими проектами.