В начале несколько слов о названии, почему Red?
Всё просто. Любому явлению, которое претендует на определённый уровень целостности, необходим идентификатор. На такой идентификатор люди ссылаются в обсуждениях и сразу становится понятно о чём речь. В случае с архитектурой не стоит делать попытку описать в названии суть, любая архитектура это сложная вещь. Поэтому — просто Red!
Наверное у кого-то возникнет вопрос — а зачем нужна ещё одна архитектура?
Основная суть всех архитектурных нововведений в уменьшении связей в коде, т.н. code decoupling. И как следствие, улучшение тестируемости, поддержки, упрощению ввода новых функций и т.д. Но пока ни одна архитектура не признана “идеальной”, остаётся много неприкладных проблем, над которыми программисты ломают голову. Предложения по улучшению архитектур будут продолжаться до тех пор пока “идеальная” архитектура не будет найдена, т.е., вероятно будут продолжаться всегда.
Задача Red Architecture — свести сложность реализации логики приложения к минимуму, оставляя при этом нетронутыми возможность применения и все преимущества других паттернов проектирования.
Red Architecture имеет один класс, необходимый для своей реализации, и четыре соглашения.
Единственный класс в Red Architecture характеризуется следующими возможностями:
- способен броадкастить (передавать зарегистрированным функциям-обработчикам) данные в формате ключ/значение
- способен добавлять и удалять хендлеры (функции-обработчики), по списку которых рассылается некоторые данные в формате ключ/значение
- содержит перечисление, содержащее все ключи, которые могут быть использованы для передачи данных функциям-обработчикам
Вы можете сказать, что похожий паттерн уже существует и называется KVO, NSNotification и NSNotificationCenter, шаблон Delegate и т.п. Но, во-первых, данный класс имеет ряд ключевых отличий. Во-вторых — главное в Red Architecture вовсе не содержание этого класса, а как он используется в совокупности с представленными здесь соглашениями. Это мы и рассмотрим дальше.
Конкретный пример:
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace Common
{
public enum k {OnMessageEdit, MessageEdit, MessageReply, Unused, MessageSendProgress, OnMessageSendProgress, OnIsTyping, IsTyping, MessageSend, JoinRoom, OnMessageReceived, OnlineStatus, OnUpdateUserOnlineStatus }
public class v : ObservableCollection<KeyValuePair<k, object> >
{
static v i;
static v sharedInstance()
{
if (i == null)
i = new v();
return i;
}
public static void h(System.Collections.Specialized.NotifyCollectionChangedEventHandler handler)
{
i.CollectionChanged += handler;
}
public static void m(System.Collections.Specialized.NotifyCollectionChangedEventHandler handler)
{
i.CollectionChanged -= handler;
}
public static void Add(k key, object o)
{
i.OnCollectionChanged(new System.Collections.Specialized.NotifyCollectionChangedEventArgs(System.Collections.Specialized.NotifyCollectionChangedAction.Add,
new List<KeyValuePair<k, object>>(new KeyValuePair<k, object>[] { new KeyValuePair<k, object>(key, o) })));
}
protected v()
{
}
}
}
Маленький класс, унаследованный от ObservableCollection. Из названия базового класса следует, что есть возможность следить за изменениями в данной коллекции, такими как добавление и удаление элементов. На самом деле, в классе v используется только механизм нотификации об изменениях в коллекции. Никакие данные в коллекцию не добавляются и не удаляются — в Red Architecture есть соглашение “Consume now or never”, которое не подразумевает хранение данных на стадии их передачи по логической цепочке с использованием класса v. Метод Add() всего лишь отправляет нотификацию, содержащую “добавляемые” данные всем функциям подписчикам. Так же в классе присутствуют функции h() для добавления нового подписчика и m() для его удаления.
Кроме того, в классе присутствует перечисление k. И если сам класс является ключевым элементом всей архитектуры, то данное перечисление является ключевым элементом этого класса. Взглянув на имена элементов перечисления (которые в самом хорошем случае могут быть дополнены подробными комментариями) можно легко понять, какие функции реализованы в приложении. Более того, если вам станет интересно, как же всё таки реализована каждая из этих функций, вам будет достаточно поискать по проекту, например “k.OnMessageEdit”. Вы удивитесь небольшому объёму кода, который найдёте, а его содержание вызовет у вас желание незамедлительно приобщиться к разработке, поскольку, очень вероятно, что вам сразу станет понятна логика рассматриваемой функции на каждом из этапов — от получения результата запроса, до отображения результата в UI. В то же время, вас вряд ли на текущий момент заинтересует какие слои есть в приложении, и вообще, как физически организован код. Даже в текущем файле вам вряд ли будет интересен близлежащий контекст.
Но давайте вернёмся к дальнейшему рассмотрению примера.
Для каждого элемента перечисления k существует два способа использования:
1. Ключ “добавляется” в класс v инициируя рассылку данных с этим ключом по подписчикам.
2. Данные, присланные с данным ключом обрабатываются объектами — подписчиками.
Приведём подробные пояснения к пункту 1:
К слову сказать, в перечислении k могут быть отражены далеко не все функции, реализованные в приложении. Т.е. я хочу обратить внимание на один немаловажный аспект Red Architecture — для начала перехода к Red Architecture не требуется масштабных реорганизаций кода. Вы можете начать применять её прямо “здесь и сейчас”. Red Architecture мирно сосуществует с прочими шаблонами проектирования благодаря не связанному со слоями или физической организацией программы способу построения логики. Единственный необходимый инфраструктурный элемент Red Architecture — программный объект, реализующий интерфейс подобный тому, который есть у рассматриваемого нами класса v.
Рассматриваемый здесь класс v не оптимален. Давайте рассмотрим что следовало бы улучшить.
Наверное, не стоит наследоваться от базового класса, в котором не используется его основное назначение — хранение элементов. Честнее было бы написать собственный класс, в котором был бы только необходимый функционал. Однако, и представленный в примере вариант абсолютно рабочий, поэтому если у вас нет предрассудков относительно “идеальности” кода, тогда текущий вариант можно оставить.
Кроме того, вместо простого словаря в качестве значения к ключу из перечисления k, следует прибегнуть к более строгой типизации: использовать структуры, модели, созданные специально для обмена данными по данному ключу. Это убережёт от возможных опечаток и путаницы, которые возможны со строковыми ключами обычного словаря.
Вероятно, можно сделать и ещё одно улучшение. На текущий момент класс v единственный и содержит в себе все ключи функций, реализованных с помощью Red Architecture. Правильнее было бы отдельные группы ключей выделить в собственные классы, подобные v. Например ключи MessageEdit и OnMessageEdit имеют очевидную связь — ключ MessageEdit используется в логической цепочке отправки отредактированного сообщения, ключ OnMessageEdit — в обработке прихода данных. Для них можно создать отдельный класс по шаблону класса v. Таким образом, на данную логическую цепочку смогут подписаться только “заинтересованные” программные объекты. В текущем же рассматриваемом примере уведомления о “добавлении” данных рассылаются всем подписчикам, в том числе и незаинтересованным в каком-то из ключей.
Если же не хочется “размножать” класс v, то можно изменить метод h(), добавив в него первым параметром ключ, на который подписывается данный объект.
Теперь приведём примеры к пункту 2, а именно возможные варианты обработки данных функциями обработчиками:
void OnEvent(object sener, NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Add)
{
var newItem = (KeyValuePair<k, object>)e.NewItems[0];
if (newItem.Key == k.OnMessageSendProgress)
{
var d = (Dictionary<string, object>)newItem.Value;
if ((string)d["guid"] == _guid && (ChatMessage.Status)d["status"] == ChatMessage.Status.Deleted)
{
Device.BeginInvokeOnMainThread(() =>
{
FindViewById<TextView>(Resource.Id.message).Text = "<deleted>";
});
}
}
else if (newItem.Key == k.OnMessageEdit)
{
var d = (Dictionary<string, object>)newItem.Value;
if ((string)d["guid"] == _guid)
{
Device.BeginInvokeOnMainThread(() =>
{
FindViewById<TextView>(Resource.Id.message).Text = (string)d["message"];
});
}
}
}
}
В приведённом примере функция OnEvent() является обработчиком, находящимся прямо в объекте класса ячейки списка (таблицы). Нас интересуют только события «добавления» данных, для фильтрации только этих событий мы в первую очередь добавляем условие if (e.Action == NotifyCollectionChangedAction.Add). Далее мы получаем данные которые пришли с событием, и проверяем чтобы ключ этих данных соответствовал ключу, для обработки данных которого предназначена данная функция:
var newItem = (KeyValuePair<k, object>)e.NewItems[0];
if (newItem.Key == k.OnMessageSendProgress)
После прохождения условия на соответствие ключа мы уже точно знаем формат данных, которые к нам пришли. В рассмартваемом примере это словарь: var d = (Dictionary<string, object>)newItem.Value;
Теперь, всё что нам осталось, это убедится, что пришедшие данные соответствуют данной ячейке таблицы if ((string)d[«guid»] == _guid И статус данного сообщения говорит о том, что оно было удалено: && (ChatMessage.Status)d[«status»] == ChatMessage.Status.Deleted). После этого мы заменяем значение текстового поля в текущей ячейке на строку "<deleted>":
Device.BeginInvokeOnMainThread(() =>
{
FindViewById<TextView>(Resource.Id.message).Text = "<deleted>";
});
Заметьте использование Device.BeginInvokeOnMainThread() — мы должны помнить, что OnEvent() может быть вызван не только в главном потоке, а как мы все знаем, перерисовка UI элементов возможна только в главном потоке. Поэтому мы должны вызывать
FindViewById<TextView>(Resource.Id.message).Text = "<deleted>";
в главном потоке.
Наличие в предложенном примере else if (newItem.Key == k.OnMessageEdit) подсказывает, что нам ничто не мешает обрабатывать более одного ключа, если это необходимо в данном конкретном случае.
Архитектурные соглашения:
— Результат операции (например, сетевого запроса или вызова функции) интерпретируется в месте его получения. Например, если в результате ошибки запроса данные не получены, возвращается не “сырые” данные результата запроса. Вместо этого результат интерпретируется в структуру, которую могут отобразить объекты, инициировавшие или обрабатывающие ответ от данного запроса. Объекты подписчики не должны знать откуда к ним пришли данные, но они “должны понимать” формат данных, который к ним приходит.
Для данного соглашения приведём пример:
string s = Response.ResponseObject["success"].ToString();
success = Convert.ToBoolean(s);
if (success)
{
var r = Response.ResponseObject["data"].ToString();
if(r.Contains("status")) // response not empty
{
Dictionary<string, object> retVal = JsonConvert.DeserializeObject<Dictionary<string, object>>(r);
// convert status from web to internal type
retVal["status"] = (ChatMessage.Status)Enum.ToObject(typeof(ChatMessage.Status), retVal["status"]);
await PersistanceService.GetCacheMessagePersistance().UpdateItemAsync(retVal);
v.Add(k.OnMessageSendProgress, retVal);
}
}
Здесь мы видим обработку поля status из ответа запроса. Сначала status приводится к внутреннему типу ChatMessage.Status и только после этого вызывается v.Add() куда передаются уже конвертированные во внутренний формат данные, которые ожидают обработчики.
Тоже касается и ошибок и полученных значений null и т.п. Если мы получили null, например, в результате ошибки, мы не делаем v.Add(k.SomeKey, null). Вместо этого мы интерпретируем null здесь — в месте его получения в употребимую понятную информацию, и отправляем её с данным ключом, например так: v.Add(k.SomeKey, {errorCode: 10, errorMessage: “Error! Try later.”});
— Потребление данных сейчас или никогда. (Consume now or never). Полученные данные (в результате вызова функции или запроса) не сохраняются в памяти для будущей обработки. Они должны быть “употреблены” сразу же после получения, в другом случае они просто не будут обработаны. Потребителями данных являются функции-обработчики. Например, если ячейка списка запросила данные для своего отображения, и данные пришли после того, как ячейка вышла из области видимости на экране смартфона пользователя, то такие данные не будут обработаны (или, если это был веб-запрос, то они могут быть сохранены в кеш, чтобы не делать в будущем ещё один запрос, когда данная ячейка вновь появится на экране пользователя и запросит данные для своего отображения).
— Статические состояния никогда не дублируются, не хранятся в оперативной памяти. Идея в том, что статические состояния (например, данные в локальной или веб базе данных) не надо дублировать, не надо в оперативной памяти создавать динамические копии статических состояний. Наличие двух копий состояния часто порождает необходимость синхронизации между ними, и тем самым усложняет и делает код менее устойчивым к ошибкам.
Модели данных в основном используются для трансфера данных внутри приложения, не используются для хранения состояния объектов или элементов списков.
Например, список элементов на экране пользователя может быть представлен в оперативной памяти массивом моделей данных, хранящих состояние каждой отображаемой ячейки таблицы. Т.е. происходит дублирование состояния, поскольку данные, отображаемые ячейкой, уже существуют в локальной или веб базе данных. Вместо этого, список элементов должен быть представлен массивом уникальных идентификаторов данных ячейки (например guid’ы элементов в базе данных), по которым можно “вытащить” полные данные из локальной или удалённой БД.
  Многим покажется данное соглашение спорным, поскольку «вытаскивание» данных из подсистемы БД является менее производительным, чем чтение данных из памяти. Однако, с архитектурной точки зрения, такой вариант точно лучше по описанным выше причинам. Прежде чем дорабатывать производительность решения нужно убедиться в её (производительности) недостаточности при прямом использовании подсистемы БД. Вполне возможно, что ваш релизный или продуктовый менеджер сочтёт производительность достаточной, а в лучшем случае и вовсе не заметит никаких задержек. Всё же хочу обратить внимание на то, что Red Architecture в первую очередь предназначена для упрощения жизни разработчикам клиентских приложений. А большинство современных клиентских «железок», таких как смартфоны, планшеты, не говоря уже про компьютеры и ноутбуки, обладают достаточной производительностью, чтобы не доставлять дискомфорта пользователям «правильно» написанных приложений.
— В Red Architecture не обязательно применять известный подход, когда внутренняя структура программы описывается в терминах объектов или явлений из реальной жизни. Данная архитектура предполагает возможность успешной реализации задач при внутренней структуре приложения не отражающей объекты или явления из предметной области реальной жизни. Вместо этого, приложение фактически поделено на маленькие части логики, которые ничего “не знают” друг о друге, и “общаются” друг с другом опосредованно, используя для этого специальный программный компонент, в нашем случае класс v. Части логики объединены в цепочку посредством передачи друг другу данных с использованием ключей имеющихся в классе v в перечислении k.
Заключение
Данная архитекрура реализует все плюсы описанные, например, в «Чистой архитектуре» (Clean Architecture) + имеет дополнительные преимущества:
- Основные принципы легко понять и применить
- Решает проблему зависимости одного объекта от жизненного цикла другого. Например, контроллер делает запрос данных, но на момент их получения контроллер или вью в котором данные должны быть обработаны/отражены уже не существует. Или в Андроиде, при перевороте экрана пересоздаются фрагменты, что для многих разработчиков является вопросом, который нужно специально прорабатывать. В Red Architecture таких проблем нет, поскольку жизненный цикл объекта важен только для него самого. Другие объекты системы, отправляющие данные с ключами, обрабатываемыми данным объектом, ничего про него не знают.
- Легко найти/понять где/как реализованы фитчи приложения по ключам в Главном классе
- Для взаимодействия между частями программы не используются интерфейсы и/или адаптеры, что избавляет объекты от необходимости «следить» за состоянием и жизненным циклом друг друга, проверять слабые ссылки (weak references), делегаты на null, и т.п.
- У данной архитектуры нет требования по организации кода в слои или модули. Можно использовать любой паттерн физической организации кода.
- Благодаря отсутствию завязки на слои (бизнес, слой данных, слой приложения и т.п.) достигается гораздо более глубокий уровень разделения (обязанностей) в коде. Прочие архитектуры, например Clean Architecture, описывают взаимодействие между слоями приложения, в то время как Red Architecture описывает взаимодействие между частями программы “сквозь” слои логики или физической организации кода.
- Части логических цепочек системы маленькие, общаются друг с другом опосредованно и поэтому ничего друг о друге “не знают”.
- Контекст для принятия решения разработчиком относительно невелик. Под контекстом подразумевается совокупность окружения — программные сущности и логика, которые необходимо понимать, чтобы принять правильное решение в данном конкретном месте в коде. Этот фактор снижает требования к опыту и профессиональному уровню разработчиков, облегчает их вхождение в разработку, упрощает поддержку кода и т.д.
Для примеров используется код из приложения на С#, Xamarin.
В названии статьи не случайно упоминаются термины одной из теорий, составляющих «тело» Agile, а именно из Теории запутанности. Red Architecture помогает эффективнее реализовывать принципы Agile в независимости от фреймворка — будь то Scrum или Kanban.
Автор: anagovitsyn