miso_soup3 Blog

主に ASP.NET 関連について書いています。

Azure Functions で1ページを cshtml で出力したい

cshtml を1つだけホストしたいけど、そのために ASP.NET と PaaS は重いなと思い、Azure Functions でレンダリングするようにしました。

下記のような条件を満たす Azure Functions を作成します。

  • Azure Functions で、1ページだけ HTML を出力したい
  • 1ページだけだけど、クエリ文字列やフォーム送信のデータは、取得して C# で処理したい
  • Razor(cshtml)で書きたい

今回作成したページの図です:

f:id:miso_soup3:20171201202116p:plain

用途としては、アクセスが少ない適当なサンプルページを公開したいものとします。

別件ですが、cshtml ではなく静的な html をホストしたい場合は、こちら Serving an HTML Page from Azure Functions を参考にします。

サンプルコード

github.com

概要

Azure Functions で、HTTP Trigger の Function を作成します。プロジェクト配下には cshtml ファイルを配置し、それを読み取り、Razor コンパイルを行って、text/html 形式で HTTP レスポンスを返します。

Razor でコンパイルする方法ですが、RazorEngine では駄目そうだったので(参照:Error in Azure Function · Issue #496 · Antaris/RazorEngine)、Westwind.RazorHosting パッケージを使用します。

手順

  • Visual Studio を使用して Azure で初めての関数を作成する | Microsoft Docs を参考し、C# プリコンパイル形式の Azure Function のプロジェクトを作成します。
  • NuGet で、NuGet Gallery | Westwind.RazorHosting をインストールします。(現在は version 3.1.0)
  • NuGet「Microsoft.NET.Sdk.Functions」を最新版にアップデートします。
  • プロジェクト配下に、「templates」フォルダを作成します。
  • 「templates」フォルダの下に、「Page.cshtml」ファイルを作成します。 「Page.cshtml」のプロパティ設定にて、「出力ディレクトリにコピー」の値を「新しい場合はコピーにする」に設定します。
  • HTTP Trigger の関数「Function1.cs」を、アクセス権は Anonymous にして作成します(参考:関数を作成する)。

作成後は、このようなソリューション構成になります。

f:id:miso_soup3:20171201204154p:plain

  • 「Page.cshtml」と「Function1.cs」の中身を、次のように記述します。

Page.cshtml:

<!doctype html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="">
    <meta name="author" content="">
    <title>Sample</title>
</head>

<body>
    <h1>Sample</h1>

    <div>NameInQuery:@Model.NameInQuery</div>
    <div>NameInBody:@Model.NameInBody</div>

    <form method="get">
        name: <input type="text" value="" name="name" />
        <input type="submit" value="GET" />
    </form>

    <form method="post">
        name: <input type="text" value="" name="name" />
        <input type="submit" value="POST" />
    </form>
</body>
</html>

Function1.cs:

using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Host;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Westwind.RazorHosting;

namespace Miso.SampleRazorFunctions
{
    public static class Function1
    {
        private static string compiledId = "";
        private static RazorEngine<RazorTemplateBase> host = new RazorEngine<RazorTemplateBase>();

        [FunctionName(nameof(Function1))]
        public static async Task<HttpResponseMessage> Run(
            [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)]HttpRequestMessage req, 
            TraceWriter log,
            ExecutionContext context)
        {
            //.dllを追加する場合
            //host.AddAssembly(Path.Combine(context.FunctionAppDirectory, $@"bin\System.ValueTuple.dll"));
            
            if (string.IsNullOrEmpty(compiledId))
            {
                compiledId = await Helper.GetCompileId(host, context, "Page.cshtml");
            }

            log.Info($"compileId:{compiledId}");

            if (!string.IsNullOrEmpty(host.ErrorMessage))
            {
                log.Error(host.ErrorMessage);
                return req.CreateResponse(HttpStatusCode.InternalServerError, host.ErrorMessage);
            }

            // parse query parameter
            string nameInQueryParameter = req.GetQueryNameValuePairs()
                .FirstOrDefault(q => string.Compare(q.Key, "name", true) == 0)
                .Value;

            // Get request body
            var formData = await req.Content.ReadAsFormDataAsync();
            var nameInBody = formData == null ? "" : formData["name"];

            var model = new Data()
            {
                NameInQuery = nameInQueryParameter,
                NameInBody = nameInBody,
            };

            string result = host.RenderTemplateFromAssembly(compiledId, model);

            return new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent(result, Encoding.UTF8, "text/html")
            };
        }
    }

    public class Data
    {
        public string NameInQuery { get; set; }
        public string NameInBody { get; set; }
    }

    public class Helper
    {
        public static async Task<string> GetCompileId(RazorEngine<RazorTemplateBase> host, ExecutionContext context, string csHtmlFileName)
        {
            string template;
            string templatePath = Path.Combine(context.FunctionAppDirectory, $@"templates\{csHtmlFileName}");
            using (var reader = File.OpenText(templatePath))
            {
                template = await reader.ReadToEndAsync();
            }
            return host.CompileTemplate(template);
        }
    }
}

以上で、デバッグ実行し、URL にアクセスすると(例:http://localhost:7071/api/Function1)、レンダリングされた HTML を表示できます。

ノウハウなど

この方法では、通常の Visual Studio における ASP.NET 開発とは違い、.cshtml にエラーがある場合(Razor コンパイルでエラーが発生する場合)は、実行してみないと内容が分かりません。ですので、エラーの原因を調査するのに少々手間がかかります。もし、エラーが発生している場合は、host.CompileTemplate(template) を実行した時点で、host オブジェクト内にエラーメッセージが格納されます。上記のサンプルコードでは、エラーが発生した場合はその内容を HTTP レスポンスで返すようにしています。

.cshtml において、NuGet で取得してきた .dll などを参照している場合は、以下のようにアセンブリの追加を宣言します(上記のコードにも記述しています)。

host.AddAssembly(Path.Combine(context.FunctionAppDirectory, $@"bin\System.ValueTuple.dll"));`

同じように、namespace を追加する場合は、このように。

host.AddNamespace("Microsoft.Azure.WebJobs.Host")

必要な .dll が存在するかどうかを確認するときは、プロジェクトを Debug ビルドした後 \ソリューション名\bin\Debug\net462\bin 内を見てみます。

コンパイルの後(host.CompileTemplate(template);実行後)に、host 変数内から、アセンブリ情報や namespace 情報を確認できます。その他 Razor コンパイルについては、パッケージの README.md を参照します。