The article shows how to implement UNDO, REDO functionality in an ASP.NET Core application using EFCore and MS SQL Server.
This is the first blog in a 3 part series. The second blog will implement the UI using Angular 2 and the third article will improve the concurrent stacks with max limits to prevent memory leaks etc.
Code: https://github.com/damienbod/Angular2AutoSaveCommands
The application was created using the ASP.NET Core Web API template. The CommandDto class is used for all commands sent from the UI. The class is used for the create, update and delete requests. The class has 4 properties. The CommandType property defines the types of commands which can be sent. The supported CommandType values are defined as constants in the CommandTypes class. The PayloadType is used to define the type for the Payload JObject. The server application can then use this, to convert the JObject to a C# object. The ActualClientRoute is required to support the UNDO and REDO logic. Once the REDO or UNDO is executed, the client needs to know where to navigate to. The values are strings and are totally controlled by the client SPA application. The server just persists these for each command.
using Newtonsoft.Json.Linq; namespace Angular2AutoSaveCommands.Models { public class CommandDto { public string CommandType { get; set; } public string PayloadType { get; set; } public JObject Payload { get; set; } public string ActualClientRoute { get; set;} } public static class CommandTypes { public const string ADD = "ADD"; public const string UPDATE = "UPDATE"; public const string DELETE = "DELETE"; public const string UNDO = "UNDO"; public const string REDO = "REDO"; } public static class PayloadTypes { public const string Home = "HOME"; public const string ABOUT = "ABOUT"; public const string NONE = "NONE"; } }
The CommandController is used to provide the Execute, UNDO and REDO support for the UI, or any other client which will use the service. The controller injects the ICommandHandler which implements the logic for the HTTP POST requests.
using Angular2AutoSaveCommands.Models; using Angular2AutoSaveCommands.Providers; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json.Linq; namespace Angular2AutoSaveCommands.Controllers { [Route("api/[controller]")] public class CommandController : Controller { private readonly ICommandHandler _commandHandler; public CommandController(ICommandHandler commandHandler) { _commandHandler = commandHandler; } [HttpPost] [Route("Execute")] public IActionResult Post([FromBody]CommandDto value) { if (!ModelState.IsValid) { return BadRequest("Model is invalid"); } if (!validateCommandType(value)) { return BadRequest($"CommandType: {value.CommandType} is invalid"); } if (!validatePayloadType(value)) { return BadRequest($"PayloadType: {value.CommandType} is invalid"); } _commandHandler.Execute(value); return Ok(value); } [HttpPost] [Route("Undo")] public IActionResult Undo() { var commandDto = _commandHandler.Undo(); return Ok(commandDto); } [HttpPost] [Route("Redo")] public IActionResult Redo() { var commandDto = _commandHandler.Redo(); return Ok(commandDto); } private bool validateCommandType(CommandDto value) { return true; } private bool validatePayloadType(CommandDto value) { return true; } } }
The ICommandHandler has three methods, Execute, Undo and Redo. The Undo and the Redo methods return a CommandDto class. This class contains the actual data and the URL for the client routing.
using Angular2AutoSaveCommands.Models; namespace Angular2AutoSaveCommands.Providers { public interface ICommandHandler { void Execute(CommandDto commandDto); CommandDto Undo(); CommandDto Redo(); } }
The CommandHandler class implements the ICommandHandler interface. This class provides the two ConcurrentStack fields for the REDO and the UNDO stack. The stacks are static and so need to be thread safe. The UNDO and the REDO return a CommandDTO which contains the relevant data after the operation which has been executed.
The Execute method just calls the execution depending on the payload. This method then creates the appropriate command, adds the command to the database for the history, executes the logic and adds the command to the UNDO stack.
The undo method pops a command from the undo stack, calls the Unexecute method, adds the command to the redo stack, and saves everything to the database.
The redo method pops a command from the redo stack, calls the Execute method, adds the command to the undo stack, and saves everything to the database.
using System; using System.Collections.Concurrent; using System.Collections.Generic; using Angular2AutoSaveCommands.Models; using Angular2AutoSaveCommands.Providers.Commands; using Microsoft.Extensions.Logging; namespace Angular2AutoSaveCommands.Providers { public class CommandHandler : ICommandHandler { private readonly ICommandDataAccessProvider _commandDataAccessProvider; private readonly DomainModelMsSqlServerContext _context; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; // TODO remove these and used persistent stacks private static ConcurrentStack<ICommand> _undocommands = new ConcurrentStack<ICommand>(); private static ConcurrentStack<ICommand> _redocommands = new ConcurrentStack<ICommand>(); public CommandHandler(ICommandDataAccessProvider commandDataAccessProvider, DomainModelMsSqlServerContext context, ILoggerFactory loggerFactory) { _commandDataAccessProvider = commandDataAccessProvider; _context = context; _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger("CommandHandler"); } public void Execute(CommandDto commandDto) { if (commandDto.PayloadType == PayloadTypes.ABOUT) { ExecuteAboutDataCommand(commandDto); return; } if (commandDto.PayloadType == PayloadTypes.Home) { ExecuteHomeDataCommand(commandDto); return; } if (commandDto.PayloadType == PayloadTypes.NONE) { ExecuteNoDataCommand(commandDto); return; } } // TODO add return object for UI public CommandDto Undo() { var commandDto = new CommandDto(); commandDto.CommandType = CommandTypes.UNDO; commandDto.PayloadType = PayloadTypes.NONE; commandDto.ActualClientRoute = "NONE"; if (_undocommands.Count > 0) { ICommand command; if (_undocommands.TryPop(out command)) { _redocommands.Push(command); command.UnExecute(_context); commandDto.Payload = command.ActualCommandDtoForNewState(CommandTypes.UNDO).Payload; _commandDataAccessProvider.AddCommand(CommandEntity.CreateCommandEntity(commandDto)); _commandDataAccessProvider.Save(); return command.ActualCommandDtoForNewState(CommandTypes.UNDO); } } return commandDto; } // TODO add return object for UI public CommandDto Redo() { var commandDto = new CommandDto(); commandDto.CommandType = CommandTypes.REDO; commandDto.PayloadType = PayloadTypes.NONE; commandDto.ActualClientRoute = "NONE"; if (_redocommands.Count > 0) { ICommand command; if(_redocommands.TryPop(out command)) { _undocommands.Push(command); command.Execute(_context); commandDto.Payload = command.ActualCommandDtoForNewState(CommandTypes.REDO).Payload; _commandDataAccessProvider.AddCommand(CommandEntity.CreateCommandEntity(commandDto)); _commandDataAccessProvider.Save(); return command.ActualCommandDtoForNewState(CommandTypes.REDO); } } return commandDto; } private void ExecuteHomeDataCommand(CommandDto commandDto) { if (commandDto.CommandType == CommandTypes.ADD) { ICommandAdd command = new AddHomeDataCommand(_loggerFactory, commandDto); command.Execute(_context); _commandDataAccessProvider.AddCommand(CommandEntity.CreateCommandEntity(commandDto)); _commandDataAccessProvider.Save(); command.UpdateIdforNewItems(); _undocommands.Push(command); } if (commandDto.CommandType == CommandTypes.UPDATE) { ICommand command = new UpdateHomeDataCommand(_loggerFactory, commandDto); command.Execute(_context); _commandDataAccessProvider.AddCommand(CommandEntity.CreateCommandEntity(commandDto)); _commandDataAccessProvider.Save(); _undocommands.Push(command); } if (commandDto.CommandType == CommandTypes.DELETE) { ICommand command = new DeleteHomeDataCommand(_loggerFactory, commandDto); command.Execute(_context); _commandDataAccessProvider.AddCommand(CommandEntity.CreateCommandEntity(commandDto)); _commandDataAccessProvider.Save(); _undocommands.Push(command); } } private void ExecuteAboutDataCommand(CommandDto commandDto) { if(commandDto.CommandType == CommandTypes.ADD) { ICommandAdd command = new AddAboutDataCommand(_loggerFactory, commandDto); command.Execute(_context); _commandDataAccessProvider.AddCommand(CommandEntity.CreateCommandEntity(commandDto)); _commandDataAccessProvider.Save(); command.UpdateIdforNewItems(); _undocommands.Push(command); } if (commandDto.CommandType == CommandTypes.UPDATE) { ICommand command = new UpdateAboutDataCommand(_loggerFactory, commandDto); command.Execute(_context); _commandDataAccessProvider.AddCommand(CommandEntity.CreateCommandEntity(commandDto)); _commandDataAccessProvider.Save(); _undocommands.Push(command); } if (commandDto.CommandType == CommandTypes.DELETE) { ICommand command = new DeleteAboutDataCommand(_loggerFactory, commandDto); command.Execute(_context); _commandDataAccessProvider.AddCommand(CommandEntity.CreateCommandEntity(commandDto)); _commandDataAccessProvider.Save(); _undocommands.Push(command); } } private void ExecuteNoDataCommand(CommandDto commandDto) { _commandDataAccessProvider.AddCommand(CommandEntity.CreateCommandEntity(commandDto)); _commandDataAccessProvider.Save(); } } }
The ICommand interface contains the public methods required for the commands in this application. The DBContext is used as a parameter in the Execute and the Unexecute method because the context from the HTTP request is used, and not the original context from the Execute HTTP request.
using Angular2AutoSaveCommands.Models; namespace Angular2AutoSaveCommands.Providers.Commands { public interface ICommand { void Execute(DomainModelMsSqlServerContext context); void UnExecute(DomainModelMsSqlServerContext context); CommandDto ActualCommandDtoForNewState(string commandType); } }
The UpdateAboutDataCommand class implements the ICommand interface. This command supplies the logic to update and also to undo an update in the execute and the unexecute methods. For the undo, the previous state of the entity is saved in the command.
using System; using System.Linq; using Angular2AutoSaveCommands.Models; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; namespace Angular2AutoSaveCommands.Providers.Commands { public class UpdateAboutDataCommand : ICommand { private readonly ILogger _logger; private readonly CommandDto _commandDto; private AboutData _previousAboutData; public UpdateAboutDataCommand(ILoggerFactory loggerFactory, CommandDto commandDto) { _logger = loggerFactory.CreateLogger("UpdateAboutDataCommand"); _commandDto = commandDto; } public void Execute(DomainModelMsSqlServerContext context) { _previousAboutData = new AboutData(); var aboutData = _commandDto.Payload.ToObject<AboutData>(); var entity = context.AboutData.First(t => t.Id == aboutData.Id); _previousAboutData.Description = entity.Description; _previousAboutData.Deleted = entity.Deleted; _previousAboutData.Id = entity.Id; entity.Description = aboutData.Description; entity.Deleted = aboutData.Deleted; _logger.LogDebug("Executed"); } public void UnExecute(DomainModelMsSqlServerContext context) { var aboutData = _commandDto.Payload.ToObject<AboutData>(); var entity = context.AboutData.First(t => t.Id == aboutData.Id); entity.Description = _previousAboutData.Description; entity.Deleted = _previousAboutData.Deleted; _logger.LogDebug("Unexecuted"); } public CommandDto ActualCommandDtoForNewState(string commandType) { if (commandType == CommandTypes.UNDO) { var commandDto = new CommandDto(); commandDto.ActualClientRoute = _commandDto.ActualClientRoute; commandDto.CommandType = _commandDto.CommandType; commandDto.PayloadType = _commandDto.PayloadType; commandDto.Payload = JObject.FromObject(_previousAboutData); return commandDto; } else { return _commandDto; } } } }
The startup class adds the interface/class pairs to the built-in IoC. The MS SQL Server is defined here using the appsettings to read the database connection string. EFCore migrations are used to create the database.
using System; using System.Linq; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Angular2AutoSaveCommands.Providers; using Microsoft.EntityFrameworkCore; namespace Angular2AutoSaveCommands { public class Startup { public Startup(IHostingEnvironment env) { var builder = new ConfigurationBuilder() .SetBasePath(env.ContentRootPath) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true) .AddEnvironmentVariables(); Configuration = builder.Build(); } public IConfigurationRoot Configuration { get; } public void ConfigureServices(IServiceCollection services) { var sqlConnectionString = Configuration.GetConnectionString("DataAccessMsSqlServerProvider"); services.AddDbContext<DomainModelMsSqlServerContext>(options => options.UseSqlServer( sqlConnectionString ) ); services.AddMvc(); services.AddScoped<ICommandDataAccessProvider, CommandDataAccessProvider>(); services.AddScoped<ICommandHandler, CommandHandler>(); } public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) { loggerFactory.AddConsole(Configuration.GetSection("Logging")); loggerFactory.AddDebug(); var angularRoutes = new[] { "/home", "/about" }; app.Use(async (context, next) => { if (context.Request.Path.HasValue && null != angularRoutes.FirstOrDefault( (ar) => context.Request.Path.Value.StartsWith(ar, StringComparison.OrdinalIgnoreCase))) { context.Request.Path = new PathString("/"); } await next(); }); app.UseDefaultFiles(); app.UseStaticFiles(); app.UseMvc(routes => { routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); }); } } }
The application api can be tested using fiddler. The following HTTP POST requests are sent in this order, execute(ADD), execute(UPDATE), Undo, Undo, Redo
http://localhost:5000/api/command/execute User-Agent: Fiddler Host: localhost:5000 Content-Type: application/json { "commandType":"ADD", "payloadType":"ABOUT", "payload": { "Id":0, "Description":"add a new about item", "Deleted":false }, "actualClientRoute":"https://damienbod.com/add" } http://localhost:5000/api/command/execute User-Agent: Fiddler Host: localhost:5000 Content-Type: application/json { "commandType":"UPDATE", "payloadType":"ABOUT", "payload": { "Id":10003, "Description":"update the existing about item", "Deleted":false }, "actualClientRoute":"https://damienbod.com/update" } http://localhost:5000/api/command/undo http://localhost:5000/api/command/undo http://localhost:5000/api/command/redo
The data is sent in this order and the undo, redo works as required.
The data can also be validated in the database using the CommandEntity table.
Links:
http://www.codeproject.com/Articles/33384/Multilevel-Undo-and-Redo-Implementation-in-Cshar
