Привет, Хабр! Продолжаю делать интернет магазин и изучать 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();
}
}