Русский
Русский
English
Статистика
Реклама

Чистая архитектура

Recovery mode Понятнее о S.O.L.I.D

29.06.2020 08:16:31 | Автор: admin
Большинство разработчиков с разговорами о принципах архитектурного дизайна, да и принципах чистой архитектуры вообще, обычно сталкивается разве что на очередном собеседовании. А зря. Мне приходилось работать с командами, которые ничего не слышали о S.O.L.I.D, и команды эти пролетали по срокам разработки на многие месяцы. Другие же команды, которые следовали принципам дизайна и тратили очень много времени на буквоедство, соблюдение принципов чистой архитектуры, код-ревью и написание тестов, в результате значительно экономили время Заказчика, писали лёгкий, чистый, удобочитаемый код, и, самое главное, получали от этого кайф.

Сегодня мы поговорим о том, как следовать принципам S.O.L.I.D и получать от этого удовольствие.



Что такое S.O.L.I.D? Погуглите и получите 5 принципов, которые в 90% случаев описываются очень скупо. Скупость эта потом выливается в непонимание и долгие споры. Я же предлагаю вернуться к одному из признанных источников и хотя бы на время закрыть этот вопрос.

Источником принципов S.O.L.I.D принято считать книгу Роберта Мартина Чистая архитектура. Если у Вас есть время прочесть книгу, лучше отложите эту статью и почитайте книгу. Если времени у Вас нет, а завтра собес велком.

Итак, 5 принципов:

Single Responsibility Principle принцип единственной ответственности.
Open-Closed Principle принцип открытости/закрытости.
Liskov Substitution Principle принцип подстановки Барбары Лисков.
Interface Segregation Principle принцип разделения интерфейсов.
Dependency Inversion Principle принцип инверсии зависимости.


Разберём каждый из принципов. В примерах я буду использовать Java и местами Kotlin.

Single Responsibility Principle принцип единственной ответственности.


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

Сформулировать его можно так:

Модуль должен иметь только одну причину для изменения. Или: модуль должен отвечать только за одну заинтересованную группу.

Вот тут становится непонятно, поэтому мы обратимся к признанному первоисточнику книге Роберта Мартина, у которого есть отличный пример на этот счёт. Воспользуемся им.

Предположим, что существует какая-то система, в которой ведётся учёт сотрудников. Сотрудники интересны как отделу кадров, так и бухгалтерии. Для их нужд, в числе прочих, в сервисе EmployeeService есть 2 метода:

fun calculateSalary(employeeId: Long): Double //рассчитывает зарплату сотрудникаfun calculateOvertimeHours(employeeId: Long): Double //рассчитывает сверхурочные часы

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

Логично, что для расчёта зарплаты бухгалтерии может потребоваться учесть сверхурочные часы сотрудника. В таком случае, метод calculateSalary() может вызвать calculateOvertimeHours() и применить его результаты в своих формулах.

Окей, прошло полгода, и в отделе кадров решили поменять алгоритм расчёта сверхурочных. Предположим, раньше один сверхурочный час рассчитывается не по коэффициенту * 2, а стал по коэффициенту * 2,5. Разработчик получил задание, изменил формулу, проверил, что всё работает, и успокоился. Город засыпает, просыпается бухгалтерия. А у бухгалтерии ничего не поменялось, они считают зарплаты по тем же формулам, вот только в этой формуле теперь будут другие цифры, потому что calculateSalary() ходит в calculateOvertimeHours(), а там теперь по просьбе отдела кадров сверхурочные не * 2, а * 2,5. Упс

Теперь, если мы вернёмся к описанию принципа, всё станет намного понятнее.

Модуль должен иметь только одну причину для изменения. Эта причина, в нашем случае потребности только отдела кадров, но не бухгалтерии. Модуль должен отвечать только за одну заинтересованную группу. calculateOvertimeHours() должен обрабатывать только одну группу отдел кадров, несмотря на то, что он может показаться универсальным. Работа этого метода никаким образом не должна касаться бухгалтерии.

Теперь, я надеюсь, стало понятнее.

Каким может быть решение проблемы выше?

Решений проблемы может быть несколько. Но что Вам точно будет необходимо сделать это разделить функциональность в разные методы например, calculateOvertimeHoursForAccounting() и calculateOvertimeHoursForHumanRelations(). Каждый из этих методов будет отвечать за свою заинтересованную группу и принцип единой ответственности не будет нарушен.

Open-Closed Principle принцип открытости/закрытости.


Принцип гласит:

Программные сущности должны быть открыты для расширения и закрыты для изменения.

Возьмём пример из суровой реальности.

У нас есть приложение, и, предположим, довольно неплохое. В нём есть сервис, который пользуется внешним сервисом. Предположим, это сервис котировок ЦБ РФ. В простой реализации архитектура этого сервиса будет выглядеть следующим образом:



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

Как решить проблему и сделать так, чтобы при добавлении новой реализации не трогать старый код?

Конечно же, через интерфейсы. Стоит нам разделить объявление функциональности и её реализацию, как эта проблема будет решена. Теперь мы можем реализовать интерфейс FinancialQuotesRestService сколько угодно раз; в нашем случае, это будет FinancialQuotesRestServiceImpl и FinancialQuotesRestServiceMock. На тестовом стенде приложение будет запускаться по Mock-профилю, на продакшене оно будет запускаться в обычном режиме.



Далее, если мы захотим добавить ещё одну реализацию, нам не придётся менять существующий код. Например, мы хотим добавить получение котировок не ЦБ, а в некоторых случаях Сбербанка. Нет ничего проще:



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

Liskov Substitution Principle принцип подстановки Барбары Лисков.


Принцип подстановки Барбары Лисков можно сформулировать так:

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

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

public interface FinancialQuotesRestService {    List<Quote> getQuotes();}

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

В нашем случае, контроллер будет выглядеть так:

@RestController("/quotes")@RequiredArgsConstructorpublic class FinancialQuotesController {        private final FinancialQuotesRestService service;        @GetMapping    public ResponseEntity<List<Quote>> getQuotes() {        return ResponseEntity.ok(service.getQuotes());    }}

Как мы видим, контроллер не знает, какая именно реализация будет реализована далее. Получит ли он котировки ЦБ, Сбербанка или просто моковые данные. Мы можем запустить приложение в любом режиме, и от этого работа контроллера не изменится.

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

Interface Segregation Principle принцип разделения интерфейсов.


Принцип можно сформулировать так:

Необходимо избегать зависимости от того, что не используется.

Например, мы решили реализовать CRUD-интерфейс, который будет поддерживать операции CRUD. И реализовать его во всех сервисах. Выглядеть интерфейс может так:

public interface CrudService<T> {        T create(T t);        T update(T t);        T get(Long id);        void delete(Long id);}

Окей, мы реализовали его в сервисе UserService:

@Service@RequiredArgsConstructorpublic class UserService implements CrudService<User> {    private UserRepository repository;        @Override    public User create(User user) {        return repository.save(user);    }    @Override    public User update(User user) {        return repository.update(user);    }    @Override    public User get(Long id) {        return repository.find(id);    }    @Override    public void delete(Long id) {        repository.delete(id);    }}

Далее, нам потребовалось реализовать класс PersonService, который мы тоже наследуем от CrudService. Но проблема в том, что сущности Person не удаляемые, и нам не нужна реализация метода delete(). Как быть? Можно сделать так, например:

    @Override    public void delete(Long id) {        //не реализуется    }

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

Что делать в такой ситуации?

Выделить реализацию CRUD без удаления в отдельный интерфейс. Например, так:

public interface CruService<T> {    T create(T t);    T update(T t);    T get(Long id);}

Да простит меня читатель за такое именование интерфейса, но для наглядности самое то.

@Service@RequiredArgsConstructorpublic class PersonService implements CruService<Person> {    private final PersonRepository repository;    @Override    public Person create(Person person) {        return repository.save(person);    }    @Override    public Person update(Person person) {        return repository.update(person);    }    @Override    public Person get(Long id) {        return repository.find(id);    }}

Теперь сущность Person нельзя удалить! PersonService реализует интерфейс, в котором не объявлено ничего лишнего. В этом и есть соблюдение Принципа разделения интерфейсов.

Dependency Inversion Principle принцип инверсии зависимости.


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

Связи должны строиться на основе абстракций. Если вы вызываете какой-то сервис (например, автовайрите его), Вы должны вызывать его интерфейс, а не реализацию.

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

У этого принципа, впрочем, есть исключения. Можно строить зависимости от класса, если он предельно стабилен. Например, класс String. Вероятность изменения чего-либо в String всё-таки очень мала (несмотря на то, что я сам описывал добавления в функциональность в String в Java 11 в одной из предыдущих статей, хехе). В случае со стабильным классом String, мы можем себе позволить вызывать его напрямую. Как и в случае с другими стабильными классами.

В общем, Принцип инверсии зависимости можно постулировать так:

Абстракции стабильны. Реализации нестабильны. Строить зависимости необходимо на основе стабильных компонентов. Стройте зависимости от абстракций. Не стройте их от реализаций.

Заключение.


Мы, разработчики, не только пишем код (и это лучшие моменты в нашей работе). Мы вынуждены его поддерживать. Чтобы эти моменты нашей работы не стали худшими, используйте принципы S.O.L.I.D. Использование принципов S.O.L.I.D, по моему опыту, окупается в течение одного спринта. Потом сами себе скажете спасибо.
Подробнее..

Перевод - recovery mode История возникновения CUPID (критика SOLID)

20.03.2021 16:14:02 | Автор: admin

Какие бы вы предложили принципы для современной разработки программного обеспечения?

На прошлом виртуальном митапе Extreme Tuesday Club мы обсуждали, не устарели ли SOLID принципы. Не так давно я толкнул шутливую речь на эту тему, так что один из организаторов митапа спросил у меня - раз я несогласен с SOLID, чем бы я его заменил. Так получилось, что я уже думал об этом какое-то время, так что я решил предложить пять своих принципов, из которых получился акроним CUPID.

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

Почему неправилен каждый элемент SOLID.

PubConf был изобретён как разновидность афтепати к конференциям NDC. В соответствии с именем, место происходит в пабе. Несколько спикеров выступают в стиле Ignite - 20 слайдов, 15 секунд на слайд, автопродолжение - и аудитория ревет, хлопает, мечет молнии и благодарит в соответствии с местом, где это происходит. Победитель что-нибудь получает, все здорово проводят время.

Несколько лет назад я был приглашён спикером на PubConf эвент в Лондоне. Мне нравится челлендж выступлений с ограничениями. Я долго думал про SOLID принципы Роберта Мартина, и мне показалось забавным опровергнуть каждый из этих принципов, пытаясь сохранить при этом серьёзное лицо. Также я хотел для каждого пункта предложить альтернативу.

Некоторые речи пишут сами себя: я понял, что могу использовать один слайд на каждый принципе, один, чтобы опровергнуть его, один, чтобы предложить альтернативу - и так 5 раз. Итого 15 слайдов, по 45 секунд на принцип. Добавь начало и конец - и вот мои 20 слайдов!

По мере того, как я писал, я заметил две вещи. Во-первых, опровергнуть принципы было гораздо легче, чем я думал (за исключением принципа подстановки Барбары Лисков, так что там пришлось зайти с другой стороны). Во-вторых, альтернативой раз за разом оказывалась одна мысль: Пишите более простой код. Опровергнуть её довольно легко вопросом "что вообще означает "простой"?", но у меня было хорошее определение для этого, так что я не слишком волновался.

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

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

Принцип единой ответственности (SRP)

SRP принцип утверждает, что код должен делать только одну вещь. Другое определение гласит, что код должен "иметь только одну причину для изменений". Я назвал это "Бессмысленный Слишком Общий Принцип" (в оригинале Pointlessly Vague Principle - прим. переводчика). Что вообще означает "одна вещь"? Является ли DataProcessor ETL (extract-transform-load - извлечение данных - их трансформация - их загрузка) одной вещью или тремя? Любой нетривиальный код может иметь любое количество причин, чтобы измениться, которые вы могли или не могли учитывать, так что, опять же, для меня это несёт не очень много смысла.

Вместо этого я предложил писать более простой код, используя для метода измерения эвристику "Помещается в Голове". Это означает, что вы можете утверждать что-то только о том, что помещается в вашей голове. Напротив, если что-то не помещается в вашей голове, то вы не сможете рационально утверждать что-то об этом предмете. Код должен помещаться в голову на любом уровне детальности, идёт ли речь о методе/функции, классе/модуле, компоненте, сделанном из классов, или о целых распределённых приложениях.

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

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

Принцип открытости-закрытости (OCP)

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

Это был мудрый совет во времена, когда код:

  • Было дорого изменять: Попробуйте поменять маленький кусок кода, а затем сделать компиляцию и линковку миллионов строк в C++ в 1990 году. Я подожду.

  • Рискованно изменять: у нас не было ни правил рефакторинга, не было IDE с нормальным рефакторингом (кроме Smalltalk), не было практик разработки.

  • Самое важное, что можно добавить: вы писали какой-то код, помещали его в систему контроля версий (если использовали её, то, скорее всего, использовали RCS или SCCS (подробнее о них тут - прим. переводчика)), а затем переходили к следующему файлу. Вы транслировали спецификацию в код, по одному кусочку за раз. Переименовывать вещи было редкой практикой, не говоря уже о переименовании файлов. CVS, которая стала вездесущей системой контроля версий, буквально забывала всю историю файла, если вы его переименовывали, поэтому переименованию было такой редкостью. Этот пункт легко проглядеть в век автоматического рефакторинга и CVS, основанных на целом наборе изменений (changeset-based version control.)

Сегодня, подходящий совет, чтобы менять код, звучит так: Меняйте код, если он должен делать что-то ещё! Звучит банально, но сейчас мы воспринимаем код, как, скорее, податливую глину, нежели чем в старые добрые деньки, когда код напоминал строительные блоки. Между спецификацией и кодом не было цикла обратной связи, в то время как сейчас у нас есть автоматизированные тесты.

В этом случае я вывел "Принцип Аккреции Круфта". (Cruft Accretion Principle) (Аккреция(лат. accrti приращение, увеличение от accrscere прирастать) повышение массы одного космического объекта за счет гравитационного притяжения. - прим. переводчика)

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

Принцип подстановки Лисков (LSP)

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

Однако, язык, который использует LSP ("подтипы"), в сочетании с тем, что большинство разработчиков объединяет вместе "подтипы" и "подклассы", и ожидание "желаемых свойств" означает, что он пытается аппелировать к моделированию сущностей из 1980 года, со всем его "является (is-a)" и "имеет (has-a)" видами наследования.

В контексте моделирования, когда мы нож используем в качестве отвертки, а многие объекты наследуются видами: "ведёт-себя-как (act-like-a)", "иногда-используется-как (sometimes-be-used-as)" или "подойдет-если-не-сильно-приглядываться (pass-off-as-a-if-you-squint)"; в этом контексте что мы действительно хотим, так это маленькие, простые типы, которые мы можем композировать в любые насколько угодно сложные структуры, и примириться со всеми нюансами, которые это вызовет. Мой совет, внезапно, "писать более простой код", о котором легко рассуждать.

Принцип разделения интерфейсов (ISP)

Из всех принципов этот разобрать - как два пальца об асфальт. По каким-то причинам, этот пункт вызвал больше всего споров, но как по мне, его развенчать проще всего. Во время исследований при подготовке речи, я обратил внимание, что этот паттерн появился, когда Роберт Мартин столкнулся с God object, во время работы над софтом в Xerox. Всё происходило в классе под названием Job. Его подход к упрощению был в том, чтобы найти все места, где он использовался, выяснить, какие методы "сочетаются друг с другом" и объединить их в отдельный интерфейс. Это принесло сразу несколько преимуществ:

  • Сбор связанных методов в разных интерфейсах показал все различные обязанности, которые выполнял класс Job.

  • Присвоение каждому интерфейсу имени, раскрывающего намерение, упростило понимание кода, чем просто работа с объектом Job, встречающимся то тут, то там.

  • Создана возможность разбить класс Job на более мелкие классы, реализующие свои интерфейсы. (Возможно, интерфейс им больше не нужен.)

Всё это имеет смысл, просто дело в том, что это не принцип. Это паттерн.

Принцип хорош в любом контексте: "Сначала пытайтесь понять, а потом быть понятым", "Будьте добры друг к другу".

Паттерн это стратегия, которая хорошо работает в определённом контекесте (God-класс). У неё есть преимущества (меньшие компоненты) и компромиссы (больше классов, которыми нужно управлять). Принципом было бы, скорее, "вообще не устраивайте в коде бардак, который к такому привёл!".

Так что моя позиция в споре была такая: если это и был принцип, то это был "Принцип Двери из Конюшни" (Stable Door Principle). (дверь, разделенная на две части - верхнюю и нижнюю. Представьте, как сверху в открытую часть выглядывает лошадка - прим. переводчика) Если у вас изначально были небольшие, основанные на ролях классы, то вы бы не оказались в ситуации, где пытаетесь декомпозировать этот огромный запутанный беспорядок.

Конечно, иногда мы оказываемся в таком положении. Тогда разделение интерфейсов - идеальная стратегия, чтобы хоть немного упорядочить бардак, наряду также с созданием тестами характеристик и другими советами Майка Фезарса из прекрасной книги Working Effectively With Legacy Code.

Принцип инверсии зависимостей (DIP)

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

Реальным принципом должна была стать опциональная инверсия. Зависимость интересна нам только тогда, когда есть несколько способов её предоставления, и вам нужно сделать инверсию отношений только если вы считаете, что в будущем это станет необходимо. Это довольно высокая планка, а чаще всего вам просто нужен только метод main.

Если вместо этого вы присоединитесь к идее, что все зависимости всегда должны инвертироваться, то вы закончите с J2EE, OSGi, Spring или любым другим фреймворком "декларативной сборки", где сама структуризация компонентов - закрученный лабиринт конфигураций. J2EE заслуживает отдельного упоминания, что каждый тип инверсии - EJB, servlets, web domains, remote service locations, даже конфигурация конфигураций - должен принадлежать разным ролям.

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

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

Если они тебе не понравились, у меня есть ещё

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

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

Подробнее..

Перевод Разработка на Android как найти подходящую абстракцию для работы со строками

15.02.2021 12:06:03 | Автор: admin

В своих проектах мы стараемся по мере необходимости покрывать код тестами и придерживаться принципов SOLID и чистой архитектуры. Хотим поделиться с читателями Хабра переводом статьи Hannes Dorfmann автора серии публикаций об Android-разработке. В этой статье описан способ, который помогает абстрагировать работу со строками, чтобы скрыть детали взаимодействия с разными типами строковых ресурсов и облегчить написание юнит-тестов.

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

Фото: UnsplashФото: Unsplash

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

Уровень абстракции для строк?

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

  • Простой строковый ресурс вроде R.string.some_text, отображаемый на экране с помощью resources.getString(R.string.some_text)

  • Отформатированная строка, которая форматируется во время выполнения, т.е. context.getString(R.string.some_text, arg1, 123) с

<string name=some_formatted_text>Some formatted Text with args %s %i</string>
  • Более сложные строковые ресурсы, такие как Plurals, которые перегружены, например resources.getQuantityString(R.plurals.number_of_items, 2):

<plurals name="number_of_items">  <item quantity="one">%d item</item>  <item quantity="other">%d items</item></plurals>
  • Простой текст, который не загружается из ресурсов Android в XML-файле вроде strings.xml, а уже загружен в переменную типа String и не требует дальнейшего преобразования (в отличие от R.string.some_text). Например, фрагмент текста, извлеченный из json ответа с сервера.

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

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

2. Нам нужно сделать текст объектом первого класса (если это возможно) слоя бизнес-логики, а не слоя пользовательского интерфейса, чтобы слой представления мог легко его визуализировать.

Давайте шаг за шагом рассмотрим эти моменты на конкретном примере: предположим, мы хотим загружать строку с сервера по http, и если это не удается, мы отображаем аварийную fallback-строку из strings.xml. Например, так:

class MyViewModel(  private val backend : Backend,  private val resources : Resources // ресурсы Android из context.getResources()) : ViewModel() {  val textToDisplay : MutableLiveData<String>  // MutableLiveData используется для удобства чтения   fun loadText(){    try {      val text : String = backend.getText()       textToDisplay.value = text    } catch (t : Throwable) {      textToDisplay.value = resources.getString(R.string.fallback_text)    }  }}

Детали реализации просочились в нашу MyViewModel, что в целом усложняет ее тестирование. Действительно, чтобы написать тест для loadText(), нам надо либо замокать Resources, либо ввести интерфейс наподобие StringRepository (по шаблону "репозиторий"), чтобы при тестировании мы могли заменить его другой реализацией:

interface StringRepository{  fun getString(@StringRes id : Int) : String} class AndroidStringRepository(  private val resources : Resources // ресурсы Android из context.getResources()) : StringRepository {  override fun getString(@StringRes id : Int) : String = resources.getString(id)} class TestDoubleStringRepository{    override fun getString(@StringRes id : Int) : String = "some string"}

Затем вью-модель получит StringRepository вместо непосредственно ресурсов, и в этом случае все будет в порядке, не так ли?

class MyViewModel(  private val backend : Backend,  private val stringRepo : StringRepository // детали реализации скрываются за интерфейсом) : ViewModel() {  val textToDisplay : MutableLiveData<String>     fun loadText(){    try {      val text : String = backend.getText()       textToDisplay.value = text    } catch (t : Throwable) {      textToDisplay.value = stringRepo.getString(R.string.fallback_text)    }  }}

На эту вью-модель можно написать такой юнит-тест:

@Testfun when_backend_fails_fallback_string_is_displayed(){  val stringRepo = TestDoubleStringRepository()  val backend = TestDoubleBackend()  backend.failWhenLoadingText = true // заставит backend.getText() выкинуть исключение  val viewModel = MyViewModel(backend, stringRepo)  viewModel.loadText()   Assert.equals("some string", viewModel.textToDisplay.value)}

С введением interface StringRepository мы добавили уровень абстракции и решили задачу, верно? Нет. Мы добавили уровень абстракции, но реальная проблема все еще перед нами:

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

  • Кроме того, если рассматривать реализацию TestDoubleStringRepository и тест, который мы написали, насколько он является значимым? TestDoubleStringRepository всегда возвращает одну и ту же строку. Мы могли бы совершенно испортить код вью-модели, передавая R.string.foo вместо R.string.fallback_text в StringRepository.getString(), и наш тест все равно бы был пройден. Конечно, можно улучшить TestDoubleStringRepository, чтобы он не просто всегда возвращал одну и ту же строку:

class TestDoubleStringRepository{    override fun getString(@StringRes id : Int) : String = when(id){      R.string.fallback_test -> "some string"      R.string.foo -> "foo"      else -> UnsupportedStringResourceException()    }}

Но насколько это поддерживаемо? Вы хотели бы так делать для всех строк в вашем приложении (если их у вас сотни)?

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

Нам поможет TextResource

Придуманная нами абстракция называется TextResource. Это модель для представления текста, которая относится к слою domain. Таким образом, это объект первого класса в нашей бизнес-логике. И выглядит это следующим образом:

sealed class TextResource {  companion object { // Используется для статических фабричных методов, чтобы файл с конкретной реализацией оставался приватным    fun fromText(text : String) : TextResource = SimpleTextResource(text)    fun fromStringId(@StringRes id : Int) : TextResource = IdTextResource(id)    fun fromPlural(@PluralRes id: Int, pluralValue : Int) : TextResource = PluralTextResource(id, pluralValue)  }} private data class SimpleTextResource( // Можно будет также использовать inline классы  val text : String) : TextResource() private data class IdTextResource(  @StringRes id : Int) : TextResource() private data class PluralTextResource(    @PluralsRes val pluralId: Int,    val quantity: Int) : TextResource() // можно будет добавить и другие виды текста...

Так выглядит вью-модель с TextResource:

class MyViewModel(  private val backend : Backend // Обратите, пожалуйста, внимание, что не надо передавать ни какие-то ресурсы, ни StringRepository.) : ViewModel() {  val textToDisplay : MutableLiveData<TextResource> // Тип уже не String     fun loadText(){    try {      val text : String = backend.getText()       textToDisplay.value = TextResource.fromText(text)    } catch (t : Throwable) {      textToDisplay.value = TextResource.fromStringId(R.string.fallback_text)    }  }}

Основные отличия:

1) textToDisplay поменялся c LiveData<String> на LiveData<TextResource>, поэтому теперь вью-модели не нужно знать, как переводить разные типы текста в String. Она должна уметь переводить их в TextResource. Однако, это нормально, как будет видно далее, TextResource это абстракция, которая решит наши проблемы.

2) Посмотрите на конструктор вью-модели. Нам удалось удалить неправильную абстракцию StringRepository (при этом нам не нужны Resources). Вас, возможно, интересует, как теперь писать тесты? Так же просто, как напрямую протестировать TextResource. Дело в том, что эта абстракция также абстрагирует зависимости Android, такие как ресурсы или контекст (R.string.fallback_text это просто Int). И вот как выглядит наш юнит-тест:

@Testfun when_backend_fails_fallback_string_is_displayed(){  val backend = TestDoubleBackend()  backend.failWhenLoadingText = true // заставит backend.getText() выкинуть исключение  val viewModel = MyViewModel(backend)  viewModel.loadText()   val expectedText = TextResource.fromStringId(R.string.fallback_text)  Assert.equals(expectedText, viewModel.textToDisplay.value)  // для data class-ов генерируются методы equals, поэтому мы легко можем их сравнивать}

Пока все хорошо, но не хватает одной детали: как нам преобразовать TextResource в String, чтобы можно было отобразить его, например, в TextView? Что ж, это касается исключительно отрисовки в Android, и мы можем создать функцию расширения и заключить ее в слое UI.

// Можно получить ресурсы с помощью context.getResources()fun TextResource.asString(resources : Resources) : String = when (this) {   is SimpleTextResource -> this.text // smart cast  is IdTextResource -> resources.getString(this.id) // smart cast  is PluralTextResource -> resources.getQuantityString(this.pluralId, this.quantity) // smart cast}

А поскольку преобразование TextResource в String происходит в UI (на уровне представления) архитектуры нашего приложения, TextResource будет переводиться при изменении конфигурации (т.е. при изменении системного языка на смартфоне), что обеспечит правильную локализацию строки для любых ресурсов R.string.* вашего приложения.

Бонус: вы можете легко написать юнит-тест для TextResource.asString(), создавая моки для ресурсов. При этом не следует создавать мок для каждого отдельного строкового ресурса в приложении, потому что на самом деле нужно протестировать всего лишь работу конструкции when. Поэтому здесь будет корректно всегда возвращать одну и ту же строку из замоканного resources.getString(). Кроме того, TextResource можно многократно использовать в коде, и он соответствует принципу открытости/закрытости. Так, его можно расширить для будущих вариантов использования, добавив всего несколько строк кода: новый класс данных, который расширяет TextResource, и новую ветку в конструкцию when в TextResource.asString().

Поправка: как правильно подметили в комментариях, TextResource не следует принципу открытости/закрытости. Можно было бы поддержать принцип открытости/закрытости для TextResource, если бы у sealed class TextResouce была abstract fun asString(r: Resources), которую реализуют все подклассы. Я лично считаю, что можно пожертвовать принципом открытости/закрытости в пользу упрощения структур данных и работать с расширенной функцией asString(r: Resources), которая находится за пределами иерархии наследования (именно этот способ описан в статье и является достаточно расширяемым, хотя и не настолько, как с принципом открытости/закрытости). Почему? Я считаю, что добавление функции с параметром Resources к публичному API TextResource проблематично, потому что только часть подклассов нуждается в этом параметре (например, SimpleTextResource такого вообще не требует). Кроме того, если такая реализация станет частью общедоступного API, это может привести к увеличению накладных расходов на поддержку кода, а также к появлению дополнительных сложностей (особенно при тестировании).

Выводы

Описанный в статье способ можно применять для работы со строками и другими ресурсами: dimens, изображениями, цветами. При этом в каждом случае важно анализировать, насколько уместна работа с абстракциями. Хотя злоупотреблять ими нежелательно, в некоторых случаях абстракции могут быть полезны в том числе, как мы уже упоминали, для более простого написания тестов. Ждем ваших отзывов об этом методе и его практическом применении!

Подробнее..

Перевод Фреймворк Quarkus как в нем реализуется чистая архитектура

30.07.2020 10:07:11 | Автор: admin
Привет, Хабр!

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


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

В качестве отправной точки я взял простой проект Maven, в котором 5 стандартных модулей для создания приложения CRUD REST в соответствии с принципами чистой архитектуры:

  • domain: объекты предметной области и интерфейсы шлюза для этих объектов
  • app-api: интерфейсы приложения, соответствующие практическим кейсам
  • app-impl: реализация этих кейсов средствами предметной области. Зависит от app-api и domain.
  • infra-persistence: реализует шлюзы, обеспечивающие взаимодействие предметной области с API базы данных. Зависит от domain.
  • infra-web: Открывает рассматриваемые кейсы для взаимодействия с внешним миром при помощи REST. Зависит от app-api.


Кроме того, мы создадим модуль main-partition, который послужит нам развертываемым артефактом приложения.

Планируя работать с Quarkus, первым делом нужно добавить спецификацию BOM к файлу POM вашего проекта. Эта BOM станет управлять всеми версиями зависимостей, которыми вы будете пользоваться. Также вам понадобится сконфигурировать стандартные плагины для проектов maven в вашем инструменте управления плагинами, как, например, плагин surefire. По мере работы с Quarkus, вы здесь же сконфигурируете и одноименный плагин. Последнее, но немаловажное: здесь понадобится сконфигурировать плагин для работы с каждым из модулей (в <build><plugins>...</plugins></build>), а именно, плагин Jandex. Поскольку Quarkus использует CDI, плагин Jandex добавляет файл индекса в каждый модуль; файл содержит записи обо всех аннотациях, используемых в данном модуле и ссылки с указанием на то, где используется какая аннотация. В результате обращение с CDI значительно упрощается, впоследствии приходится выполнять значительно меньше работы.

Теперь, когда базовая структура готова, можно приступать к созданию полноценного приложения. Чтобы это сделать, необходимо убедиться, что main-partition создает исполняемое приложение Quarkus. Данный механизм проиллюстрирован в любом примере для быстрого старта, предоставляемом в Quarkus.

Сначала конфигурируем сборку для использования плагина Quarkus:

<build>  <plugins>    <plugin>      <groupId>io.quarkus</groupId>      <artifactId>quarkus-maven-plugin</artifactId>      <executions>        <execution>          <goals>            <goal>build</goal>          </goals>        </execution>      </executions>    </plugin>  </plugins></build>


Далее давайте добавим зависимости в каждый из модулей приложения, где они будут вместе с зависимостями quarkus-resteasy и quarkus-jdbc-mysql. В последней зависимости можно заменить базу данных на ту, что вам больше нравится (учитывая, что впоследствии мы собираемся пойти по нативному пути разработки, и поэтому не можем использовать встраиваемую базу данных, например, H2).

В качестве варианта, можно добавить профиль, позволяющий позже собрать нативное приложение. Для этого вам в самом деле потребуется дополнительный стенд для разработки (GraalVM, native-image и XCode, если вы используете OSX).

<profiles>  <profile>    <id>native</id>    <activation>      <property>        <name>native</name>      </property>    </activation>    <properties>      <quarkus.package.type>native</quarkus.package.type>    </properties>  </profile></profiles>


Теперь, если вы запустите mvn package quarkus:dev из корня проекта, у вас будет действующее приложение Quarkus! Смотреть пока особенно не на что, поскольку у нас пока нет ни контроллеров, ни контента.

Добавляем REST-контроллер

В данном упражнении пойдем с периферии к сути. Для начала создадим REST-контроллер, который будет возвращать пользовательские данные (в данном примере к ним относится всего лишь имя).

Чтобы использовать JAX-RS API, необходимо добавить зависимость к infra-web POM:

<dependency>  <groupId>io.quarkus</groupId>  <artifactId>quarkus-resteasy-jackson</artifactId></dependency>


Простейший код контроллера выглядит так:

@Path("/customer")@Produces(MediaType.APPLICATION_JSON)public class CustomerResource {    @GET    public List<JsonCustomer> list() {        return getCustomers.getCustomer().stream()                .map(response -> new JsonCustomer(response.getName()))                .collect(Collectors.toList());    }    public static class JsonCustomer {        private String name;        public JsonCustomer(String name) {            this.name = name;        }        public String getName() {            return name;        }    }


Если мы сейчас запустим приложение, то сможем вызвать localhost:8080/customer и увидим Joe в формате JSON.

Добавляем конкретный кейс

Далее добавим кейс и реализацию для данного практического случая. В app-api определим следующий кейс:

public interface GetCustomers {    List<Response> getCustomers();    class Response {        private String name;        public Response(String name) {            this.name = name;        }        public String getName() {            return name;        }    }}


В app-impl создадим простейшую реализацию данного интерфейса.

@UseCasepublic class GetCustomersImpl implements GetCustomers {    private CustomerGateway customerGateway;    public GetCustomersImpl(CustomerGateway customerGateway) {        this.customerGateway = customerGateway;    }    @Override    public List<Response> getCustomers() {        return Arrays.asList(new Response("Jim"));    }}


Чтобы CDI мог увидеть компонент GetCustomersImpl, вам понадобится специальная аннотация UseCase в том виде, как она определена ниже. Также можно использовать стандартный ApplicationScoped и аннотацию Transactional, но, создавая собственную аннотацию, вы получаете возможность более логично группировать код и откреплять код вашей реализации от таких фреймворков как CDI.

@ApplicationScoped@Transactional@Stereotype@Retention(RetentionPolicy.RUNTIME)public @interface UseCase {}


Для пользования аннотациями CDI необходимо добавить следующие зависимости в POM-файл app-impl в дополнение к зависимостям app-api и domain.

<dependency>  <groupId>jakarta.enterprise</groupId>  <artifactId>jakarta.enterprise.cdi-api</artifactId></dependency><dependency>  <groupId>jakarta.transaction</groupId>  <artifactId>jakarta.transaction-api</artifactId></dependency>


Далее нам потребуется изменить контроллер REST, чтобы использовать его в кейсах app-api.
...private GetCustomers getCustomers;public CustomerResource(GetCustomers getCustomers) {    this.getCustomers = getCustomers;}@GETpublic List<JsonCustomer> list() {    return getCustomers.getCustomer().stream()            .map(response -> new JsonCustomer(response.getName()))            .collect(Collectors.toList());}...


Если теперь вы запустите приложение и вызовете localhost:8080/customer, то увидите Jim в формате JSON.

Определение и реализация предметной области

Далее займемся предметной областью (domain). Здесь сущность domain довольно проста, она состоит из Customer и интерфейса шлюза, через который мы будем получать потребителей.

public class Customer {private String name;public Customer(String name) {this.name = name;}public String getName() {return name;}}public interface CustomerGateway {List<Customer> getAllCustomers();}


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

Для данной реализации мы воспользуемся поддержкой JPA, имеющейся в Quarkus, а также применим фреймворк Panache, который немного упростит нам жизнь. В дополнение к domain нам придется добавить к infra-persistence следующую зависимость:

<dependency>  <groupId>io.quarkus</groupId>  <artifactId>quarkus-hibernate-orm-panache</artifactId></dependency>


Сначала определяем сущность JPA, соответствующую потребителю.

@Entitypublic class CustomerJpa {@Id@GeneratedValueprivate Long id;private String name;public String getName() {return name;}public void setName(String name) {this.name = name;}}


Работая с Panache, можно выбрать один из двух вариантов: либо ваши сущности будут наследовать PanacheEntity, либо вы воспользуетесь паттерном репозиторий/DAO. Я не являюсь фанатом паттерна ActiveRecord, поэтому сам останавливаюсь на репозитории, но с чем будете работать вы решать вам.

@ApplicationScopedpublic class CustomerRepository implements PanacheRepository<CustomerJpa> {}


Теперь, когда у нас есть наша сущность JPA и репозиторий, можно реализовать шлюз Customer.

@ApplicationScopedpublic class CustomerGatewayImpl implements CustomerGateway {private CustomerRepository customerRepository;@Injectpublic CustomerGatewayImpl(CustomerRepository customerRepository) {this.customerRepository = customerRepository;}@Overridepublic List<Customer> getAllCustomers() {return customerRepository.findAll().stream().map(c -> new Customer(c.getName())).collect(Collectors.toList());}}


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

...private CustomerGateway customerGateway;@Injectpublic GetCustomersImpl(CustomerGateway customerGateway) {    this.customerGateway = customerGateway;}@Overridepublic List<Response> getCustomer() {    return customerGateway.getAllCustomers().stream()            .map(customer -> new GetCustomers.Response(customer.getName()))            .collect(Collectors.toList());}...


Пока мы не можем запустить наше приложение, поскольку приложение Quarkus еще требуется сконфигурировать с необходимыми параметрами персистентности. В src/main/resources/application.properties в модуле main-partition добавим следующие параметры.

quarkus.datasource.url=jdbc:mysql://localhost/testquarkus.datasource.driver=com.mysql.cj.jdbc.Driverquarkus.hibernate-orm.dialect=org.hibernate.dialect.MySQL8Dialectquarkus.datasource.username=rootquarkus.datasource.password=rootquarkus.datasource.max-size=8quarkus.datasource.min-size=2quarkus.hibernate-orm.database.generation=drop-and-createquarkus.hibernate-orm.sql-load-script=import.sql


Чтобы просмотреть исходные данные, также добавим файл import.sql в тот же каталог, из которого добавляются данные.

insert into CustomerJpa(id, name) values(1, 'Joe');insert into CustomerJpa(id, name) values(2, 'Jim');


Если теперь вы запустите приложение и вызовете localhost:8080/customer, то увидите Joe и Jim в формате JSON. Итак, у нас получилось полноценное приложение, от REST до базы данных.

Нативный вариант

Если вы желаете собрать нативное приложение, то делать это необходимо при помощи команды mvn package -Pnative. На это может потребоваться около пары минут, в зависимости от того, каков ваш стенд для разработки. Quarkus довольно быстр при запуске и без поддержки нативного режима, стартует за 2-3 секунды, но, когда он скомпилирован в нативный исполняемый файл при помощи GraalVM, соответствующее время сокращается менее чем до 100 миллисекунд. Для приложения на Java это просто молниеносная скорость.

Тестирование

Протестировать приложение Quarkus можно при помощи соответствующего тестового фреймворка Quarkus. Если снабдить тест аннотацией @QuarkusTest, то JUnit сначала запустит контекст Quarkus, а затем выполнит тест. Тест целого приложения в main-partition будет выглядеть примерно так:

@QuarkusTestpublic class CustomerResourceTest {@Testpublic void testList() {given().when().get("/customer").then().statusCode(200).body("$.size()", is(2),"name", containsInAnyOrder("Joe", "Jim"));}}


Заключение

Во многих отношениях Quarkus лютый конкурент Spring Boot. На мой взгляд, некоторые вещи в Quarkus решены даже лучше. Даже притом, что в app-impl есть зависимость фреймворка, это всего лишь зависимость для аннотаций (в случае со Spring, когда мы добавляем spring-context, чтобы получить @Component, мы тем самым добавляем множество зависимостей ядра Spring). Если вам такое не нравится, вы также можете добавить файл Java в главный раздел, использующий аннотацию @Produces из CDI и создающий там компонент; в таком случае вам не понадобится никаких дополнительных зависимостей в app-impl. Но по какой-то причине зависимость jakarta.enterprise.cdi-api мне хочется видеть там меньше, чем зависимость spring-context.

Quarkus быстр, реально быстр. С приложениями такого типа он быстрее Spring Boot. Поскольку, согласно Чистой Архитектуре, большинство (если не все) зависимостей фреймворка должны находиться на внешней стороне приложения, выбор между Quarkus и Spring Boot становится очевиден. В данном отношении достоинство Quarkus заключается в том, что он сразу создавался с учетом поддержки GraalVM, и поэтому ценой минимальных усилий позволяет превратить приложение в нативное. Spring Boot пока отстает от Quarkus в этом отношении, но не сомневаюсь, что скоро наверстает.

Правда, эксперименты с Quarkus также помогли мне осознать, какие многочисленные несчастья ожидают тех, кто попытается применить Quarkus с классическими серверами приложений Jakarta EE. Хотя, пока при помощи Quarkus можно сделать не так много, его генератор кода поддерживает разнообразные технологии, которые пока не так просто использовать в контексте Jakarta EE с традиционным сервером приложений. Quarkus охватывает все основы, которые понадобятся людям, знакомым с Jakarta EE, а разработка на нем гораздо более гладкая. Будет интересно посмотреть, как экосистема Java переварит такую конкуренцию.

Весь код к данному проекту выложен на Github.
Подробнее..

Перевод Как событийно-ориентированная архитектура решает проблемы современных веб-приложений

30.11.2020 10:17:56 | Автор: admin
Привет, Хабр!



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


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

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

  • Перенос приложений в облако;
  • Внедрение микросервисной архитектуры.


Эти идеи во многом определяют, как сегодня проектируется и создается программное обеспечение. Можно сказать, что сегодня мы строим уже не приложения, а платформы. Приложения более не занимают общего вычислительного пространства. Вместо этого им приходится обмениваться друг с другом информацией по легковесным коммуникационным протоколам, в частности, REST API или вызовы удаленных процедур (RPC). Эта модель позволила создать такие замечательные продукты как Facebook, Netflix, Uber и многие другие.
В этой статье будут рассмотрены некоторые проблемы, подстегивающие развитие инноваций в современной веб-разработке. Далее мы погрузимся в тему событийно-ориентированной архитектуры (EDA), призванной решить эти проблемы, по-новому трактуя архитектуру серверной части.

Актуальные проблемы современного веба



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

Доступность



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

Масштабируемость



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

Единый источник истины



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

Синхронность



При реализации типичного сценария вида запрос-отклик клиент дожидается, пока ответит сервер; он блокирует все действия, пока не получит ответ, либо пока не истечет заданная задержка. Если взять такое поведение и внедрить его в микросервисную архитектуру при помощи цепочек вызовов, пронизывающих всю систему, то можно легко оказаться в так называемом микросервисном аду. Все начинается с вызова всего одного сервиса, назовем его сервис А. Но затем сервис A должен вызвать сервис B, и начинается самое интересное. Проблема с данным поведением такова: если сам сервис связан с заблокированными ресурсами (например, висит поток), то задержки растут экспоненциально. Если у нас разрешена задержка в 500 мс на сервис, а в цепочке пять вызовов сервисов, то первому сервису понадобится задержка в 2500 мс (2,5 секунды), а последнему 500 мс.



Вызовы современного веба

Знакомство с событийно-ориентированной архитектурой



Событийно-ориентированная архитектура (EDA) это парадигма программной архитектуры, способствующая порождению, обнаружению, потреблению событий и реакции на них.


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

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

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



Пример приложения для бронирования, описанного методом событийного штурма

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

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

События происходят в результате действия, поэтому целевой системы здесь нет; нельзя сказать, что сервис A инициирует события в сервисе B; но можно сказать, что сервис B интересуют события, порождаемые сервисом A. Правда, в этой системе могут быть и другие заинтересованные стороны, например, сервисы C или D.

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

Что насчет хранилища данных? Можно ли хранить события в базе данных, либо вместо базы данных требуется что-то иное? Определенно, события можно хранить в базах данных, но в таком случае утрачивается их событийная сущность. Как только событие произошло, скорректировать его мы уже не можем, поэтому события по сути своей неизменяемы. Базы данных, в свою очередь изменяемы, после занесения данных в базу их вполне можно изменить.

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

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

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



Хотя, событийно-ориентированная архитектура существует уже более 15 лет, она лишь недавно снискала серьезную популярность, и это неслучайно. Большинство компаний проходят этап цифровой трансформации, сопровождаемый дикими требованиями. Из-за сложности этих требований инженерам приходится осваивать новые подходы к проектированию ПО, предполагающие, в частности, ослабление связанности сервисов друг с другом и снижение издержек на обслуживание сервисов. EDA одно из возможных решений этих проблем, но не единственное. Также не рассчитывайте, что все проблемы решатся, стоит только перейти на EDA. Для реализации некоторых фич по-прежнему могут потребоваться надежные дедовские REST API или хранение информации в базе данных. Выберите наиболее подходящий для вас вариант и спроектируйте его как следует!
Подробнее..

Как скрам помогает стать более сильным разработчиком?

28.11.2020 00:23:46 | Автор: admin

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

 Copyright Max Degtyarev (http://personeltest.ru/aways/www.behance.net/maxdwork) Copyright Max Degtyarev (http://personeltest.ru/aways/www.behance.net/maxdwork)

Если спросить программиста, то как правило, его будут заботить в основном знание какого-либо языка программирования, платформы, фреймворка, алгоритмов и структур данных, паттернов и принципов проектирования. В мире это принято называть Hard Skills.

Что до Soft skills, то, к сожалению, это считается чуть ли не лишним ингредиентом, пустой тратой времени, или же несущественным элементом. Ценность Hard Skills сильно выше, чтобы переставать работать с человеком из-за проблем с Soft Skills.

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

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

Однако, разработчики, которые пытались хотя бы раз создать продукт для конечного потребителя, должны знать о том, как много вещей необходимо сделать и учесть: продуманный UX, дружественный UI, хорошая производительность и стабильность, безопасность, доступность, и так до бесконечности.

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

Итого: строить продукт сложно и дорого. Крайне сложно удержать в одной голове все эти моменты, а потому продукты строят, как правило, команды, а не личности.

Цель

Многие помнят эту картинку: идеальный цикл жизни разработчика. Это может показаться шуткой, но ведь так и есть на самом деле! Я разговаривал как минимум с десятью разработчиками, которые ровно так и хотели бы провести остаток своих дней. Это просто я и мой кодмного кода.

Однако, здесь не достаёт чуточку цели.

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

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

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

Итерации

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

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

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

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

Если что-то из вышесказанного кажется недостаточно аргументированным, то я хочу порекомендовать вам набор статей по Theory of constraints, который содержит выборку необходимой и достаточной информации по выше изложенным фактам.

Для разработчиков из всего вышеизложенного важно одно: направление развития продукта (и компании) будет меняться, точно!

Работая в стартапах сложно сказать какую проблему придётся решать через 6 месяцев. Никто не знал, что видео-сервис для свиданий станет самым популярным в мире видео-стриминговым сервисом YouTube. Так же, как никто не предполагал, что игра для социализации может превратиться в чат для коллег, как в случае Slack. Бизнесы, которые не смогли повернуть свои продукты в правильном направлении, либо уже исчезли, либо близки к провалу.

Пример плохого развития событий в фазовом подходе Waterfall.Пример плохого развития событий в фазовом подходе Waterfall.

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

  • Возможность скорректировать путь развития продукта, определив новое направление или возможности заработка.

  • Снизить потери в случае неправильных решений или не оправдавших себя ожиданий.

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

Конечно, когда продукт развивается короткими итерациями, то очень часто нельзя сделать полное решение задачи в одну итерацию. Работа делится на части и само собой появляется требование доставлять продукт инкрементально: всё больше и больше функций с каждой итерацией.

 Пример инкрементальной доставки изменений в продукте. Пример инкрементальной доставки изменений в продукте.

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

Гибкая архитектура

Есть одна фундаментальная вещь, которую нужно понять с точки зрения разработки: преждевременное принятие решений (up-front design)это не всегда хорошо, а чаще плохо.

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

Итеративный подход Scrum ставится в противовес фазовому Waterfall, но так как это не является темой моих рассуждений, я приведу отличную статью на этот счёт.

Самое важное, что нужно понять программисту, это то, что решения бывают 2х видов:
1. Решения, которые можно легко откатить назад
2. Решения, которые нельзя просто откатить назад

Последние как раз и являются самыми проблематичными. И когда я говорю про Up-Front design, подразумевается именно ряд решений, которые невозможно или слишком долго повернуть вспять.

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

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

  2. Выбрав фреймворк для написания программы, подумали ли вы насколько легко будет переключиться на другой фреймворк в случае необходимости?

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

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

Если вы или ваши коллеги уже применяете такого рода анализ, тогда возможно вы уже в безопасности.

Ещё один сложный вопрос: как вести разработку в условиях изменяющихся требований? Изменяющиеся требования среди разработчиков принято считать чем то очень плохим.
Это происходит из-за того, что необходимость менять требования заложена в понятие программного (Softгибкий) обеспечения, но не указана явно.

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

Каждый уважающий себя разработчик должен прочитать книгу Чистая Архитектура Роберта Мартина, в которой эта тема раскрыта очень широко и приведены примеры решений:

When requirements change, the difficulty in making such a change should be proportional to the scope of the change, not to the shape of the change. The difference between scope and shape often drives the growth in software development costs. It is the reason that the first year of development is much cheaper than the second, and the second year is much less expensive than the third.

The goal of software architecture is to minimize the human resources required to build and maintain the required system.

Robert C. Martin, Clean Architecture: A Craftsmans Guide to Software Structure andDesignRobert C. Martin, Clean Architecture: A Craftsmans Guide to Software Structure andDesign Роберт Мартин, Чистая Архитектура: искусство разработки программного обеспечения. Роберт Мартин, Чистая Архитектура: искусство разработки программного обеспечения.

Позвольте добавить один совет, которого в книге нет: разработчики должны создавать настолько мало кода, насколько это вообще возможно, а в идеале вообще обойтись без кода. При этом нужно всё также добавлять функционал. Феноменально, не правда ли?

Нет, я не говорю перестать писать код вообще и перейти на готовые конструкторы. И нет, я не говорю перестать создавать ПО. Я говорю о том, чтобы начать делать ровно то, что приносит ценность, и перестать делать то, что только отнимает усилия. Вот некоторые факты:

  • Меньше нового кода, означает меньше поддержки.

  • Меньше нового кода также означает меньше всего что может сломаться (работает, не трогай! помните?).

  • Меньше нового кодаменьше когнитивной нагрузки.

  • Меньше нового кодабольше уверенности в результате работы.

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

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

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

Может быть этот совет и покажется кому-то теоретическим. Однако, с практикой и опытом, всё больше начинаешь замечать моменты, в которых лучшее решение лежит совсем не в коде. Как инженеры с развитым системным мышлением, вы можете помочь команде и указать на подобные решения.


Если говорить о коде, то программисты нанимаются для того, чтобы вносить изменения в поведение программного обеспечения так быстро, насколько это возможно. Разработчики могут поддерживать скорость изменений на достаточном уровне только создавая достаточно гибкую архитектуру. Для этого требуется своевременная коммуникация с бизнесом для выяснения что на самом деле ему надо. Также, требуется тщательно продумывать каждое изменение, контролируя архитектуру и технический долг, и постоянно уточняя требования. Если не обратить внимание хотя бы на одну из этих составляющих (перестать коммуницировать с бизнесом или же не обращать внимание на архитектуру), то так или иначе, но скорость внесения изменений очень быстро упадёт, и к вам возникнут вопросы. В последнее время всё чаще встречаются термины Agile Architecture и Lean Architecture. Я предпочитаю объединить эти понятия в одном термине: Гибкая архитектура.

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

Сильный разработчик

Понятие очень размытое и меняет своё значение от компании к компании, от бизнеса к бизнесу, и от команды к команде. Оно часто зависит от мнения начальства, менеджеров, и вообще коллег. Soft skills как раз нужны для того, чтобы это мнение было положительным. Но многим разработчикам эти навыки даются нелегко. Зато, одно остаётся верным всегда: сильный разработчик тот, кто умеет проектировать программное обеспечение, которое соответствует нуждам бизнеса.

Если сегодня бизнесу требуется итеративный подход в создании продукта, то сильный разработчик будет выражать это требование в архитектуре.

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

Представьте, насколько сильно изменилась архитектура Android, который изначально был задуман как операционная система для камер, и только. А теперь это одна из двух самых популярных мобильных платформ. Другой пример, PayPal, который создавался как сервис перевода денег между телефонов с операционной системой Palm OS. А теперь сервис обрабатывает миллионы платежей по всему миру.
Врядли, развитие кодовой базы этих проектов было монотонным ростом функционала из года в год. Уверен, что такие изменения, словно большой взрыв, порождают массу работ по адаптации, рефакторингу и переписыванию с ноля. Это конечно одни из самых сложных случаев. Но кто может заранее предположить путь вашего проекта?

А где жескрам?

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

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


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

  • Скрам позволяет командам действовать независимо, самостоятельно принимать решения и не бояться пробовать новые идеи. Представьте, что вы приняли неверное решение в одной из 2-недельных итераций, внесли изменения, а во время презентации работы выяснились неожиданные подробности.


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

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


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

Вывод

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

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

Подробнее..

Flutter чистая архитектура разбираем на примере

10.10.2020 22:18:47 | Автор: admin

На определённом этапе изучения новой технологии начинаешь задаваться вопросом - как правильно организовать архитектуру проекта? Мне в своё время повезло - попались опытные наставники, которые дали мудрые советы. Однако я считаю, что знания не должны лежать мёртвым грузом, поэтому пишу эту статью в помощь начинающим (и не только) flutter-разработчикам.

Чистая архитектура - это концепция построения архитектуры систем, предложенная Робертом Мартином (также известного как "дядюшка Боб"). Концепция предполагает построение приложения в виде набора независимых слоёв, что облегчает тестирование, уменьшает связность и делает приложение более простым для понимания.

Flutter - стремительно набирающий популярность фреймворк для разработки кроссплатформенных приложений. В списке поддерживаемых платформ - iOS, Android, web, в бете находится поддержка десктопа.

Под катом - рассказ о том, как построить flutter-приложение с использованием идей чистой архитектуры.


Конкретно с этой имплементацией я познакомился, когда пришёл работать в Progressive Mobile (пользуясь случаем, хочу передать привет - ребят, вы крутые!). Она хорошо себя показала на множестве проектов, которые мы делали, при этом развивалась от проекта к проекту.

Обычно приложение состояло из четырёх слоев:

  • data - слой работы с данными. На этом уровне, например, описываем работу с внешним API.

  • domain - слой бизнес-логики.

  • internal - слой приложения. На этом уровне происходит внедрение зависимостей.

  • presentation - слой представления. На этом уровне описываем UI приложения.

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

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

Создание проекта

Я предполагаю, что у вас уже установлен Flutter, если нет - почитать о том, как это делается, можно в официальной документации.

Создать проект можно с помощью инструментов вашей любимой IDE или из командной строки. В последнем случае вы должны выполнить в терминале команду:

flutter create myapp

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

Как уже говорилось выше, приложение будет состоять из 4 слоёв, поэтому создадим соответствующие папки. Заодно заметим, что код из стандартного примера содержит вёрстку экрана - то есть UI, а значит, место ему в слое представления.

Получилась следующая структура каталогов:

Содержание файлов main.dart, application.dart и home.dart можно посмотреть под спойлерами.

main.dart
import 'package:flutter/material.dart';import 'internal/application.dart';void main() {  runApp(Application());}
application.dart
import 'package:flutter/material.dart';import 'package:habr_flutter_clean_arch/presentation/home.dart';class Application extends StatelessWidget {  @override  Widget build(BuildContext context) {    return MaterialApp(      title: 'Flutter Demo',      theme: ThemeData(        primarySwatch: Colors.blue,        visualDensity: VisualDensity.adaptivePlatformDensity,      ),      home: Home(),    );  }}
home.dart
import 'package:flutter/material.dart';class Home extends StatefulWidget {  @override  _HomeState createState() => _HomeState();}class _HomeState extends State<Home> {  @override  Widget build(BuildContext context) {    return Scaffold();  }}

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

Готовим domain

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

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

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

Мы будем делать запросы к этому сервису и использовать из полученной информации следующие данные:

  • время восхода

  • время захода

  • время, в которое наступает астрономический полдень

  • продолжительность дня

Теперь мы можем создать нашу первую модель. Добавим в папку domain директорию model, в которой создадим файл с именем day.dart. Опишем в этом файле нашу модель:

import 'package:meta/meta.dart';class Day {  final DateTime sunrise;  final DateTime sunset;  final DateTime solarNoon;  final int dayLength;  Day({    @required this.sunrise,    @required this.sunset,    @required this.solarNoon,    @required this.dayLength,  });}

Здесь мы определили конструктор с именованными аргументами, а аннотация @required говорит нам о том, что все аргументы являются обязательными.

Некоторые добавляют в модели специальные методы а-ля fromJson, которые используют для преобразования сырых данных из мапы в модель, однако я считаю это в корне неверным.

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

Итак, мы описали модель, теперь нужно поговорить об источнике данных.

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

Создадим в этой директории файл day_repository.dart следующего содержания:

import 'package:meta/meta.dart';import 'package:habr_flutter_clean_arch/domain/model/day.dart';abstract class DayRepository {  Future<Day> getDay({    @required double latitude,    @required double longitude,  });}

Поскольку в языке Dart нет интерфейсов в явном виде, вместо них используют абстрактные классы. В данном случае, мы указали, что наследники этого репозитория должны реализовывать метод getDay, который вернёт объект Future, разрешающий нашу модель Day. Обязательные аргументы этого метода - широта и долгота интересующей нас точки.

На данный момент у нас должна получиться следующая структура каталогов:

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

Готовим data слой

Помните я говорил о методе создания модели из сырого json? Настало время для его реализации.

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

Здесь мы опишем модель ApiDay, которая будет содержать метод fromApi - получение данных из json. На этом уровне у нас все модели будут начинаться с префикса Api, чтобы отличить их от моделей слоя бизнес-логики.

Зачем нужна отдельная модель ApiDay? С одной стороны, она может содержать методы манипуляции с сырыми данными - fromApi/toApi, что соответствует как раз уровню данных, а не бизнес-логики. Кроме того, полученные с бэкенда данные могут иметь довольно сложную структуру, не всегда удобную для нашего приложения и мы можем произвести необходимую подготовку на данном уровне. С другой стороны, благодаря такому разделению, мы получаем более прозрачную структуру - на уровне бизнес-логики нам не будут мешать бесполезные там методы fromApi/toApi, плюс, если бэкенд изменит формат присылаемых данных, нам будет достаточно поправить в одном месте нашу модель ApiDay, и на уровне бизнес-логики все будет работать без изменений.

Итак, давайте создадим в папке api/model файл api_day.dart следующего содержания:

api_day.dart
class ApiDay {  final String sunrise;  final String sunset;  final String solarNoon;  final num dayLength;  ApiDay.fromApi(Map<String, dynamic> map)      : sunrise = map['results']['sunrise'],        sunset = map['results']['sunset'],        solarNoon = map['results']['solar_noon'],        dayLength = map['results']['day_length'];}

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

Обратите внимание: в общем случае типы полей этой модели не совпадают с типами модели из domain слоя. Они имеют именно тот тип, который возвращает бэкенд. Для преобразования api-модели в обычную модель, создадим специальный класс-маппер, который будет сопоставлять поля двух моделей и выполнять преобразования при необходимости.

Добавим в директорию data/api папку mapper, в которой создадим файл day_mapper.dart,

day_mapper.dart
import 'package:habr_flutter_clean_arch/data/api/api_day.dart';import 'package:habr_flutter_clean_arch/domain/model/day.dart';class DayMapper {  static Day fromApi(ApiDay day) {    return Day(      sunrise: DateTime.tryParse(day.sunrise),      sunset: DateTime.tryParse(day.sunset),      solarNoon: DateTime.tryParse(day.solarNoon),      dayLength: day.dayLength.toInt(),    );  }}

Класс DayMapper содержит статический метод, принимающий на входе объект ApiDay и превращающий его в модель бизнес-слоя Day. Этот метод потребуется нам на следующем шаге. А пока можно зафиксировать изменения в системе контроля версий.

Работаем с API

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

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

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

У нас пока всего один сервис (напомню, мы используем Sunrise Sunset), давайте создадим для него в data/api/service файл sunrise_service.dart.

В экосистеме Flutter существует несколько пакетов для работы с сетью, мне больше нравится dio, но вы можете использовать любой другой.

Итак, давайте добавим в зависимости проекта этот пакет и вернёмся к нашему sunrise_service.dart.

sunrise_service.dart
import 'package:dio/dio.dart';import 'package:habr_flutter_clean_arch/data/api/model/api_day.dart';import 'package:meta/meta.dart';class SunriseService {  static const _BASE_URL = 'https://api.sunrise-sunset.org';  final Dio _dio = Dio(    BaseOptions(baseUrl: _BASE_URL),  );  Future<ApiDay> getDay({    @required double latitude,    @required double longitude,  }) async {    final query = {'lat': latitude, 'lng': longitude, 'formatted': 0};    final response = await _dio.get(      '/json',      queryParameters: query,    );    return ApiDay.fromApi(response.data);  }}

Здесь мы создали объект dio и описали метод getDay, который с помощью этого объекта делает GET запрос к сервису и из полученных данных создает объект ApiDay.

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

Поэтому давайте вынесем подготовку данных для запроса в отдельный этап.

Готовим данные для запроса

Для этого в data/api создадим каталог request, в котором создадим файл get_day_body.dart, с таким содержанием:

get_day_body_dart
import 'package:meta/meta.dart';class GetDayBody {  final double latitude;  final double longitude;  GetDayBody({    @required this.latitude,    @required this.longitude,  });  Map<String, dynamic> toApi() {    return {      'lat': latitude,      'lng': longitude,      'formatted': 0,    };  }}

Все наши подобные классы будут называться по шаблону <ИМЯ_МЕТОДА>Body и реализовывать метод toAPi для приведения данных к нужному виду.

В данном случае я добавил поле 'formatted': 0, потому что в этом случае сервис вернёт данные в формате ISO 8601 - фактически, это маленький костыль, который я добавил, чтобы быть уверенным, что данные всегда будут в нужном нам формате. Правильнее было бы передавать этот параметр явным образом.

Теперь мы можем изменить метод getDay в классе SunriseService:

sunrise_service.dart
import 'package:dio/dio.dart';import 'package:habr_flutter_clean_arch/data/api/model/api_day.dart';import 'package:habr_flutter_clean_arch/data/api/request/get_day_body.dart';class SunriseService {  static const _BASE_URL = 'https://api.sunrise-sunset.org';  final Dio _dio = Dio(    BaseOptions(baseUrl: _BASE_URL),  );  Future<ApiDay> getDay(GetDayBody body) async {    final response = await _dio.get(      '/json',      queryParameters: body.toApi(),    );    return ApiDay.fromApi(response.data);  }}

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

Нижний слой API реализован, переходим к верхнему. Создадим в каталоге data/api файл api_util.dart:

api_util.dart
import 'package:habr_flutter_clean_arch/data/api/request/get_day_body.dart';import 'package:habr_flutter_clean_arch/data/mapper/day_mapper.dart';import 'package:meta/meta.dart';import 'package:habr_flutter_clean_arch/data/api/service/sunrise_service.dart';import 'package:habr_flutter_clean_arch/domain/model/day.dart';class ApiUtil {  final SunriseService _sunriseService;  ApiUtil(this._sunriseService);  Future<Day> getDay({    @required double latitude,    @required double longitude,  }) async {    final body = GetDayBody(latitude: latitude, longitude: longitude);    final result = await _sunriseService.getDay(body);    return DayMapper.fromApi(result);  }}

На этом уровне мы преобразуем данные бизнес-слоя в необходимый сервису вид, выполняем фактический запрос и преобразуем полученные данные в вид, приемлемый для бизнес-слоя.

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

Итак, мы подготовили всё необходимое для работы с API, пора переходить к репозиториям.

Готовим репозитории

Ранее мы определили на уровне бизнес-логики интерфейс репозитория DayRepository, теперь мы можем описать его конкретную реализацию. Для этого в каталоге data/api создадим папку repository и добавим в неё файл day_data_repository.dart со следующим содержанием:

day_data_repository.dart
import 'package:habr_flutter_clean_arch/data/api/api_util.dart';import 'package:habr_flutter_clean_arch/domain/model/day.dart';import 'package:habr_flutter_clean_arch/domain/repository/day_repository.dart';class DayDataRepository extends DayRepository {  final ApiUtil _apiUtil;  DayDataRepository(this._apiUtil);  @override  Future<Day> getDay({double latitude, double longitude}) {    return _apiUtil.getDay(latitude: latitude, longitude: longitude);  }}

Как видим, всё, что нужно нашему репозиторию, чтобы реализовать абстрактные методы класса DayRepository - это объект ApiUtil, который вернёт необходимые данные.

На данном этапе у нас должна получиться такая структура файлов в директории data:

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

Внедряем зависимости

Если взглянуть на наш код со стороны, то можно обратить внимание, что репозитории функционально зависят от ApiUtil, а тот, в свою очередь, от одного или нескольких ApiService (конкретно в нашем случае - от SunriseService). Начнем с ApiUtil.

В директорию internal добавим папку dependencies, в которой создадим файл api_module.dart со следующим содержанием:

api_module.dart
import 'package:habr_flutter_clean_arch/data/api/api_util.dart';import 'package:habr_flutter_clean_arch/data/api/service/sunrise_service.dart';class ApiModule {  static ApiUtil _apiUtil;  static ApiUtil apiUtil() {    if (_apiUtil == null) {      _apiUtil = ApiUtil(SunriseService());    }    return _apiUtil;  }}

Класс ApiModule содержит в себе статический метод apiUtil, который возвращает нам единственный экземпляр класса ApiUtil и создает его при необходимости. Используя этот модуль, мы можем поступить аналогичным образом и для репозиториев.

Добавим файл repository_module.dart и запишем в него следующий код:

repository_module.dart
import 'package:habr_flutter_clean_arch/data/repository/day_data_repository.dart';import 'package:habr_flutter_clean_arch/domain/repository/day_repository.dart';import 'api_module.dart';class RepositoryModule {  static DayRepository _dayRepository;  static DayRepository dayRepository() {    if (_dayRepository == null) {      _dayRepository = DayDataRepository(        ApiModule.apiUtil(),      );    }    return _dayRepository;  }}

В классе RepositoryModule описываются статические методы, которые для каждого абстрактного репозитория из domain/repository создают объекты-наследники, реализующие методы этих репозиториев.

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

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

Слой представления

У нас уже есть заготовка для экрана Home в папке presentation, давайте внесём в неё изменения.

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

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

home.dart
import 'package:flutter/material.dart';import 'package:habr_flutter_clean_arch/domain/model/day.dart';class Home extends StatefulWidget {  @override  _HomeState createState() => _HomeState();}class _HomeState extends State<Home> {  final _latController = TextEditingController();  final _lngController = TextEditingController();  Day _day;  @override  Widget build(BuildContext context) {    return GestureDetector(      onTap: FocusScope.of(context).unfocus,      child: Scaffold(        body: _getBody(),      ),    );  }  Widget _getBody() {    return SafeArea(      child: Padding(        padding: EdgeInsets.all(10),        child: Column(          crossAxisAlignment: CrossAxisAlignment.stretch,          children: [            _getRowInput(),            SizedBox(height: 20),            RaisedButton(              child: Text('Получить'),              onPressed: _getDay,            ),            SizedBox(height: 20),            if (_day != null) _getDayInfo(_day),          ],        ),      ),    );  }  Widget _getRowInput() {    return Row(      children: [        Expanded(          child: TextField(            controller: _latController,            keyboardType: TextInputType.numberWithOptions(decimal: true, signed: true),            decoration: InputDecoration(hintText: 'Широта'),          ),        ),        SizedBox(width: 20),        Expanded(          child: TextField(            controller: _lngController,            keyboardType: TextInputType.numberWithOptions(decimal: true, signed: true),            decoration: InputDecoration(hintText: 'Долгота'),          ),        ),      ],    );  }  Widget _getDayInfo(Day day) {    return Column(      crossAxisAlignment: CrossAxisAlignment.stretch,      children: [        Text('Восход: ${day.sunrise.toLocal()}'),        Text('Заход: ${day.sunset.toLocal()}'),        Text('Полдень: ${day.solarNoon.toLocal()}'),        Text('Продолжительность: ${Duration(seconds: day.dayLength)}'),      ],    );  }  void _getDay() {    // здесь получаем данные  }}

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

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

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

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

Disclaimer: я не явлюсь специалистом по MobX, поэтому относитесь к моей реализации этого паттерна критически. Дайте знать, если я допустил ошибки.

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

Управление состоянием с помощью MobX

Итак, для начала нам потребуется добавить необходимые зависимости в наш проект:

dependencies:...  mobx: ^1.2.1+3  flutter_mobx: ^1.1.0+2

Также добавим в dev_dependencies зависимости для генерации файлов, добавляющих возможность использовать аннотации @observable, @computed, @action:

dev_dependencies:...  mobx_codegen: ^1.1.1+1  build_runner: ^1.10.0

Управление состоянием относится к слою бизнес-логики, поэтому давайте добавим в директорию domain папку state. В этом каталоге у нас будут классы, описывающие состояние экранов (а возможно - и других компонентов). Кажется разумным выделить для каждого из них свой подкаталог. В нашем примере экран всего один, поэтому давайте добавим подкаталог home.

Создадим в нём файл home_state.dart с таким содержанием:

home_state.dart
import 'package:mobx/mobx.dart';import 'package:meta/meta.dart';import 'package:habr_flutter_clean_arch/domain/repository/day_repository.dart';import 'package:habr_flutter_clean_arch/domain/model/day.dart';part 'home_state.g.dart';class HomeState = HomeStateBase with _$HomeState;abstract class HomeStateBase with Store {  HomeStateBase(this._dayRepository);  final DayRepository _dayRepository;  @observable  Day day;  @observable  bool isLoading = false;  @action  Future<void> getDay({    @required double latitude,    @required double longitude,  }) async {    isLoading = true;    final data = await _dayRepository.getDay(latitude: latitude, longitude: longitude);    day = data;    isLoading = false;  }}

В целом он соответствует шаблону из примера по MobX, обсудим некоторые детали.

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

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

Также имеется метод, помеченный аннотацией @action, то есть то самое событие, инициирующее изменение состояния. В данном случае, мы будем вызывать его для того, чтобы получить данные из репозитория.

Теперь необходимо сгенерировать файл home_state.g.dart, для этого выполните команду:

flutter packages pub run build_runner build

У меня поначалу всё пошло не очень гладко: скрипт уходил в бесконечный цикл и наотрез отказывался генерировать необходимый файл. В одном из issue к mobx порекомендовали выполнить в этом случае команды

flutter cleanflutter pub getflutter packages upgrade

Мне это помогло, после их выполнения предыдущий скрипт завершился успехом.

Итак, у нас есть класс HomeState, управляющий состоянием экрана Home, но ему требуется DayRepository репозиторий. А значит пора снова вернуться к слою внедрения зависимостей.

Добавим в директорию internal/dependencies файл home_module.dart со следующим содержанием:

home_module.dart
import 'package:habr_flutter_clean_arch/domain/state/home/home_state.dart';import 'package:habr_flutter_clean_arch/internal/dependencies/repository_module.dart';class HomeModule {  static HomeState homeState() {    return HomeState(      RepositoryModule.dayRepository(),    );  }}

Теперь всё необходимое у нас есть, и мы можем наконец-то организовать грамотное управление состоянием экрана Home.

Внесём изменения в файл presentation/home.dart:

home.dart
import 'package:flutter/material.dart';import 'package:flutter_mobx/flutter_mobx.dart';import 'package:habr_flutter_clean_arch/domain/state/home/home_state.dart';import 'package:habr_flutter_clean_arch/internal/dependencies/home_module.dart';class Home extends StatefulWidget {  @override  _HomeState createState() => _HomeState();}class _HomeState extends State<Home> {  final _latController = TextEditingController();  final _lngController = TextEditingController();  HomeState _homeState;  @override  void initState() {    super.initState();    _homeState = HomeModule.homeState();  }  @override  Widget build(BuildContext context) {    return GestureDetector(      onTap: FocusScope.of(context).unfocus,      child: Scaffold(        body: _getBody(),      ),    );  }  Widget _getBody() {    return SafeArea(      child: Padding(        padding: EdgeInsets.all(10),        child: Column(          crossAxisAlignment: CrossAxisAlignment.stretch,          children: [            _getRowInput(),            SizedBox(height: 20),            RaisedButton(              child: Text('Получить'),              onPressed: _getDay,            ),            SizedBox(height: 20),            _getDayInfo(),          ],        ),      ),    );  }  Widget _getRowInput() {    return Row(      children: [        Expanded(          child: TextField(            controller: _latController,            keyboardType: TextInputType.numberWithOptions(decimal: true, signed: true),            decoration: InputDecoration(hintText: 'Широта'),          ),        ),        SizedBox(width: 20),        Expanded(          child: TextField(            controller: _lngController,            keyboardType: TextInputType.numberWithOptions(decimal: true, signed: true),            decoration: InputDecoration(hintText: 'Долгота'),          ),        ),      ],    );  }  Widget _getDayInfo() {    return Observer(      builder: (_) {        if (_homeState.isLoading)          return Center(            child: CircularProgressIndicator(),          );        if (_homeState.day == null) return Container();        return Column(          crossAxisAlignment: CrossAxisAlignment.stretch,          children: [            Text('Восход: ${_homeState.day.sunrise.toLocal()}'),            Text('Заход: ${_homeState.day.sunset.toLocal()}'),            Text('Полдень: ${_homeState.day.solarNoon.toLocal()}'),            Text('Продолжительность: ${Duration(seconds: _homeState.day.dayLength)}'),          ],        );      },    );  }  void _getDay() {    // здесь получаем данные    final lat = double.tryParse(_latController.text);    final lng = double.tryParse(_lngController.text);    _homeState.getDay(latitude: lat, longitude: lng);  }}

Здесь мы создаём объект класса HomeState с помощью HomeModule. Нажатие на кнопку инициирует событие getDay, а с помощью виджета Observer приложение отслеживает изменение состояния и перерисовывает экран.

Результат работы приложения представлен ниже.

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

Такой проект легче модифицировать - например, переезд с REST на GraphQL пройдёт безболезненно для слоёв бизнес-логики и представления. Вы также легко можете использовать их совместно или добавлять дополнительные сервисы. Можно вносить изменения в слой представления - например, подготовить разный дизайн UI для разных платформ и использовать единую бизнес-логику и данные.

Независимость слоёв также облегчает тестирование приложения.

Наконец, вы всегда можете заменить условный MobX на BLoC, Redux или что там вам по вкусу, и использовать эту архитектуру на полную катушку.

Исходный код проекта доступен на Github.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru