Hosting your ASP.NET Core Razor Pages inside Azure Functions
Image by https://undraw.co/

Hosting your ASP.NET Core Razor Pages inside Azure Functions

Azure Functions

Summary:

Migrate your ASP.NET Core Razor Pages based application inside Azure Functions to leverage the Consumption plan billing model for your existing applications.

TLDR: Check the sample on GitHub.

Disclaimer:

This is mainly a proof of concept to show the possibilities. TestServer, which is used in this recipe, should be reviewed for thread safety/performance and a new custom server should be developed to use this recipe in production. Thanks to Christian Weyer for pointing me to this blog post which provides an alternative approach inspired from TestServer.

Ingredients:

 Directions:

⏲️ Preparation👨‍🍳 Ready In
20 minutes1 hour
  1. Create  a new Razor Pages web app based on ASP.NET Core 2.1 by following this tutorial: Get started with Razor Pages in ASP.NET Core.
  2. Start your newly created application, make sure it starts properly.
  3. Edit the newly created project properties.
    Edit Project Properties
    Edit Project Properties
  4. Do the following changes:
    1. Change Project SDK to Microsoft.NET.Sdk.Razor:
      <Project Sdk="Microsoft.NET.Sdk.Razor">
    2. Add Razor Pages and Azure Functions configuration items inside the first PropertyGroup
      <PropertyGroup>
         <TargetFramework>netcoreapp2.1</TargetFramework>
         <AzureFunctionsVersion>v2</AzureFunctionsVersion>
         <RazorCompileOnBuild>True</RazorCompileOnBuild>
         <RazorCompileOnPublish>True</RazorCompileOnPublish>
         <RazorEmbeddedResource>True</RazorEmbeddedResource>
         <PreserveCompilationContext>true</PreserveCompilationContext>
         <MvcRazorExcludeRefAssembliesFromPublish>false</MvcRazorExcludeRefAssembliesFromPublish>
      </PropertyGroup>
      
    3. Add the following PackageReference items inside the first ItemGroup
      <ItemGroup>
        <PackageReference Include="Microsoft.NET.Sdk.Functions" Version="1.0.24" />
        <PackageReference Include="Microsoft.AspNetCore.App" version="2.1.6" />
        <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.1.3" />
        <PackageReference Include="Microsoft.AspNetCore.TestHost" Version="2.0.3" />
        <PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.1.2" PrivateAssets="All" />
        <PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="2.1.6" PrivateAssets="All" />
        <PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.5.1" />
      </ItemGroup>  
  5. Edit project launchSettings.json file
    Edit Launch Settings
    Edit Launch Settings
  6. Do the following changes:
    1. Delete iisSettings and keep only profiles.
    2. Inside profiles, remove IIS Express.
    3. Inside profiles, modify RazorPagesMovie to look like this: 
      {
        "profiles": {
          "RazorPagesMovie": {
            "commandName": "Project",
            "commandLineArgs": "host start --pause-on-error",
            "environmentVariables": {
              "ASPNETCORE_ENVIRONMENT": "Development"
            },
            "applicationUrl": "http://localhost:7071/"
          }
        }
      }
  7. Add two files to the project root:
    1. Add host.json
      {
        "version": "2.0",
        "extensions": {
          "http": {
            "routePrefix": ""
          }
        }  
      }
    2. Add local.settings.json
      {
        "IsEncrypted": false,
        "Values": {
          "AzureWebJobsDisableHomepage": "true",  
          "FUNCTIONS_WORKER_RUNTIME": "dotnet",
          "AzureWebJobsStorage": "UseDevelopmentStorage=true",
          "AzureWebJobsDashboard": "UseDevelopmentStorage=true",
          "APPINSIGHTS_INSTRUMENTATIONKEY": ""
        },
        "Host": {
          "LocalHttpPort": 7071,
          "CORS": "*"
        }
      }
    3. Make sure that both newly added files have a Copy to Output Directory action of Copy if newer.
      Copy if newer
      Copy if newer properties for new files
  8. Go back to project properties, and make sure that local.settings.json will not be published by making sure "CopyToPublishDirectory=Never" to it:
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
    
  9. Go into project properties and configure following Post-build events:
    Post-Build Events
    Post-Build Events
    xcopy /y "$(TargetDir)appsettings*.json" "$(TargetDir)\"
    xcopy /y "$(TargetDir)*.Views.dll" "$(TargetDir)bin\"
    xcopy /y "$(TargetDir)*.Views.pdb" "$(TargetDir)bin\"
    md "$(TargetDir)bin\wwwroot\"
    xcopy /s /y "$(ProjectDir)wwwroot\*.*" "$(TargetDir)wwwroot\"
  10. Add a new Azure Functions HTTP Trigger, by right clicking on your project and adding a New Azure Function.
    Add a New Azure Function
    Add a New Azure Function
    1. In the list of project items, select Azure Function, name it Host.cs and press Add.
      Create New Function
      Create New Function
    2. In the New Azure Function dialog, pick Http trigger, and select Access rights of Anonymous. This is where we will be adding our host code.
      Select HTTP Trigger
      Select HTTP Trigger
  11. In Host.cs, remove any code that was generated and create one Function to cover Root API path and another one to cover other roots.
      1. The first HttpTrigger will cover all routes except the root.  Important: Note that the HttpTrigger attribute route RegEx excludes some specific paths that are required for Azure Functions to operate normally. Make sure those paths are not used by the Razor Pages.
          [FunctionName("AllPaths")]
          public static async Task<HttpResponseMessage> RunAllPaths(
          CancellationToken ct,
          [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", "put", "patch", "options", Route = "{*x:regex(^(?!admin|debug|runtime).*$)}")]HttpRequestMessage req,
          ILogger log,
          ExecutionContext ctx)
          {
             // [Insert code here] 
          }
      2. The second HttpTrigger will cover only the root: 
        [FunctionName("Root")]
          public static async Task<HttpResponseMessage> RunAllPaths(
          CancellationToken ct,
          [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", "put", "patch", "options", Route = "/")]HttpRequestMessage req,
          ILogger log,
          ExecutionContext ctx)
          {
             // [Insert code here] 
          }
  12. In order to host our Razor Pages, we will need to leverage the TestServer class which is usually used for hosting inside unit tests. Keep in mind that this recipe is a proof of concept and that this approach will need to be revisited (refer to Disclaimer at the start of this recipe). In Host.cs, add two static members and a static constructor in order to initialize the Host stack:
    private static readonly TestServer Server;
    private static readonly HttpClient ServerHttpClient;
    
    static Host()
    {
        var functionPath = Path.Combine(new FileInfo(typeof(Host).Assembly.Location).Directory.FullName, "..");
        Environment.SetEnvironmentVariable("HOST_FUNCTION_CONTENT_PATH", functionPath, EnvironmentVariableTarget.Process);
        
        Server = new TestServer(WebHost
            .CreateDefaultBuilder()
            .ConfigureAppConfiguration((builderContext, config) =>
            {
                config
                    .SetBasePath(functionPath)
                    .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
                    .AddJsonFile($"appsettings.{builderContext.HostingEnvironment.EnvironmentName}.json",
                        optional: true, reloadOnChange: true)
                    .AddEnvironmentVariables();
            })
            .UseStartup<Startup>()
            .UseApplicationInsights()
            .UseContentRoot(functionPath));
    
        ServerHttpClient = Server.CreateClient();
    }
  13. In each previously created Function, you can now simply use the ServerHttpClient variable to forward any incoming request to the host. To do so, simply insert the following code as the implementation of each Function:
    return await ServerHttpClient.SendAsync(req, ct);
  14. Modify Startup.cs in order to be Functions friendly. Modify UseStaticFiles to look like this in order to load properly content from wwwroot:
    app.UseStaticFiles(new StaticFileOptions
    {
        FileProvider = new PhysicalFileProvider(Path.Combine(Environment.GetEnvironmentVariable("HOST_FUNCTION_CONTENT_PATH"), "wwwroot")),
    });
  15. To simplify local testing, comment out UseHttpsRedirection and UseCookiePolicy.
    // app.UseHttpsRedirection();
    // app.UseCookiePolicy();
  16. Start your Azure Function application by pressing F5. If you are running Azure Functions for the first time, the following dialogs might appear:
    1. Azure Functions CLI tools download dialog, wait for the download to complete, this is mandatory to run your app.
      Downloading Azure Functions CLI Tools
      Downloading Azure Functions CLI Tools
    2. Windows Security Alert dialog. Check both "Private networks" and "Public networks" and press "Allow Access".
      Windows Security Alert
      Windows Security Alert
  17. To see your Razor Pages hosted in Azure Functions, navigate your browser to: http://localhost:7071
  18. Make sure the application works as expected and then stop it.
  19. The application will now be manually published to a new Azure Functions App hosted on Azure.
  20. In order to publish the newly created web site, right-click on the project name in the Solution Explorer and select Publish
    Configuration Publication
    Configuration Publication
  21. From the Pick a publish target dialog, select Create New and check Run from package file. Press Publish.
    Publication Settings
    Publication Settings
  22. Select your Function App name and create a new Resource Group, a new Hosting Plan and a new Storage Account. Press Create and wait for resource to be created.
    Create App Service
    Create App Service
  23. Once the resources have been created, the publish will start.
    Publishing Function
    Publishing Function
    1. If you get the following dialog, simply press Yes.
      Update Functions Version on Azure
      Update Functions Version on Azure
  24. Click on the Site URL and browse your newly deployed Function. 
    1. If you encounter any error, configure Application Insights to see the logs of the Function. Make sure you added the APPINSIGHTS_INSTRUMENTATIONKEY key into the local.settings.json. Configure your publish application settings with the Application Insights instrumentation key and configure the Remote value with the value from the newly created Application Insights.
      Configure Application Settings For Publication
      Configure Application Settings For Publication
      Configure Application Insights Instrumentation Key
      Configure Application Insights Instrumentation Key
    2. Publish your application again and start troubleshooting.


Tips and substitutions:

  1. You can replace Razor Pages by MVC API in order to migrate your existing APIs to Azure Functions. See Hosting your ASP.NET Core MVC APIs inside Azure Functions.

 References:

 

Comments