A Multi-Tenant Website in MVC

2
610

In my most recent project we had the need to build multiple websites that would share some common functionalities but would be installed in separate servers and would be available in different domains.

We were using .NET 4.5 and MVC 5 and there was no out of the box solution for this problem. Throughout my search I came upon this paper and we took some ideas from it. This post describes the main changes to a normal website project we had to make in order to make this work.

First of all we had to create a Core MVC5 project that we set up to be used as a Class Library. We must also reference System.Runtime.Remoting. This Core project contains the shared functionality that is used on each site and as such is referenced by them. This project follows the usual MVC architecture where the only difference is that the views must have the build action set to embedded resource. It’s set to this build action so that the site projects can get them from the Core project assembly upon launch. This is accomplished through a custom view engine that is used on each site:

public class EmbeddedResourceViewEngine : RazorViewEngine
{
    public EmbeddedResourceViewEngine()
    {
        ViewLocationFormats = new[]
        {
            // Code below will search the client before core.
            "~/Views/{1}/{0}.aspx", "~/Views/{1}/{0}.ascx", "~/Views/Shared/{0}.aspx",
            "~/Views/Shared/{0}.ascx", "~/Views/{1}/{0}.cshtml",
            "~/Views/{1}/{0}.vbhtml", "~/Views/Shared/{0}.cshtml",
            "~/Views/Shared/{0}.vbhtml", "~/Views/Shared/Partials/{0}.cshtml",
            "~/Views/Shared/Partials/{0}.vbhtml", 

            // The embedded view will be copied to a tmp folder
            // using a similar structure to the View Folder
            "~/CoreViews/Views/{1}/{0}.cshtml",
            "~/CoreViews/Views/Shared/{0}.cshtml",
            "~/CoreViews/Views/{1}/{0}.vbhtml"
        };

        PartialViewLocationFormats = new[]
        {
            "~/Views/Shared/{0}.cshtml", // Client first
            "~/CoreViews/Views/Shared/{0}.cshtml",
            "~/Views/Shared/Partials/{0}.cshtml",
        };

        // Handle all areas generically
        AreaViewLocationFormats = new[]
            {
                "~/Areas/{2}/Views/{1}/{0}.cshtml", 

                // client Specific
                "~/CoreViews/Areas/{2}/Views/{1}/{0}.cshtml", 

                // Pick up core  if no client
            };

        AreaPartialViewLocationFormats = new[]
            {
                "~/Areas/{2}/Views/Shared/{0}.cshtml", 

                // Client Specific – not used in demo
                "~/CoreViews/Areas/{2}/Views/Shared/{0}.cshtml"

                // Pick up core  if no client
            };

        SaveAllViewsToTempLocation();
    }

    private static void SaveAllViewsToTempLocation()
    {
        IEnumerable<string> resources =
            typeof(EmbeddedResourceViewEngine).Assembly.GetManifestResourceNames()
                .Where(name => name.EndsWith(".cshtml"));
        foreach (string res in resources)
        {
            SaveViewToTempLocation(res);
        }
    }

    private static void SaveViewToTempLocation(string res)
    {
        // Get the file path to manipulate and the fileName for re-addition later.
        string[] resArray = res.Split('.');

        // rebuild split to get the paths.
        string filePath = string.Join("/", resArray, 0, resArray.Count() - 2) + "/";
        string fileName = string.Join(".", resArray, resArray.Count() - 2, 2);

        // replace name of project, with temp file to save to.
        string rootPath = filePath.Replace("ProjectName", "~/CoreViews");

        // Set in line with the server folder...
        rootPath = HttpContext.Current.Server.MapPath(rootPath);
        if (!Directory.Exists(rootPath))
        {
            Directory.CreateDirectory(rootPath);
        }

        // Save the file to the new location.
        string saveToLocation = rootPath + fileName;
        Stream resStream = typeof(EmbeddedResourceViewEngine).Assembly.GetManifestResourceStream(res);

        System.Runtime.Remoting.MetadataServices.MetaData.SaveStreamToFile(resStream, saveToLocation);
    }
}

With this Core project in place we can now have multiple website projects that use some common features defined in Controllers, Models, Views or any other custom code, such as custom attributes. We can also redefine Actions for a specific website, or use a different view for each website.

Views and PartialViews defined in the Core project must explicitly inherit from System.Web.Mvc.WebViewPage because they no longer know their base type from the Web.Config.

The project that motivated the use of this solution required the creation of 4 sites. One that contained all functionality (common), and 3 others that only contained an homepage similar to the common site (with one exception). For this reason we created 2 website projects:

  • Common – Used to create the main site that contains all functionality
  • Specific – Used to create 3 sites where only one contains a specific homepage

Instead of creating 3 specific website projects we decided to create only one because we can define what type of site it is by configuration when we publish it (using different publish profiles and web.config transforms).

With this setup, the Core project basically contains the Homepage Models,Views and Controller and some other core functionalities. The Common site project contains almost everything, except an homepage which it inherits from the Core project. The specific site project redefines the homepage for 1 of the 3 sites it represents and that also inherit from the Core project.

Without this setup we would have to replicate the homepage and other core functionalities across 4 website projects.

 

2 COMMENTS

  1. Olá André! Também estou fazendo uma aplicação multi cliente e estou utilizando o mesmo PDF que vc para estudo. Possuo um projeto Site.Generic e um Site.Cliente1. O problema é que o Site.Generic possui arquivos CSS que gostaria que fossem reaproveitados por todos os sites filhos (como o Site.Cliente1, por exemplo). Porém, os CSSs não são copiados de jeito nenhum, todos ficam com 404 quando rodo a aplicação. Vc tentou isso? Como vc resolveu a questão do CSS do projeto Generic? Abraços!

    • Viva!

      No meu caso tinha os assets num site separado para onde apontava a CDN. Por isso não precisava de andar a copiar assets de um lado para outro.
      No entanto, penso que uma forma simples de o fazer seria através de Build Actions:

      Copy /Y “$(SolutionDir)Third Party\SomeLibrary\*” “$(TargetDir)”

      Abraço!

LEAVE A REPLY

Please enter your comment!
Please enter your name here