Blazor Client Side Интернет Магазин: Часть 6 — Создание заказа и работа с компенсирующими действиями


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

Содержание

Ссылки

→ Исходники
Образы на 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

Специально для сайта ITWORLD.UZ. Новость взята с сайта Хабр