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

Реактивное программирование

Реактивное программирование из первых рук

18.06.2021 18:20:53 | Автор: admin

Василий Прокофьев разработчик Usetech в Рязани. На Java Meeting Point он расскажет о своем опыте использования реактивного программирования.

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


На Java Meeting Point ты расскажешь о реактивном программировании. Расскажи, почему ты выбрал именно такую тему?

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

Я приготовил примеры кода, на которых покажу разные тонкости реактивного программирования. Старался доклад подготовить так, чтобы понятно было каждому нужны только базовые знания функционального программирования, Java Core и Java 8.

Чему научатся участники конференции после твоего доклада?

Участники познакомятся с реактивным программированием на Java, научатся базовым навыкам работы с Рroject Reactor, а также разберутся с базовыми методами библиотеки.

Почему ты заинтересовался реактивным программированием?

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

Когда я только начинал вливаться в эту тему, читал, как сделать сервер для Minecraft на домашнем слабеньком компьютере. И получалось, если делать его стандартным способом, с помощью синхронного программирования, на сервер могли зайти 4 человека. При реактивном подходе 1,5 тыс. человек могли спокойно работать на этом сервере, и еще куча ресурсов оставалась на компьютере.

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

В каких проектах нужно реактивное программирование?

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

Присоединяйтесь к конференции регистрация открыта на сайте.

Подробнее..

Избавляемся от мистических строк в системе реактивного связывания на Unity

17.12.2020 10:10:32 | Автор: admin
Любая система, которая часто используется в проекте, со временем обречена на эволюцию. Так случилось и с нашей системой реактивного связывания reactive bindings.

Что это за система? Она позволяет нам связывать данные на префабе с данными в коде. У нас есть ViewModel, лежащая на префабе. В ней есть некие ключи с разными типами. Соответственно, вся остальная логика, которая у нас привязана к UI, привязана к этим ключам и их изменениям. То есть, если у нас есть некая логическая переменная, меняя ее в коде, мы можем менять любые состояния UI автоматически.



Использование reactive bindings принесло нам как множество новых возможностей, так и ряд зависимостей. Для связи переменных кода и ViewModel, лежащей на префабе, нам необходимо было соответствие строковых имен. Это приводило к тому, что в результате неосторожной правки префаба или ошибки мерджа могли быть утеряны какие-то из этих связей, а ошибка замечалась уже на этапе поздних тестов в виде отвалившегося UI-функционала.

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

Два основных неудобства, с которыми мы столкнулись:

  • Строковые ключи в коде;
  • Нет проверки соответствия ключей в коде и ключей в модели.

Эта статья о том, как мы дополнили систему и тем самым закрыли эти потребности.

Но обо всем по порядку.

В наших reactive bindings доступ к полям происходит по связке тип поля-строковый путь во ViewModel. Отсюда повсеместно мы имели подобный код:

Посмотреть код
public static class AbilitiesPresenter{  private static readonly PropertyName MechAbilities = "mech/abilities";  private static readonly PropertyName MechAbilitiesIcon = "mech/abilities/icon";  private static readonly PropertyName MechAbilitiesName = "mech/abilities/name";  private static readonly PropertyName MechAbilitiesDescription = "mech/abilities/description";  public static void Present(IViewModel viewModel, List<AbilityInfo> data)  {     var collection = viewModel.GetMutableCollection(MechAbilities);     collection.Fill(data, SetupAbilityItem);  }  private static void SetupAbilityItem(AbilityInfo data, IViewModel model)  {     model.GetString(MechAbilitiesIcon).Value = data.Icon;     model.GetString(MechAbilitiesName).Value = data.Name;     model.GetString(MechAbilitiesDescription).Value = data.Desc;  }}

То есть, посредством GetString/GetInteger/GetBoolean и т. д. мы получаем ссылку на поле в модели и пишем/читаем значения.

В чем проблема этой системы? А в том, что чем больше полей в модели тем больше строк в коде. Читать и поддерживать подобный стиль становится весьма проблематично.

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

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

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

Желаемый формат работы выглядел примерно так:

  • Для работы с ViewModel создается некий контракт, в котором описаны все поля и строковые связи;
  • Далее нам нужно вызвать некий механизм инициализации этого контракта;
  • В редакторе во ViewModel мы должны иметь явные сообщения об ошибках при отсутствии каких-то полей в модели или во вложенной коллекции.

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

Теперь ближе к коду.

Раньше описание работы с элементами у нас было в следующем стиле:

Посмотреть код
public static class AbilitiesPresenter{  private static readonly PropertyName MechAbilities = "mech/abilities";  private static readonly PropertyName MechAbilitiesIcon = "mech/abilities/icon";  private static readonly PropertyName MechAbilitiesName = "mech/abilities/name";  private static readonly PropertyName MechAbilitiesDescription = "mech/abilities/description";  public static void Present(IViewModel viewModel, List<AbilityInfo> data)  {     var collection = viewModel.GetMutableCollection(MechAbilities);     collection.Fill(data, SetupAbilityItem);  }  private static void SetupAbilityItem(AbilityInfo data, IViewModel model)  {     model.GetString(MechAbilitiesIcon).Value = data.Icon;     model.GetString(MechAbilitiesName).Value = data.Name;     model.GetString(MechAbilitiesDescription).Value = data.Desc;  }}

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

Стало же все выглядеть так:

Посмотреть код
namespace DroneDetails{  public class DroneDetailScreenView : UIScreenViewWith3D<DroneViewUI3DScreen>  {     [ExpectReactiveContract(typeof(DroneInfoViewModel))] [ExpectNotNull] [SerializeField]     private ViewModel _droneInfoModel;     [ExpectReactiveContract(typeof(DroneScreenMainEventsModel))] [ExpectNotNull] [SerializeField]     private ViewModel _droneScreenMainEventsModel;     [ExpectReactiveContract(typeof(DroneScreenInfoModel))] [ExpectNotNull] [SerializeField]     private ViewModel _droneScreenInfoModel;     [ExpectReactiveContract(typeof(DroneSpawnInfoViewModel))] [ExpectNotNull] [SerializeField]     private ViewModel _droneSpawnInfoViewModel;     [ExpectReactiveContract(typeof(ScrollListViewModel))] [ExpectNotNull] [SerializeField]     private ViewModel _scrollListViewModel;//.   }}

ViewModel приписывается атрибут ExpectReactiveContract, который получает параметры контракта. Пример контракта выглядит следующим образом:

public struct ConnectionStatusViewModel : IBindViewModel{//пример описания полей  [Bind("connection/is-lost")]  public IMutableProperty<string>IsConnectionLost;  [Bind("mech/slots-count")]   public IMutableProperty<int> SlotsCount;//задание контракта для элементов вложенной коллекции  [Bind("current-drone-info/scheme-slots-info")]     [SchemaContract(typeof(SchemeSlotInfoViewModel))]  public IMutableCollection SchemeSlotsInfo;}

В этом варианте есть явное типизированное поле. Сверху атрибутом Bind описывается строка, которая связывает это поле с ViewModel.

private void OnPreviewDrone(int index){  _droneDetailModel.DroneScrollStateModel.SaveState(index);  var droneId = _dronesListModel.GetDroneIdByIndex(index);  _view.DroneInfoViewModel.DroneId.Value = droneId;  //...}

Способ использования теперь стал каноничным: мы берем структуру (контракт) и устанавливаем новое значение одному из полей (в примере это DroneId).

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

Для описания контракта используются два основных атрибута: Bind и SchemaContract. Bind отвечает за связывание поля структуры с полем во ViewModel. Атрибут получает ключ и опциональное поле IsRequired, говорящее о том, действительно ли во ViewModel необходимо иметь конкретный ключ или ничего не произойдет, если его упустить.

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

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field |               AttributeTargets.GenericParameter)]public class BindAttribute : Attribute{  public string ViewModelPath { get; }  public bool IsRequired { get; }  public BindAttribute(string value, bool isRequired = true)  {     ViewModelPath = value;     IsRequired = isRequired;  }}

Атрибут SchemaContract служит с целью указания контракта для элементов коллекции:

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field |  AttributeTargets.GenericParameter)]public class SchemaContractAttribute : Attribute{  public System.Type[] BindViewModelContracts;  public SchemaContractAttribute(params System.Type[]contracts)  {     BindViewModelContracts = contracts;  }}

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

Резолвер класс, который может проинициализировать поля структуры (контракта). Он и выполняет роль связывания между контрактом и ViewModel на префабе.

Резолверы имеют простую структуру и хорошо подходят для кодогенерации:

Посмотреть код
// ------------------------------------------------------------------------------// <auto-generated>//     This code was generated by ViewModelBindingsGenerator//     Changes to this file may cause incorrect behavior and will be lost if//     the code is regenerated.// </auto-generated>// ------------------------------------------------------------------------------using PS.ReactiveBindings;using Test;namespace BindViewModel{   public partial struct BindViewModelResolver   {       private static ConnectionStatusViewModel ResolveConnectionStatusViewModel(IViewModel viewModel)           => new ConnectionStatusViewModel           {               IsConnectionLost = LookupProperty<IMutableProperty<string>>(               "ConnectionStatusViewModel",               viewModel,                PropertyType.String,                "connection/is-lost",                true),               SomeCollection = LookupProperty<IMutableCollection>(               "ConnectionStatusViewModel",       viewModel,                PropertyType.Collection,                "mech/tempCollection",                true),           };   }}

Темплейт для генерации:

Посмотреть код
<#@ template debug="false" hostspecific="false" language="C#" #><#@ parameter name ="m_GenerationInfo" type="WarRobots.RBViewModelWrapperGenerator.BindViewModel.GenerationInfo"#>// ------------------------------------------------------------------------------// <auto-generated>//     This code was generated by ViewModelBindingsGenerator//     Changes to this file may cause incorrect behavior and will be lost if//     the code is regenerated.// </auto-generated>// ------------------------------------------------------------------------------using PS.ReactiveBindings;using <#=m_GenerationInfo.Namespace #>;namespace BindViewModel{   public partial struct BindViewModelResolver   {       private static <#=m_GenerationInfo.ClassName #> Resolve<#=m_GenerationInfo.ClassName #>(IViewModel viewModel)           => new <#=m_GenerationInfo.ClassName #>           {<#  foreach (var property in m_GenerationInfo.PropertiesInfo)  {     var requiredString = property.Required.ToString().ToLower();#>               <#=property.Name #> = LookupProperty<<#=property.PropertyTypeName #>>("<#=m_GenerationInfo.ClassName #>",viewModel, <#=property.ReactivePropertyTypeName #>, "<#=property.ViewModelPath #>", <#=requiredString #>),<#  }#>           };   }}

Класс BindViewModelResolver partial и имеет генерируемую часть. Задача метода resolve найти нужный резолвер для контракта и с его помощью выполнить связывание между логической и префабной частью.

Также есть метод ResolveWithReflection (fallback), который выполняет данное связывание через рефлексию. Это сделано на случай, если у нас отсутствует сгенерированный резолвер. Рефлексия работает медленнее, поэтому мы стараемся ее избегать.

Посмотреть код
public partial struct BindViewModelResolver{  private static Dictionary<System.Type, IResolver> _resolvers;  static partial void InitResolvers();  public static T Resolve<T>(IViewModel viewModel) where T : struct, IBindViewModel  {     InitResolvers();     if (_resolvers != null && _resolvers.ContainsKey(typeof(T)))     {        var resolver = (Resolver<T>) _resolvers[typeof(T)];        return resolver.Resolve(viewModel);     }     return ResolveWithReflection<T>(viewModel);  }  private class CanNotResolvePropertyException : System.Exception  {     public CanNotResolvePropertyException(string message) : base(message)     {     }  }  private interface IResolver  {  }  private struct Resolver<T> : IResolver     where T : struct, IBindViewModel  {     public delegate T ResolveDelegate(IViewModel viewModel);     public ResolveDelegate Resolve;  }  private static Resolver<T> FromDelegate<T>(Resolver<T>.ResolveDelegate resolveDelegate)     where T : struct, IBindViewModel     => new Resolver<T> {Resolve = resolveDelegate};  private static T LookupProperty<T>(     string holderName,     IViewModel viewModel,     PropertyType type,     PropertyName id,     bool required)     where T : class, IReactive  {     T obj = viewModel.LookupProperty(id, type) as T;     if (obj == null)     {        if (required)        {           throw new CanNotResolvePropertyException(              $"{holderName} -> Can't resolve {id} path => \n PropertyType.{type} \n {id}"           );        }        Debug.LogWarning($"{holderName} -> Can't resolve {id} path => \n PropertyType.{type} \n {id}");     }     return obj;  }  private static T ResolveWithReflection<T>(IViewModel viewModel)  {     var type = typeof(T);     var fields = type.GetFields();     var resolvedStruct = System.Activator.CreateInstance(type);     foreach (var field in fields)     {        var bindAttribute = field.GetCustomAttribute<BindAttribute>();        if (bindAttribute != null)        {           var viewModelPath = bindAttribute.ViewModelPath;           var result = ResolveFieldValue(type.Name, field.FieldType, viewModelPath, viewModel, bindAttribute.IsRequired);           field.SetValue(resolvedStruct, result);        }     }     return (T) resolvedStruct;  }

Сами резолверы лежат в словаре по типам. Этот список резолверов и описан в сгенерированной части. А сама она выглядит так:

Посмотреть код
public partial struct BindViewModelResolver{   static partial void InitResolvers()   {        if (_resolvers != null) return;       _resolvers = new Dictionary<System.Type, IResolver>       {           {typeof(DroneInfoViewModel), FromDelegate(ResolveDroneInfoViewModel)},           {typeof(DroneSchemeMetaphorViewModel), FromDelegate(ResolveDroneSchemeMetaphorViewModel)},           {typeof(DroneScreenInfoModel), FromDelegate(ResolveDroneScreenInfoModel)},           {typeof(DroneScreenMainEventsModel), FromDelegate(ResolveDroneScreenMainEventsModel)},           {typeof(DroneSpawnInfoViewModel), FromDelegate(ResolveDroneSpawnInfoViewModel)},           {typeof(DroneStoreItemViewModel), FromDelegate(ResolveDroneStoreItemViewModel)},           {typeof(HangarSlotViewModel), FromDelegate(ResolveHangarSlotViewModel)},           {typeof(SchemeSlotInfoViewModel), FromDelegate(ResolveSchemeSlotInfoViewModel)},           {typeof(ScrollListViewModel), FromDelegate(ResolveScrollListViewModel)},           {typeof(StateItemViewModel), FromDelegate(ResolveStateItemViewModel)},           {typeof(ConnectionStatusViewModel), FromDelegate(ResolveConnectionStatusViewModel)},           {typeof(TitanStateViewModel), FromDelegate(ResolveTitanStateViewModel)},           {typeof(MechStateViewModel), FromDelegate(ResolveMechStateViewModel)},           {typeof(ChipOfferItemViewModel), FromDelegate(ResolveChipOfferItemViewModel)},           {typeof(DroneOfferItemViewModel), FromDelegate(ResolveDroneOfferItemViewModel)},       };   }}

Темплейт для генерируемой части:

Посмотреть код
<#@ template debug="false" hostspecific="false" language="C#" #><#@ parameter name ="m_GenerationInfos" type="System.Collections.Generic.List<WarRobots.RBViewModelWrapperGenerator.BindViewModel.GenerationInfo>"#><#@ import namespace="System.Collections.Generic" #><#@ import namespace="BindViewModel" #>// ------------------------------------------------------------------------------// <auto-generated>//     This code was generated by ViewModelBindingsGenerator//     Changes to this file may cause incorrect behavior and will be lost if//     the code is regenerated.// </auto-generated>// ------------------------------------------------------------------------------using System.Collections.Generic;<#List<string> namespaces = new List<string>();  foreach (var generationInfo in m_GenerationInfos)  {     if (!namespaces.Contains(generationInfo.Namespace))     {#>using <#=generationInfo.Namespace #>;<#        namespaces.Add(generationInfo.Namespace);     }  }#>namespace BindViewModel{   public partial struct BindViewModelResolver   {       static partial void InitResolvers()       {            if (_resolvers != null) return;           _resolvers = new Dictionary<System.Type, IResolver>           {<#  foreach (var generationInfo in m_GenerationInfos)  {#>               {typeof(<#=generationInfo.ClassName #>), FromDelegate(Resolve<#=generationInfo.ClassName #>)},<#  }#>           };       }   }}

Итак, теперь у нас есть инструмент создания резолверов. Осталось создать инструмент для его вызова. А это задача генератора.

Генератор проходится по assemblies и выискивает контракты-наследники IBindViewModel. Найдя контракт, он проходит по нему и заполняет информацию для генерации. Текущая информация состоит из имени переменной, типа, пути для связывания и прочего. Затем подготовленная информация передается непосредственно в T4-генератор.

Код для сбора информации:

Посмотреть код
List<GenerationInfo> generationInfos = new List<GenerationInfo>();Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();foreach (var assembly in assemblies){   var types = assembly.GetTypes();  var iBindViewModelType = typeof(IBindViewModel);  foreach (Type type in types)  {     if (type.IsValueType && iBindViewModelType.IsAssignableFrom(type))     {        GenerationInfo generationInfo = new GenerationInfo {ClassName = type.Name, Namespace = type.Namespace};        var props = new List<PropertyInfo>();        var fields = type.GetFields();        foreach (var field in fields)        {           var bindAttribute = field.GetCustomAttribute<BindAttribute>();           if (bindAttribute != null)           {              var propertyInfo = new PropertyInfo();              propertyInfo.Name = field.Name;              propertyInfo.ViewModelPath = bindAttribute.ViewModelPath;              var propertyTypeNames = GetPropertyTypeName(field.FieldType);              propertyInfo.ReactivePropertyTypeName = propertyTypeNames.ReactivePropertyTypeName;              propertyInfo.PropertyTypeName = propertyTypeNames.PropertyTypeName;              propertyInfo.ValueTypeName = propertyTypeNames.ValueTypeName;              propertyInfo.Required = bindAttribute.IsRequired;              props.Add(propertyInfo);           }        }        generationInfo.PropertiesInfo = props;        generationInfos.Add(generationInfo);     }  }}

Передача информации и запуск T4-генератора:

Посмотреть код
foreach (var gInfo in generationInfos){  var viewModelBindingsTemplateGenerator = new ViewModelBindingsTemplate  {     Session = new Dictionary<string, object> {["_m_GenerationInfoField"] = gInfo}  };  viewModelBindingsTemplateGenerator.Initialize();  var generationData = viewModelBindingsTemplateGenerator.TransformText();  File.WriteAllText(fullOutputPath + gInfo.ClassName + ".cs", generationData);}var viewModelResolverTemplateGenerator = new ViewModelResolverTemplate(){  Session = new Dictionary<string, object> {["_m_GenerationInfosField"] = generationInfos}};viewModelResolverTemplateGenerator.Initialize();var generationResult = viewModelResolverTemplateGenerator.TransformText();File.WriteAllText(fullOutputPath + "BindViewModelResolverGenerated.cs", generationResult);

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

Var DroneInfoViewModel = BindViewModelResolver.Resolve<DroneInfoViewModel>(_droneInfoModel);

Пример сгенеренного резолвера для DroneInfoViewModel:

Посмотреть код
public partial struct BindViewModelResolver{   private static DroneInfoViewModel ResolveDroneInfoViewModel(IViewModel viewModel)       => new DroneInfoViewModel       {           OnTopInfoClick = LookupProperty<IEvent>("DroneInfoViewModel",viewModel, PropertyType.Event, "current-drone-info/on-top-info-click", true),           OnBottomInfoClick = LookupProperty<IEvent>("DroneInfoViewModel",viewModel, PropertyType.Event, "current-drone-info/on-bottom-info-click", true),           DroneName = LookupProperty<IMutableProperty<string>>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/drone-name", true),           DroneTier = LookupProperty<IMutableProperty<string>>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/drone-tier", true),           VoltageCurrent = LookupProperty<IMutableProperty<int>>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/voltage-current", true),           VoltageMax = LookupProperty<IMutableProperty<int>>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/voltage-max", true),           VoltageRange = LookupProperty<IMutableProperty<string>>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/voltage-range", true),           SpawnChargeCost = LookupProperty<IMutableProperty<int>>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/spawn-charge-cost", true),           SpawnHardCost = LookupProperty<IMutableProperty<int>>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/spawn-hard-cost", true),           BuyCurrency = LookupProperty<IMutableProperty<string>>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/buy-currency", true),           BuyPriceValue = LookupProperty<IMutableProperty<int>>("DroneInfoViewModel",viewModel, PropertyType.Integer, "current-drone-info/buy-price-value", true),           SchemeSlotsInfo = LookupProperty<IMutableCollection>("DroneInfoViewModel",viewModel, PropertyType.Collection, "current-drone-info/scheme-slots-info", true),           DroneId = LookupProperty<IMutableProperty<string>>("DroneInfoViewModel",viewModel, PropertyType.String, "current-drone-info/drone-id", true),           InLoadingState = LookupProperty<IMutableProperty<bool>>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/in-loading-state", true),           DroneExist = LookupProperty<IMutableProperty<bool>>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-exist", true),           DroneNoSlot = LookupProperty<IMutableProperty<bool>>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-no-slot", true),           DroneNoDrone = LookupProperty<IMutableProperty<bool>>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-no-drone", true),           IsDroneBlueprint = LookupProperty<IMutableProperty<bool>>("DroneInfoViewModel",viewModel, PropertyType.Boolean, "drone-info/drone-blueprint", true),       };}

Напоследок в паре слов о валидаторах.

Чтобы включить валидацию для модели, нужно всего лишь прописать атрибут ExpectReactiveContract:

[ExpectReactiveContract(typeof(DroneInfoViewModel))]private ViewModel _droneInfoModel;

При наличии ошибок в редакторе будет выведено предупреждение вида:



Валидатор работает на основе рефлексии, пробегая по Bind-полям и проверяя их наличие в модели.

Наличие валидации принесло нам ряд преимуществ:

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

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

Интеграция синхронное, асинхронное и реактивное взаимодействие, консистентность и транзакции

25.02.2021 10:21:06 | Автор: admin

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

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

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

Синхронное взаимодействие

Синхронное взаимодействие самое простое. Оно скрывает все детали удаленного вызова, что для вызывающего сервиса превращается в обычный вызов функции с получением ответа. Для его организации есть множество протоколов например, давно известные RPC и SOAP. Но очевидная проблема синхронности в том, что удаленный сервис может отвечать не очень быстро даже при простой операции на время ответа влияет загруженность сетевой инфраструктуры, а также другие факторы. И все это время вызывающий сервис находится в режиме ожидания, блокируя память и другие ресурсы (хотя и не потребляя процессор). В свою очередь, блокированные ресурсы могут останавливать работу других экземпляров сервиса по обработке сообщений, замедляя тем самым уже весь поток обработки. А если в момент обращения к внешнему сервису у нас есть незавершенная транзакция в базе данных, которая держит блокировки в БД, мы можем получить каскадное распространение блокировок.

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

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

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

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

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

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

Я бы рекомендовал избегать его совсем, но, оказывается, есть одно место, в котором протокол поддерживает только синхронное взаимодействие. А именно взаимодействие между сервером приложений и базой данных по JDBC синхронно принципиально. И только некоторые NoSQL базы данных поддерживают реально асинхронное взаимодействие со стороны сервера приложений и вызовы callback по результату обработки. Хотя казалось бы, мы находимся в поле бэкенд-разработки, которая в наше время должна быть ориентирована на асинхронное взаимодействие... Но нет и это печально.

Транзакции и консистентность

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Может возникнуть вопрос а какое все это имеет отношение к интеграции, это же проблемы разработки приложения как такового? Это, было бы так, если бы многие legacy-системы не выставляли API интеграции именно на уровне базы данных и не реализовывали логику на этом же уровне. А это уже имеет прямое отношение к интеграции в распределенном IT-ландшафте.

Замечу, что взаимодействие между базами данных тоже не обязательно должно быть синхронным. Тот же Oracle имеет различные библиотеки, которые позволяют организовывать асинхронное взаимодействие между узлами распределенной базы данных. И появились они очень давно мы успешно использовали асинхронное взаимодействие в распределенной АБС Банка еще в 1997 году, даже при скорости канала между городами, по которому шло взаимодействие, всего 64К на всех пользователей интернета (а не только нашей системы).

Асинхронное и реактивное взаимодействие

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

Для получения ответа есть два основных способа:

  • обычное асинхронное взаимодействие, когда передающая система сама периодически опрашивает состояние документа;

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

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

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

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

Реактивное взаимодействие требует определенной перестройки мышления, которая не столь проста, как кажется, потому что есть желание не просто упростить запись, а скрыть реактивное программирование и писать в традиционном стиле. Впервые я это осознал, когда был в 2014 году на конференции GoToCon в Копенгагене (мой отчет) и там же услышал про Реактивный манифест (The Reactive Manifesto). Там как раз обсуждалось создание различных библиотек, поддерживающих эту парадигму взаимодействия, потому что она позволяет гибко работать с производительностью. Сейчас это встроено в ряд языков через конструкции async/await, а не просто в библиотеки.

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

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

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

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

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

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

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

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

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

Консистентность данных

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

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

Организация транзакций

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

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

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

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

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

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

С 1 февраля стоимость очного участия в DevOpsConf 2021 составит 36000 рублей. Забронируйте билет сейчас, и у вас будет ещё несколько дней на оплату.

На данный момент Программный комитет одобрил уже около 40 докладов, но до 28 февраля ещё принимает заявки. Если вы хотите быть спикером, то подать доклад можно здесь.

Чтобы быть на связи, подписывайтесь на наши соцсети, чтобы не упустить важные новости о конференции и наших онлайн-событиях VK, FB, Twitter, Telegram-канал, Telegram-чат.

Подробнее..

Реактивное программирование на Java как, зачем и стоит ли? Часть II

09.03.2021 10:13:46 | Автор: admin

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

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

Reactivity

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

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

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

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

Идея реактивности построена на паттерне проектирования Observer.

Давайте вспомним, что это за паттерн. У нас есть подписчики и то, на что мы подписываемся. В качестве примера здесь рассмотрен Твиттер, но подписаться на какое-то сообщество или человека, а потом получать обновления можно в любой соцсети. После подписки, как только появляется новое сообщение, всем подписчикам приходит notify, то есть уведомление. Это базовый паттерн.

В данной схеме есть:

  • Publisher тот, кто публикует новые сообщения;

  • Observer тот, кто на них подписан. В реактивных потоках подписчик обычно называется Subscriber. Термины разные, но по сути это одно и то же. В большинстве сообществ более привычны термины Publisher/Subscriber.

Это базовая идея, на которой все строится.

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

У нас есть датчик дыма и градусник. Когда дыма становится много и/или температура растет, на соответствующих датчиках увеличивается значение. Когда значение и температура на датчике дыма оказываются выше пороговых, включается колокольчик и оповещает о тревоге.

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

От детектора дыма идет поток данных: например, значение 10, потом 12, и т.д. Температура тоже меняется, это другой поток данных 20, 25, 15. Каждый раз, когда появляется новое значение, результат пересчитывается, что приводит к включению или выключению системы оповещения. Нам достаточно сформулировать условие, при котором колокольчик должен включиться.

Если вернуться к паттерну Observer, у нас детектор дыма и термометр это публикаторы сообщений, то есть источники данных (Publisher), а колокольчик на них подписан, то есть он Subscriber, или наблюдатель (Observer).

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

Reactive approach

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

Клики здесь это поток щелчков мышкой (на схеме 1, 2, 1, 3). Нам нужно их сгруппировать. Для этого мы используем оператор throttle. Говорим, что если два события (два клика) произошли в течение 250 мс, их нужно сгруппировать. На второй схеме представлены сгруппированные значения (1, 2, 1, 3). Это поток данных, но уже обработанных в данном случае сгрупированных.

Таким образом начальный поток преобразовался в другой. Дальше нужно получить длину списка ( 1, 2, 1, 3). Фильтруем, оставляя только те значения, которые больше или равны 2. На нижней схеме осталось только два элемента (2, 3) это и были двойные клики. Таким образом, мы преобразовали начальный поток в поток двойных кликов.

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

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

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

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

Observable example

Теперь посмотрим на код, в котором мы публикуем события:

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

Девушка (Publisher) опубликовала эти значения, а Observers на них подписываются и печатают значения из потока.

Это похоже на потоки данных (Stream) в Java 8. И тут, и там синхронные потоки. И здесь, и в Java 8 список значений нам известен сразу. Но если бы использовался обычный для Java 8 поток, мы не могли бы туда что-то докладывать. В стрим ничего нельзя добавить: он синхронный. В нашем примере потоки асинхронные, то есть в любой момент времени в них могут появляться новые события скажем, если через год откроется учебный центр в новой локации она может добавиться в поток, и реактивные операторы правильно обработают эту ситуацию. Мы добавили события и сразу же на них подписались:

locations.subscribe(s -> System.out.println(s)))

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

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

Implementing and subscribing to an observer

В Java 9 нет реализации реактивных потоков только спецификация. Но есть несколько библиотек реализаций реактивного подхода. В этом примере используется библиотека RxJava. Мы подписываемся на поток данных, и определяем несколько обработчиков, то есть методы, которые будут запущены в начале обработки потока (onSubscribe), при получении каждого очередного сообщения (onNext), при возникновении ошибки (onError) и при завершении потока (onComplete):

Давайте посмотрим на последнюю строчку.

locations.map(String::length).filter(l -> l >= 5).subscribe(observer);

Мы используем операторы map и filter. Если вы работали со стримами Java 8, вам, конечно, знакомы map и filter. Здесь они работают точно так же. Разница в том, что в реактивном программировании эти значения могут появляться постепенно. Каждый раз, когда приходит новое значение, оно проходит через все преобразования. Так, String::length заменит строчки на длину в каждой из строк.

В данном случае получится 5 (Minsk), 6 (Krakow), 6 (Moscow), 4 (Kiev), 5 (Sofia). Фильтруем, оставляя только те, что больше 5. У нас получится список длин строк, которые больше 5 (Киев отсеется). Подписываемся на итоговый поток, после этого вызывается Observer и реагирует на значения в этом итоговом потоке. При каждом следующем значении он будет выводить длину:

public void onNext(Integer value) {

System.out.println("Length: " + value);

То есть сначала появится Length 5, потом Length 6. Когда наш поток завершится, будет вызван onComplete, а в конце появится надпись "Done.":

public void onComplete() {

System.out.println("Done.");

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

Если где-то произойдет ошибка, мы можем на нее отреагировать:

public void onError(Throwable e) {

e.printStackTrace();

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

Reactive Streams spec

Реактивные потоки вошли в Java 9 как спецификация.

Если предыдущие технологии (Completable Future, Fork/Join framework) получили свою имплементацию в JDK, то реактивные потоки имплементации не имеют. Есть только очень короткая спецификация. Там всего 4 интерфейса:

Если рассматривать наш пример из картинки про Твиттер, мы можем сказать, что:

Publisher девушка, которая постит твиты;

Subscriber подписчик. Он определяет , что делать, если:

  • Начали слушать поток (onSubscribe). Когда мы успешно подписались, вызовется эта функция;

  • Появилось очередное значение в потоке (onNext);

  • Появилось ошибочное значение (onError);

  • Поток завершился (onComplete).

Subscription у нас есть подписка, которую можно отменить (cancel) или запросить определенное количество значений (request(long n)). Мы можем определить поведение при каждом следующем значении, а можем забирать значения вручную.

Processor обработчик это два в одном: он одновременно и Subscriber, и Publisher. Он принимает какие-то значения и куда-то их кладет.

Если мы хотим на что-то подписаться, вызываем Subscribe, подписываемся, и потом каждый раз будем получать обновления. Можно запросить их вручную с помощью request. А можно определить поведение при приходе нового сообщения (onNext): что делать, если появилось новое сообщение, что делать, если пришла ошибка и что делать, если Publisher завершил поток. Мы можем определить эти callbacks, или отписаться (cancel).

PUSH / PULL модели

Существует две модели потоков:

  • Push-модель когда идет проталкивание значений.

Например, вы подписались на кого-то в Telegram или Instagram и получаете оповещения (они так и называются push-сообщения, вы их не запрашиваете, они приходят сами). Это может быть, например, всплывающее сообщение. Можно определить, как реагировать на каждое новое сообщение.

  • Pull-модель когда мы сами делаем запрос.

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

Для Push-модели мы определяем callbacks, то есть функции, которые будут вызваны, когда придет очередное сообщение, а для Pull-модели можно воспользоваться методом request, когда мы захотим узнать, что новенького.

Pull-модель очень важна для Backpressure напирания сзади. Что же это такое?

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

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

Implementations

Давайте рассмотрим существующие реализации реактивных потоков:

  • RxJava. Эта библиотека реализована для разных языков. Помимо RxJava существует Rx для C#, JS, Kotlin, Scala и т.д.

  • Reactor Core. Был создан под эгидой Spring, и вошел в Spring 5.

  • Akka-стримы от создателя Scala Мартина Одерски. Они создали фреймворк Akka (подход с Actor), а Akka-стримы это реализация реактивных потоков, которые дружат с этим фреймворком.

Во многом эти реализации похожи, и все они реализуют спецификацию реактивных потоков из Java 9.

Посмотрим подробнее на Springовский Reactor.

Function may return

Давайте обобщим, что может возвращать функция:

  • Single/Synchronous;

Обычная функция возвращает одно значение, и делает это синхронно.

  • Multipple/Synchronous;

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

  • Single/Asynchronous;

Здесь уже используется асинхронный подход, но функция возвращает только одно значение:

  • либо CompletableFuture (Java), и через какое-то время приходит асинхронный ответ;

  • либо Mono, возвращающая одно значение в библиотеке Spring Reactor.

  • Multiple/Asynchronous.

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

Например, вы читаете файл, а он меняется. В случае Single/Asynchronous вы через какое-то время получаете целиком весь файл. В случае Multiple/Asynchronous вы получаете поток данных из файла, который сразу же можно начинать обрабатывать. То есть можно одновременно читать данные, обрабатывать их, и, возможно, куда-то записывать. . Реактивные асинхронные потоки называются:

  • Publisher (в спецификации Java 9);

  • Observable (в RxJava);

  • Flux (в Spring Reactor).

Netty as a non-blocking server

Рассмотрим пример использования реактивных потоков Flux вместе со Spring Reactor. В основе Reactor лежит сервер Netty. Spring Reactor это основа технологии, которую мы будем использовать. А сама технология называется WebFlux. Чтобы WebFlux работал, нужен асинхронный неблокирующий сервер.

Схема работы сервера Netty похожа на то, как работает Node.js. Есть Selector входной поток, который принимает запросы от клиентов и отправляет их на выполнение в освободившиеся потоки. Если в качестве синхронного сервера (Servlet-контейнера) используется Tomcat, то в качестве асинхронного используется Netty.

Давайте посмотрим, сколько вычислительных ресурсов расходуют Netty и Tomcat на выполнение одного запроса:

Throughput это общее количество обработанных данных. При небольшой нагрузке, до первых 300 пользователей у RxNetty и Tomcat оно одинаковое, а после Netty уходит в приличный отрыв почти в 2 фраза.

Blocking vs Reactive

У нас есть два стека обработки запросов:

  • Традиционный блокирующий стек.

  • Неблокирующий стек в нем все происходит асинхронно и реактивно.

В блокирующем стеке все строится на Servlet API, в реактивном неблокирующем стеке на Netty.

Сравним реактивный стек и стек Servlet.

В Reactive Stack применяется технология Spring WebFlux. Например, вместо Servlet API используются реактивные стримы.

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

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

В Reactive Stack мы получаем преимущество за счет реактивности. Netty работает с пользователем, Reactive Streams Adapters со Spring WebFlux, а в конце находится реактивная база: то есть весь стек получается реактивным. Давайте посмотрим на него на схеме:

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

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

Операторы

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

Filter operator

Скорее всего, вы уже знакомы с фильтрами из интерфейса Stream.

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

Take 2 означает, что нужно взять только первые два значения.

Map operator

Оператор Map тоже хорошо знаком:

Это действие, происходящее с каждым значением. Здесь умножить на десять: было 3, стало 30; было 2, стало 20 и т.д.

Delay operator

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

Reduce operator

Еще один всем известный оператор:

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

Scan operator

Этот оператор отличается от предыдущего тем, что не дожидается конца работы потока.

Оператор scan рассчитывает текущее значение нарастающим итогом: сначала был 1, потом прибавил к предыдущему значению 2, стало 3, потом прибавил 3, стало 6, еще 4, стало 10 и т.д. На выходе получили 15. Дальше мы видим вертикальную черту onComplete. Но, может быть, его никогда не произойдет: некоторые потоки не завершаются. Например, у термометра или датчика дыма нет завершения, но scan поможет рассчитать текущее суммарное значение, а при некоторой комбинации операторов текущее среднее значение всех данных в потоке.

Merge operator

Объединяет значения двух потоков.

Например, есть два температурных датчика в разных местах, а нам нужно обрабатывать их единообразно, в общем потоке .

Combine latest

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

Если в потоке возникает новое событие, мы его комбинируем с последним полученным значением из другого потока. Скажем, таким образом мы можем комбинировать значения от датчика дыма и термометра: при появлении нового значения температуры в потоке temperatureStream оно будет комбинироваться с последним полученным значением задымленности из smokeStream. И мы будем получать пару значений. А уже по этой паре можно выполнить итоговый расчет:

temperatureStream.combineLatest(smokeStream).map((x, y) -> x > X && y > Y)

В итоге на выходе у нас получается поток значений true или false включить или выключить колокольчик. Он будет пересчитываться каждый раз, когда будет появляться новое значение в temperatureStream или в smokeStream.

FlatMap operator

Этот оператор вам, скорее всего, знаком по стримам Java 8. Элементами потока в данном случае являются другие потоки. Получается поток потоков. Работать с ними неудобно, и в этих случаях нам может понадобиться уплостить поток.

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

Flatmap часто используется при обработке потока данных, полученных с сервера. Т.к. сервер возвращает поток, чтобы мы смогли обрабатывать отдельные данные, этот поток сначала надо развернуть. Это и делает flatMap.

Buffer operator

Это оператор, который помогает группировать данные. На выходе Buffer получается поток, элементами которого являются списки (List в Java). Он может пригодиться, когда мы хотим отправлять данные не по одному, а порциями.

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

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

Всего существует более сотни операторов реактивного программирования. Здесь разобрана только небольшая часть.

Итого

Есть два подхода:

  • Spring MVC традиционная модель, в которой используется JDBC, императивная логика и т.д.

  • Spring WebFlux, в котором используется реактивный подход и сервер Netty.

Есть кое-что, что их объединяет. Tomcat, Jetty, Undertow могут работать и со Spring MVC, и со Spring WebFlux. Однако дефолтным сервером в Spring для работы с реактивным подходом является именно Netty.

Конференция HighLoad++ 2020 пройдет 20 и 21 мая 2021 года.Приобрести билетыможно уже сейчас.

А совсем скоро состоится еще одно интересное событие, на сей раз онлайн: 18 марта в 17:00 МСК пройдет митап Как устроена самая современная платежная система в МИРе: архитектура и безопасность.

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

Хотите бесплатно получить материалы конференции мини-конференции Saint HighLoad++ 2020?Подписывайтесьна нашу рассылку.

Подробнее..

Работа с толстофичами как разобрать слона на части и собрать обратно

28.12.2020 10:22:36 | Автор: admin

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


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



Цель статьи: поделиться нашим текущим опытом на тернистом пути к безболезненному росту и переиспользованию фич приложения, а так же рассмотреть и опробовать на реальном кейсе общий принцип к проектированию архитектуры, описанный в докладе The immense benefits of not thinking in screens.


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


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


Black box компоненты


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


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


Рассмотрим более частный вариант black box, который мы будем называть Feature, и при помощи которого будем моделировать функциональность приложения.


Схема Feature


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


  • Изменить свое текущее состояние (State)
  • Отправить одно или несколько событий (News)

Приведем пример: представьте фичу форма заявки от пользователя в виде black box. Форма может содержать правила валидации, автозамены, предзаполнения etc., но всё это является внутренним устройством фичи, которое не имеет значения в контексте интеграции с другими компонентами приложениями. Для пользователей формы Wish-ами станут события ввода данных, State текущее содержимое полей формы, а News ошибки заполнения формы, которые, например, будут нарисованы на UI одноразовыми сообщениями в Snackbar.


Схема разницы между State и News


В терминах реактивных фреймворков (RxJava, Reaktive) Feature реализует интерфейсы Consumer<Wish>, ObservableSource<State> и ObservableSource<News>.


class Feature : Consumer<Wish>, ObservableSource<State> {    override fun accept(wish: Wish) { ... }    override fun subscribe(observer: Observer<State>) { ... }    val news = ObservableSource<News> { ... }}

Мы можем подписывать Consumer<B> на ObservableSource<A>, если нам известно преобразование между типами (A) -> B. Это позволяет нам научить наши фичи общаться друг с другом. Так мы получаем инструмент для создания систем реактивных компонентов, который попробуем применить для описания фич приложения и их взаимодействия друг с другом.


Толстофича и ее проблемы


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



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


Давайте попробуем подумать об этом экране, как об изолированном black box компоненте.


Чтобы однозначно описать фичу в виде black box, достаточно описать 3 типа Wish, State и News. Если попробовать описать типы "в лоб" и проектировать экран при помощи подхода "фича == экран" (как мы и сделали изначально), то получится нечто подобное:


Black box экрана откликов
State экрана

Wish экрана

News экрана

Вот несколько наблюдений, которые можно сделать по этим сниппетам:


  • Фича имеет довольно много "внешних ручек" (Wish). При обработке такого количества вариантов Wish в when-выражении мы регулярно сталкиваемся с ворнингами статического анализатора о том, что метод слишком сложный. У фичи слишком сложный внешний интерфейс.
  • Как на уровне состояния фичи, так и на уровне ее Wish и News можно выделить отдельные ответственности экрана. И есть предположение, что разные Wish независимо друг от друга влияют на разные части состояния. Явный намек на то, что плоская иерархия Wish и полей в State может быть как-то сгруппирована.
  • Из-за настолько объёмного контракта фичи практически невозможно отделить специфичные функциональные особенности экрана откликов от более общих, которые мы бы хотели использовать на других экранах. Например, логику показа баннера с рекомендацией ("Откликайтесь чаще!") можно добавить и в другие списки внутри приложения, но она уже привязана к логике экрана откликов. Низкая переиспользуемость.
  • Если приоткрыть ящик Пандоры дверцу black box-а и посмотреть на детали внутренней реализации фичи, то мы найдём там ещё одну sealed-иерархию из множества data-классов, предназначенных для изменения состояния фичи (Effect-ы в терминах MVICore), и набор сущностей, специфичных для библиотеки MVICore: Actor, Reducer, Bootstraper, PostProcessor, реализация которых может насчитывать несколько сотен строк. Я не буду вдаваться в подробности о том, что именно происходит внутри этих сущностей, просто отмечу сложность и масштабы содержимого black box, который мы получили с таким подходом. Сложная реализация.

Кроме того:


  • В контексте интеграции фичи с остальным приложением неважно, насколько сложно фича устроена внутри, если рассматривать её как "чёрный ящик", потому что есть чёткий контракт входов и выходов. С другой стороны, если вся логика работы экрана сосредоточена внутри одного black box, то при добавлении новой функциональности придётся заново изучать все детали его реализации. Только так мы сможем гарантировать, что новый код будет правильно дружить с написанным ранее. Сложная поддержка.
  • Ещё отмечу, что хоть зачастую мы и можем поделить экран на набор обособленных кусочков функциональности, не всегда получается сделать их полностью независимыми друг от друга. Например, на приведенном выше экране откликов есть панель с табами-статусами. У каждого таба есть счетчик непрочитанных откликов, который периодически обновляется. По бизнес-правилам экрана если счётчик при обновлении получит новое значение, необходимо выполнить обновление соответствующего списка откликов. При использовании подхода "фича = экран" такие бизнес-правила (которые описывают связи между разными кусочками функциональности) будут растворяться во внутренней реализации фичи, и порой тяжело узнать об их существовании, не говоря о том, чтобы чётко сформулировать. Неочевидно как одна функциональность может затрагивать другую.

Декомпозиция фичи списка откликов


Но что будет, если мы попробуем разбить один большой "чёрный ящик" на несколько маленьких, разделяя их по функциональности?


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


Авторизация



Экран списка откликов (и некоторые другие экраны приложения) доступны как авторизованным, так и неавторизованным пользователям. Если пользователь не авторизован, логика экрана предельно простая: мы показываем заглушку, которая предлагает пройти авторизацию.


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


Получился black box авторизации с простейшим контрактом:


AuthFeature

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


Статусы откликов



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


StatusFeature

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


Рекомендация Откликайтесь чаще



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


RecommendationFeature

Wish CheckVisibility и ClearVisibility нужны для проверки состояния и сброса флага о показе баннера при смене состояния авторизации.


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


Список откликов с пагинацией



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


PaginationFeature

Статистика работодателя



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


EmployerStatsFeature

Действия с откликами



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


NegotiationActionsFeature



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


Промежуточный итог декомпозиции


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



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


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



Корень иерархии фрагмент NegotiationsContainerFragment, который нужен для показа содержимого вкладки нижней навигации, а заодно в нем мы можем показать bottom sheet-диалог для действий с откликами. В контейнер кладётся фрагмент NegotiationPagerFragment, отображающий состояние экрана списка откликов в авторизованной и неавторизованной зоне, а заодно содержащий ViewPager для списков откликов. Списки откликов, разбитые по статусам, находятся в отдельных StatusPageFragment.


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


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


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


Как связать фичи в коде



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


Каждая фича реализует интерфейсы Consumer<Wish>, ObservableSource<State>, ObservableSource<News>. И по сути, связывание фич это реактивные подписки с маппингом State/News одних фич в Wish для других фич.


Важное наблюдение. Black box очень общая концепция, которой можно описывать не только stateful бизнес-логику фич. Следовательно, и связывание можно делать не только между фичами, а между произвольными ObservableSource<A> и Consumer<B>. Например, UI тоже можно рассматривать как black box с контрактами Consumer<UiState> и ObservableSource<UiEvent>, а любые внешние события, которые происходят за рамками данного фрагмента, как ObservableSource<ExternalEvent>.


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


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


В DI-скоупе самого верхнего уровня (который привязан к жизненному циклу NegotiationsPagerFragment) создается экземпляр фичи авторизации (AuthFeature), действий с откликами (NegotiationActionsFeature) и фича статусов откликов (StatusFeature). Также на этом уровне мы слушаем внешние события, которые могут произойти на других экранах приложения и повлиять на состояния этих фич.


NegotiationsPagerBindings

Binder автоматически выполнит отписку связей между фичами, когда переданный ему жизненный цикл (объект Lifecycle) перейдет в уничтоженное состояние. Отмечу, что здесь мы используем два lifecycle. featureLifecycle соответствует жизненному циклу DI-скоупа, связанного с фрагментом, а viewLifecycle соответствует жизненному циклу View этого же фрагмента.


Трансформации между типами событий описываются несложными чистыми функциями, вот пример:


authToStatus, actionToStatus

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


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


NegotiationsStatusPageBindings

Примеры трансформаций для этого уровня:


statusToNegotiationsList, externalEventsToNegotiationsList

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


Преимущества подхода



"Look around we live in a perfect world where everything fits together and no one gets hurt."
Homer Jay Simpson


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

Пример теста на фичу для списков с пагинацией

Проблемы подхода


Binding hell: связи между фичами могут выходить из-под контроля


Один из вариантов решения проблемы делать иерархию структурных компонентов менее плоской. Под структурными компонентами здесь мы понимаем сущности типа Fragments, RIBs, Controller из Conductor и т.п., в контексте которых мы связываем фичи.


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



Нарушение целостности системы


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


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



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


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


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


Заключение и альтернативные способы композиции фич


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


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


Дабы избежать холиваров, сразу отмечу, что целью статьи является не сравнение различных подходов в стиле лучше/хуже, а лишь практическая демонстрация особенностей подхода к декомпозиции составных фич на реактивную систему black box компонентов. Выбор подходящего инструмента под нужды конкретно вашего проекта is your own. Однако нам всегда интересно поделиться с вами нашим опытом.


Отдельное спасибо за помощь при подготовке текста статьи: Ztrel, alaershov, Xanderblinov

Подробнее..

Категории

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

  • Имя: Макс
    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