Merge branch 'idempotent-command'

This commit is contained in:
etomas 2017-03-03 12:11:12 +01:00
commit b1b3abfcff
26 changed files with 543 additions and 15 deletions

View File

@ -0,0 +1,9 @@
version: '2'
services:
sql.data:
environment:
- SA_PASSWORD=Pass@word
- ACCEPT_EULA=Y
ports:
- "5433:1433"

View File

@ -0,0 +1,10 @@
version: '2'
services:
sql.data:
image: microsoft/mssql-server-linux
basket.data:
image: redis
ports:
- "6379:6379"

View File

@ -49,7 +49,7 @@
</ItemGroup>
<ItemGroup>
<DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="1.0.0-msbuild3-final" />
<DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="1.0.0" />
</ItemGroup>
<ItemGroup>

View File

@ -12,6 +12,7 @@
using Microsoft.Extensions.Logging;
using System;
using System.IO;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
@ -28,7 +29,7 @@
if (env.IsDevelopment())
{
builder.AddUserSecrets();
builder.AddUserSecrets(typeof(Startup).GetTypeInfo().Assembly);
}
builder.AddEnvironmentVariables();

View File

@ -4,9 +4,23 @@
using Domain.AggregatesModel.OrderAggregate;
using MediatR;
using Microsoft.eShopOnContainers.Services.Ordering.API.Infrastructure.Services;
using Microsoft.eShopOnContainers.Services.Ordering.Infrastructure.Repositories;
using System;
using System.Threading.Tasks;
public class CreateOrderCommandIdentifiedHandler : IdentifierCommandHandler<CreateOrderCommand, bool>
{
public CreateOrderCommandIdentifiedHandler(IMediator mediator, IRequestManager requestManager) : base(mediator, requestManager)
{
}
protected override bool CreateResultForDuplicateRequest()
{
return true; // Ignore duplicate requests for creating order.
}
}
public class CreateOrderCommandHandler
: IAsyncRequestHandler<CreateOrderCommand, bool>
{

View File

@ -0,0 +1,20 @@
using MediatR;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Microsoft.eShopOnContainers.Services.Ordering.API.Application.Commands
{
public class IdentifiedCommand<T, R> : IAsyncRequest<R>
where T : IAsyncRequest<R>
{
public T Command { get; }
public Guid Id { get; }
public IdentifiedCommand(T command, Guid id)
{
Command = command;
Id = id;
}
}
}

View File

@ -0,0 +1,60 @@
using MediatR;
using Microsoft.eShopOnContainers.Services.Ordering.Infrastructure.Repositories;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Microsoft.eShopOnContainers.Services.Ordering.API.Application.Commands
{
/// <summary>
/// Provides a base implementation for handling duplicate request and ensuring idempotent updates, in the cases where
/// a requestid sent by client is used to detect duplicate requests.
/// </summary>
/// <typeparam name="T">Type of the command handler that performs the operation if request is not duplicated</typeparam>
/// <typeparam name="R">Return value of the inner command handler</typeparam>
public class IdentifierCommandHandler<T, R> : IAsyncRequestHandler<IdentifiedCommand<T, R>, R>
where T : IAsyncRequest<R>
{
private readonly IMediator _mediator;
private readonly IRequestManager _requestManager;
public IdentifierCommandHandler(IMediator mediator, IRequestManager requestManager)
{
_mediator = mediator;
_requestManager = requestManager;
}
/// <summary>
/// Creates the result value to return if a previous request was found
/// </summary>
/// <returns></returns>
protected virtual R CreateResultForDuplicateRequest()
{
return default(R);
}
/// <summary>
/// This method handles the command. It just ensures that no other request exists with the same ID, and if this is the case
/// just enqueues the original inner command.
/// </summary>
/// <param name="message">IdentifiedCommand which contains both original command & request ID</param>
/// <returns>Return value of inner command or default value if request same ID was found</returns>
public async Task<R> Handle(IdentifiedCommand<T, R> message)
{
var alreadyExists = await _requestManager.ExistAsync(message.Id);
if (alreadyExists)
{
return CreateResultForDuplicateRequest();
}
else
{
await _requestManager.CreateRequestForCommandAsync<T>(message.Id);
var result = await _mediator.SendAsync(message.Command);
return result;
}
}
}
}

View File

@ -28,9 +28,21 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.API.Controllers
[Route("new")]
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody]CreateOrderCommand createOrderCommand)
public async Task<IActionResult> CreateOrder([FromBody]CreateOrderCommand createOrderCommand, [FromHeader(Name = "x-requestid")] string requestId)
{
var result = await _mediator.SendAsync(createOrderCommand);
bool result = false;
if (Guid.TryParse(requestId, out Guid guid) && guid != Guid.Empty)
{
var requestCreateOrder = new IdentifiedCommand<CreateOrderCommand, bool>(createOrderCommand, guid);
result = await _mediator.SendAsync(requestCreateOrder);
}
else
{
// If no x-requestid header is found we process the order anyway. This is just temporary to not break existing clients
// that aren't still updated. When all clients were updated this could be removed.
result = await _mediator.SendAsync(createOrderCommand);
}
if (result)
{
return Ok();

View File

@ -33,6 +33,10 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.API.Infrastructure.Autof
builder.RegisterType<OrderRepository>()
.As<IOrderRepository<Order>>()
.InstancePerLifetimeScope();
builder.RegisterType<RequestManager>()
.As<IRequestManager>()
.InstancePerLifetimeScope();
}
}
}

View File

@ -0,0 +1,245 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.eShopOnContainers.Services.Ordering.Infrastructure;
namespace Ordering.API.Migrations
{
[DbContext(typeof(OrderingContext))]
[Migration("20170303085729_RequestsTable")]
partial class RequestsTable
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
modelBuilder
.HasAnnotation("ProductVersion", "1.1.0-rtm-22752")
.HasAnnotation("SqlServer:Sequence:.orderitemseq", "'orderitemseq', '', '1', '10', '', '', 'Int64', 'False'")
.HasAnnotation("SqlServer:Sequence:ordering.buyerseq", "'buyerseq', 'ordering', '1', '10', '', '', 'Int64', 'False'")
.HasAnnotation("SqlServer:Sequence:ordering.orderseq", "'orderseq', 'ordering', '1', '10', '', '', 'Int64', 'False'")
.HasAnnotation("SqlServer:Sequence:ordering.paymentseq", "'paymentseq', 'ordering', '1', '10', '', '', 'Int64', 'False'")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
modelBuilder.Entity("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasAnnotation("SqlServer:HiLoSequenceName", "buyerseq")
.HasAnnotation("SqlServer:HiLoSequenceSchema", "ordering")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.SequenceHiLo);
b.Property<string>("IdentityGuid")
.IsRequired()
.HasMaxLength(200);
b.HasKey("Id");
b.HasIndex("IdentityGuid")
.IsUnique();
b.ToTable("buyers","ordering");
});
modelBuilder.Entity("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.BuyerAggregate.CardType", b =>
{
b.Property<int>("Id")
.HasDefaultValue(1);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200);
b.HasKey("Id");
b.ToTable("cardtypes","ordering");
});
modelBuilder.Entity("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasAnnotation("SqlServer:HiLoSequenceName", "paymentseq")
.HasAnnotation("SqlServer:HiLoSequenceSchema", "ordering")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.SequenceHiLo);
b.Property<string>("Alias")
.IsRequired()
.HasMaxLength(200);
b.Property<int>("BuyerId");
b.Property<string>("CardHolderName")
.IsRequired()
.HasMaxLength(200);
b.Property<string>("CardNumber")
.IsRequired()
.HasMaxLength(25);
b.Property<int>("CardTypeId");
b.Property<DateTime>("Expiration");
b.HasKey("Id");
b.HasIndex("BuyerId");
b.HasIndex("CardTypeId");
b.ToTable("paymentmethods","ordering");
});
modelBuilder.Entity("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.OrderAggregate.Address", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("City");
b.Property<string>("Country");
b.Property<string>("State");
b.Property<string>("Street");
b.Property<string>("ZipCode");
b.HasKey("Id");
b.ToTable("address","ordering");
});
modelBuilder.Entity("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasAnnotation("SqlServer:HiLoSequenceName", "orderseq")
.HasAnnotation("SqlServer:HiLoSequenceSchema", "ordering")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.SequenceHiLo);
b.Property<int?>("AddressId");
b.Property<int>("BuyerId");
b.Property<DateTime>("OrderDate");
b.Property<int>("OrderStatusId");
b.Property<int>("PaymentMethodId");
b.HasKey("Id");
b.HasIndex("AddressId");
b.HasIndex("BuyerId");
b.HasIndex("OrderStatusId");
b.HasIndex("PaymentMethodId");
b.ToTable("orders","ordering");
});
modelBuilder.Entity("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.OrderAggregate.OrderItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasAnnotation("SqlServer:HiLoSequenceName", "orderitemseq")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.SequenceHiLo);
b.Property<decimal>("Discount");
b.Property<int>("OrderId");
b.Property<string>("PictureUrl");
b.Property<int>("ProductId");
b.Property<string>("ProductName")
.IsRequired();
b.Property<decimal>("UnitPrice");
b.Property<int>("Units");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("orderItems","ordering");
});
modelBuilder.Entity("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.OrderAggregate.OrderStatus", b =>
{
b.Property<int>("Id")
.HasDefaultValue(1);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200);
b.HasKey("Id");
b.ToTable("orderstatus","ordering");
});
modelBuilder.Entity("Ordering.Infrastructure.ClientRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("Name")
.IsRequired();
b.Property<DateTime>("Time");
b.HasKey("Id");
b.ToTable("requests","ordering");
});
modelBuilder.Entity("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", b =>
{
b.HasOne("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer")
.WithMany("PaymentMethods")
.HasForeignKey("BuyerId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.BuyerAggregate.CardType", "CardType")
.WithMany()
.HasForeignKey("CardTypeId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.OrderAggregate.Order", b =>
{
b.HasOne("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.OrderAggregate.Address", "Address")
.WithMany()
.HasForeignKey("AddressId");
b.HasOne("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer", "Buyer")
.WithMany()
.HasForeignKey("BuyerId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.OrderAggregate.OrderStatus", "OrderStatus")
.WithMany()
.HasForeignKey("OrderStatusId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", "PaymentMethod")
.WithMany()
.HasForeignKey("PaymentMethodId");
});
modelBuilder.Entity("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.OrderAggregate.OrderItem", b =>
{
b.HasOne("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.OrderAggregate.Order")
.WithMany("OrderItems")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
});
}
}
}

View File

@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Ordering.API.Migrations
{
public partial class RequestsTable : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "requests",
schema: "ordering",
columns: table => new
{
Id = table.Column<Guid>(nullable: false),
Name = table.Column<string>(nullable: false),
Time = table.Column<DateTime>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_requests", x => x.Id);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "requests",
schema: "ordering");
}
}
}

View File

@ -183,6 +183,21 @@ namespace Ordering.API.Migrations
b.ToTable("orderstatus","ordering");
});
modelBuilder.Entity("Ordering.Infrastructure.ClientRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("Name")
.IsRequired();
b.Property<DateTime>("Time");
b.HasKey("Id");
b.ToTable("requests","ordering");
});
modelBuilder.Entity("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.BuyerAggregate.PaymentMethod", b =>
{
b.HasOne("Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.BuyerAggregate.Buyer")

View File

@ -51,10 +51,11 @@
<PackageReference Include="System.Reflection" Version="4.3.0" />
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="1.0.1-rc3" />
<PackageReference Include="Dapper" Version="1.50.2" />
<PackageReference Include="System.ValueTuple" Version="4.3.0" />
</ItemGroup>
<ItemGroup>
<DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="1.1.0-preview4-final" />
<DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="1.0.0" />
</ItemGroup>
<ItemGroup>

View File

@ -29,7 +29,7 @@
if (env.IsDevelopment())
{
builder.AddUserSecrets();
builder.AddUserSecrets(typeof(Startup).GetTypeInfo().Assembly);
}
builder.AddEnvironmentVariables();
@ -82,7 +82,7 @@
// Add application services.
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddTransient<IIdentityService,IdentityService>();
services.AddTransient<IIdentityService, IdentityService>();
services.AddOptions();
@ -92,7 +92,7 @@
container.Populate(services);
container.RegisterModule(new MediatorModule());
container.RegisterModule(new ApplicationModule(Configuration["ConnectionString"] ));
container.RegisterModule(new ApplicationModule(Configuration["ConnectionString"]));
return new AutofacServiceProvider(container.Build());
}

View File

@ -22,6 +22,7 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.O
public OrderStatus OrderStatus { get; private set; }
private int _orderStatusId;
// DDD Patterns comment
// Using a private collection field, better for DDD Aggregate's encapsulation
// so OrderItems cannot be added from "outside the AggregateRoot" directly to the collection,
@ -46,7 +47,6 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.Domain.AggregatesModel.O
_paymentMethodId = paymentMethodId;
_orderStatusId = OrderStatus.InProcess.Id;
_orderDate = DateTime.UtcNow;
Address = address;
}

View File

@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Microsoft.eShopOnContainers.Services.Ordering.Infrastructure
{
public class ClientRequest
{
public Guid Id { get; set; }
public string Name { get; set; }
public DateTime Time { get; set; }
}
}

View File

@ -30,6 +30,8 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.Infrastructure
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ClientRequest>(ConfigureRequests);
modelBuilder.Entity<Address>(ConfigureAddress);
modelBuilder.Entity<PaymentMethod>(ConfigurePayment);
modelBuilder.Entity<Order>(ConfigureOrder);
@ -39,6 +41,14 @@ namespace Microsoft.eShopOnContainers.Services.Ordering.Infrastructure
modelBuilder.Entity<Buyer>(ConfigureBuyer);
}
private void ConfigureRequests(EntityTypeBuilder<ClientRequest> requestConfiguration)
{
requestConfiguration.ToTable("requests", DEFAULT_SCHEMA);
requestConfiguration.HasKey(cr => cr.Id);
requestConfiguration.Property(cr => cr.Name).IsRequired();
requestConfiguration.Property(cr => cr.Time).IsRequired();
}
void ConfigureAddress(EntityTypeBuilder<Address> addressConfiguration)
{
addressConfiguration.ToTable("address", DEFAULT_SCHEMA);

View File

@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
namespace Microsoft.eShopOnContainers.Services.Ordering.Infrastructure.Repositories
{
public interface IRequestManager
{
Task<bool> ExistAsync(Guid id);
Task CreateRequestForCommandAsync<T>(Guid id);
}
}

View File

@ -0,0 +1,42 @@
using Microsoft.eShopOnContainers.Services.Ordering.Infrastructure;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
namespace Microsoft.eShopOnContainers.Services.Ordering.Infrastructure.Repositories
{
public class RequestManager : IRequestManager
{
private readonly OrderingContext _context;
public RequestManager(OrderingContext ctx)
{
_context = ctx;
}
public async Task<bool> ExistAsync(Guid id)
{
var request = await _context.FindAsync<ClientRequest>(id);
return request != null;
}
public async Task CreateRequestForCommandAsync<T>(Guid id)
{
var exists = await ExistAsync(id);
var request = exists ?
throw new Exception($"Request with {id} already exists") :
new ClientRequest()
{
Id = id,
Name = typeof(T).Name,
Time = DateTime.UtcNow
};
_context.Add(request);
await _context.SaveChangesAsync();
}
}
}

View File

@ -55,6 +55,10 @@ namespace Microsoft.eShopOnContainers.WebMVC.Models
public List<OrderItem> OrderItems { get; }
[Required]
public Guid RequestId { get; set; }
public void CardExpirationShortFormat()
{
CardExpirationShort = CardExpiration.ToString("MM/yy");

View File

@ -79,7 +79,7 @@ namespace Microsoft.eShopOnContainers.WebMVC.Services
_apiClient = new HttpClient();
_apiClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
_apiClient.DefaultRequestHeaders.Add("x-requestid", order.RequestId.ToString());
var ordersUrl = $"{_remoteServiceBaseUrl}/new";
order.CardTypeId = 1;
order.CardExpirationApiFormat();

View File

@ -89,6 +89,7 @@
</div>
</section>
<input asp-for="ZipCode" type="hidden" />
<input asp-for="RequestId" type="hidden" value="@Guid.NewGuid().ToString()"/>
</form>
</div>

View File

@ -0,0 +1,8 @@
export class Guid {
static newGuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
}

View File

@ -44,7 +44,7 @@ export class OrdersService {
}
postOrder(item): Observable<boolean> {
return this.service.post(this.ordersUrl + '/api/v1/orders/new', item).map((response: Response) => {
return this.service.postWithId(this.ordersUrl + '/api/v1/orders/new', item).map((response: Response) => {
return true;
});
}

View File

@ -9,6 +9,7 @@ import 'rxjs/add/operator/map';
import 'rxjs/add/operator/catch';
import { SecurityService } from './security.service';
import { Guid } from '../../../guid';
@Injectable()
export class DataService {
@ -28,13 +29,25 @@ export class DataService {
}).catch(this.handleError);
}
postWithId(url: string, data: any, params?: any): Observable<Response> {
return this.doPost(url, data, true, params);
}
post(url: string, data: any, params?: any): Observable<Response> {
return this.doPost(url, data, false, params);
}
private doPost(url: string, data: any, needId: boolean, params?: any): Observable<Response> {
let options: RequestOptionsArgs = {};
if (this.securityService) {
options.headers = new Headers();
if (this.securityService) {
options.headers.append('Authorization', 'Bearer ' + this.securityService.GetToken());
}
if (needId) {
let guid = Guid.newGuid();
options.headers.append('x-requestid', guid);
}
return this.http.post(url, data, options).map(
(res: Response) => {