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

Mobx

Из песочницы Urban Bot или как писать чат-ботов для Telegram, Slack, Facebook на React.js

27.07.2020 22:10:16 | Автор: admin

image


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


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

В отличии от большинства чат-бот библиотек, которые чаще всего просто оборачивают http запросы в функции с готовыми аргументами и предоставляют подписки вида bot.on('message', callback), иногда позволяя передавать контекст между вызовами, Urban Bot предлагает совершенно иной подход к разработке чат-ботов через декларативное программирование и компонентный подход. Живой пример, написанный на Urban Bot, вы можете попробовать в Telegram, cсылка на чат-бот, и посмотреть код на GitHub.


Как мы заметили выше, чат-боты это полноценные UI приложения. А какой язык в 2020 и какая библиотека наиболее подходит для разработки UI приложений? Правильно, JavaScript и React. Такая интеграция позволяет легко и непринужденно строить чат-боты любой сложности без единого знания об API мессенджеров. Далее я расскажу, как создавать простые компоненты и на их основе строить сложных чат-ботов, работать с навигацией, создавать диалоги любой вложенности, писать одно приложение и запускать в любых мессенджерах, и многое другое.


Отправка сообщений


Так выглядит самый простой пример на Urban Bot. Для отправки текстового сообщения нам нужно создать функцию и вернуть из него готовый компонент Text с текстом внутри, который мы хотим отправить. Когда компонент отрендериться, все пользователи чат-бота получат сообщение "Hello, world!".


import React from 'react';import { Text } from '@urban-bot/core';function App() {    return (        <Text>           Hello, world!        </Text>    );}

Изображение можно отправить так:


import React from 'react';import { Image } from '@urban-bot/core';function App() {    const imageByURL =  'https://some-link.com/image.jpg';    return <Image file={imageByURL} />;}

Urban Bot имеет готовый набор компонентов, для каждого вида сообщений, для файлов File, для кнопок ButtonGroup и много других, подробнее можно взглянуть здесь. В каждый из них можно передать определенный набор пропсов, например, имитировать будто бот печатает сообщение 1 секунду <Text simulateTyping={1000}>.


Получение сообщений


Мы рассмотрели как посылать сообщения, давайте разберемся как подписываться на сообщения от пользователей. За подписки в Urban Bot отвечают React Hooks.


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


import React from 'react';import { Text, useText } from '@urban-bot/core';function App() {    useText(({ text }) => {        console.log(`Пользователь отправил сообщение ${text}`);    });    return (        <Text>            Hello, world!        </Text>    );}

Urban Bot предоставляет готовы набор хуков для разных типов сообщений. Например, useImage, если пользователь отправил изображение, useFile и т.д. Полный список здесь. В каждый хук также приходит мета информация, кто отправил сообщение и т.д.


Эхо бот


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


В этом компоненте мы впервые добавим работу с переменными через React хук useState. Этот хук возвращает переменную и функцию, чтобы ее изменять. React.useState нужен, чтобы изменение переменной приводило к ререндеру и, соответсвенно, отправке нового сообщения. Мы определим начальное значение переменной text как "Привет" и передадим в компонент Text. Также мы подпишемся на сообщения от пользователей с помощью хука useText, и будем изменять text через функцию setText. После вызова setText React перерендерит компонент Echo с новым значением, и пользователь получит новое сообщение с тем что он сам отправил боту.


import React from 'react';import { Text, useText } from '@urban-bot/core';function Echo() {    const [text, setText] = React.useState('Привет!');    useText(({ text }) => {        setText(text);    });    return (        <Text>            {text}        </Text>    );}

Кнопки


Давайте также напишем пример с кнопками, сделаем простейший счетчик. Для этого нам понадобятся компоненты ButtonGroup и Button. Каждой кнопке мы определим свой обработчик, который будет менять count на +1 или -1 и будем передавать результат в ButtonGroup в проп title. Мы установим проп isNewMessageEveryRender как false, чтобы при последующих ререндерах отправлялось не новое сообщение с новыми кнопками, а просто изменялось начальное сообщение.


import React from 'react';import { ButtonGroup, Button } from '@urban-bot/core';function Counter() {    const [count, setCount] = React.useState(0);    const increment = () => setCount(count + 1);    const decrement = () => setCount(count - 1);    return (        <ButtonGroup title={count} isNewMessageEveryRender={false}>            <Button onClick={increment}>+1</Button>            <Button onClick={decrement}>-1</Button>        </ButtonGroup>    );}


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


Теперь, когда пользователь напишет "/echo" отрендериться компонент Echo, когда "/counter" управление перейдет в Counter. Роуты также могут принимать path как regexp.


import React from 'react';import { Router, Route } from '@urban-bot/core';import { Echo } from './Echo';import { Counter } from './Counter';function App() {    return (        <Router>            <Route path="/echo">                <Echo />            </Route>            <Route path="/counter">                <Counter />            </Route>        </Router>    );}

Визуальный пример, чтобы увидеть как будет работать код.


image


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


Форматирование текста


Urban Bot позволяет стилизовать сообщения через привычные HTML теги. Писать жирным <b>, курсивом <i>, зачеркнутым <s>, переносить строки <br /> и так далее, полный список здесь.


Пример
const someCode = `function sum2() {    return 2 + 2;}if (sum2() !== 4) {    console.log('WTF');}`;<Text>    Usual text    <br />    <b>Bold text</b>    <br />    <i>Italic text</i>    <br />    <u>Underscore text</u>    <br />    <s>Strikethrough text</s>    <br />    <q>quote</q>    <br />    <b>        Bold and <s>Strikethrough text</s>    </b>    <br />    <code >Code 2 + 2</code >    <br />    <pre>{someCode}</pre>    <br />    <a href="http://personeltest.ru/aways/github.com/urban-bot/urban-bot">External link</a></Text>


Диалоги


Чат-боты могут вести диалоги с пользователем, что-то спрашивать, реагировать на сообщения в зависимости от ответа пользователя. В Urban Bot встроен компонент Dialog, который позволяет легко создавать диалоги любой сложности.


Пример плоского диалога. Бот задаст первый вопрос, после ответа задаст второй, потом третий, потом запустит callback onFinish с готовыми ответами. На каждом шаге можно обрабатывать ответ пользователя через callback onNext.


import React from 'react';import { Dialog, DialogStep, Text } from '@urban-bot/core';function FlatDialogExample() {    return (        <Dialog onFinish={(answers) => console.log(answers)}>            <DialogStep                content={<Text>Привет, как тебя зовут?</Text>}                 id="name"                onNext={(name) => console.log(name)}            >                <DialogStep                    content={<Text>Cколько тебе лет?</Text>}                    id="age"                >                    <DialogStep                         content={<Text>Из какого ты города?</Text>}                        id="city"                    />                </DialogStep>            </DialogStep>        </Dialog>    );}

Можно получать на следующем шаге прошлый ответ через паттерн render-props .


function FlatDialogExample() {    return (        <Dialog>            <DialogStep content={<Text>Привет, как тебя зовут?</Text>}>                {(name) => (                    <DialogStep                         content={<Text>{`${name}, cколько тебе лет?`}</Text>}                    />                )}            </DialogStep>        </Dialog>    );}

Можно добавить валидацию на каждый шаг.


function FlatDialogExample() {    return (        <Dialog onFinish={(answers) => console.log(answers)}>            <DialogStep                content={<Text>Привет, как тебя зовут?</Text>}                id="name"                validation={{                     isValid: (answer) => answer !== 'Самуэль',                     errorText: 'Самуэль заблокирован.'                 }}            >                // ...            </DialogStep>        </Dialog>    );}

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


import React from 'react';import { Dialog, DialogStep, Text, ButtonGroup, Button } from '@urban-bot/core';function TreeDialogExample() {    return (        <Dialog>            <DialogStep                content={                    <ButtonGroup title="Привет, что вы хотите купить?">                        <Button id="hat">Футболка</Button>                        <Button id="glasses">Очки</Button>                    </ButtonGroup>                }            >                <DialogStep                    match="hat"                    content={                        <ButtonGroup title="Футболка какого размера?">                            <Button id="m">S</Button>                            <Button id="s">M</Button>                            <Button id="l">L</Button>                        </ButtonGroup>                    }                />                <DialogStep                    match="glasses"                    content={                        <ButtonGroup title="Очки какого цвета?">                            <Button id="black">Черный</Button>                            <Button id="white">Белый</Button>                        </ButtonGroup>                    }                />            </DialogStep>        </Dialog>    );}

Состояние


Что вы можете использовать для управления состоянием? Все то же что и в любом React приложении. Можете использовать React useState и передавать состояние ниже по дереву компонентов через пропсы или React Context. Можно использовать библиотеки для управления состоянием: Redux (пример), MobX (пример), Apollo и любые другие, которые обычно используют вместе с React, вы можете даже переиспользовать готовые части из готовых React Web или React Native приложений, так как Urban Bot использует тот же чистый React, который работает в миллионах приложений.


Сессия


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


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


function Counter() {    const [count, setCount] = React.useState(0);    const increment = () => setCount(count + 1);    const decrement = () => setCount(count - 1);    return (        <ButtonGroup title={count} isNewMessageEveryRender={false}>            <Button onClick={increment}>+1</Button>            <Button onClick={decrement}>-1</Button>        </ButtonGroup>    );}

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


function Counter() {    const [count, setCount] = useGlobalCount();   // ...}

Также, в любом месте приложения мы можем получить данные о конкретном чате, какой пользователь написал, с каким никнеймом и т.д. Если чат-бот персональный, то chat и from будут совпадать.


import React from 'react';import { Text, useText, useBotContext } from '@urban-bot/core';function UserId() {    const { chat } = useBotContext();    useText(({ from }) => console.log(`Пришло сообщение от ${from.username}`));   return <Text>Чат id {chat.id}</Text>;}

Типизация


Urban Bot написан на TypeScript, соответсвенно проект полностью типизирован, и если вы пишете на TypeScript, вам будет очень удобно.


Запуск в мессенджерах


Большой плюс Urban Bot, что он не привязан ни к одному мессенджеру. Есть основной пакет @urban-bot/core, который позволяет создавать абстрактных чат-ботов, а уже их подключать к определенным мессенджерам. В данный момент есть поддержка Telegram, Slack, Facebook. В дальнейшем, мы планируем добавлять любые мессенджеры, где есть чат-боты и открытое API. Если вам интересно, и вы хотите писать Urban Bot приложения для других мессенджеров, скажем Viber, Discord или у вас есть свой мессенджер то пишите к нам в группу https://t.me/urbanbotjs, одной просьбы будет достаточно, чтобы появилось большая мотивация реализовать ваш функционал.


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


Скажем, у нас есть готовое приложение App и мы хотим его запустить его в Telegram. Для этого нам понадобится класс UrbanBotTelegram из пакет @urban-bot/telegram. Функция render из @urban-bot/core подобная ReactDOM.render и компонент Root. Мы создаем экземпляр UrbanBotTelegram и передаем туда бот токен из Telegram, также можно передать isPolling, чтобы не настраивать вебхук, и бот работал локально. Готовый экземпляр мы передаем в компонент Root, и оборачиваем наше готовое приложение и, соответсвенно, передаем все в функцию render, которая запустит все процессы.


Более подробная инструкция с видео как разрабатывать локально и деплоить Telegram бота можно прочитать в инструкции.


import React from 'react';import { render, Root } from '@urban-bot/core';import { UrbanBotTelegram } from '@urban-bot/telegram';import { App } from './App';const urbanBotTelegram = new UrbanBotTelegram({    token: 'telegramToken',    isPolling: true,});render(    <Root bot={urbanBotTelegram}>        <App />    </Root>);

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


// ...import { UrbanBotSlack } from '@urban-bot/slack';// ...render(    <Root bot={urbanBotTelegram}>        <App />    </Root>);const urbanBotSlack = new UrbanBotSlack({    signingSecret: 'slackSigningSecret',    token: 'slackToken',});render(    <Root bot={urbanBotSlack}>        <App />    </Root>);

Прямой вызов API


С помощью Urban Bot вы можете создавать чат-ботов просто описывая их через компоненты. А что если вам будет нужно вручную вызвать API? Каждый экземпляр UrbanBot* содержит в себе API клиент для активного мессенджера. Рассмотрим пример для Telegram.


Мы можем получить bot с помощью хука useBotContext. bot содержит client и type c типом мессенджера. client будет представлять собой экземпляр библиотеки node-telegram-bot-api . В любом месте приложения можно получить client и вызвать любой метод на ваше усмотрение, скажем блокировать пользователя, если он написал нецензурное сообщение.


import React from 'react';import { useText, useBotContext } from '@urban-bot/core';function SomeComponent() {    const { bot } = useBotContext();    useText(({ text, chat, from }) => {        if (text.includes('бл***')) {            bot.client.kickChatMember(chat.id, from.id);        }    });    // ...}

В каждом мессенджере уникальный API. Если вы разрабатываете несколько мессенджеров одновременно, можно отделять функционал сравнивая bot.type.


import { useBotContext } from '@urban-bot/core';import { UrbanBotTelegram } from '@urban-bot/telegram';import { UrbanBotSlack } from '@urban-bot/slack';function SomeComponent() {    const { bot } = useBotContext();    if (bot.type === UrbanBotTelegram.type) {        // telegram api        bot.client.kickChatMember(/* ... */);    }    if (bot.type === UrbanBotSlack.type) {        // slack api        bot.client.conversations.kick(/* ... */);    }    // ...}

Как попробовать?


У Urban Bot есть стартер, который позволит вам начать разрабатывать чат-ботов за минуту, сделан по аналогии с create-rect-app. Все что вам нужно, чтобы попробовать Urban Bot это выполнить команду в терминале для


TypeScript


npx create-urban-bot my-app

JavaScript


npx create-urban-bot my-app --template js

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


Итого


Несмотря на то что Urban Bot запустился только недавно, на мой взгляд это библиотека с огромным потенциалом. Только представьте, если у вас есть базовые знания React, вы можете написать чат-бот любой сложности на все возможные платформы, создавать и использовать библиотеки с готовым набором компонентов на манер ui-kit, переиспользовать код между вашими другими UI приложеними на React, будь то web или mobile.


Если вы уже разрабатывали чат-ботов, попробуйте Urban Bot, вы почувствуете как библиотека делает за вас кучу работы. Если никогда не разрабатывали, но имеете представление о React, то напишете вашего первого чат-бота за 5 минут. Если вам понравилась идея и хотите, чтобы проект развивался дальше, вступайте в нашу группу в Telegram, ставьте звезду на гитхабе, будем рады любому фидбеку.


Полезные ссылки


Сайт
Github
Группа в Telegram
Наглядный чат-бот в Telegram, с открытым кодом.
Как создать Todo List чат-бот в Telegram с помощью React.js

Подробнее..

Реализация архитектуры Redux на MobX. Часть 1 Проблемные места Redux

13.03.2021 14:14:35 | Автор: admin

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

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

В этой части статьи я хочу показать, что:

  • редьюсеры - это аналоги обычных чистых функции для получения нового состояния.

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

  • dispatch + action + action creators - это аналог обычных вызовов функций, и разбиение на dispatch, action, action creators является зачастую ненужным и используются не к месту.

В статье не будет рассматриваться Redux Toolkit и прочие библиотеки для уменьшения бойлерплейта. Только то, в каком виде Redux использовался изначально. Отмечу, что похожая структура кода сторов, к которой пришли разработчики библиотеки Redux, существовала до появления Redux Toolkit в более user-friendly виде в других менеджерах состояний, вроде MobX, Vuex (я буду иногда его упоминать, т.к. он похож на MobX, и я немного знаком с ним).

Содержание первой части

Одно хранилище (стор) vs множество хранилищ

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

"Единственный источник правды" в определенном понимании действительно хорошая идея, упрощающая структуру приложения. Проще понять систему, когда ее структура везде одинаковая и все не локальные данные (данные с сервера, данные с localStorage/sessionStorage, общие данные не связанных напрямую компонентов) компонентов проходят через стор, хоть это и требует написание дополнительного кода. Но в отличие от Redux подхода, я бы считал единственным источником правды не один единственный стор на все приложение, а сам слой сторов.

Reducer vs чистая функция для мутации состояния. Нарушение SOLID

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

Из минусов - изначальная реализация сделана на ugly switch и имеет сложность O(n), а также количество ответственностей равное количеству actions в редьюсере. На практике сложность O(n) вряд ли заметно повлияет на производительность, если у вас не графическое приложения с перерисовкой по 60 раз в секунду. Другое дело лишний и усложненный код и ухудшение масштабируемости. Даже в редьюсерах можно заменить switсh на словарь с парами ключ-значение [actionNameKey][function] и код уже станет лучше.

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

Редьюсеры нарушают 3 принципа SOLID и один из принципов GRASP

Насколько я вижу, редьюсеры нарушают некоторые принципы проектирования. Некоторые из принципов проектирования довольно похожи, а соблюдение/нарушение одного ведет к соблюдению/нарушению второго. Нарушать их можно и зачастую даже нужно. Чрезмерное следование принципам проектирования ведет к ненужному усложнению кода. Об этом можно почитать по ссылкам "Когда не нужно использовать SOLID" и "О принципах проектирования". Особо интересный комментарий: "вопрос не в том, когда не нужно использовать SOLID, а в том, насколько использовать каждый из его принципов в своем, конкретном случае". И это вполне применимо и к другим принципам. Если какой-то принцип противоречит другому, то стоит выбирать в какой степени какой из принципов уместней будет нарушить/применить. В случае редьюсеров, хоть они и нарушают несколько принципов, к серьезным проблемам это не приводит, если нарушать принципы в меру.

Далее я буду употреблять термин "программные сущности". Имеются ввиду классы, модули, функции ит.п.

Нарушение single-responsibility principle (SRP)

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

Ошибочно может показаться, что у редьюсера только одна ответственность - вычислить новое состояние. На самом деле их много. Одна функция редьюсер отвечает за обработку разных actions. То есть количество причин для изменения редьюсера равно количеству actions. Когда редьюсер разрастается, он начинает отвечать за слишком многое. Конечно, тут приходит на помощь combineReducers.

Нарушение принципа открытости/закрытости

Принцип открытости/закрытости означает, что программные сущности должны быть:

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

  • закрыты для изменения: в результате расширения поведения сущности, не должны вноситься изменения в код, который эту сущность использует.

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

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

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

Нарушение принципа подстановки Барбары Лисков

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

https://medium.com/webbdev/solid-4ffc018077da - в этой статье описан случай, при котором нарушается этот принцип: "Если оказывается, что в коде проверяется тип класса, значит принцип подстановки нарушается."

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

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

Последствия нарушения этого принципа аналогичны последствиям нарушения предыдущего принципа.

Нарушение принципа высокой связности (High Cohesion) из GRASP

Связность (не путать со связанностью/зацеплением) - то, насколько данные внутри модуля связанны друг с другом. "То, насколько хорошо все методы класса или все фрагменты метода соответствуют главной цели." Хорошо, когда связность высокая и плохо, когда низкая.

В редьюсере, использующем switch, несколько действий объединены в одну функцию. Они связаны состоянием, которое изменяют. Иногда есть связность по передаваемым в редьюсер данным. Но отсутствует связность по action.type. К тому же, сами действия в разных case не зависимы друг от друга и выполняют разные задачи. Для объекта/класса естественно хранить в себе несколько функций их зачастую можно переопределить/заменить. Но когда функция содержит в себе несколько незаменяемых функций для выполнения разных задач - это уже низкая связность, что плохо.

Данный принцип тесно пересекается с SRP принципом из SOLID. Последствием нарушения данного принципа в редьюсерах является снижение читабельности кода и усложнение их повторного использования. Редьюсер, обрабатывающий несколько actions, мало где можно использовать повторно. Функцию же, которая обрабатывает только один action, гораздо чаще можно использовать повторно.

Заключение по редьюсерам

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

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

case 'todos/todoAdded': {  return {    ...state,    todos: [      ...state.todos,      action.paylod.newTodo    ]  }}

можно было бы писать примерно такие функции:

function todoAdded(state, newTodo) {  return {    ...state,    todos: [      ...state.todos,      newTodo    ]  }}

Функция-редьюсер заменена обычной чистой функцией, возвращающей новое состояние. Вместо поля type в action, как в Redux, здесь используется имя функции. И у функции только одна ответственность - изменить определенный участок состояния. Даже если потребуется вызывать функции изменения стора с помощью событий, все равно есть возможность вызвать функцию, передав имя события/функции через строковую переменную: todoStore['todoAdded'].

Селектор vs функция с мемоизацией, возвращающая данные

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

Аналогом селекторов в MobX являются вычисляемые значения (computed values). Так же, если надо просто сократить запись, можно использовать обычные JS геттеры. Можно сделать геттеры вычисляемыми значениями. Стоит упомянуть, что и в Vuex есть аналог селекторов - геттеры.

Согласно Redux, селекторы можно использовать как в компонентах, так и в middleware. Я использую аналогичную логику. Только геттеры и вычисляемые значения в случае MobX являются частью сторов.

Бизнес-логика в сторах MobX vs бизнес-логика в middleware's

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

В Vuex и MobX очень распространен подход, когда в action пишется вызов API и прочая бизнес-логика. В Vuex сторах сторонняя бизнес-логика вообще является частью сторов. Я считаю, что это превращает стор в аналог контроллера с пассивной моделью. То есть в контроллере пишется бизнес-логика, а модель ответственна за получения данных (из базы или с сервера). В MVC это считается плохим подходом. О пассивнойи активной модели MVC можно прочитать в wikipedia - MVC, наиболее частые ошибки. С другой стороны, сторы Vuex и MobX - это вариация MVVM, а не MVC.

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

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

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

Action creators, actions, dispatch VS прямые вызовы функций

Действиев Redux - это просто объект с именем события. Фактическиможно рассматривать действие как событие.

События нужны, чтобы оповестить определенные программные сущности, что в системе произошло какое-то действие. Диспетчеризация событий в Redux является вариацией паттерна pub/sub (издатель-подписчик).

Pub/sub нужен для передачи событий от издателя к подписчикам через посредника (канал событий) так, чтобы издатель и подписчик не знали друг о друге.

Я полагаю, что события можно использовать для случаев, если:

  • нужно избавиться от большой связанности. Например, когда один (либо несколько) объект должен вызывать схожий метод у множества других объектов.

  • программная сущность должна уметь вызывать методы других объектов или оповещать, что выполнилось такое-то действие, но сама она закрыта для изменений. То есть она может вызывать только методы по заранее заданным именам, либо отправлять заранее определенные уведомления. Пример такой ситуации - вызовы методов жизненного цикла в React компонентах. Свой метод жизненного цикла в компонент не добавить, т.к. он не будет вызываться внутренним механизмом react-а.

Где в Redux используются action-ы? В трех местах:

  1. в компоненте, чтобы вызвать middleware;

  2. в middleware, чтобы обновить стор;

  3. в редьюсере, чтобы знать, как именно нужно обновить стор.

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

1. Подписка middleware-ом на событие компонента (отправителя событие). Посмотрим, зачем здесь нужна подписка?

Action, используемый для вызова middleware, не используется в сторе. То есть со стором он не связан.

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

По-моему, подписку здесь можно безболезненно заменить обычным вызовом функции.

2. Middleware обновляет стор. Аналогично. Часто ли нужно обработать один action разными редьюсерами? Довольно редко такое встречается на практике и лучше заменить неявный вызов явным.Думаю, что подписку и здесь можно заменить на обычный вызов функции.

3. actions в редьюсере. Редьюсер принимает много actions и по имени action определяет, какой код надо выполнить. Это случай я уже рассматривал в главе о редьюсерах.

Дополнение - а нужен ли useReducer?

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

У useReducer аналогичная задача, но для состояния компонента. Я уже писал выше про недостатки редьюсеров и как упросить код. В компонентах обычно меньше actions, т.к. они локальные, поэтому и проблем меньше. Основные оставшиеся недостатки у useReducer - лишний код и снижение читабельности.

Функционал, аналогичный useReducer, можно сделать вручную через useState, но это долго и не удобно. Но можно не делать это каждый раз, а вынести отдельно, что я и сделал. Я написал хук useStateWithUpdaters, чтобы писать более читабельный и удобный код. Ниже пример его использования:

const updaters = {  subtract: (prevState, value) => (    { ...prevState, count: prevState.count - value }  ),  add: (prevState, value) => (    { ...prevState, count: prevState.count + value }  ),};const MyComponent = () => {  const [{ count }, {add, subtract}] =         useStateWithUpdaters({ count: 0 }, updaters);  return (    <div>      Count: {count}      <button onClick={() => subtract(1)}>-</button>      <button onClick={() => add(1)}>+</button>    </div>  );};

Его реализацию вы можете найти в issue.
Есть TypeScript версия.

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

Подробнее..

Фреймворк-независимое браузерное SPA

14.03.2021 16:17:43 | Автор: admin

1. Но... зачем?

  1. Существует огромное количество фреймворков для разработкиSPA(Single Page Application).

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

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

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

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

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

2. Архитектурные цели иограничения

Цели:

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

  2. Стимулируется разделение ответственностей (separation ofconcerns) иследовательно модульность кода так что:

    • Модули легко поддаются тестированию

    • Интеграции свнешними сервисами (boundaries) атакже грязные хаки иворкэраунды вынесены вотдельные модули инепротянуты через несколько различных файлов. Таким образом смена реализации интеграции ссервисом или отказ отхака становится реалистичной задачей анедолгосрочным рефакторингом

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

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

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

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

Ограничения:

Приложение должно работать вбраузере. Следовательно оно должно быть написано сиспользованием (или скомпилированов) HTML+CSS для определения статического интерфейса иJavaScript для добавления динамического поведения.

3. Ограничим тему данной статьи

Существует большое количество архитектурных подходов кструктурированию кода. Наиболее распространенные наданный момент: слоеная (layered), луковичная (onion) ишестигранная (hexagonal). Беглое сравнение было дано вмоей предыдущейстатье.

Данная статья ограничивается слоем представления втерминологии слоеной/луковичной архитектур поскольку большинство SPA занимается исключительно отображением данных. Таким образом слои домена (domain) иприложения (application) могут быть проигнорированы. Как следствие, наиболее естественный способ понять назначение такого приложения получить обзорное представление ослое представления.

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

Интересно отметить что вслучае отсутствия вышеупомянутых слоев приложение напоминает классическую шестигранную структуру (также называемуюPorts and Adapters) вкоторой представлениеявляетсяприложением. Взгляните наинтеграцию сlocalStorage вTodoMVCпримере созданном вкачестве иллюстрации кданной статье (папкаboundaries/local-storage).

4. Структура файлов. Как заставить SPAкричать?

Будем исходить из терминологии дяди Боба.

Рассмотрим типичный онлайн магазин. Приблизительно так онмогбы быть нарисован насалфетке владельцем бизнеса:

Рисунок1: типичный онлайн магазин, нарисованный насалфетке

Каким может быть наиболее кричащий способ структурировать кодовую базу? Нарисунке 2все страницы отражены как папки.

Рисунок2: структура папок верхнего уровня, отражающая страницы определённые нарисунке 1

Заметим что мыдобавили папку shared как место где будут определены общие UIблоки, такие как шаблон, панель навигации, корзина.

Наши страницы построены излогических (ивидимых) частей. Пока что назовем их блоками иположим впапку сименем parts. Посмотрим что получилось (рисунок 3).

Рисунок3: размещение вложенных блоков внутри подпапки parts

Как видно, вложенность выглядит отвратительно уже для второго уровня для страницы goods catalogue. Путь goods-catalogue/parts/goods-list/parts/good-details.jsуже награнице адекватной длины пути кфайлу. При том что вреальных приложениях два уровня вложенности далеко непредел.

Давайте избавимся отпапок parts вфайловой структуре. Посмотрим нарисунок 4.

Рисунок4: вложенные блоки вынесены изпапок parts

Теперь внутри пути goods-catalogue/goods-listнаходится три файла.goods-list.js(родительский) расположен между файлами, определяющими вложенные внего блоки. Вреальных проектах, учитывая кол-во разнородных файлов (js, html, css) это приводит кневозможности разделить файлы, определяющие текущий блок ифайлы, отвечающими завложенные внего блоки.

Решение:

  1. Если конкретный блок определяется несколькими файлам создаем для него папку.

    • goods-listявляется блоком исостоит изболее чем одного файла, потому для него создана папка.

    • filtersявляется блоком состоящим изодного файла, потому для него несоздана отдельная папка.

  2. Если конкретный блок (неважно изодного файла или изнескольких) являетсявложенным блоком добавим кназванию файла префикс _. Таким образом все вложенные блоки будут подняты кверху папки вфайловом обозревателе.

    • _goods-list folderявляется вложенным блоком относительноgoods-catalogueсоответственно кназванию папки добавлен префикс.

    • goods-list.jsявляется частью определения блока_goods-listсоответственно префикс недобавлен.

    • _good-details.jsявляется вложенным блоком относительно_goods-listсоответственно префикс добавлен.

Рисунок5: использование префикса _ для разделения вложенных блоков отихродителей

Готово! Теперь открывая папку сблоком мыможем сразуже увидеть иоткрыть основной файл, определяющий данный блок. После чего при необходимости перейти квложенному блоку. Обратите внимание что папкаpagesбыла переименована вcomponentsнарисунке 5. Так сделано поскольку страницы иблоки логически являются разными вещами новтерминологии HTML итоидругое можетбы представлено какcomponent. Сэтого момента папкаcomponentsявляется основной папкой нашего приложения, домом для слоя представления.

5. Язык разработки. JavaScript?

Единственный язык который может быть выполнен вбраузере это JavaScript. Существует множество статей посвященных его несуразности. Выможетепосмеяться онем (тайм код1-20), ноэто только веселая часть...

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

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

Глубокий анализ возможных опций лежит вне границ данной статьи, номой персональный выборTypeScriptпосколькуон:

  • Обеспечивает проверку типов наэтапе компиляции

  • Будучи над-множеством JavaScript, может выполнять JavaScript код без дополнительного интеграционного кода

  • Определения типов (typings) могут быть добавлены поверх существующего JavaScript кода без его изменения. Благодаря простоте этой возможности, большинство существующих npm пакетов уже покрыты тайпингами. Таким образом выможете использовать эти пакеты так, как будтобы они являются TypeScript пакетами. Соответственно ихиспользование также является типо-безопасным.

Хинт: рекомендую посмотреть всторонуasm.js,blazorиelmесли вызаинтересованы вдругих опциях

6. Требования кдизайну приложения

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

Таким образомпервой целью [6.1]будет возможность определения компонентов средствами HTML иCSS иихпоследующее переиспользование другими компонентами.

Существенный недостаток чистого HTML состоит втом что оннетипизирован. Существует достаточное количество движков шаблонизации, таких какunderscore.js,handlebars.js. Однако все они принимают навход строки, что ограничивает нас впроверке корректности используемых вшаблоне данных наэтапе компиляции приложения.

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

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

Таким образомтретьей целью [6.3]является возможность компонентов принимать данные изатрибутов иизхранилищ одновременно. Компоненты должны быть перерисованы при изменении любой части принимаемых данных.

Четвертой целью [6.4]станет определение требований ктаким хранилищам:

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

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

  • Хранилища должны иметь возможность использовать сервисы ифункции слоев Domain иApplication. Воизбежание сильной связности между хранилищем играницами приложения, сервисы должны быть использованы спомощью механизмаDependency Injection. Хранилища должны ссылаться только наинтерфейсы.

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

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

  • Лучшей читаемости кода, т.к. разработчик может предположить назначение компонента изнабора данных принимаемых этим компонентом.

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

Таким образом,пятая цель [6.5] позволить хранилищам данных быть определенными как классические TypeScript классы. Обозначить механику определения среза данных, используемого конкретным компонентом.

Держа эти цели вголове, давайте перечислим необходимые логические блоки кода:

  • Компоненты (Components) строго типизированные HTML шаблоны + CSS стили

  • Модели вида (ViewModels) классы, инкапсулирующие состояние данных, используемое компонентом (ивсей иерархией компонентов под ним).

  • Фасады моделей вида (ViewModel facades) ограничивают видимость свойств модели вида теми, которые используются вконкретном компоненте.

Рисунок6: желаемая структура кода вслое представления

  • Не-пунктирные стрелки отражают рендеринг компонентов родительскими компонентами. Направление стрелки отражает направление передачи атрибутов.

  • Пунктирные линии отражают зависимости одних логических кусков кода отдругих (ссылки).

  • Блоки сзеленой рамкой границы модуля. Каждый модуль/подмодуль отражен выделенной под него папкой. Общие модули лежат впапке shared.

  • Голубые блоки модели вида. Модели вида определены поштуке намодуль/подмодуль.

Что упущено? Заметьте как модели вида нарисунке 6не имеют никаких параметров. Это всегда справедливо для модулей верхнего уровня (страниц) иглобальных моделей вида. Ноподмодули зачастую зависят отпараметров, определённых впроцессе работы сприложением.

Обозначимшестую цель [6.6] позволить атрибутам подмодуля быть использованными моделью вида этого подмодуля.

Рисунок7: атрибуты передаются нетолько вкорневой компонент модуля ноивего модель вида

7. Техническая реализация

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

7.1. Компоненты

Для отрисовки строго-типизированной разметки можно использовать синтаксис tsx (типизированныйjsx). Рендеринг tsx поддерживается различными библиотеками, такими какReact,PreactandInferno. TsxНЕявляется чистым HTML, тем неменее онможет быть автоматически сконвертирован в/из HTML. Потому зависимость отtsx мне кажется допустимой т.к.вслучае миграции начистый HTML, значительная часть работы может быть выполнена автоматически.

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

Хинт: впоследние годы функциональные компоненты ввиде чистых функций вышли измоды всообществе React. Использованиеreact hooksнаделяет функциональные реакт компоненты сайд-еффектами ипоощряет смешивание рендера слогикой управления состоянием. Хуки являются специфическим API для React инедолжны использоваться при разработке вподходе, описанном вданной статье.

Другими словами,компонентылишены состояния. Представим ихчерез выражение UI=F(S) где

  • UI видимая разметка

  • F определение компонента

  • S текущее значение данных внутри модели вида (здесь идалее вьюмодели)

Пример компонента может выглядет так:

interfaceITodoItemAttributes{name:string;status:TodoStatus;toggleStatus:()=>void;removeTodo:()=>void;}constTodoItemDisconnected=(props:ITodoItemAttributes)=>{constclassName=props.status===TodoStatus.Completed?'completed':'';return(<liclassName={className}><divclassName="view"><inputclassName="toggle"type="checkbox"onChange={props.toggleStatus}checked={props.status===TodoStatus.Completed}/><label>{props.name}</label><buttonclassName="destroy"onClick={props.removeTodo}/></div></li>)}

Этот компонент отвечает заотрисовку одного todo элемента внутриTodoMVCприложения.

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

Итого мыдостигли целей[6.1]и[6.2].

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

7.2. Модели Вида (вьюмодели)

Как было сказано ранее, мыхотим чтобы вьюмодели были написаны ввиде TypeScript классов стем что-бы:

  • Обеспечивать инкапсуляцию данных.

  • Предоставлять возможность взаимодействия сослоями domain/application посредством механизма dependency injection.

Однако, классы непредоставляют встроенных механик перерисовки компонентов использующих данные, инкапсулированные экземпляром класса.

Применим принципы реактивного интерфейса (reactive UI). Подробное описание этих принципов приведено вэтом документе. Данный подход был впервые представлен вWPF (C#) иназванModel-View-ViewModel. ВJavaScript сообществе, объекты предоставляющие доступ кобозреваемым (observable) данным чаще называются хранилищами (stores) следуя терминологииflux. Отмечу чтохранилищеэто очень абстрактный термин, онможет определять:

  • Глобальное хранилище данных для всего приложения.

  • Доменный объект, инкапсулирующий логику логику бизнеса инепривязанный кконкретному компоненту ноинеявляющийся глобальным.

  • Локальное хранилище данных для конкретного компонента или иерархии компонентов.

Таким образом любая вьюмодель является хранилищем, нонекаждое хранилище является вьюмоделью.

Определим ограничения креализации вьюмоделей:

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

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

Яиспользуюmobxдекораторы для того, чтобы сделать поля класса обозреваемыми. Пример вьюмодели:

classTodosVM{@mobx.observableprivatetodoList:ITodoItem[];//use"poormanDI",butintherealapplicationstodoDaowillbeinitializedbythecalltoIoCcontainerconstructor(props:{status:TodoStatus},privatereadonlytodoDao:ITodoDAO=newTodoDAO()){this.todoList=[];}publicinitialize(){this.todoList=this.todoDao.getList();}@mobx.actionpublicremoveTodo=(id:number)=>{consttargetItemIndex=this.todoList.findIndex(x=>x.id===id);this.todoList.splice(targetItemIndex,1);this.todoDao.delete(id);}publicgetTodoItems=(filter?:TodoStatus)=>{returnthis.todoList.filter(x=>!filter||x.status===filter)asReadonlyArray<Readonly<ITodoItem>>;}///...othermethodssuchascreationandstatustogglingoftodoitems...}

Обратите внимание что мыссылаемся наmobx напрямую, однако декораторы неприсутствуют втеле методов.

Япродемонстрирую как абстрагировать реактивность иубрать зависимость отmobx вследующей статье. Пока что посчитаем достаточным обращаться кдекораторам через пространство имен mobx. При таком ограничении заменить декораторы надекораторы издругой библиотеки можно будет спомощью автоматизированного скрипта.

Также обратите внимание что конструктор вьюмодели принимает первый аргумент типа{status:TodoStatus}. Это позволяет удовлетворитьцели [6.6]. Тип должен совпадать стипом определяющим атрибутыкорневого компонентамодуля. Ниже обобщенный интерфейс вьюмодели:

interfaceIVMConstructor<TProps,TVMextendsIViewModel<TProps>>{new(props:TProps,...dependencies:any[]):TVM;}interfaceIViewModel<IProps=Record<string,unknown>>{initialize?:()=>Promise<void>|void;cleanup?:()=>void;onPropsChanged?:(props:IProps)=>void;}

Все методы вьюмодели необязательны. Они могут быть определены для:

  • Выполнения кода при создании вьюмодели

  • Выполнения кода при удалении вьюмодели

  • Выполнения кода при изменении атрибутов (под-)модуля.

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

Как показано нарисунке7, точкой входа для модуля является его корневой компонент. Таким образом вьюмодель должна быть создана когда корневой компонент модуля добавлен вструктуру DOM(mounted) иудалена когда онудаляется состраницы(unmounted). Решить эту задачу можно спомощью техники компонентов высшего порядка (higher order components).

Определим тип функции:

typeTWithViewModel=<TAttributes,TViewModelProps,TViewModel>(moduleRootComponent:Component<TAttributes&TViewModelProps>,vmConstructor:IVMConstructor<TAttributes,TViewModel>,)=>Component<TAttributes>

Эта функция возвращает компонент высшего порядка над moduleRootComponent, который:

  • Должен обеспечить создание вьюмодели перед созданием имонтированием (mount) компонента.

  • Должен обеспечить зачистку(удаление) вьюмодели при демонтировании (unmount).

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

Пример использования данной функции:

constTodoMVCDisconnected=(props:{status:TodoStatus})=>{return<sectionclassName="todoapp"><Header/><TodoListstatus={props.status}/><FooterselectedStatus={props.status}/></section>};constTodoMVC=withVM(TodoMVCDisconnected,TodosVM);

Вразметку корневой страницы приложения (либо роутера, зависит оттого что как построено ваше приложение), результирующий компонент будет вставлен как<TodoMVCstatus={statusReceivedFromRouteParameters}/>. После чего, экземплярTodosVMстановится доступным для всех под-компонентов внутри компонентаTodoMVC.

Важно что реализация создания вьюмодели, атакже доступности вьюмодели для дочерних компонентов, скрыта внутри withVM.

  • TodoMVCDisconnected компонент независит отбиблиотеки рендера

  • TodoMVC компонент может быть прорендерен вкомпоненте, независящем отбиблиотеки рендера

  • TodosVM ссылается только надекораторы. Потому, как описано выше, еёотвязка отmobx реальна.

Хинт: вреализации изпримера, функцияwithVMзависит отreact context API. Выможете попробовать реализовать аналогичное поведение вобход контекст апи. Важно, что реализация должна быть синхронизирована среализацией доступа квьюмодели изфасадов вьюмоделей смотрите описание функцииconnectFnвследующем разделе.

7.3. Фасады вьюмоделей

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

Попробуем вместо классических фасадов использовать функции, принимающие вьюмодель (или несколько вьюмоделей) ивозвращающие набор функций/данных, необходимых конкретному компоненту. Назовем ихфункциями среза (slicing function). Что если такая функция будет получать атрибуты компонента, который она обслуживает, вкачестве последнего аргумента?

Рисунок8: передача атрибутов компонента фасаду вьюмодели (функции среза/slicing function)

Посмотрим насинтаксис (вслучае одной вьюмодели):

typeTViewModelFacade=<TViewModel,TOwnProps,TVMProps>(vm:TViewModel,ownProps?:TOwnProps)=>TVMProps

Выглядит очень похоже нафункцию connectизбиблиотеки Redux. Стой лишь разницей что вместо аргументовmapStateToProps,mapDispatchToActionsиmergePropsмы имеем один аргумент функцию среза, которая должна вернуть данные иметоды одним объектом. Ниже пример функции среза для компонентаTodoItemDisconnectedивьюмоделиTodosVM.

constsliceTodosVMProps=(vm:TodosVM,ownProps:{id:string,name:string,status:TodoStatus;})=>{return{toggleStatus:()=>vm.toggleStatus(ownProps.id),removeTodo:()=>vm.removeTodo(ownProps.id),}}

Заметка: Яназвал аргумент функции, содержащий атрибуты компонента OwnProps что-бы приблизить его ктерминологии применяемой вreact/redux.

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

typeconnectFn=<TViewModel,TVMProps,TOwnProps={}>(ComponentToConnect:Component<TVMProps&TOwnProps>,mapVMToProps:TViewModelFacade<TViewModel,TOwnProps,TVMProps>,)=>Component<TOwnProps>constTodoItem=connectFn(TodoItemDisconnected,sliceTodosVMProps);

Отрисовка такового компонента всписке todo элементов:<TodoItemid={itemId}name={itemName}status={itemStatus}/>

Заметьте чтоconnectFnскрывает детали реализации реактивности:

  • Она берёт компонентTodoItemDisconnectedифункцию срезаsliceTodosVMProps обе незнающие ничего ореактивности иобиблиотеке для рендеринга JSX.

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

Смотрите нареализациюфункции connectFnдля TodoMVCприложения, сделанного вкачестве примера.

8. Заключение

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

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

Всеже, можетли слой представления быть полностью независим отфреймворков вреальном приложении?

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

  • Абстрагироваться отmobx декораторов

  • Абстрагировать все фреймворко-зависимые библиотеки, используемые слоем представления. КпримеруTodoMVCзависит отбиблиотек react-router иreact-router-dom.

  • Абстрагироваться отсинтетических событий, специфичных для конкретной библиотеки, отрисовывающей JSX.

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

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

P.S. Сравнение рассмотренной структуры иеереализации спопулярными фреймворками для разработки SPA:

  • Всравнении сосвязкойReact/Redux: вьюмодели заменяютreducers,action creatorsиmiddlewares. Вьюмодели содержат состояние (являются stateful). Нет time-travel. Множество хранилищ. Отсутствие просадки производительности вызванной наличием большого числа использований функции connect скакой тологикой внутри. Redux-dirven приложения становятся все медленнее имедленнее стечением времени иззадобавления новых connected компонентов вприложение. При этом несуществует какого токонкретного ботлнека, устранением которого можно былобы исправить ситуацию.

  • Всравнении сvue: строго типизированные представления благодаря TSX. Вьюмодели являются обычными классами инетребуют использования функций сторонних библиотек, равно как необязаны удовлетворять интерфейсу, определенному сторонними фреймворками. Vue.js заставляет определять состояниевнутри определенной структурыимеющей свойства data,methods, ит.д. Отсутствие vue-специфических директив исинтаксиса привязки кмодели.

  • Всравнении сangular: строго типизированные представления благодаря TSX. Отсутствие angular-специфических директив исинтаксиса привязки кмодели. Инкапсуляция данных внутри вьюмоделей вотсутствие двусторонней привязки данных (two-way data binding).Хинт: для определенных сценариев, таких как формы, двусторонняя привязка данных удобна иполезна.

  • Всравнении счистым react где управление состоянием выполняется спомощью хуков (hooks, такие какuseState/useContext):Лучшее разделение ответственностей. Вьюмодели могут восприниматься втерминологии реакта как контейнер компоненты, которые лишены возможность рендерить что-либо иявляются ответственными исключительно заработу сданными. Нет необходимости:

    • следить запоследовательностью вызова хуков.

    • отслеживать зависимости хуков useEffect внутри deps массива.

    • проверять смонтированли все еще компонент после каждого асинхронного действия.

    • следить что замыкания изпредыдущих рендеров неиспользуются внутри обработчика хука эффекта.

    Как любая технология, хуки (ивчастности useEffect) требует разработчика следовать некоторым рекомендациям. Эти рекомендации неявляются частью интерфейсов, ноприняты как подход, модель мышления (mental model) или стандартные практики (best practices). Прекраснаястатья про использование хуковотчлена команды разработки react. Прочитайте ееиответьте себе надва вопроса:

    • Что выполучаете используя хуки?

    • Как много правил, неконтролируемых компилятором/линтером исложно отслеживаемых наручном код ревью, нужно соблюдать чтобы использование хуков оставалось предсказуемым?Если второй список получился больше первого это хороший сигнал что относится кхукам стоит сбольшой осторожностью.Хинт: примерфрустрацииотхуков

  • Всравнении сreact-mobx интеграцией. Структура кода неопределяется пакетом react-mobx инепредлагается документацией кнему. Разработчик должен придумать подход кструктурированию кода сам. Рассмотренную встатье структуру можно считать таким подходом.

  • Всравнении сmobx-state-tree: Вьюмодели являются обычными классами инетребуют использования функций сторонних библиотек, равно как необязаны удовлетворять интерфейсу, определенному сторонними фреймворками.Определение типавнутри mobx-state-tree опирается наспецифические функции этого пакета. Использование mobx-state-tree всвязке сTypeScript провоцирует дублирование информации поля типа объявляются как отдельный TypeScript интерфейс нопри этом обязаны быть перечислены вобъекте, используемом для определения типа.

Оригинал статьи наанглийском языке вблоге автора (меня же)

Подробнее..

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