ASP.NET
In Identify Distribution Strategy we discussed the framework-agnostic database changes required for using Canopy in the multi-tenant use case. The current section investigates how to build multi-tenant ASP.NET applications that work with a Canopy storage backend.
Example App
To make this migration section concrete, let’s consider a simplified version of StackExchange. For reference, the final result exists on Github.
Schema
We’ll start with two tables:
CREATE TABLE tenants (
id uuid NOT NULL,
domain text NOT NULL,
name text NOT NULL,
description text NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE TABLE questions (
id uuid NOT NULL,
tenant_id uuid NOT NULL,
title text NOT NULL,
votes int NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
);
ALTER TABLE tenants ADD PRIMARY KEY (id);
ALTER TABLE questions ADD PRIMARY KEY (id, tenant_id);
Each tenant of our demo application will connect via a different domain
name. ASP.NET Core will inspect incoming requests and look up the domain
in the tenants
table. You could also look up tenants by subdomain
(or any other scheme you want).
Notice how the tenant_id
is also stored in the questions
table. This will make it possible to colocate the
data. With the tables created, use create_distributed table
to tell
Canopy to shard on the tenant ID:
SELECT create_distributed_table('tenants', 'id');
SELECT create_distributed_table('questions', 'tenant_id');
Next include some test data.
INSERT INTO tenants VALUES (
'c620f7ec-6b49-41e0-9913-08cfe81199af',
'bufferoverflow.local',
'Buffer Overflow',
'Ask anything code-related!',
now(),
now());
INSERT INTO tenants VALUES (
'b8a83a82-bb41-4bb3-bfaa-e923faab2ca4',
'dboverflow.local',
'Database Questions',
'Figure out why your connection string is broken.',
now(),
now());
INSERT INTO questions VALUES (
'347b7041-b421-4dc9-9e10-c64b8847fedf',
'c620f7ec-6b49-41e0-9913-08cfe81199af',
'How do you build apps in ASP.NET Core?',
1,
now(),
now());
INSERT INTO questions VALUES (
'a47ffcd2-635a-496e-8c65-c1cab53702a7',
'b8a83a82-bb41-4bb3-bfaa-e923faab2ca4',
'Using lightdb for multitenant data?',
2,
now(),
now());
This completes the database structure and sample data. We can now move on to setting up ASP.NET Core.
ASP.NET Core project
If you don’t have ASP.NET Core installed, install the .NET Core SDK
from Microsoft. These instructions will use
the dotnet
CLI, but you can also use Visual Studio 2017 or newer if
you are on Windows.
Create a new project from the MVC template with dotnet new
:
dotnet new mvc -o QuestionExchange
cd QuestionExchange
You can preview the template site with dotnet run
if you’d like. The
MVC template includes almost everything you need to get started, but
LightDB support isn’t included out of the box. You can fix this by
installing the
Npgsql.EntityFrameworkCore.PostgreSQL
package:
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
This package adds LightDB support to Entity Framework Core, the default
ORM and database layer in ASP.NET Core. Open the Startup.cs
file and
add these lines anywhere in the ConfigureServices
method:
var connectionString = "connection-string";
services.AddEntityFrameworkNpgsql()
.AddDbContext<AppDbContext>(options => options.UseNpgsql(connectionString));
You’ll also need to add these declarations at the top of the file:
using Microsoft.EntityFrameworkCore;
using QuestionExchange.Models;
Replace connection-string
with your Canopy connection string. Mine
looks like this:
Server=myformation.db.canopydata.com;Port=5432;Database=canopy;Userid=canopy;Password=mypassword;SslMode=Require;Trust Server Certificate=true;
备注
You can use the Secret Manager to avoid storing your database credentials in code (and accidentally checking them into source control).
Next, you’ll need to define a database context.
Adding Tenancy to App
Define the Entity Framework Core context and models
The database context class provides an interface between your code and your database. Entity Framework Core uses it to understand what your data schema looks like, so you’ll need to define what tables are available in your database.
Create a file called AppDbContext.cs
in the project root, and add
the following code:
using System.Linq;
using Microsoft.EntityFrameworkCore;
using QuestionExchange.Models;
namespace QuestionExchange
{
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
}
public DbSet<Tenant> Tenants { get; set; }
public DbSet<Question> Questions { get; set; }
}
}
The two DbSet
properties specify which C# classes to use to model
the rows of each table. You’ll create these classes next. Before you do
that, add a new method below the Questions
property:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var mapper = new Npgsql.NpgsqlSnakeCaseNameTranslator();
var types = modelBuilder.Model.GetEntityTypes().ToList();
// Refer to tables in snake_case internally
types.ForEach(e => e.Relational().TableName = mapper.TranslateMemberName(e.Relational().TableName));
// Refer to columns in snake_case internally
types.SelectMany(e => e.GetProperties())
.ToList()
.ForEach(p => p.Relational().ColumnName = mapper.TranslateMemberName(p.Relational().ColumnName));
}
C# classes and properties are PascalCase by convention, but your
LightDB tables and columns are lowercase (and snake_case). The
OnModelCreating
method lets you override the default name
translation and let Entity Framework Core know how to find the entities
in your database.
Now you can add classes that represent tenants and questions. Create a
Tenant.cs
file in the Models directory:
using System;
namespace QuestionExchange.Models
{
public class Tenant
{
public Guid Id { get; set; }
public string Domain { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
}
And a Question.cs
file, also in the Models directory:
using System;
namespace QuestionExchange.Models
{
public class Question
{
public Guid Id { get; set; }
public Tenant Tenant { get; set; }
public string Title { get; set; }
public int Votes { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
}
Notice the Tenant
property. In the database, the question table
contains a tenant_id
column. Entity Framework Core is smart enough
to figure out that this property represents a one-to-many relationship
between tenants and questions. You’ll use this later when you query your
data.
So far, you’ve set up Entity Framework Core and the connection to Canopy. The next step is adding multi-tenant support to the ASP.NET Core pipeline.
Install SaasKit
SaasKit is an excellent piece
of open-source ASP.NET Core middleware. This package makes it easy to
make your Startup
request pipeline
tenant-aware, and
is flexible enough to handle many different multi-tenancy use cases.
Install the SaasKit.Multitenancy package:
dotnet add package SaasKit.Multitenancy
SaasKit needs two things to work: a tenant model and a tenant resolver.
You already have the former (the Tenant
class you created earlier),
so create a new file in the project root called
CachingTenantResolver.cs
:
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using SaasKit.Multitenancy;
using QuestionExchange.Models;
namespace QuestionExchange
{
public class CachingTenantResolver : MemoryCacheTenantResolver<Tenant>
{
private readonly AppDbContext _context;
public CachingTenantResolver(
AppDbContext context, IMemoryCache cache, ILoggerFactory loggerFactory)
: base(cache, loggerFactory)
{
_context = context;
}
// Resolver runs on cache misses
protected override async Task<TenantContext<Tenant>> ResolveAsync(HttpContext context)
{
var subdomain = context.Request.Host.Host.ToLower();
var tenant = await _context.Tenants
.FirstOrDefaultAsync(t => t.Domain == subdomain);
if (tenant == null) return null;
return new TenantContext<Tenant>(tenant);
}
protected override MemoryCacheEntryOptions CreateCacheEntryOptions()
=> new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromHours(2));
protected override string GetContextIdentifier(HttpContext context)
=> context.Request.Host.Host.ToLower();
protected override IEnumerable<string> GetTenantIdentifiers(TenantContext<Tenant> context)
=> new string[] { context.Tenant.Domain };
}
}
The ResolveAsync
method does the heavy lifting: given an incoming
request, it queries the database and looks for a tenant matching the
current domain. If it finds one, it passes a TenantContext
back to
SaasKit. All of tenant resolution logic is totally up to you - you could
separate tenants by subdomains, paths, or anything else you want.
This implementation uses a tenant caching strategy so you don’t hit the database with a tenant lookup on every incoming request. After the first lookup, tenants are cached for two hours (you can change this to whatever makes sense).
With a tenant model and a tenant resolver ready to go, open up the
Startup
class and add this line anywhere inside the
ConfigureServices
method:
services.AddMultitenancy<Tenant, CachingTenantResolver>();
Next, add this line to the Configure
method, below
UseStaticFiles
but above UseMvc
:
app.UseMultitenancy<Tenant>();
The Configure
method represents your actual request pipeline, so
order matters!
Update views
Now that all the pieces are in place, you can start referring to the
current tenant in your code and views. Open up the
Views/Home/Index.cshtml
view and replace the whole file with this
markup:
@inject Tenant Tenant
@model QuestionListViewModel
@{
ViewData["Title"] = "Home Page";
}
<div class="row">
<div class="col-md-12">
<h1>Welcome to <strong>@Tenant.Name</strong></h1>
<h3>@Tenant.Description</h3>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h4>Popular questions</h4>
<ul>
@foreach (var question in Model.Questions)
{
<li>@question.Title</li>
}
</ul>
</div>
</div>
The @inject
directive gets the current tenant from SaasKit, and the
@model
directive tells ASP.NET Core that this view will be backed by
a new model class (that you’ll create). Create the
QuestionListViewModel.cs
file in the Models directory:
using System.Collections.Generic;
namespace QuestionExchange.Models
{
public class QuestionListViewModel
{
public IEnumerable<Question> Questions { get; set; }
}
}
Query the database
The HomeController
is responsible for rendering the index view you
just edited. Open it up and replace the Index()
method with this
one:
public async Task<IActionResult> Index()
{
var topQuestions = await _context
.Questions
.Where(q => q.Tenant.Id == _currentTenant.Id)
.OrderByDescending(q => q.UpdatedAt)
.Take(5)
.ToArrayAsync();
var viewModel = new QuestionListViewModel
{
Questions = topQuestions
};
return View(viewModel);
}
This query gets the newest five questions for this tenant (granted, there’s only one question right now) and populates the view model.
For a large application, you’d typically put data access code in a service or repository layer and keep it out of your controllers. This is just a simple example!
The code you added needs _context
and _currentTenant
, which
aren’t available in the controller yet. You can make these available by
adding a constructor to the class:
public class HomeController : Controller
{
private readonly AppDbContext _context;
private readonly Tenant _currentTenant;
public HomeController(AppDbContext context, Tenant tenant)
{
_context = context;
_currentTenant = tenant;
}
// Existing code...
To keep the compiler from complaining, add this declaration at the top of the file:
using Microsoft.EntityFrameworkCore;
Test the app
The test tenants you added to the database were tied to the (fake)
domains bufferoverflow.local
and dboverflow.local
. You’ll need
to edit your hosts
file
to test these on your local machine:
127.0.0.1 bufferoverflow.local
127.0.0.1 dboverflow.local
Start your project with dotnet run
or by clicking Start in Visual
Studio and the application will begin listening on a URL like
localhost:5000
. If you visit that URL directly, you’ll see an error
because you haven’t set up any default tenant
behavior
yet.
Instead, visit http://bufferoverflow.local:5000 and you’ll see one
tenant of your multi-tenant application! Switch to
http://dboverflow.local:5000 to view the other tenant. Adding more
tenants is now a simple matter of adding more rows in the tenants
table.