LTS版の.NET Core 3.1が正式リリースされたので、当サイトもこれで3回目のアップグレードです。

前回は2.0から2.1へのアップグレードだったので、そこまで大変ではありませんでした。
多少ハマりましたけど...

今回は2.1から3.1へと、メジャーバージョンのアップグレードとなるのでちょっと不安がありましたが、まぁ、なんとかなりました。

ハマりポイントは結構多かったかもしれません...。

ASP.NET Core自体のアップグレードはそこまで大変ではなかったのですが、それ以外(CIビルド環境の辺り)の部分で想定外が多かったです。

とりあえず、順番に記録に残していこうと思います。

ASP.NET Core 3.1へのアップグレード

まずは情報収集。

前回もお世話になったしばやん雑記を見て、当サイトに関係ありそうなところはEndpoint Routingの辺りかなとメモ。ふむふむ。

そして公式のガイドラインを上から下まで読みます。
この段階では「ふーん」って感じで読み流していきます。
公式のドキュメントはなんというか、必要十分な内容が記載されているんでしょうけど、なかなか頭に残らないのは何故なんでしょうか...

また、前回同様、VS2019でASP.NET Core3.1の新規プロジェクトを作成して、Program.csとStartup.csの差異を見ていきました。

Program.csはパッと見であまり変わっていないように見えるのですが、CreateWebHostBuilderメソッドがCreateHostBuilderメソッドとなっていて、WebHostからHostへとちょこちょこ変わっています。

Startup.csはConfigureServicesメソッドでMVCサービスの登録方法が変わっているくらい。ConfigureメソッドはDIコンテナから受ける引数envの型がIHositingEnvironmentからIWebHostEnvironmentへ変更されています。
MiddlewareのUseRoutingやUseAuthorizationの追加はガイドライン通りなので良いとして、最後のUseMvcがUseEndpointsになってる辺りに一抹の不安がよぎります。

当サイトのルート設定は、参照系の部分はdefaultのパターンから特に逸脱していないので問題ないのですが、記事を追加・更新する部分でXML-RPCを使っています。

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

MapRouteの最後にMapXmlRpcRouteという拡張メソッドをくっつけています。この中でxmlの内容からXML-RPCの処理を行なうコントローラへマッピングしているのですが、この部分は 改修が必要 なのは間違い無さそうです。

修正開始

Program.cs、Startup.csを収集した情報の通り、修正していきます。

懸念点のXML-RPCはひとまず置いておいて、Dockerfileのチェック。
ASP.NET Core 3.1の新規プロジェクトと見比べて、基になるDockerイメージが違うくらいだったのでささっと修正。

そして、おもむろに実行ボタンをクリック!

うん。無事起動!めでたしめでたし。

XML-RPC部分の改修

この部分のアイデアはここから頂いてました。

前出のroutes.MapRouteにくっつけていたMapXmlRpcRoute拡張メソッドは次のようになっています。

  public static class RouteBuilderExtensions
  {
      public static IRouteBuilder MapXmlRpcRoute(this IRouteBuilder routeBuilder, ILoggerFactory loggerFactory)
      {
          var inlineConstraintResolver = routeBuilder
              .ServiceProvider
              .GetService<IInlineConstraintResolver>();

          routeBuilder.Routes.Add(new XmlRpcRoute(routeBuilder.DefaultHandler,
                                                              "xmlrpc",
                                                              "サービスのurl",
                                                              new RouteValueDictionary(),
                                                              new RouteValueDictionary(),
                                                              new RouteValueDictionary(),
                                                              inlineConstraintResolver,
                                                              loggerFactory));

          return routeBuilder;
      }
  }

IRouteBuilderのRoutesコレクションにXmlRpcRouteというRouteクラスの派生クラスを追加しています。
コンストラクタ第3引数の"サービスのurl"の部分は実際のurlパターンが来ます。

XmlRpcRouteクラスは以下のような感じです。

  public class XmlRpcRoute : Route
  {
      ILogger<XmlRpcRoute> _logger;

      ・・・中略

      public override async Task RouteAsync(RouteContext context)
      {
          if (context.HttpContext.Request.Body != null 
                  && context.HttpContext.Request.ContentLength != null 
                  && context.HttpContext.Request.ContentLength > 0)
          {
              XDocument xDoc = XDocument.Load(context.HttpContext.Request.Body);

              string methodName = xDoc.Document
                                      .Element("methodCall")
                                      .Element("methodName")
                                      .Value;

              if (_logger != null)
                  _logger.LogInformation("XML-RPC methodName {@methodName}", methodName);

              context.RouteData.Values["controller"] = "XmlRpc";
              context.RouteData.Values["action"] = methodName.Replace(".", "_");
              context.HttpContext.Items[Resources.HttpContextItemXmlRpcRequest] = xDoc;
          }

          await base.RouteAsync(context);
      }
  }

リクエストのBodyに入ってくるxmlの内容からメソッド名を取得して、XmlRpcというコントローラのactionにマッピングしています。
また、xmlの内容を読み込んだXDocumentクラスをHttpContextのItemsに追加しています。
XmlRpcコントローラ側でこのItemsからXDocumentを読み取り、処理を行なっています(実際はXmlRpcServiceAttributeという属性でactionメソッドへのパラメータを割り当てています)。

XML-RPC、ASP.NET CoreでググってみるとMiddlewareで処理しているものが多いです。
慣れ親しんだControllerによる処理で実現しているこのアイデアはお気に入りだったので、同様の形で移行したい。コントローラからMiddlewareに書き直すのも面倒ですし...。

前出のUseMvcの部分はEndpoint Routingとなり、新しく以下のようになっています。

  app.UseEndpoints(endpoints =>
  {
      endpoints.MapControllerRoute(
          name: "default",
          pattern: "{controller=Home}/{action=Index}/{slug?}");
  });

endpointsという変数はIEndpointRouteBuilderというインタフェース型です。
MapControllerRouteは拡張メソッドでしょう。
ということで、IEndpointRouteBuilderに他にどんな拡張メソッドがあるのか調べてみました。そして、良さげなのを見つけましたよ!

MapDynamicControllerRoute

名前からして 凄そう 動的にコントローラへルーティングしてくれそうですね!

このメソッドに指定する型パラメータTTransformer(DynamicRouteValueTransformerの派生クラス)を実装すれば良さそうです。
そして実装した結果がこちら。

  /// <summary>
  /// MetaWeblogコントローラへのルーティング用DynamicRouteValueTransformer
  /// </summary>
  public class MetaWeblogRouteValueTransformer : DynamicRouteValueTransformer
  {
      private readonly ILogger<MetaWeblogRouteValueTransformer> _logger;


      public MetaWeblogRouteValueTransformer(ILoggerFactory loggerFactory) : base() 
      {
          _logger = loggerFactory?.CreateLogger<MetaWeblogRouteValueTransformer>();
      }

      public override async ValueTask<RouteValueDictionary> TransformAsync(HttpContext httpContext, RouteValueDictionary values)
      {
          if (!(httpContext.Request.Body != null
                  && httpContext.Request.ContentLength != null
                  && httpContext.Request.ContentLength > 0)) return null;


          try
          {
              // ASP.NET Core 3以降、同期IOが既定ではサポートされない
              // https://docs.microsoft.com/ja-jp/dotnet/core/compatibility/2.2-3.0
              //var syncIOFeature = httpContext.Features.Get<IHttpBodyControlFeature>();
              //if (syncIOFeature != null)
              //    syncIOFeature.AllowSynchronousIO = true;
              //var xDoc = await Task.FromResult(XDocument.Load(httpContext.Request.Body));
              var xDoc = await XDocument.LoadAsync(httpContext.Request.Body, LoadOptions.PreserveWhitespace, CancellationToken.None);

              // XMLからメソッド名を取得
              var methodName = xDoc.Document
                                  .Element("methodCall")
                                  .Element("methodName")
                                  .Value;
              if (_logger != null)
                  _logger.LogInformation("XML-RPC methodName {@methodName}", methodName);

              // XmlRpcServiceAttributeでパラメータのマッピングが行なわれる
              httpContext.Items[Resources.HttpContextItemXmlRpcRequest] = xDoc;

              // MetaWeblogControllerにルーティング
              var ret = new RouteValueDictionary
              {
                  ["controller"] = "MetaWeblog",
                  ["action"] = methodName.Replace(".", "_")
              };
              return ret;

          }
          catch(Exception e)
          {
              if (_logger != null)
                  _logger.LogError(e, string.Empty, null);
              return null;
          }
      }
  }

いつのまにかマッピング先のXmlRpcControllerがMetaWeblogControllerへとリネームされていますが、気にしないでくださいw

TransformAsyncメソッドのロジックはXmlRpcRouteクラスのRouteAsyncメソッドとほぼ同じ。

違いは、

  • ASP.NET Core 3.xはデフォルトで同期IOが使えなくなっているため、XDocumentへの読み込みは非同期メソッドを使用
  • 上記の原因を知らなくて、エラーになっていたのでTry~Catchブロックを追加

くらいです。

TransformAsyncメソッドで要注意なのはvalues引数に渡ってきているRouteValueDictionaryを変更して返してはいけないということ。
公式のここに書いてありました。

values
RouteValueDictionary
The route values associated with the current match. Implementations should not modify values.


追記(2020/01/02)

画像のアップロードをテストしていたら、XDocument.LoadAsyncの内部で同期IOが使われていることが発覚しました。
発覚したと言っていいのかどうかよくわかっていないのですけど...。

他のXML-RPCのメソッドは問題無かったのですが、画像(のバイナリ)を埋め込んでいるMetaweblog.NewMediaObjectメソッドの時だけ例外が発生することを観測しました。メッセージにはやっぱり同期IOが許可されていないという旨が...。
HttpContext.Request.BodyのStreamをそのまま渡していることが原因なのでしょうか...。

HttpContext.Requestには他にBodyReaderというプロパティが有って、PipeReaderという型になっています。
公式に説明があるのですが、面倒くさそう...。

ちょっと釈然としませんが、今回はAllowSynchronousIO = true;で逃げることにします。

    // ASP.NET Core 3以降、同期IOが既定ではサポートされない
    // https://docs.microsoft.com/ja-jp/dotnet/core/compatibility/2.2-3.0
    var syncIOFeature = httpContext.Features.Get<IHttpBodyControlFeature>();
    if (syncIOFeature != null)
        syncIOFeature.AllowSynchronousIO = true;
    //var xDoc = await Task.FromResult(XDocument.Load(httpContext.Request.Body));
    // 非同期IOにしたが、画像が埋め込まれているとLoadAsyncで同期IOしている?釈然としないが仕方が無いのでAllowSynchronousIO=Trueで運用
    var xDoc = await XDocument.LoadAsync(httpContext.Request.Body, LoadOptions.PreserveWhitespace, CancellationToken.None);

情報をお持ちの方、教えてくださいませませ。


さて、MapDynamicControllerRouteメソッドを使ったルーティング設定を行なう拡張メソッドを以下のように作成し、

  /// <summary>
  /// MetaWeblogコントローラへのルーティング追加用拡張メソッド
  /// </summary>
  /// <param name="routeBuilder"></param>
  /// <returns></returns>
  public static IEndpointRouteBuilder MapMetaWeblog(this IEndpointRouteBuilder routeBuilder)
  {
      routeBuilder.MapDynamicControllerRoute<MetaWeblogRouteValueTransformer>("サービスのurl");
      
      return routeBuilder;
  }

Endpoint Routingのところは以下のように修正しました。

  app.UseEndpoints(endpoints =>
  {
      endpoints.MapMetaWeblog();

      endpoints.MapControllerRoute(
          name: "default",
          pattern: "{controller=Home}/{action=Index}/{slug?}");
  });

これで開発環境では記事の投稿が出来るようになりました。わーい。

Azure DevOpsへプッシュ

動作確認出来たので、git pushでリポジトリにアップします。リポジトリはAzure DevOpsを使用しています。
リポジトリにgit pushするとビルドしてVPSへのデプロイまで行なえるようにしてあります。
結果はメールで通知されるようにしてあるので、一服しながら待ちます。ふぅ。

そして、ビルドエラーのメールが来ました。

まぁ、そうすんなりは行きませんよね。

ということでAzure DevOpsのダッシュボードを見に行くと...
使っていたビルド環境がVS2017というやつでした。ASP.NET Core 3.xはVS2019が必要ですよね。さらにAzure Pipelineというものに変わってるし...。
ビルド環境を修正(Windows-2019というvmイメージにしました)して、ビルドしてみます。

すると、今度は別のエラーが...

ビルド環境が変わって色々問題が...

.NET Core 3.1のビルドはうまく行ったようですが、npm installでエラーになってます。
普段意識していないので忘れていましたが、Releaseビルド時のnode.jsのコマンドをプロジェクトファイルに仕込んでいて、npm install、fuse.jsによるcss再作成とかしているのでした...(何故、webpackではなくfuse.jsなのかは聞かないでください...)。

その中のnode-sassの再構築でエラーになっています。

ビルド時のログを見ていくと、node.jsのバージョンが12.x。ローカルの開発環境は10.xでした。ふ、古かったのね...

node-sassはsassのコンパイルに使用するnodeモジュールですが、libsassというc++のライブラリに依存しています。
node.jsのバージョンによって対応するバージョンが決まっています。
詳しくはGitHubのREADME.mdをご覧ください。

Azure DevOpsのビルド環境に合わせて、ローカル開発環境のnode.jsもアップグレードしました。chocolatey素敵。

念のためnpm installの後にnpm rebuild node-sassも入れて開発環境でReleaseビルドのテストを...
普段は「リポジトリへpush」=「リリース」なので開発環境ではReleaseビルドしていないんですよねぇ。

で、結果はまたまたエラー...

今度はdockerのビルドでエラーでした。コンテナにnodejsが無い?
ググると、公式のここに書いてありました。

上記のサンプルでは実行環境のDockerイメージにnode.jsをインストールしていますが、今回必要なのはビルド環境なのでビルド用のコンテナに追記しました。

FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim AS base
WORKDIR /app
EXPOSE 5000

FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build
WORKDIR /src
RUN curl -sL https://deb.nodesource.com/setup_12.x |  bash -
RUN apt-get install -y nodejs
COPY *.sln ./
COPY ["NekoniDotnet.Web/NekoniDotnet.Web.csproj",  "NekoniDotnet.Web/"]
RUN dotnet restore "NekoniDotnet.Web/NekoniDotnet.Web.csproj"
COPY . .
WORKDIR "/src/NekoniDotnet.Web"
RUN dotnet build "NekoniDotnet.Web.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "NekoniDotnet.Web.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "NekoniDotnet.Web.dll"]

再度トライ。ビルド成功。やったー。

以上の修正点をコミットしてAzure DevOpsにプッシュ!

ビルド成功、デプロイ成功のメールが来ました。ふぅ。


まとめ

実は、VPS側のDockerfileの書き換えを忘れていて起動に失敗していたのですが、それはまぁ、置いておいて おい

一時はどうなるかと思いましたが、こうしてアップグレードには成功しました。
XML-RPC対応でコードを書いている時は楽しかったのですが、それ以外は苦行でした... つらい

今回はハマりポイントがそれなりにありましたが、原因がわかることばかりだったのは幸いだったと思います。
これで不具合とかに悩まされてたら泣いてます。

今回の記事も、技術的な情報はほとんど無く、ただの記録になってしまいました。
誰かの参考になることを祈って締めくくりたいと思います。
(このセリフは前回も書いたなぁ...)