Всем привет! Сегодня я бы хотел рассказать вам про свое приложение Profitocracy, которое помогает мне следить за личными расходами, а также автоматически планировать бюджет на месяц.
Данный проект является open source, так что, если вам интересно сразу перейти к коду, то вы можете ознакомиться с его исходниками на GitHub.
Скрытый текст
Если вам интересно попробовать данное приложение в действии, то для устройств на базе ОС Android я выложил APK файл для установки. Его вы сможете найти среди прикрепленных файлов на странице последнего релиза. Если же вы хотите попробовать другие версии приложения, то их вы найдете на странице всех релизов.
Про идею проекта
С момента, как я стал зарабатывать первые деньги, я всегда старался следить за своими расходами. Одним из подходов для распределения средств и планирования бюджета, который показался мне довольно интересным — это правило 50-30-20.
Само правило довольно простое:
-
50% уходят на обязательные траты. Это могут быть продукты, одежда, аренда, то есть расходы первой необходимости;
-
30% идут на второстепенные траты: походы в кафе или кино, покупки брендовых вещей. То есть то, без чего можно обойтись;
-
20% идут на сбережения. Это может быть накопительный счет, вклад, инвестиции или просто наличные под подушкой.
Ничего сложного, это правило просто помогает в общем формировании бюджета, от чего далее можно отталкиваться в более детальном распределении средств.
Я пробовал разные варианты для этого. Пытался использовать разные приложения для этого, которые только находил в Play Market, но в итоге самым удобным для меня стали таблицы в Excel (вернее, Google Sheets), ввиду своей гибкости, которую не смогли мне предложить остальные. Позже я пришел в мысли, что можно было бы создать самому такое приложение, которое закрывало бы мои потребности.
Почему .NET MAUI?
Жизнь сложилась так, что моими первыми языками программирования, которые я использовал в коммерческой разработке стали C# и JavaScript. Всё стандартно для веб разработки: бэк и фронт. В последствии, я стал больше уходить в сторону серверной разработки, поэтому больший акцент я стал делать именно на .NET.
Но так как на момент появления идеи создания своего приложения .NET MAUI еще не существовал, да и .NET сам по себе работал преимущественно под ОС Windows, пришлось рассмотреть другие варианты.
Первым из них стал React Native, с которым я уже, хоть и поверхностно, но уже был знаком. Без лишних пояснений, идея оказалась неудачной и я бросил эту затею через некоторое время. Позже еще была попытка создания приложения с использованием SwiftUI, которая также оказалась безрезультатна, да и, если честно, бессмысленна ввиду моего основного направления деятельности.
С тех пор прошло довольно продолжительное время, и тут внезапно прошла новость про релиз нового (на самом деле, нет) фреймворка для создания кросс-платформенных приложений .NET MAUI. Без лишних слов, я решил «потыкать» его, взяв за основу идею своего трекера расходов. Как известно, сам MAUI был сделан на основе другого фреймворка, Xamarin, который также был предназначен для разработки кросс-платформенных приложений.
Говоря честно, стоит сразу отметить, что на момент первого релиза MAUI был сырой и приходилось сталкиваться со множеством ошибок и трудностей, которые замедляли тем разработки приложения. Но об этом я постараюсь рассказать подробнее в заключении.
Архитектурный подход к проектированию приложения
Так совпало, что к тому моменту, как я начал свою работу над данным приложением, я стал глубже изучать подходы к проектированию монолитных систем. Тогда я уже ознакомился с «Чистой архитектурой» и «Чистым кодом», которые стал брать за основу всего, что нужно было разрабатывать, а также находился в процессе чтения двух книг по DDD, в народе более известных как синяя и красная книги по DDD.
Очевидно, что материал данных произведений был больше теоретический, чем практический. Для того, чтобы закрепить знания, полученные из них, требовалось опробовать их в деле, поэтому я решил применить их в своем приложении. Как бы мне этого не хотелось, а разработка бэкенда и разработка мобильных приложений — это совершенно разные сферы деятельности, хоть и построенные на одной платформе .NET.
Таким образом, я пришел к выводу, что лучшим вариантом в данном случае будет разделение системы на две части: внутреннюю бизнес-логику, в которой будут проводиться основные расчеты, а также само мобильное приложение. В первой части основным подходом будет DDD, а во второй — MVVM, при этом внутри мобильное приложение будет обращаться к модели предметной области для вызова операций бизнес-логики.
Скрытый текст
Разумеется, все немного сложнее, ведь нам, как минимум, также требуется где-то хранить данные. Поэтому, по сути, весь проект делится на 3 части: бизнес-логика (ядро системы, DDD), представление (мобильное приложение, MVVM), а также инфраструктура (работа с БД, внешними сервисами). Но так как интерфейсы для работы с этими внешними системами определяются в ядре системы, а реализуются уже в инфраструктуре, то с точки зрения самого мобильного приложения, оно общается только с ядром системы, поэтому я не включил его в общий список.
Проектирование модели предметной области
Первым делом, стоит определить что будет вообще делать само приложение. Так как на данном этапе я следовал подходу DDD, то сначала предстояло описать основные агрегаты в модели предметной области. Такими «кирпичиками» в приложении стали:
-
Профиль. Представляет собой точку агрегации всех расходов пользователя за определенный период. В нем производятся все основные расчеты. Он, в свою очередь, состоит из целого ряда других вспомогательных сущностей;
-
Транзакция. Единица движения средств в приложении, содержащая данные, необходимые для расчетов в профиле: тип самой транзакции (расход/поступление), сумма, дата, категория, описание и тип расхода, если это расходная операция (основные/второстепенные траты или отложенные средства);
-
Категория. Сущность, по которой можно сгруппировать транзакции. Например: еда, развлечения, одежда, транспорт и прочее.
Основная логика в системе — это подсчеты и распределение расходов в профиле. Давайте без лишних подробностей выразим это в коде:
public class Profile { // Обрабатывает список транзакций public void HandleTransactions(ICollection<Transaction> transactions, DateTime currentDate) { // Логика обработки и расчета расходов по транзакциям } // Добавляет список существующих категорий в профиль, // чтобы потом он мог учитывать их при обработке транзакций public void AddCategories(IEnumerable<ProfileCategory> categories) { // Добавление списка категорий в профиль } }
Также в ядре нашей системы требуется объявить интерфейсы для типов, которые будут хранить наши данные (репозитории) и обрабатывать комплексные пользовательские запросы с привлечением нескольких сущностей (сервисы предметной области). В рамках данной статьи мы опустим детали реализации этих интерфейсов, так что давайте просто опишем их. Итак, первым определим интерфейс сервиса предметной области, который будет заниматься получением профиля и передачи ему всех необходимых данных для вычислений:
public interface IProfileService { Task<Profile?> GetCurrentProfile(); }
Также определим какие методы нам требуются для работы с хранилищем наших данных:
public interface IProfileRepository { Task<Guid?> GetCurrentProfileId(); Task<Profile?> GetCurrentProfile(); Task<Profile> Create(Profile profile); Task<Profile> Update(Profile profile); }
public interface ICategoryRepository { Task<List<Category>> GetAllByProfileId(Guid profileId); Task<Category> Create(Category category); Task<Guid> Delete(Guid categoryId); }
Для работы же с транзакциями применим так называемый паттерн Спецификация и добавим метод для его обработки. Он будет определять набор параметров фильтрации транзакции по каким-либо признакам:
public record TransactionsSpecification { public Guid? ProfileId { get; init; } public Guid? CategoryId { get; init; } public SpendingType? SpendingType { get; init; } public DateTime? FromDate { get; init; } public DateTime? ToDate { get; init; } } public interface ITransactionRepository { Task<List<Transaction>> GetFiltered(TransactionsSpecification spec); Task<List<Transaction>> GetAllByProfileId(Guid profileId); Task<List<Transaction>> GetForPeriod(Guid profileId, DateTime dateFrom, DateTime dateTo); Task<Transaction> Create(Transaction transaction); Task<Guid> Delete(Guid transactionId); }
Проект мобильного приложения
Как я уже говорил, для реализации самого мобильного приложения, я использовал паттерн MVVM. Давайте также кратко опишем его и зарисуем, на данный момент без описания взаимодействия с моделью предметной области.
В общем виде, MVVM представляет собой паттерн, разделяющий приложение на несколько элементов управления пользовательским интерфейсом: Model (M, модель), View (V, представление) и ViewModel (VM, модель представления). Модель представляет собой описание данных, которыми оперирует приложение. Представление отвечает за отображение пользовательского интерфейса и взаимодействие с пользователем. Модель представления же является хранилищем состояния представления, содержащее также методы для обработки пользовательского ввода. Зачастую, получается так, что каждому представлению соответствует своя модель представления, которая хранит в себе данные для отображения пользователю, а также посылает представлению события для обновления этих данных в UI. Сама же модель представления
В общем виде, это можно изобразить в виде такой схемы:
Итого, на данном этапе у нас получились два разных проекта. Модель предметной области, содержащая бизнес-логику приложения, а также схема работы самого мобильного приложения. Но как же их можно объединить? Достаточно просто, в данном случае можно заменить модель на сущность предметной области, в результате чего наши модели представления будут взаимодействовать с моделью предметной области, обращаясь к внутренней бизнес-логике. Давайте это визуально отобразим:
Немного про инфраструктурный слой приложения
Как я уже упомянул, в ядре системы имеются лишь интерфейсы для работы с хранилищами данных, но для полноценной работы приложения требуется реализация этих интерфейсов. Давайте кратко пройдемся по ним.
Во-первых, ввиду того, что приложение является мобильным, я решил, что лучше всего будет хранить данные на самом устройстве пользователя, не пересылая их никуда. Для этого лучше всего подойдет SQLite, в частности, для использования в .NET нам подойдет библиотека sqlite-net-pcl
для общей поддержки SQLite в нашем приложении, а также SQLitePCLRaw.bundle_green
для корректной работы SQLite на всех системах (в нашем случае Android и iOS).
Хранить сущности предметной области напрямую в SQLite может быть весьма проблематично, поэтому лучше всего ввести в этом слое специальные DTO, которые через которые мы и будем взаимодействовать с БД. Например:
internal class CategoryModel { [PrimaryKey] public Guid Id { get; set; } public Guid ProfileId { get; set; } [NotNull] public string Name { get; set; } public decimal? PlannedAmount { get; set; } } internal class TransactionModel { [PrimaryKey] public Guid Id { get; set; } public decimal Amount { get; set; } public Guid ProfileId { get; set; } // Прочие поля... }
Нам необходимо преобразовывать эти DTO в доменные сущности, и обратно, поэтому напишем специальные адаптеры (мапперы) для этой цели (на примере маппера для категории):
internal class CategoryMapper { public Category MapToDomain(CategoryModel model) { return new Category(model.Id) { // Преобразование полей }; } public CategoryModel MapToModel(Category entity) { return new CategoryModel { // Преобразование полей }; } }
Вместо подробностей реализации репозиториев, схематично зарисуем процесс взаимодействия компонентов на этом уровне:
В данном примере использовался метод на создание/обновление сущности. Но, в целом, то же самое происходит и с операциями чтения, только в обратном порядке.
Создание пользовательского интерфейса в .NET MAUI
Как мы уже обсуждали выше, элементы пользовательского интерфейса называются общим словом View. MAUI для реализации UI два основных типа:
-
ContentView. Подобно тому, как в других компонентных фреймворках реализуются сами компоненты, в .NET MAUI используется ContentView. Он представляет собой «строительный блок» нашего UI, который мы также можем переиспользовать в других частях приложения;
-
ContentPage. Используется для создания отдельных страниц приложения, на которых располагаются ContentView.
Для людей, знакомых с WPF или Xamarin (на базе которого и был сделан MAUI), ничего сильно нового не будет. У нас есть разметка в формате XAML, с помощью которой мы можем описывать интерфейс пользователя, а также есть т.н. backing file (или code-behind file), написанный уже на C#. Этот файл, в свою очередь, нужен для реализации поведения самой страницы. В нем определяются методы-обработчики каких-либо событий, возникающих на экране пользователя.
Здесь есть одна важная деталь, про которую я бы хотел рассказать. Методы-обработчики не могут сами по себе возвращать объект типа Task
, из-за того, что страница ожидает метод в возвращаемым значением void
. C# позволяет нам определить метод async void
, но в любом случае нам лучше всего завернуть весь метод-обработчик в конструкцию try-catch
. Для того, чтобы не писать в каждом методе такую обработку, я просто сделал отдельный абстрактный класс, который наследуется от ContentPage:
public abstract class BaseContentPage : ContentPage { protected async void ProcessAction(Func<Task> action) { try { await action(); } catch (Exception ex) { await DisplayAlert( AppResources.ErrorAlert_Title, $"{AppResources.ErrorAlert_Description}: {ex.Message}", AppResources.ErrorAlert_Ok); } } }
Скрытый текст
DisplayAlert
в данном случае отображает, как несложно догадаться, alert с определенным сообщением о произошедшей ошибке в приложении.
AppResources
содержит все строковые константы, нужные для реализации локализации приложения, но об этом чуть позже.
Теперь у нас есть унифицированная форма обработки ошибок. Давайте рассмотрим один из примеров страницы в MAUI. За пример я возьму SetupPage, которая открывается пользователю при первом входе в приложение и нужное для создания нового профиля (некоторые подробности убраны):
<?xml version="1.0" encoding="utf-8"?> <!-- Общее описание страницы --> <abstractions:BaseContentPage ios:Page.ModalPresentationStyle="FullScreen" Shell.PresentationMode="ModalAnimated" Padding="16"> <Shell.BackButtonBehavior> <BackButtonBehavior IsVisible="False" IsEnabled="False" /> </Shell.BackButtonBehavior> <!-- Содержимое страницы, может вмещать только один элемент --> <abstractions:BaseContentPage.Content> <!-- Основной контейнер тела страницы --> <Grid VerticalOptions="Fill" RowDefinitions="auto,*,auto"> <FlexLayout Grid.Row="0" JustifyContent="SpaceBetween"> </FlexLayout> <StackLayout Margin="0,32,0,0" Grid.Row="1"> <StackLayout> <Label Text="{x:Static resx:AppResources.ProfileName}"/> <!-- Поле ввода с привязкой через Binding --> <Entry Margin="0,4,0,0" Text="{Binding Name, Mode=TwoWay}" /> </StackLayout> </StackLayout> <!-- Кнопка c привязанным обработчиком Button_OnClicked--> <Button Grid.Row="2" Text="{x:Static resx:AppResources.GoToExpenses}" Clicked="Button_OnClicked"/> </Grid> </abstractions:BaseContentPage.Content> </abstractions:BaseContentPage>
А также его code-behind файл:
public partial class SetupPage : BaseContentPage { private readonly SetupPageViewModel _viewModel; public SetupPage(SetupPageViewModel viewModel) { InitializeComponent(); _viewModel = viewModel; BindingContext = _viewModel; } // Отключает кнопку "Назад" protected override bool OnBackButtonPressed() { return true; } // Обработчик кнопки private void Button_OnClicked(object? sender, EventArgs e) { ProcessAction(async () => { await _viewModel.CreateFirstProfile(); await Navigation.PopModalAsync(); }); } }
Как можно заметить, метод-обработчик просто пересылает запрос в ViewModel и не несет в себе как таковой логики. А также, в обоих файлах есть упоминание какого-то Binding
. Для того, чтобы понять что это такое, перейдем к рассмотрению ViewModels.
ViewModels в .NET MAUI
Как уже говорилось, ViewModel — это специальный тип объекта в MVVM, который хранит состояние страницы, а также принимает вызовы логики поведения приложения и, в нашем случае, пересылает их в модель предметной области.
.NET MAUI из коробки не предоставляет реализацию ViewModel, но дает нам интерфейс для его создания самим. Интерфейс этот называется INotifyPropertyChanged
и предоставляет такое определение:
public interface INotifyPropertyChanged { event PropertyChangedEventHandler? PropertyChanged; }
PropertyChanged
как раз и будет отвечать за уведомление представления об изменениях в модели представления. Напишем базовый тип для ViewModel, от которого потом будет наследовать каждую модель представления:
public abstract class BaseNotifyObject : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; protected void SetProperty<T>(ref T property, T value, [CallerMemberName] string? propertyName = null) { if (Equals(property, value)) { return; } property = value; OnPropertyChanged(propertyName); } protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } }
Собственно, когда наследуемая модель представления будет использовать метод SetProperty
или OnPropertyChanged
, то будет вызываться выполнение события по обновлению данных в представлении. Для того, чтобы понять, как это всё будет выглядеть вместе, рассмотрим всё на том же примере SetupPageViewModel, которые мы рассмотрели выше:
public class SetupPageViewModel : BaseNotifyObject { private string _name = ""; private string _initialBalance = "0"; private readonly IProfileRepository _profileRepository; public SetupPageViewModel(IProfileRepository profileRepository) { _profileRepository = profileRepository; } public string Name { get => _name; set => SetProperty(ref _name, value); } public string InitialBalance { get => _initialBalance; set => SetProperty(ref _initialBalance, value); } public async Task CreateFirstProfile() { // Для корректного приведения строки к числу, // нужно исправить разделители целой и дробной // части числа _initialBalance = _initialBalance.Replace( ",", CultureInfo.CurrentCulture.NumberFormat.CurrencyDecimalSeparator); if (!decimal.TryParse(_initialBalance, out var numValue)) { throw new Exception(AppResources.CommonError_BalanceNumber); } // Создание новой сущности предметной области Профиль var profile = new ProfileBuilder() .AddName(_name) .AddStartDate(DateTime.Now, numValue) .AddIsCurrent(true) .Build(); // Создание профиля await _profileRepository.Create(profile); } }
В коде самой модели представления есть те самые вызовы SetProperty
, которые отправляют уведомления в представление SetupPage для их отображения.
В самом же коде страницы SetupPage были использованы конструкции Binding
и в конструкторе самой страницы было присваивание BindingContext
. Это ни что иное как создание той самой привязки представления к модели представления. Когда мы устанавливаем BindingContext
, мы создаем связь между View и ViewModel. А когда мы используем конструкцию {Binding...}
в XAML разметке страницы, то мы непосредственно говорим элементу следить за изменениями, происходящими в конкретном свойстве модели представления.
Что касается работы с моделью предметной области, то в этом примере наша модель представление вызывает ProfileBuilder из нашего ядра системы, который знает про особенности создания профиля и инкапсулирует эту логику в себе, для создания нового профиля, а затем обращается к репозиторию для его сохранения.
Вы могли заметить, что в ViewModel используется внедрение зависимостей через конструктор. Да, в отличие от Avalonia, в MAUI предусмотрен DI и работает он, в целом, также, как и в ASP.NET, только с несколькими отличиями. Вот пример использования DI:
public static class MauiProgram { public static MauiApp CreateMauiApp() { var builder = MauiApp.CreateBuilder(); builder .UseMauiApp<App>() .UseMauiCommunityToolkit() .ConfigureFonts(fonts => { fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); }) .RegisterAppServices() .RegisterViewModels() .RegisterViews(); return builder.Build(); } private static MauiAppBuilder RegisterAppServices(this MauiAppBuilder mauiAppBuilder) { _ = mauiAppBuilder.Services.AddSingleton<AppShell>(); return mauiAppBuilder; } private static MauiAppBuilder RegisterViewModels(this MauiAppBuilder mauiAppBuilder) { _ = mauiAppBuilder.Services.AddTransient<SetupPageViewModel>(); return mauiAppBuilder; } private static MauiAppBuilder RegisterViews(this MauiAppBuilder mauiAppBuilder) { _ = mauiAppBuilder.Services.AddTransient<SetupPage>(); return mauiAppBuilder; } }
Специальные функции. Локализация в .NET MAUI
Также мне хотелось добавить в приложение несколько тем (светлую, темную и системную), а также сделать её мультиязычной. Для реализации работы с темой приложения можете ознакомиться с ThemeService на GitHub. Здесь же мы рассмотрим более подробно локализацию приложения.
Для локализации требуется создать в директории Resources/Strings
два файла:
-
AppResources.resx — для добавления строк по умолчанию. Лучше всего использовать английский, так как он точно будет поддерживаться всеми ОС;
-
AppResources.<lang_code>.resx — файл, содержащий локализованные строки. В моем приложении, кроме английского, доступен также русский язык, поэтому у меня <lang_code> равен «ru».
Для смены языка в приложении есть специальная страница — LanguageSettingsPage. Она также работает с привязкой к модели представления, которая, в свою очередь, вызывает методы специального сервиса — LocalizationService. Давайте рассмотрим его принцип работы:
public static class LocalizationService { public const string English = "en"; public const string Russian = "ru"; public static readonly string[] SupportedLanguages = [English, Russian]; public static string CurrentLanguage => CultureInfo.CurrentUICulture.TwoLetterISOLanguageName; public static void ChangeCurrentLanguage(string language) { if (!SupportedLanguages.Contains(language)) { throw new ArgumentException("This language is not supported."); } var culture = new CultureInfo(language); AppResources.Culture = culture; Thread.CurrentThread.CurrentCulture = culture; Thread.CurrentThread.CurrentUICulture = culture; CultureInfo.DefaultThreadCurrentCulture = culture; CultureInfo.DefaultThreadCurrentUICulture = culture; } }
После этого мы можем использовать локализованные строки в нашем приложении. В XAML разметке мы можем сделать это так:
<BaseContentPage xmlns:resx="clr-namespace:Profitocracy.Mobile.Resources.Strings"> <Label Text="{x:Static resx:AppResources.CreateFirstProfile}"/> </BaseContentPage>
Где CreateFirstProfile — это идентификатор нашей локализованной строки. В C# это будет выглядеть таким образом (на примере выброса исключения):
throw new Exception(AppResources.CommonError_PlannedAmountNumber);
Итоговое приложение и мнение о .NET MAUI
Я очень старался создать приятное по внешнему виду приложение, для чего даже создал целый макет в Figma, чтобы точно понимать что я хочу получить в итоге:
Все скриншоты и гифки с демонстрацией работы приложения вы сможете найти на странице проекта в GitHub. На Хабре они занимают очень много места.
Что касается самого фреймворка .NET MAUI, то среди плюсов я могу выделить следующее:
-
.NET. В принципе, оно и понятно. Сам я пишу преимущественно на C# и мне было не особо сложно, хоть и поверхностно, но освоить этот фреймворк. Конечно, я использовал шаблон проекта без использования Blazor, так что порог входа был не так велик, плюс, я уже был знаком с XAML, поэтому воспринималось все легче, чем могло бы быть;
-
Кросс-платформенное приложение. Банально, но можно тоже отнести в плюс. Всё же MAUI действительно позволяет использовать единую кодовую базу для большинства платформ с минимальными отличиями друг от друга;
-
Стабильность. С первым релизом было довольно сложно: много ошибок, частые проблемы со сборкой, а также не совсем очевидный процесс публикации приложений. Но сейчас, всё достаточно подробно описано, а многие Issue на GitHub были исправлены, так что к стабильности работы у меня на данный момент особых вопросов нет;
-
Хорошая документация. В принципе, это субъективно, но мне документация по MAUI от Microsoft показалась достаточно подробной, хоть местами и не хватает деталей или большей ориентированностью на реальные проекты, а не примеры ради примеров.
Из минусов я бы хотел выделить:
-
Недостаточно библиотек именно для MAUI. Действительно, их очень мало, а за большинство из них требуется платить. Но я надеюсь, что со временем это будет исправлено, так как фреймворк всё-таки еще молодой и только развивается;
-
Hot Reload. Он есть, но если ваша страница использует данные из ViewModel, то всё равно придется перезапускать приложение. Потому что горячая перезагрузка обновляет только View, а состояние сбрасывает и не инициализирует его снова;
-
XAML. Кому-то может показаться это странным, но большие интерфейсы создавать, используя XAML, несколько сложнее, чем аналогичные в ReactNative или Flutter;
-
Неудобства некоторых элементов на некоторых платформах. Например, CollectionView для отображения списка элементов может просто перестать скроллиться, если его элементами будут SwipeItem. Притом, на iOS это возникает часто, но на Android я с этим не сталкивался.
В целом, я считаю, что .NET MAUI имеет право на существование. Он хорошо подойдет для небольших приложений, для MVP и подобного. Плюс, если у вас уже есть команда шарпистов, которые не против попробовать что-то новое, а бюджета для найма полноценных мобильных разработчиков нет, то это достойная альтернатива.
Спасибо, что дочитали эту статью до конца. Буду рад, если она была для вас полезна или интересна. Также, буду признателен, если вы оцените мой проект и, возможно, даже поставите звездочку 🙂
ссылка на оригинал статьи https://habr.com/ru/articles/867768/
Добавить комментарий