Skip to content

Latest commit

 

History

History
189 lines (137 loc) · 11.8 KB

File metadata and controls

189 lines (137 loc) · 11.8 KB

Working with Data in your ASP.NET Core App

by James Chambers

Sample Files

Download a ZIP containing this tutorial's sample files:

  • [Completed Version (Pending)] - Includes the completed versions of all MVC tutorials

To really start to add value to your application you're going to need to start loading, saving and displaying data. In ASP.NET Core MVC the tool of choice will likely be Entity Framework Core, or EF Core. In this example you're going to explore the idea of logging HTTP 404 errors to an in-memory database.

Entity Framework Core

According to the documentation, Entity Framework is "an object-relational mapper (O/RM) that enables .NET developers to work with a database using .NET objects". The database you connect with doesn't really matter to your code and you don't have to worry about the details. In fact, you can use a relational data store such as SQL Server, or you can use an in-memory database or any other back-end and your code will largely remain the same.

By using a pre-built library you can avoid having to write a lot of the data access code on your own, which you can observe by using the in-memory database provider that EF Core supports. To do so, you need to first add a reference to the relevant library to your project. Right-click on your project and select "Manage NuGet Packages..." then install Microsoft.EntityFrameworkCore.InMemory. Behind the scenes, NuGet will pull in the Microsoft.EntityFrameworkCore package as well, giving you all the primitives, base types and features of the OR/M, while the InMemory package gives you the ability to use an in-memory data store.

Configuring a Database Provider

With a goal of making it easier to use EF across your project, you can take a cue from what you learned about Dependency Injection and register your service with the DI container. To do this, you'll add a class that inherits from DbContext, one of the primary abstractions in Entity Framework.

In this example, you're creating an in-memory database that will be used to track error messages while the application is running. Create a Data folder in your project and add the following classes:

public class ErrorContext : DbContext
{
    public ErrorContext(DbContextOptions<ErrorContext> options) : base(options) { }
    public DbSet<ErrorInformation> Errors { get; set; }
}

public class ErrorInformation
{
    public int ErrorInformationId { get; set; }
    public string Path { get; set; }
    public int StatusCode { get; set; }
}

DbContext and DbSet are provided to us in the Microsoft.EntityFrameworkCore package. The context is a class that contains the code needed to talk to a database provider, and the DbSet<T> represents a collection of data which you can envision as rows stored in a table. The breadth and depth of these classes and the methods supported and exposed by them can be further studied at the docs site.

Now, go to your Startup class and add the following code before the call to services.AddMvc();:

public void ConfigureServices(IServiceCollection services)
{
    // ... other services
    services.AddDbContext<ErrorContext>(opt => opt.UseInMemoryDatabase());
    services.AddMvc();
}

You may have noticed that the ErrorContext class has a constructor that accepts a generic DbContextOptions<T> parameter. This is what allows the framework to assemble dependency chains at runtime. You'll note in the call to AddDbContext that there is a lambda expression where the application is configured to use an in memory database. This pattern, combined with the constructor, provides the abstraction required to separate the configuration needed to talk to a database from the objects used to manipulate it.

You could just as easily store a connection string in your application configuration and connect your context to a SQL database. Rather than using the Microsoft.EntityFrameworkCore.InMemory package above you'd use Microsoft.EntityFrameworkCore.SqlServer instead, and your call in ConfigureServices would look similar to the following:

services.AddDbContext<ErrorContext>(options =>
    options.UseSqlServer(Configuration.GetConnectionString("ErrorDatabaseConnectionString")));

Note that the InMemory provider is intended for testing and prototyping and is not a production data store. Each time your application starts a new copy of the database is created and, likewise, any data built up at runtime is lost when your process ends.

Collecting Some Data

The next step is to start adding some data to your database, and one way to do this in your error logging feature is through Middleware. Create a folder in your project called Middleware and then add a new middleware class by right-clicking on the folder and selecting "Add -> New Item". Choose the Middleware class template, and name the class ErrorCollectingMiddleware.

Modify the constructor so that it accepts the ErrorContext that you created and configured earlier:

public ErrorCollectingMiddleware(RequestDelegate next, ErrorContext context)
{
    _next = next;
    _context = context;
}

Next, create a backing field for the context so that you can access it throughout your class:

private readonly ErrorContext _context;

Finally, update the Invoke method to the following:

public async Task Invoke(HttpContext httpContext)
{
    await _next(httpContext);

    if (httpContext.Response.StatusCode >= 400)
    {
        _context.Errors.Add(new ErrorInformation
        {
            Path = httpContext.Request.Path,
            StatusCode = httpContext.Response.StatusCode
        });
        await _context.SaveChangesAsync();
    }
}

There are two important things to note about the data and the context after the application waits for the call _next to complete. First, whenever it sees an HTTP status code at or above 400 it creates a new ErrorInformation object on the fly and adds it to the DbSet<ErrorInformation> property that is defined in the context. Secondly, a call is made to SaveChangesAsync to commit the new row to the table.

To bring the logging online you have to wire in the middleware, which you've seen before. The intent here is to wrap the execution around the MVC pipeline to capture the errors. To do so, add it to the Configure method in your Startup class, just before the call to app.UseMvc(). You can use the extension method that was created for you in the templated middleware class:

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{

    loggerFactory.AddConsole(Configuration.GetSection("Logging"));
    loggerFactory.AddDebug();

    app.UseErrorCollectingMiddleware();

    app.UseMvc(routes =>
    {
        routes.MapRoute(
            name: "default",
            template: "{controller=Home}/{action=Index}/{id?}");
    });

    app.UseStaticFiles();

}

All that's left to do is for you to cause some errors! The easiest way to do this is to try to access pages that you know don't exist in your application, which gives an HTTP 404 error, indicating that the client has requested a resource that doesn't exist. You could also add some faulty code or throw an exception from a controller action, which would translate into an HTTP 500 server side error. As you navigate and cause problems for your application, you'll be storing the error information in your in-memory database.

Displaying Data in A View

Now add a new controller called ViewErrorsController to your project in the Controllers folder and add a constructor that accepts an instance of your ErrorContext as well as the corresponding backing field.

private readonly ErrorContext _context;

public ViewErrorsController(ErrorContext context)
{
    _context = context;
}

Add an Index action to the controller that uses the context to capture a list of errors and pass them to a view.

public IActionResult Index()
{
    var errors = _context.Errors.ToList();
    return View(errors);
}

Note {.note} Controllers are supposed to be concerned about views, about validating application input and assembling the components required to render a web page for the user. So why are we talking to the database in a controller? The DI piece makes it easy in this case, but it doesn't make it right. The biggest problem here is that our view and our controller have become tightly coupled to the underlying data entities, which will cause problems down the road. Be sure to check the Next Steps at the end of this article for ideas on how to improve this technical debt by using repositories or the mediator pattern.

To see your data rendered in the browser at runtime you're going to need a view. You'll follow standard convention here, so add a ViewErrors folder under your Views directory in the project and add a new view called Index.cshtml. The requisite code to render a table of errors is as follows:

@model IEnumerable<MvcBasics.Data.ErrorInformation>

<table class="table">
    @foreach (var error in Model)
    {
        <tr>
            <td>@error.ErrorInformationId</td>
            <td>@error.Path</td>
            <td>@error.StatusCode</td>
        </tr>
    }
</table>

When you restart your application you will have the ability to navigate to /ViewErrors/ and see any that have surfaced. Remember that your in-memory database will have reset on each execution, so you may have to generate a few errors to get the data showing. Your end result should look something like this:

Viewing a List of Application Errors

Filtering and Shaping Our Data

The Errors property you have on your ErrorContext class is of type DbSet<T> which implements the IEnumerable and IQueryable interfaces. This enables many interesting LINQ scenarios such as deferred execution, grouping and ordering. You can chain together various query parts before using the ToList extension method, and Entity Framework will take care of optimizing the query behind the scenes for you. For instance, if you wanted to extract a list of 404 errors by page and get a count of each, ordered by the path you could put together a LINQ statement like so:

var notFoundErrors = _context.Errors
    .Where(e => e.StatusCode == 404)
    .GroupBy(e => e.Path)
    .OrderBy(g => g.Key)
    .ToList();

At runtime, the IQueryable components of the above statement defer execution until an iterator is invoked on the set. By putting off a query to the database until the data is needed, an optimized version of the query can be assembled and executed a single time, only returning the data that is required. For more information about working with data sets in LINQ, be sure to review the EF tutorial on the docs site;

Next Steps