A few words about our project. We are an open-source eCommerce platform on ASP.NET, which by the time of the transfer had already existed successfully for 9 years. We did the migration 2 years ago - but our hands got to write about it only now. At that time, we were one of the first major projects who decided on such a step.
Why switch to ASP.NET Core?
Before proceeding with the steps to migrate from ASP.NET MVC to ASP.NET Core, a few words about the benefits of this platform.
Benefits of ASP.NET Core
So, ASP.NET Core is already a fairly well-known and developed framework, which has already undergone several major updates, which means that today it is quite stable, technologically advanced and resistant to XSRF / CSRF attacks.
Cross - platform is one of the distinguishing features that allow him to gain more and more popularity. From now on, your web application can run both in a Windows environment and in Unix.
Modularity - ASP.NET Core comes fully in the form of NuGet packages, this allows you to optimize the application, including the selected necessary packages. This improves the performance of the solution and reduces the time it takes to upgrade individual parts. This is the second important feature that allows developers to more flexibly integrate new features into their solution.
Performance is another step towards building a high-performance application, ASP.NET Core handles 2300% more requests per second than ASP.NET 4.6, and 800% more requests per second than node.js. You can examine detailed performance tests yourself here or here .
Middleware is the new lightweight high-performance modular pipeline for in-app requests. Each piece of middleware processes an HTTP request, and then either decides to return the result, or passes the next piece of middleware. This approach gives the developer full control over the HTTP pipeline and contributes to the development of simple modules for the application, which is important for a growing open source project.
ASP.NET Core MVC provides features that simplify web development. NopCommerce already used features such as the Model-View-Controller template, Razor syntax, model binding and validation, but new tools appeared:
Of course, ASP.NET Core has much more features, but we just examined the most interesting.
Cross - platform is one of the distinguishing features that allow him to gain more and more popularity. From now on, your web application can run both in a Windows environment and in Unix.
Modularity - ASP.NET Core comes fully in the form of NuGet packages, this allows you to optimize the application, including the selected necessary packages. This improves the performance of the solution and reduces the time it takes to upgrade individual parts. This is the second important feature that allows developers to more flexibly integrate new features into their solution.
Performance is another step towards building a high-performance application, ASP.NET Core handles 2300% more requests per second than ASP.NET 4.6, and 800% more requests per second than node.js. You can examine detailed performance tests yourself here or here .
Middleware is the new lightweight high-performance modular pipeline for in-app requests. Each piece of middleware processes an HTTP request, and then either decides to return the result, or passes the next piece of middleware. This approach gives the developer full control over the HTTP pipeline and contributes to the development of simple modules for the application, which is important for a growing open source project.
ASP.NET Core MVC provides features that simplify web development. NopCommerce already used features such as the Model-View-Controller template, Razor syntax, model binding and validation, but new tools appeared:
- Tag Helpers. This is server-side code for contributing to the creation and rendering of HTML elements in Razor files.
- View components. This is a new tool, similar to partial views, but much more powerful. nopCommerce uses view components when reusing rendering logic is required and when the task is too complex for partial presentation.
- DI in submission. Although most of the data displayed in the views is passed from the controller, nopCommerce has views in which dependency injection is more convenient.
Of course, ASP.NET Core has much more features, but we just examined the most interesting.
Now let's talk about what you should consider when porting your application to a new platform.
Migration
The text will contain a large number of links to official ASP.NET Core documentation to help get more detailed information on the topic. Especially relevant for developers who are faced with a similar task for the first time.
Step 1. Preparation of tools
The first thing you need to do is upgrade Visual Studio 2017 to version 15.3 or higher. And install the latest version of the .NET Core SDK.
Before starting the migration, we recommend that you use the .NET .Net Portability Analyzer portability analysis tool. This is a good starting point to understand how laborious the transition from one platform to another will be. But, of course, this tool does not solve all the problems, and in the process there will be many pitfalls. Next, the main stages that will need to be passed will be described, and the solutions used in our project will be shown.
The very first thing you need is to update the links to the libraries used in the project that would support .NET Standard.
Step 2. Compatibility analysis of NuGet packages to support .Net Standard
If you use NuGet packages in your project, you need to check whether they are compatible with .NET Core. One way to do this is to use the NuGetPackageExplorer tool.
Step 3. .NET Core uses the new csproj file format
It is important to use the new approach for adding third-party links introduced in .NET Core: when a new class library is added to the solution, you must open the main project file and replace its contents with the following:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netcoreapp2.2</TargetFramework> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.6" /> ... </ItemGroup> ... </Project>
Links from connected libraries will be downloaded automatically. More information on the mapping between project.json and CSPROJ properties can be found in the official documentation here and here .
Step 4. Updating the namespace
You must remove all uses of System.Web and replace them with Microsoft.AspNetCore.
Step 5. You must configure the Startup.cs file. instead of using global.asax
ASP.NET Core has a new mechanism for loading the application. The entry point to the application becomes
Startup
, and the dependency on the Global.asax file disappears.
Startup
registers the middleware suite in the application.
Startup
must include the
Configure
method. In
Configure
add the required middleware to the pipeline.
Startup.cs problems
- Configuring middleware for MVC and WebAPI requests
- Configuration settings for:
- Exception handling
Since in the transition process you will inevitably have to deal with various kinds of collisions, you must immediately prepare and set up exception handling in the Development environment . Using UseDeveloperExceptionPage , middleware is added to catch exceptions.
- Routing MVC
The registration of new routes has also been changed. IRouteBuilder is now used instead of RouteCollection, a new way to register constraints (IActionConstraint)
- MVC / WebAPI Filters
It is necessary to rewrite the filters in accordance with the new ASP.NET Core implementation .
- MVC / WebAPI Formatters
- Binding Models
//add basic MVC feature var mvcBuilder = services.AddMvc(); //add custom model binder provider (to the top of the provider list) mvcBuilder.AddMvcOptions(options => options.ModelBinderProviders.Insert(0, new NopModelBinderProvider())); /// <summary> /// Represents model binder provider for the creating NopModelBinder /// </summary> public class NopModelBinderProvider : IModelBinderProvider { /// <summary> /// Creates a nop model binder based on passed context /// </summary> /// <param name="context">Model binder provider context</param> /// <returns>Model binder</returns> public IModelBinder GetBinder(ModelBinderProviderContext context) { if (context == null) throw new ArgumentNullException(nameof(context)); var modelType = context.Metadata.ModelType; if (!typeof(BaseNopModel).IsAssignableFrom(modelType)) return null; //use NopModelBinder as a ComplexTypeModelBinder for BaseNopModel if (context.Metadata.IsComplexType && !context.Metadata.IsCollectionType) { //create binders for all model properties var propertyBinders = context.Metadata.Properties .ToDictionary(modelProperty => modelProperty, modelProperty => context.CreateBinder(modelProperty)); return new NopModelBinder(propertyBinders, EngineContext.Current.Resolve<ILoggerFactory>()); } //or return null to further search for a suitable binder return null; } }
app.UseMvc(routes => { routes.MapRoute("areaRoute", "{area:exists}/{controller=Admin}/{action=Index}/{id?}"); routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); });
At the same time, the folder with the name Area, inside which the Admin folder is located, should be placed in the root of the application. Now, the
[Area("Admin")] [Route("admin")]
attribute will be used to associate the controller with this area.
It remains only to create views for all the actions described in the controller.
[Area("Admin")] [Route("admin")] public class AdminController : Controller { public IActionResult Index() { return View(); } }
Validation
Now you donβt need to pass IFormCollection to the controllers, since in this case, asp.net server validation is disabled - MVC is suppressing further validation if the IFormCollection is found to be not null. The solution to the problem may be adding this property to the model, this will prevent us from passing directly to the controller method. This rule is valid only if there is a model, but if there is no model, then there will be no validation.
Child properties are no longer automatically validated. It must be specified manually.
Step 6. Transferring HTTP handlers and HTTP modules to Middleware
HTTP handlers and HTTP modules are essentially very similar to the Middleware concept in ASP.NET Core , but unlike modules, the middleware order is based on the order in which they are inserted into the request pipeline. The order of the modules, for the most part, is based on application life cycle events. The order of the middleware for the answers is opposite to the order for the requests, and the order of the modules for the requests and answers is the same. Based on this, you can proceed with the upgrade.
So, what remains to be updated:
- Migration of modules for Middleware (AuthenticationMiddleware, CultureMiddleware, etc.)
- Handlers to Middleware
- Using New Middleware
Authentication in our project does not use the built-in credential system; for these purposes, middleware AuthenticationMiddleware is used, developed in accordance with the new ASP.NET Core structure.
public class AuthenticationMiddleware { private readonly RequestDelegate _next; public AuthenticationMiddleware(IAuthenticationSchemeProvider schemes, RequestDelegate next) { Schemes = schemes ?? throw new ArgumentNullException(nameof(schemes)); _next = next ?? throw new ArgumentNullException(nameof(next)); } public IAuthenticationSchemeProvider Schemes { get; set; } public async Task Invoke(HttpContext context) { context.Features.Set<IAuthenticationFeature>(new AuthenticationFeature { OriginalPath = context.Request.Path, OriginalPathBase = context.Request.PathBase }); var handlers = context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>(); foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync()) { try { if (await handlers.GetHandlerAsync(context, scheme.Name) is IAuthenticationRequestHandler handler && await handler.HandleRequestAsync()) return; } catch { // ignored } } var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync(); if (defaultAuthenticate != null) { var result = await context.AuthenticateAsync(defaultAuthenticate.Name); if (result?.Principal != null) { context.User = result.Principal; } } await _next(context); } }
ASP.NET provides a lot of embedded middleware that you can use in your application, but note that the developer has the ability to create his own middleware and add it to the HTTP request pipeline. To simplify this mechanism, we added a special interface, and now itβs enough to just create a class that implements it.
public interface INopStartup { /// <summary> /// Add and configure any of the middleware /// </summary> /// <param name="services">Collection of service descriptors</param> /// <param name="configuration">Configuration of the application</param> void ConfigureServices(IServiceCollection services, IConfiguration configuration); /// <summary> /// Configure the using of added middleware /// </summary> /// <param name="application">Builder for configuring an application's request pipeline</param> void Configure(IApplicationBuilder application); /// <summary> /// Gets order of this startup configuration implementation /// </summary> int Order { get; } }
Here you can add and configure your middleware:
/// <summary> /// Represents object for the configuring authentication middleware on application startup /// </summary> public class AuthenticationStartup : INopStartup { /// <summary> /// Add and configure any of the middleware /// </summary> /// <param name="services">Collection of service descriptors</param> /// <param name="configuration">Configuration of the application</param> public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { //add data protection services.AddNopDataProtection(); //add authentication services.AddNopAuthentication(); } /// <summary> /// Configure the using of added middleware /// </summary> /// <param name="application">Builder for configuring an application's request pipeline</param> public void Configure(IApplicationBuilder application) { //configure authentication application.UseNopAuthentication(); } /// <summary> /// Gets order of this startup configuration implementation /// </summary> public int Order => 500; //authentication should be loaded before MVC }
Step 7. Using the built-in DI
Dependency injection is one of the key features in the application design process in ASP.NET Core. It allows you to create loosely coupled applications that are more testable, modular and, as a result, maintainable. This was made possible by following the principle of dependency inversion. To install dependencies, IoC containers (Inversion of Control) are used. In ASP.NET Core, such a container is represented by the IServiceProvider interface. Services are installed in the application in the
Startup.ConfigureServices()
method.
Any registered service can be configured with three scopes:
- transient
- scoped
- singleton
services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))); services.AddSingleton<Isingleton,MySingleton>();
Step 8. Using WebAPI Project Compatibility Shells (Shim)
To simplify the migration of an existing Web API implementation, it is recommended to use the Microsoft.AspNetCore.Mvc.WebApiCompatShim NuGet package. The following compatible functions are supported:
- Adds ApiController Type
- Enables web API style model binding
- Extends model binding so that controller actions can accept parameters of type HttpRequestMessage.
- Adds message formatters that allow actions to return results of type HttpResponseMessage
services.AddMvc().AddWebApiConventions(); routes.MapWebApiRoute(name: "DefaultApi", template: "api/{controller}/{id?}" );
Step 9. Transfer Application Configuration
Previously, some settings were saved in the web.config file. We are now taking a new approach based on key-value pairs established by configuration providers . This is the recommended mechanism in ASP.NET Core, and we use the appsettings.json file.
You can also use the NuGet package
System.Configuration.ConfigurationManager
if for some reason you want to continue using * .config. In this case, you will have to abandon the ability to run the application on Unix platforms and run it only under IIS.
If you want to use the Azure Key Vault configuration provider, you should refer to the Content Migration to Azure Key Valut content . Our project did not have such a task.
Step 10. Transferring static content to wwwroot
To serve static content, you must tell the web host the root of the contents of the current directory. The default is wwwroot. You can customize your directory for storing static files by setting up middleware.
Step 11. Porting EntityFramework to EF Core
If the project uses some specific features of Entity Framework 6 that are not supported in
EF Core
, then it makes sense to run the application on the
NET Framework
. In this case, however, you have to sacrifice multiplatform. The application will work only on Windows and under IIS.
Let's take a look at the main changes to be taken into account:
- System.Data.Entity namespace replaced by Microsoft.EntityFrameworkCore
- The signature of the DbContext constructor has been changed. Now you need to inject DbContextOptions
- HasDatabaseGeneratedOption (DatabaseGeneratedOption.None) method replaced by ValueGeneratedNever ()
- WillCascadeOnDelete (false) method replaced by OnDelete (DeleteBehavior.Restrict)
- OnModelCreating (DbModelBuilder modelBuilder) method replaced by OnModelCreating (ModelBuilder modelBuilder)
- HasOptional method is no longer available
- the configuration of objects has been changed, now you need to use OnModelCreating, because EntityTypeConfiguration is no longer available
- ComplexType attribute is no longer available
- IDbSet interface replacements with DbSet
- ComplexType - complex type support appeared in EF Core 2 with the Owned Entity type ( https://docs.microsoft.com/en-us/ef/core/modeling/owned-entities ), and tables without a Primary Key with QueryType in EF Core 2.1 ( https://docs.microsoft.com/en-us/ef/core/modeling/query-types )
- foreign keys in EF Core generate shadow properties using the [Entity] Id pattern, unlike EF6, which uses the [Entity] _Id pattern. Therefore, first add foreign keys as a regular property to the entity.
- To support DI for DbContext, configure your DbContex in
ConfigureServices
/// <summary> /// Register base object context /// </summary> /// <param name="services">Collection of service descriptors</param> public static void AddNopObjectContext(this IServiceCollection services) { services.AddDbContextPool<NopObjectContext>(optionsBuilder => { optionsBuilder.UseSqlServerWithLazyLoading(services); }); } /// <summary> /// SQL Server specific extension method for Microsoft.EntityFrameworkCore.DbContextOptionsBuilder /// </summary> /// <param name="optionsBuilder">Database context options builder</param> /// <param name="services">Collection of service descriptors</param> public static void UseSqlServerWithLazyLoading(this DbContextOptionsBuilder optionsBuilder, IServiceCollection services) { var nopConfig = services.BuildServiceProvider().GetRequiredService<NopConfig>(); var dataSettings = DataSettingsManager.LoadSettings(); if (!dataSettings?.IsValid ?? true) return; var dbContextOptionsBuilder = optionsBuilder.UseLazyLoadingProxies(); if (nopConfig.UseRowNumberForPaging) dbContextOptionsBuilder.UseSqlServer(dataSettings.DataConnectionString, option => option.UseRowNumberForPaging()); else dbContextOptionsBuilder.UseSqlServer(dataSettings.DataConnectionString); }
Use the SQL Compare tool to verify that
EF Core
generates a similar database schema during migration as the
Entity Framework
.
Step 12. Removing all HttpContext references, replacing obsolete classes, and changing the namespace
During the migration of your project, you will find that a sufficiently large number of classes have been renamed or moved, and now you need to bring everything into line with the new requirements. Here is a list of the main transitions that you may encounter:
- HttpPostedFileBase -> IFormFile
- Access to access HttpContext is now through IHttpContextAccessor
- HtmlHelper -> IHtmlHelper
- ActionResult -> IActionResult
- HttpUtility -> WebUtility
- Instead of HttpSessionStateBase - ISession, is accessible from HttpContext.Session. from Microsoft.AspNetCore.Http
- Request.Cookies returns IRequestCookieCollection: IEnumerable <KeyValuePair <string, string >>, so instead of HttpCookie, KeyValuePair <string, string> from Microsoft.AspNetCore.Http
Namespace replacement:
- SelectList -> Microsoft.AspNetCore.Mvc.Rendering
- UrlHelper -> WebUtitlity
- MimeMapping -> FileExtensionContentTypeProvider
- MvcHtmlString -> IHtmlString and HtmlString
- ModelState, ModelStateDictionary, ModelError -> Microsoft.AspNetCore.Mvc.ModelBinding
- FormCollection -> IFormCollection
- Request.Url.Scheme -> this.Url.ActionContext.HttpContext.Request.Scheme
Other:
- MvcHtmlString.IsNullOrEmpty (IHtmlString) -> String.IsNullOrEmpty (variable.ToHtmlString ())
- [ValidateInput (false)] - it generally is no more and it is not needed
- HttpUnauthorizedResult -> UnauthorizedResult
- [AllowHtml] - there is no more directive and it is not needed
- TagBuilder.SetInnerText method replaced - now this is InnerHtml.AppendHtml
- JsonRequestBehavior.AllowGet when returning Json is no longer needed
- HttpUtility.JavaScriptStringEncode. -> JavaScriptEncoder.Default.Encode
- Request.RawUrl. It is necessary to separately connect Request.Path + Request.QueryString
- AllowHtmlAttribute - There is no more class
- XmlDownloadResult - now you can use just return File (Encoding.UTF8.GetBytes (xml), "application / xml", "filename.xml");
- [ValidateInput (false)] - there is no more directive and it is not needed
Step 13. Updating Authentication and Authorization
I already wrote above that in our project authentication is not implemented using the built-in identity system, but is taken out in a separate layer of middleware. However, ASP.NET Core has its own mechanism for providing credentials. More details can be found in the documentation here .
As for data protection - we no longer use MachineKey . Instead, we use the built-in data protection feature. By default, keys are generated when the application starts. The data warehouse can be:
- File system - file system-based keystore
- Azure Storage - data protection keys in Azure Blob Storage
- Redis - data protection keys in the Redis cache
- Registry - must be used if the application does not have access to the file system
- EF Core - keys are stored in the database
If the built-in mechanisms are not suitable, you can specify your own key storage mechanism by providing a custom IXmlRepository .
Step 14. Updating JS / CSS
The way of working with static resources has changed: now all of them should be stored in the root folder of the wwwroot project, unless, of course, other settings are specified.
When using javascript built-in blocks, it is recommended that you move them to the end of the page. Just use the asp-location = βFooterβ attribute for your tags. The same rule applies to js files.
Use the BundlerMinifier extension as a replacement for System.Web.Optimization - this will allow you to bind and minimize JavaScript and CSS while building the project. Link to the documentation.
Step 15. Migrating Views
Child Actions are no longer used. Instead, ASP.NET Core offers a new powerful tool - ViewComponents , which is called asynchronously.
How to get a string from ViewComponent:
/// <summary> /// Render component to string /// </summary> /// <param name="componentName">Component name</param> /// <param name="arguments">Arguments</param> /// <returns>Result</returns> protected virtual string RenderViewComponentToString(string componentName, object arguments = null) { if (string.IsNullOrEmpty(componentName)) throw new ArgumentNullException(nameof(componentName)); var actionContextAccessor = HttpContext.RequestServices.GetService(typeof(IActionContextAccessor)) as IActionContextAccessor; if (actionContextAccessor == null) throw new Exception("IActionContextAccessor cannot be resolved"); var context = actionContextAccessor.ActionContext; var viewComponentResult = ViewComponent(componentName, arguments); var viewData = ViewData; if (viewData == null) { throw new NotImplementedException(); } var tempData = TempData; if (tempData == null) { throw new NotImplementedException(); } using (var writer = new StringWriter()) { var viewContext = new ViewContext( context, NullView.Instance, viewData, tempData, writer, new HtmlHelperOptions()); // IViewComponentHelper is stateful, we want to make sure to retrieve it every time we need it. var viewComponentHelper = context.HttpContext.RequestServices.GetRequiredService<IViewComponentHelper>(); (viewComponentHelper as IViewContextAware)?.Contextualize(viewContext); var result = viewComponentResult.ViewComponentType == null ? viewComponentHelper.InvokeAsync(viewComponentResult.ViewComponentName, viewComponentResult.Arguments): viewComponentHelper.InvokeAsync(viewComponentResult.ViewComponentType, viewComponentResult.Arguments); result.Result.WriteTo(writer, HtmlEncoder.Default); return writer.ToString(); } }
There is no need to use HtmlHelper anymore - ASP.NET Core has a large number of helper tag functions ( Tag Helpers ) built in. When the application is running, they are processed by the Razor engine on the server side and ultimately are converted to standard html elements. This greatly simplifies application development. And, of course, you can implement your own tag-helpers.
We started using dependency injection in views instead of allowing settings and services using
EngineContext
.
So, the main points on the migration of views:
- Convert
Views/web.config Views/_ViewImports.cshtml
- used to import namespaces andViews/web.config Views/_ViewImports.cshtml
dependencies. This file does not support otherRazor
functions, such as function and section definitions. - Convert
namespaces.add
to@using
- Transferring any settings to the main application configuration
-
Scripts.Render
andStyles.Render
does not exist. Replacelibman
orBundlerMinifier
links to output
In conclusion
We have seen from our experience that the process of migrating a large web application is a very time-consuming task that can hardly be carried out without pitfalls. We planned to switch to the new framework as soon as its first stable version was released, but we were not able to complete it right away: there were some critical functions that by that time had not yet been transferred to .NET Core, in particular, related to EntityFramework. Therefore, we had to first release the next release using a mixed approach - the .NET Core architecture with the .NET Framework dependencies.
We were able to fully adapt the project after the release of .NET Core 2.1, having at that time a stable solution already working on the new architecture - all that was left was to replace some packages and rewrite the work with EF Core. Thus, the complete migration to the new framework took several months of work.
You can learn more about our project from our repository on GitHub .