
Привет, Хабр! Продолжаю делать интернет магазин и изучать Blazor. В этой части расскажу о том как добавил в него возможность создания заказа, просмотра заказов и работу с последовательностью действий одно из которых может завершиться ошибкой. За подробностями добро пожаловать под кат.
Содержание
- Blazor + MVVM = Silverlight наносит ответный удар, потому что древнее зло непобедимо
- Blazor Client Side Интернет Магазин: Часть 1 — Авторизация oidc (oauth2) + Identity Server4
- Blazor Client Side Интернет Магазин: Часть 2 — CI/CD
- Blazor Client Side Интернет Магазин: Часть 3 — Витрина товаров
- Blazor Client Side Интернет Магазин: Часть 4 — Добавления товара в корзину
- Blazor Client Side Интернет Магазин: Часть 5 — Просмотр корзины и работа с Stateful
- Blazor Client Side Интернет Магазин: Часть 6 — Создание заказа и работа с компенсирующими действиями
Ссылки
→ Исходники
→ Образы на Docker Registry
Saga
Мне стало скучно и я решил имитировать ситуацию когда у нас есть два микросервиса — для корзины и оформления заказов. Обычно для таких ситуаций создается микросервис Оркестратор, который управляет последовательностью действий и выполняет компенсирующие действия. Например такое встроено в MassTransit, NServiceBus, MS Orleans (распределенные транзакции). Для примера я решил немного смоделировать ситуацию, когда нужно постучаться в два разных сервиса которые у вас нет возможности изменить. Google и FaceBook например. Хотя тут тоже лучше сделать это через сервер, а на бекенд послать один запрос. Тут максимально простой пример. Чуть посложнее надо было бы в LocalStorage браузера сохранять незавершенное состояние и по таймеру пытаться откатить, а не так топорно как я тут сделал.
Код OrdersViewModel:
public async Task Create() { //Загружаем состояние корзины с сервера. await _basket.Load(); if (!string.IsNullOrWhiteSpace(_basket.Error)) return; //Глубокое клонирование лучше сделать автомаппером. // Для учебного проекта и так сойдет. //Сохранять прежнее состояние корзины лучше в локалстораже. var lines = _basket.Model.Select(l => new LineModel() { Product = new ProductModel() { Id = l.Product.Id, Price = l.Product.Price, Title = l.Product.Title, Version = l.Product.Version }, Quantity = l.Quantity }).ToList(); //Посылаем на сервер команду - очистить корзину. await _basket.Clear(); if (!string.IsNullOrWhiteSpace(_basket.Error)) return; //Пытаемся создать заказ и если не получается то восстанавливаем прежнее состояние корзины. try { await _order.Create(lines, Address); } catch { //Компенсирующие операции чтобы восстанавливаем прежнее состояние. await Restore(lines); throw; } if (!string.IsNullOrWhiteSpace(_order.Error)) { await Restore(lines); } State = OrderVmState.List; } private async Task Restore(IEnumerable<LineModel> lines) { //Тут надо бы сделать один метод на сервере который сразу коллекцию предметов корзины //принимает но мне для учебного проекта лень. // Поэтому в цикле поочередно добавляю предметы обратно в корзину. foreach (var line in lines) { for (int i = 0; i < line.Quantity; i++) { await _basket.Add(line.Product); } } }
Код
1)Model
public class LineModel { public uint Quantity { get; set; } public ProductModel Product { get; set; } } public enum OrderStatus { Created = 10, Delivered, } public class OrderModel { public Guid Id { get; set; } public string Buyer { get; set; } public OrderStatus Status { get; set; } public List<LineModel> Lines { get; set; } = new List<LineModel>(); public string Address { get; set; } }
2)Service
public sealed class OrderService : IOrderService { private readonly IApiRepository _api; private List<OrderModel> _orders; public OrderService(IApiRepository api) { _api = api; _orders = new List<OrderModel>(); } public string Error { get; private set; } public IReadOnlyList<OrderModel> Orders => _orders?.AsReadOnly(); public async Task Create(IEnumerable<LineModel> lines, string address) { var (_, e) = await _api.CreateOrder(lines, address); Error = e; if (!string.IsNullOrWhiteSpace(e)) return; await Load(); } public async Task Load() { var (r, e) = await _api.GetOrders(); _orders = r; Error = e; } }
3)ViewModel
public class OrdersViewModel { private readonly IOrderService _order; private readonly IBasketService _basket; public OrdersViewModel(IOrderService order, IBasketService basket) { _order = order; _basket = basket; OrderFormContext = new EditContext(this); } public bool CanCreateOrder => _basket.ItemsCount > 0; public string Error => _order.Error + _basket.Error; public IReadOnlyList<OrderModel> Model => _order.Orders; public decimal Sum => _basket.Model.Sum(m => m.Quantity * m.Product.Price); public EditContext OrderFormContext { get; } public OrderVmState State { get; set; } [Required] [StringLength(255, MinimumLength = 3)] public string Address { get; set; } public void ChangeState(string value) { State = OrderVmState.List; if (string.IsNullOrWhiteSpace(value)) return; if (Enum.TryParse(value, true, out OrderVmState state)) State = state; if (_basket.ItemsCount == 0 && State == OrderVmState.Create) State = OrderVmState.List; } public async Task OnInitializedAsync() { await _order.Load(); await _basket.Load(); } public async Task Create() { if (!OrderFormContext.Validate()) return; await _basket.Load(); if (!string.IsNullOrWhiteSpace(_basket.Error)) return; var lines = _basket.Model.Select(l => new LineModel() { Product = new ProductModel() { Id = l.Product.Id, Price = l.Product.Price, Title = l.Product.Title, Version = l.Product.Version }, Quantity = l.Quantity }).ToList(); await _basket.Clear(); if (!string.IsNullOrWhiteSpace(_basket.Error)) return; try { await _order.Create(lines, Address); } catch { await Restore(lines); throw; } if (!string.IsNullOrWhiteSpace(_order.Error)) { await Restore(lines); } State = OrderVmState.List; } private async Task Restore(IEnumerable<LineModel> lines) { foreach (var line in lines) { for (int i = 0; i < line.Quantity; i++) { await _basket.Add(line.Product); } } } }
4)View
@page "/orders" @page "/orders/{operation}" @attribute [Authorize] @inject OrdersViewModel ViewModel <h3>Orders</h3> <div> <Error Model="@ViewModel.Error" /> </div> @if (ViewModel.State == OrderVmState.Create) { <EditForm EditContext="@ViewModel.OrderFormContext" OnValidSubmit="@ViewModel.Create"> <DataAnnotationsValidator /> <div class="form-group"><label class="form-label"> Sum: @ViewModel.Sum</label></div> <div class="form-group"> <label class="form-label" for="address">Address</label> <InputTextArea id="address" name="address" class="form-control" @bind-Value="@ViewModel.Address" /> <ValidationMessage For="@(() => ViewModel.Address)" /> </div> <button type="submit" class="btn btn-primary" disabled="@(!context.Validate())">Save</button> <button class="btn btn-default" @onclick="@(x => ViewModel.State = OrderVmState.List)">Cancel</button> </EditForm> } else { @if (ViewModel.CanCreateOrder) { <input type="button" class="btn btn-primary" value="Create Order" @onclick="@(x=>ViewModel.State = OrderVmState.Create)" /> } <div class="table-responsive"> <table class="table"> <thead> <tr> <AuthorizeView Roles="admin"> <Authorized> <th>Id</th> <th>Buyer Id</th> </Authorized> </AuthorizeView> <th>Status</th> <th>Products Count</th> <th>Sum</th> <th>Address</th> </tr> </thead> <tbody> @if (ViewModel.Model == null) { <tr> <td> <em>Loading...</em> </td> </tr> } else { foreach (var order in ViewModel.Model) { <tr> <AuthorizeView Roles="admin"> <Authorized> <td>@order.Id</td> <td>@order.Buyer</td> </Authorized> </AuthorizeView> <td>@order.Status.ToString("G")</td> <td>@order.Lines.Sum(l => l.Quantity)</td> <td>@order.Lines.Sum(l => l.Quantity * l.Product.Price)</td> <td>@order.Address</td> </tr> } } </tbody> </table> </div> } @functions { [Parameter] public string Operation { get => ViewModel.State.ToString("G"); set => ViewModel.ChangeState(value); } protected override async Task OnInitializedAsync() { await ViewModel.OnInitializedAsync(); } }
Скриншоты


Вариант на Angular 9

