@jsakamoto:.NET
2024-03-26T09:03:02+09:00
developer-adjust
C#、ASP.NET、TypeScript、Angular を中心にプログラミングに関した話題を諸々。
Excite Blog
C# で背景透過な 2 つの画像を合成した 1 枚の画像を生成する - OpenCVSharp、ImageSharp、SkiaSharp を使った作例
http://devadjust.exblog.jp/30007598/
2024-03-24T20:56:00+09:00
2024-03-26T09:03:02+09:00
2024-03-24T20:56:26+09:00
developer-adjust
.NET
先日、C# の日本語 Discord サーバーにて、以下のような C# プログラミングのお題が出ていた (出典はこちら)。
まず以下のような、机の画像データと、
以下のような背景透過なリンゴの画像データがあって、
この 2 つの画像データを重ね合わせた、つまり、机の上にリンゴが載っている新たな画像データを C# プログラム上で生成する、というものだ (下図)。
そのスレッド上では、WindowsForms や WPF などのプラットフォーム依存の解決策の他に、以下の 3 つのライブラリを使った選択肢が話題にあがっていた。
OpenCVSharp (Apache-2.0 License)ImageSharp (Six Labors Split License)SkiaSharp (MIT License)
そこで自分でも試しに、上記 3 種のライブラリを使って、先のお題を .NET 8 の C#コンソールアプリケーション x 3 本として実装してみた。ちなみに画像データのフォーマットは、いずれも PNG で試してみた。
その他の前提条件・仕様としては、いずれのプログラムにおいても、元画像となる机とリンゴの PNG ファイルは、出力フォルダ以下にそれぞれ ./assets/table.png および ./assets/apple.png として配置しておくものとし、および、合成後の画像データは、出力フォルダ直下に image.png というファイル名で保存することとした。
また上記グラフィクスライブラリ x 3種においては、画像ファイルからの読み込みと書き込みについては、それぞれ直接にファイルパスを指定するだけでよいメソッド類が用意されていたりする。しかし今回はちょっと訳ありで、画像ファイルの読み書きは、System.IO 名前空間の File クラスを使って byte[] として読み書きすることとし、上記グラフィクスライブラリは、画像データは byte[] で入出力するよう、いずれのプログラム例も統一した。
OpenCVSharp
まず OpenCVSharp 版。実はこれはかなり難儀した。
何はともあれまずは、プロジェクトファイル (*.csproj) に、OpenCVSharp の NuGet パッケージを以下のように追加しておく。
<ItemGroup>
<PackageReference Include="OpenCvSharp4" Version="4.9.0.20240103" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.9.0.20240103" />
</ItemGroup>
今回自分は Windows 上で作業していたので、Windows 向けランタイムの NuGet パッケージも併せて追加しておく必要があった。
そして、Program.cs に実装していくのだが、これが一苦労。自分の作例としては最終的に以下のようになった。
using OpenCvSharp;
// Create an empty image canvas that is 256 x 256 px by OpenCVSharp.
var canvas = new Mat(256, 256, MatType.CV_8UC3, Scalar.Black);
// Load the table image and draw it on the canvas
var tableImage = await LoadImage("./assets/table.png");
tableImage.CopyTo(canvas);// [new Rect(0, 0, tableImage.Width, tableImage.Height)]);
// Load the apple image with alpha channel
var appleImage = await LoadImage("./assets/apple.png");
// Create a mask from the alpha channel of the apple image
var mask = new Mat();
Cv2.ExtractChannel(appleImage, mask, 3);
Cv2.CvtColor(appleImage, appleImage, ColorConversionCodes.BGRA2BGR);
Cv2.CvtColor(mask, mask, ColorConversionCodes.GRAY2BGR);
// Create a masked version of the apple image
Cv2.BitwiseAnd(appleImage, mask, appleImage);
// Blend the masked apple image with the masked tableImage
var appleRect = new Rect(96, 50, appleImage.Width, appleImage.Height);
var blendedImage = tableImage[appleRect];
Cv2.BitwiseNot(mask, mask);
Cv2.BitwiseAnd(blendedImage, mask, blendedImage);
Cv2.BitwiseOr(blendedImage, appleImage, blendedImage);
// Draw back the belnded image to the canvae
blendedImage.CopyTo(canvas[appleRect]);
// Get the canvas image as a byte array.
var imageBytes = canvas.ToBytes(".png");
// Save it.
await SaveImage("image.png", imageBytes);
Console.WriteLine("The image was saved.");
Console.ReadLine();
async ValueTask<Mat> LoadImage(string imageName)
{
var imageBytes = await File.ReadAllBytesAsync(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, imageName));
// Create a Mat from the byte array.
return Cv2.ImDecode(imageBytes, ImreadModes.Unchanged);
}
async ValueTask SaveImage(string imageName, byte[] imageBytes)
{
await File.WriteAllBytesAsync(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, imageName), imageBytes);
}
何が大変だったかというと、自分ができた範囲では、どうやら OpenCVSharp (というか OpenCV) では、「背景透過な画像の描画」という機能が直接的には提供されていないようなのだ。ではどうするかというと、元の PNG 画像データからアルファチャネル部分を抜き出した "マスク" 画像データを生成し、そのマスク画像データと AND および OR の論理ビット演算で画像を合成していく必要があった。実のところ上記作例は少々手抜きをしており、透明度は正確には反映しておらず「まったく透明かそうでないか」の二択を前提として実装してある。もしかすると自分が間抜けなことをやっているだけで、もっとスマートな解法があるのかもしれないが、もしその場合はコメント欄や SNS 上で知らせていただきたい。
ImageSharp
ついで ImageSharp 版。
プロジェクトファイル (*.csproj) には、以下のように ImageSharp の NuGet パッケージを以下のように追加しておく。
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.3" />
</ItemGroup>
ImageSharp はマネージドコードだけでできているのか (間違ってたらすみません)、特段の実行時プラットフォームを気にすることなく、単一 NuGet パッケージを指定するだけでよいのは、使い勝手がよさそう。
続いて Program.cs は以下のとおり。
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
// Create an empty image canvas that is 256 x 256 px by ImageSharp.
using var canvas = new Image<Rgba32>(256, 256);
// Load the table image and draw it on the canvas
using var tableImage = await LoadImage("./assets/table.png");
canvas.Mutate(ctx => ctx.DrawImage(tableImage, 1f));
// Load the apple image and draw it on the canvas
using var appleImage = await LoadImage("./assets/apple.png");
canvas.Mutate(ctx => ctx.DrawImage(appleImage, new Point(96, 50), 1f));
// Get the canvas image as a byte array.
var memoryStream = new MemoryStream();
await canvas.SaveAsPngAsync(memoryStream);
var imageBytes = memoryStream.ToArray();
// Save it.
await SaveImage("image.png", imageBytes);
Console.WriteLine("The image was saved.");
Console.ReadLine();
async ValueTask<Image> LoadImage(string imageName)
{
var imageBytes = await File.ReadAllBytesAsync(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, imageName));
// Create a ImageSharp.Image from the byte array.
return Image.Load(imageBytes);
}
async ValueTask SaveImage(string imageName, byte[] imageBytes)
{
await File.WriteAllBytesAsync(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, imageName), imageBytes);
}
極めて直感的に、「画像データを読み込んで、キャンバスに描き込むだけ」で背景透過処理もやってくれる。
SkiaSharp
最後に SkiaSharp 版。
プロジェクトファイル (*.csproj) に、SkiaSharp の NuGet パッケージを以下のように追加しておく。
<ItemGroup>
<PackageReference Include="SkiaSharp" Version="2.88.7" />
</ItemGroup>
実は SkiaSharp は、実行時プラットフォームに応じて、各プラットフォームのランタイムが必要である。しかしどうやら NuGet パッケージシステムの依存性解決の仕組みを使って、必要な実行時プラットフォーム向けランタイムを自動的に参照するようだ (間違ってたらすみません)。つまり、アプリケーションプロジェクトのレベルでは、難しいことを考えずに、上記のように単一の NuGet パッケージを参照するだけでよいらしい。
そして Program.cs は以下のとおり。
using SkiaSharp;
// Create an empty image canvas that is 256 x 256 px by SkiaSharp.
using var surface = SKSurface.Create(new SKImageInfo(width: 256, height: 256));
var canvas = surface.Canvas;
// Load the table image and draw it on the canvas.
using var tableImage = await LoadImage("./assets/table.png");
canvas.DrawImage(tableImage, 0, 0);
// Load the apple image and draw it on the canvas.
using var appleImage = await LoadImage("./assets/apple.png");
canvas.DrawImage(appleImage, 96, 50);
// Get the canvas image as a byte array.
var imageBytes = surface
.Snapshot()
.Encode(SKEncodedImageFormat.Png, quality: 100)
.ToArray();
// Save it.
await SaveImage("image.png", imageBytes);
Console.WriteLine("The image was saved.");
Console.ReadLine();
async ValueTask<SKImage> LoadImage(string imageName)
{
var imageBytes = await File.ReadAllBytesAsync(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, imageName));
// Create a SKImage from the byte array.
return SKImage.FromEncodedData(imageBytes);
}
async ValueTask SaveImage(string imageName, byte[] imageBytes)
{
await File.WriteAllBytesAsync(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, imageName), imageBytes);
}
こちらも ImageSharp 同様、極めて直感的でシンプル。「画像データを読み込んで、キャンバスに描画する」だけだ。ちゃんと背景透過 PNG を考慮して描画してくれる。
実装してみてどうだったか
以上の実装例を以下の GitHub リポジトリに公開しておく。
ひととおり実装してみたが、ソースコード上の書き味に関しては、自分は ImageSharp 版より SkiaSharp 版が好み。
OpenCVSharp は自前でマスク処理をしなければならず、このような用途では面倒くさくて採用しないと思う。実行時プラットフォームにあわせたランタイム NuGet パッケージを別途参照追加しなくてはならないのも厄介に感じた。もっとも OpenCVSharp はどちらかといえばこのような 2D グラフィクス描画というよりはエッジ抽出や顔認識などといった "画像処理" 向けだと自分は思っているので、今回のお題で比較するのは筋が違うのだろうな、とは思った。
メモリ消費量について、雑に Visual Studio 上でデバッグ実行した際に、その診断ツールウィンドウに表示されていたヒープサイズ、および、リリースビルドを Windows 上で直接実行したときのタスクマネージャーに表示されていたメモリ消費量を以下に記しておく。
OpenCVSharp ... ヒープサイズ 454 KB
(リリースビルド、Task Manager 上 Peek 時 7,304 K > Cool Down 後 6,612 K)ImageSharp ... ヒープサイズ 951 KB
(リリースビルド、Task Manager 上 Peek 時 8,656 K > Cool Down 後 5,184 K)SkiaSharp ... ヒープサイズ 367 KB
(Task Manager 上 Peek 時 4,464 K > Cool Down 後 4,168 K)
ヒープサイズが最大で、ImageSharp と SkiaSharp との間で 500 KB 前後ほどの差異があるようではあるが、この数字がどの程度信頼できるものか少々怪しい気もするし、500 KB 程のサイズ差を大きいとみるかどうかはケースバイケースだと思うので、ここではそれ以上のことはコメントしないでおく。
Blazor WebAssembly ではどうか
実は先のお題は、このような画像合成処理を行なう C# プログラムを、Blazor WebAssembly として実装・ブラウザ上で実行する、という、プラットフォーム要件があった。本記事ではコンソールアプリケーションとして作成したのだが、もちろんこれらコンソールアプリケーションは、ほぼ同じ実装で、そのまま Blazor WebAssembly プログラムとして実現可能なはずだ。
ということで、Blazor WebAssembly 版も試作した。しかし実は OpenCVSharp 版だけがどうも画像データの読み込みが機能せず、実現に至っていない。ImageSharp 版および SkiaSharp 版はさくっと Blazor WebAssembly 版に移植でき、動作確認できた。
Blazor WebAssembly 版についてはまた日を改めて公開したい。
]]>
[C# プログラム] 百万件のデータベースレコードを EnityFramework Core で列挙したら、メモリはどれくらい消費されるのか
http://devadjust.exblog.jp/29836607/
2024-01-30T23:46:00+09:00
2024-01-30T23:46:54+09:00
2024-01-30T23:46:54+09:00
developer-adjust
.NET
C# プログラミングにおいてデータベースの読み書きを行なうにはいろいろな方法があるが、ひとつのよく使われる方法として、"EntityFramework Core" という、いわゆる "O/R マッパー" に分類されるようなライブラリを利用する方法がある。
今回はふとした思いつきから、この EntityFramework Core を使い、データベースから百万件のレコードを C# のオブジェクトにマップしながら列挙、コンソールに各レコードをひたすら表示するだけのプログラムを作成・実行したときに、どれくらいメモリが消費されるのか、実際にそのようなプログラムを作って調べてみることにした。
データベースの選定と百万件のレコードの用意
この際、データベースは PostgreSQL でも MySQL でも何でもよいのだが、とりあえず自分が慣れ親しんでいる Microsoft SQL Server を使うことにした。百万件の実レコードを含む実テーブルを作るのはだるかったので、下記の SQL を使って、On the fly で百万件のレコードを生成することにした。
SELECT TOP 1000000
Z.Id,
CONVERT(nvarchar(36), NEWID()) AS Text1,
CONVERT(nvarchar(36), NEWID()) AS Text2,
CONVERT(nvarchar(36), NEWID()) AS Text3
FROM
(SELECT ROW_NUMBER() OVER (ORDER BY X.object_id) AS Id
FROM sys.all_objects X
CROSS JOIN sys.all_objects Y) Z
これを実行すると、64 bit 整数値の列 x 1 つと ("Id" 列)、36文字の文字列型の列 x 3 列 ("Text1", "Text2", "Text3") をもつレコードが百万件ほど返る。雑な計算で、正味のデータ量だけも 110 MB 以上になるデータ量だ。
C# プログラムの実装
C# 側を実装しよう。まずはコンソールアプリケーションのプロジェクトをひとつ新規に作成する。
続けて、先の SQL 文の実行結果 (百万件のレコード) を、EntityFramework Core を使って C# のオブジェクトにマップしながら列挙したいので、そのマップ先の C# クラスを実装する。前述の SQL 文が返す結果に合せて、以下のように実装。
public class MyRecord
{
public Int64 Id { get; set; }
public string? Text1 { get; set; }
public string? Text2 { get; set; }
public string? Text3 { get; set; }
}
あとは接続のためのデータベースコンテキストクラスを用意して (下記)、
using Microsoft.EntityFrameworkCore;
public class MyDbContext : DbContext
{
public DbSet<MyRecord> MyRecords => Set<MyRecord>();
public MyDbContext(DbContextOptions<MyDbContext> options) : base(options)
{
}
}
これらを使用して Program.cs を以下のように実装した。
using Microsoft.EntityFrameworkCore;
var option = new DbContextOptionsBuilder<MyDbContext>()
.UseSqlServer("Server=(省略)")
.Options;
using var dbContext = new MyDbContext(option);
var sql = @"(先に掲載の SQL)";
var records = dbContext.MyRecords.FromSqlRaw(sql).AsNoTracking();
foreach (var record in records)
{
Console.WriteLine($"{record.Id} - {record.Text1}, {record.Text2}, {record.Text3}");
}
ここでちょっと注意点。
このプログラムにおいては、クエリの最後に「AsNoTracking()」メソッド呼び出しを追加しておく必要がある。そうしないと、列挙によって生成・マップされたオブジェクトが、その後に書き換えられたり削除用にマークされてたりしないかを "追跡 (Tracking)" できるようにするため、それらすべての生成されたオブジェクトへの参照をデータベースコンテキストが握ってしまうのだ。
今回のプログラムは、ひたすらクエリ結果のレコードを列挙しては、1レコードごとコンソールに表示しておしまい、である。なので、用が済んだレコードのオブジェクトは次々破棄したい。そのために、"NoTracking"、"このクエリの結果については追跡不要" とデータベースコンテキストに示すことで、データベースコンテキストが、マップ先として生成したオブジェクトを参照を掴んだままにしないように指示している。
実行
ではいよいよ実行である。
自分は Windows 上で Visual Studio を使って実装していたので、このままデバッグ実行を開始。Visual Studio の診断ツールウィンドウでプロセスメモリの欄を眺めてみた。
そうして、プログラムを実行開始してから、百万件を列挙しきるまでの結果が以下のとおり。
グラフを見る限り、消費メモリはまったく増えていない。
プロセスメモリのグラフはほぼ平ら、というか、むしろ右肩下がりで減っている。実行時間が数十秒に及んだせいか、プログラムが暖まってきてガベージコレクション (GC) によって開始直後よりも消費メモリが減ったのかもしれない。上図でわかるように、百万件を列挙し終わる少し前の時点で 28 MB ほどであった。ちなみに開始直後は 32MB ほど。また、試しにデータベースに対してクエリを実行しなかった場合のプロセスメモリも見てみたが、その場合は 14 MB だった。
まとめ
O/R マッパーに分類されるであろう、C# プログラミングにおけるデータベースアクセス用ライブラリの定番 EntityFramework Core を使って、百万件のレコードをオブジェクトにマップしながら列挙しつつ、コンソール出力を行なってみた。
そのような C# コンソールプログラムの消費メモリの変化を観察してみたが、プログラム開始直後からメモリ消費量はほぼ変わらずのまま (というかむしろ減るくらい)、百万件のレコードを列挙しきることができた。
大量のレコードを EntityFramework Core を使ってオブジェクトにマップしながら読み取る場合でも、読み取るだけ、かつ、数レコードずつ列挙しながら処理するだけであれば、メモリを大量消費するようなことはないことが確認できた。
もちろん、前述のとおり、"AsNoTracking" を指定しなかった場合は、マップ先として生成したオブジェクトはガベージコレクタに回収されず (データベースコンテキストがその参照を保持しているため)、そうなるとすべてのレコードぶんのオブジェクトがプロセスメモリ内に存在したままとなり、相当な量のメモリが消費されることだろう。ToArray() や ToList() などを呼び出して、全レコードをメモリ上の配列や List<T> に収納しようとしても同様のことが起きる。あくまでも、foreach などを使って、IEnumerable<T> 越しにちょっとずつフェッチするぶんには、そのときに必要なメモリしか消費されない、という理解が必要である。
]]>
XProcess を使ってプロセスを起動し所定の出力が表示されるまで待機する
http://devadjust.exblog.jp/29802826/
2023-12-30T15:35:00+09:00
2023-12-30T15:35:34+09:00
2023-12-30T15:35:34+09:00
developer-adjust
.NET
とある要件で、ASP.NET Core Web アプリケーションを起動し、HTTP リッスン状態になるまでを待機して、それからその ASP.NET Core Web アプリに HTTP 要求を送信して応答を得るという、そのようなコンソールアプリケーションを作成する需要が発生した。
もちろん、.NET の標準ライブラリにある System.Diagnostics.Process クラスを使えば実装できる。Process クラスに対し、起動したい実行可能ファイルパスやコマンドライン引数を指定するのに加えて、標準出力や標準エラー出力のリダイレクトを指定し、および Process クラスから発火される、標準出力や標準エラー出力への書き込み発生のイベントをハンドルすればよい。
ASP.NET Core Web アプリケーションであれば、HTTP リッスン状態になると「Now listening on: https://localhost:7241」といったテキストが標準出力への書き込まれるので (下図例)、これを検知すればよい。
具体的な実装例は以下のとおり。
using System.Diagnostics;
// ASP.NET Core Web アプリプロジェクトを "dotnet run"
// コマンドで起動する Process オブジェクトを準備。
// その際、標準出力のリダイレクトを指定。
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = "run",
WorkingDirectory = @"...",
RedirectStandardOutput = true
}
};
// "Now listening on..." の出力を待ち合わせるための
// タスク完了ソースを用意
var tcs = new TaskCompletionSource();
// 標準出力発生のイベントをハンドルし、"Now listening on..." が出力
// されたら、タスクを完了させる
process.OutputDataReceived += (_, e) =>
{
var output = e.Data?.Trim() ?? string.Empty;
if (output.StartsWith("Now listening on: http://localhost:"))
{
tcs.TrySetResult();
}
};
// プロセスを開始 & 標準出力読み取りを開始
process.Start();
process.BeginOutputReadLine();
// "Now listening on..." の出力が検出されるまで非同期で待機
await tcs.Task;
// ここまで来たら、起動した ASP.NET Core Web アプリは、
// HTTP リッスン開始している
Console.WriteLine("DETECT START LISTENING!");
ちゃんと実装するとなると大変!ただ、上記実装例は、もう既にずいぶんと冗長な感じがすることに加え、標準エラー出力については実装していない。また、正常に動作しているぶんにはよいが、起動した ASP.NET Core Web アプリケーションプロセスが何らかのエラーを発生していた場合に、コンソールには標準出力や標準エラー出力の内容は表示されないため (出力をリダイレクトしてイベントで拾うようになってしまっているので)、何かあったときのトラブルシューティングが困難極める。タイムアウトの仕組みもないので、"Now listening on:..." の出力がされないと永遠に await し続けてしまい、エラーの発生に気づくことすらできなさそうだ。その他にも、このコンソールアプリケーション自体が例外を発生した時に、起動した ASP.NET Core Web アプリケーションプロセスをどう始末するのか、といったことも考慮が必要だろう。
それら考慮点をすべて網羅していくと、結構なコード量に膨れ上がることになる。
Process クラスの強化版的なライブラリ世の中には、こういった需要に応えるような、Process クラスの強化版的なライブラリが、無償利用可能な NuGet パッケージとして公開されていたりする。それらライブラリのうち、自分の中での推しのひとつは、ProcessX というライブラリだ (下記リンク先)。
非同期 Stream で標準出力を扱えたり、文字列に対する await 構文で外部コマンドを手軽に実行、完了を待機できたりと、なかなかに便利で興味深い機能が提供されている。
ただ、この ProcessX をもってしても、「指定の出力が現れるまで待機」や「トラブル発生時に、それまでの標準出力・標準エラー出力を見たい」といった今回の要件に伴う需要に応えるには、もう一息、実装を積み重ねる必要があった。
結局自作したそこで、今回要件のような、エッジケースに特化した、新たな "Process クラスの強化版" ライブラリを、もうひとつ新たに作成して OSS として世に送り出すことにした。
そうして作成したのが "XProcess" である (下記リンク先、MIT license)。
この XProcess は、前述のとおり今回の自分の要件に特化してあるため、先に挙げた「ASP.NET Core Web アプリケーションを起動し、HTTP リッスン状態になるまで待機」のコードは、以下にまで端的に書けるようになる。
using Toolbelt.Diagnostics;
// 作業フォルダ指定して "dotnet run" で ASP.NET Core Web アプリ
// プロジェクトを起動
using var process = XProcess.Start("dotnet", "run", workingDirectory: @"...");
// 指定した Predection が true を返す出力があるまで非同期で待機、
// 但し、最大 5 秒のタイムアウトを指定する。
var found = await process.WaitForOutputAsync(output =>
{
output = output.Trim();
return output.StartsWith("Now listening on: http://localhost:");
}, millsecondsTimeout: 5000);
// true が返されたら、目的の出力があったということ
if (found) {
Console.WriteLine("DETECT START LISTENING!");
} else {
Console.WriteLine("SOME ERROR OCCURED!");
// Output プロパティを参照すれば、それまでの標準出力と標準エラー出力
// の内容を参照できる
Console.WriteLine(process.Output);
}
まとめこのように、特定のユースケースに特化された XProcess は、ProcessX ほどの柔軟性と高度な機能は備えていないものの、その利用シナリオに適合した場合は、より簡潔にそのやりたいことを実現可能だ。
XProcess は、もっぱら自分の要件を満たすために作成した、特化されたライブラリであり、とはいえどうせ作ったのなら OSS & 無償利用可能なパッケージとして世の中に公開したら、同じ需要を持つ誰かの便利に役立つかもしれない、というノリでリリースしている。なので、"打倒、ProcessX!" みたいなつもりはないので悪しからず。
なので、XProcess に関しては、機能追加要望やバグ修正の Issue を上げられても、あまり積極的には対応しないかもしれない。その代わり、自分の新たな需要に対して機能不足やバグがあった場合は、速やかに改善・修正されるかもしれない、そんな性格のゆるい感じのライブラリだ。
]]>
.NET の HttpClient の Timeout は何の時間切れを指しているのか
http://devadjust.exblog.jp/29756261/
2023-11-30T08:35:00+09:00
2023-12-09T21:14:40+09:00
2023-11-30T08:35:05+09:00
developer-adjust
.NET
HttpClient クラスには TimeSpan 型の "Timeout" というパブリックプロパティが設けられている。下記公式ドキュメントの説明に依れば「要求がタイムアウトするまで待機する期間を取得または設定」するプロパティであるとのこと。
しかし自分は、この HttpClient.Timeout プロパティによる "HTTP 要求のタイムアウト (時間切れ)" がどのように・何に対して機能するのかよくわかっていなかった。相手先サーバーとの TCP 接続が確立されるまでの時間なのだろうか? 接続が確立してから最初の応答バイト列が返ってくるまでの時間なのだろうか? ある程度応答が返ってきても、すべての応答が返却し終わる前に暫し応答が来なくなったらそれはタイムアウトになるのだろうか?
今までちゃんと理解しないで HttpClient を使ってきたので、今回、改めて調べてみることにした。なお、.NET ランタイムは GtHub 上でソースコードが公開されているので (下記リンク先)、ソースコードを確認すればこれら挙動について理解できることとも思う。
だが今回は実験方式で、 ASP.NET Core Minimal API による HTTP Web API サーバーと、その API サーバーに HttpClient を使って HTTP 要求を送信するコンソールアプリケーションとを C# でそれぞれ実装して、実際に実行して動作を観察してみた。
API サーバー側の C# ソースコードは以下。"/hello" の URL パスに HTTP GET 要求を送ると、"World!" の文字列が返却される。
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/hello", () => "World!");
app.Run();
コンソールアプリケーション側の C# ソースコードは以下。前述の API サーバーの "/hello" へ HTTP GET 要求で文字列取得を試みるコードだ。以下では HttpClient の Timeout には 3 秒を設定してある。
using var httpClient = new HttpClient {
Timeout = TimeSpan.FromSeconds(3),
BaseAddress = new Uri("http://localhost:****")
};
var message = await httpClient.GetStringAsync("hello");
Console.WriteLine($"message: [{message}]");
TCP 接続が確立できないとき接続先のホスト上でこの API サーバーが起動していない場合や、指定した IP アドレスではホストが TCP ネットワーク上に存在しない場合など、そもそも TCP 接続が確立しない場合を確認してみた。
この場合、HttpClient の Timeout プロパティに指定した時間が経過したのち、TaskCanceledException 例外が発生した。
Unhandled exception. System.Threading.Tasks.TaskCanceledException:
The request was canceled due to the configured HttpClient.Timeout of 3 seconds elapsing.
---> System.TimeoutException: A task was canceled.
---> System.Threading.Tasks.TaskCanceledException: A task was canceled.
なお、HttpClient からの HTTP 要求送信開始後 (GetStringAsync 呼び出し後)、Timeout プロパティに指定した時間が経過してしまう前に、API サーバー側プロセスを起動するなどして TCP 確立が成功する状態にすると、例外は発生せずに要求は成功した。
TCP 接続は成功するが、最初の応答がすぐに返らないときAPI サーバー側実装を以下のように変え、最初の応答を返すまで指定時間待機するようにしてみた。
app.MapGet("/hello", async () => {
await Task.Delay(4000); // 4秒待つ
return "World!";
});
念のため curl コマンドで "curl -i http://localhost:***/hello" を実行し、応答ボディのみならず最初の応答ヘッダも含めて、Task.Delay() で指定した時間、何の応答も返らないことを確認する。
この状態でコンソールアプリ側を実行すると、HttpClient の Timeout プロパティに指定した時間が経過したのち、TaskCanceledException 例外が発生した。例外中のコールスタックが、そもそも TCP 接続が確立できなかった場合に比べて微妙に変わっている。
Unhandled exception. System.Threading.Tasks.TaskCanceledException:
The request was canceled due to the configured HttpClient.Timeout of 3 seconds elapsing.
---> System.TimeoutException: The operation was canceled.
---> System.Threading.Tasks.TaskCanceledException: The operation was canceled.
---> System.IO.IOException: Unable to read data from the transport connection: The I/O operation has been aborted because of either a thread exit or an application request.
TCP 接続は成功し、最初の応答もすぐ返るが、すべての応答に時間がかかるときさらに API サーバー側実装を以下のように変え、最初の応答はすぐに返るが、すべての応答を実施しきるまでに数百ミリ秒の待機を挟みながら、だらだら応答を続けるようにしてみる。
var utf8 = Encoding.UTF8;
app.MapGet("/hello", async (HttpResponse res) => {
foreach (var term in new[] { "lorem", "ipsum", "dolor", "sit", "amet" })
{
await res.BodyWriter.WriteAsync(utf8.GetBytes(term + " "));
await res.BodyWriter.FlushAsync();
await Task.Delay(600);
}
});
こちらも念のため curl コマンドで動作を確認しておく。
この状態でコンソールアプリ側を実行すると、この場合も同じく、HttpClient の Timeout プロパティに指定した時間が経過したのち、TaskCanceledException 例外が発生した。
Unhandled exception. System.Threading.Tasks.TaskCanceledException:
The request was canceled due to the configured HttpClient.Timeout of 3 seconds elapsing.
---> System.TimeoutException: The operation was canceled.
---> System.Threading.Tasks.TaskCanceledException: The operation was canceled.
---> System.IO.IOException: Unable to read data from the transport connection: The I/O operation has been aborted because of either a thread exit or an application request.
まとめ以上の実験と観察結果を見ると、どうやら、HttpClient の Timeout プロパティに指定したタイムアウト時間というのは、「HTTP 要求を開始してから、すべての応答を受信し終わるまでの時間全体」を指しているらしい。
そもそも TCP 接続が確立できないままタイムアウト時間が経過した場合はもちろん、HTTP 応答が断片的に返って来続けている場合でも、Timeout プロパティに指定されたタイムアウト時間が経過してしまったら、TaskCanceledException 例外で中断されるようだ。また、タイムアウトが発生した場合は、発生理由の如何に依らず一律、TaskCanceledException 例外のスローという形で表現されることもわかった。
なお、今回は HttpClient の GetStringAsync メソッドに基づく実験だったが、GetStreamAsync で応答ストリームを入手してそのストリームから読み取りを繰り返すような場合は、もしかするともう少し違った挙動になるかもしれない。そうでないと、MJPEG をはじめ、HTTP 上でのストリーミング通信ができないのではないかと想像される。
今後要確認である。
]]>
.NET Conf 2023 のセッションスケジュールを iCal 形式で公開するようにした件
http://devadjust.exblog.jp/29731751/
2023-10-28T15:18:00+09:00
2023-10-28T15:18:30+09:00
2023-10-28T15:18:30+09:00
developer-adjust
.NET
ここ数年、.NET は毎年1回、メジャーバージョンアップを行なっている。そして、そのバージョンアップのタイミングで、.NET Conf というオンラインイベントが開催されている。今年 2023 年は、11月14日から16日にかけて開催される予定なので、.NET 8 のリリースもおそらくこれと同時になるだろうと思われる。
さて今年 2023 年の .NET Conf、各セッションスケジュールは公式サイトの Agenda ページに公開されている (下記リンク先)。
これを見ると1セッションあたりの時間は 30 分程のものが多数を占め、その総セッション数は 90 を超え、2日目は 24 時間近くのセッションスケジュールとなっており、3日目に至っては同時間帯に2トラック並行で開催される。
オンラインカレンダーで扱いたい
こういったセッションスケジュールを、自分のオンラインカレンダー (自分は主に Google カレンダーを使っている) にセッションスケジュールを登録しておけるといろいろと便利だ。Agenda ページの一覧で見るのに比べ、オンラインカレンダーのユーザーインターフェースでは、.NET Conf の全セッションを俯瞰でき、各セッションの時間帯もグラフィカルで直感的に把握しやすい。自分の興味のあるセッションだけを選び出すのも捗りそうだ。また、そうやってライブで視聴する予定のセッションスケジュールを自分のオンラインカレンダー上に構築できれば、「今日は何時からどれを視聴するのか」「次はどのセッションか」といった予定を、スマホやスマートウォッチでも手軽に把握できるようになる。
さらには、.NET Conf の Agenda ページでは、各セッションの開催時刻は PST (太平洋標準時間) での記載なのである。しかし自分は日本在住なので、Agenda ページを見るだけでは、日本の時刻 (JST) ではいったいこのセッションは何時に開催されるのかが、すぐにはわからないのである。PST から JST への時刻変換は、暗算が得意ならどうということはないのかもしれないが、あいにくと自分は暗算はかなり苦手なので、これは大変苦労する。その点、オンラインカレンダーの類いであれば、このようなタイムゾーンの換算はお手のものだ。
このように、セッションスケジュールを何かしらのオンラインカレンダーで扱えたらよいのであるが、しかし自分が把握できた限りでは、どうにも、この .NET Conf のセッションスケジュールを各種オンラインカレンダーで参照できる仕組みが用意されていないようなのである。
さりとて、これだけのセッション数を、自分のオンラインカレンダーに手作業でせっせと転記するのは、かなりの手間である。
さてどうしたものか。
スクレイピングして iCal で公開する Web API を自作
...という背景事情があったので、今回、.NET Conf 公式サイトの Agenda ページの HTML をスクレイピングし、記載されているセッションスケジュールを iCal 形式に変換して返す、そのような Web API サイトをインターネット上に公開した (下記リンク先)。
すごく難しいことをしている訳ではないので、様々な言語・フレームワーク・処理系で同様のものを実装できると思うが、今回は自分が得意な C# と ASP.NET Core を用い、Minimal API 形式で実装した。実装した Web API アプリケーションは、Microsoft のパブリッククラウドサービスである Azure App Service に配置して公開した。気が早いかもしれないが、前述のとおり今日現在では公式リリース前の .NET 8 のリリース候補第2版を使って実装・稼働させている。
AngleSharp
.NET Conf 公式サイトの Agenda ページを取得するのは普通に HttpClient クラスを使って実行している。
そうして取得した Agenda ページの HTML 文字列だが、これを走査して、各セッションスケジュールを読み取る処理は、その筋では有名な AngleSharp というライブラリ (MIT ライセンス) を使わせてもらった。
この AngleSharp を使って、ひとたび HTML 文字列を AngleSharp が提供するドキュメントオブジェクトへパースすると、Web ブラウザ上の JavaScript で querySelector とかを実行するのと同じように、C# コード内で、HTML の DOM 構造を CSS セレクタ構文など使いながらトラバースすることができるようになる。
iCal.NET
こうして Agenda ページの HTML から各セッションスケジュールを表現した C# オブジェクトの集合に変換できたら、次はこれを iCal 形式のカレンダー情報文字列に変換する必要がある。さて、iCal 形式は究極的にはプレーンテキストであり、そこまで難解なフォーマットではないので、同形式の文字列を生成する処理を素にスクラッチで組むこともできなくはない。とはいえ、1行あたりの文字数に制約があるので折り返しを所定の仕様でマークアップする必要があるなど、細かいところで注意すべき点がいろいろでてくる。そこで今回は iCal.NET というライブラリ (MIT ライセンス) を使わせてもらうことにした。
iCal.NET を使えば、iCal 形式の諸々について気を払うこと無く、iCal.NET が提供するオブジェクトモデルでスケジュールを組み立てれば、それを最終的に iCal フォーマットの文字列にシリアル化することができる。
GitHub 上で公開
以上を実装して、あとは ASP.NET Core の Minimal API の仕組みで URL エンドポイントで text/calendar の MIME-Type で公開して完了である。
この .NET Conf のセッションスケジュールを iCal 形式で公開するプログラム、ソースコードは GPL v3 ラインセンスで以下の GitHub リポジトリで公開してある。
実装でつまずいたところ
開始する前に終わっているスケジュール
実装にあたってはつまづいたところもあった。
まずひとつめは、Agenda ページでの時刻の記載で、次のような記載があった。
23:30 - 0:00 PST
つまり、終了時刻が翌日になっているのである。しかし自分が最初に実装したときはこれを考慮しておらず、「開始 11/15 23:30」「終了 11/15 0:00」という、「開始する 23時間以上前に、終了してしまっている」というおかしなスケジュール情報で読み取ってしまっていた。とりあえず終了時刻が 0:00 なら日付を次の日付にする処理を足して乗り切った (ただし今考えるとこれはよくなくて、開始時刻よりも終了時刻が前の時刻を指していたら、終了時刻の日付を翌日にする、という処理にすべきであった)。
出力された iCal 上、終日イベントになっている
もうひとつは、iCal.NET のオブジェクトモデルの扱い方である。iCal.NET が提供するクラスにスケジュール情報を詰め直す際、開始と終了時刻 (いずれも System.DateTime 型) について、最初は以下のように、iCal.NET の IDateTime 型である Start および End プロパティに、CalDateTime 型への明示的型変換で設定していた。
var icalEvent = new CalendarEvent
{
DtStart = (CalDateTime)session.StartTime,
DtEnd = (CalDateTime)session.EndTime,
...
};
これで大抵の場合は上手く動作した。ところが、開始時刻が 0:00 のイベントだけ、オンラインカレンダー上では終日イベントとして取り込まれてしまったのだ。これはどうやら iCal.NET の仕様に基づく動作のようで、時刻が 0:00 である日時情報は既定では「時刻部分がない」として扱われるようである。幸い、キャストによる明示的型変換ではく、日付だけではなく時刻付きであることを明示した時刻情報 CalDateTime オブジェクトを明示的にインスタンス化して設定することで、この問題は解決できた。
var icalEvent = new CalendarEvent
{
DtStart = new CalDateTime(session.StartTime) { HasTime = true },
DtEnd = new CalDateTime(session.EndTime) { HasTime = true },
...
};
タイムゾーンの略称から TimeZoneInfo を取得する
.NET Conf の Agenda ページ上、セッションの開催時刻の記載には、以下のとおり、太平洋標準時間であることを示す "PST" の文字が付加されている。
8:00 - 9:00 PST
この時刻表現を C# 上では最終的に UTC 日時に変換する必要があったので、そのために、"PST" というタイムゾーンの「略称」から、System.TimeZoneInfo を取得する必要がある。しかし TimeZoneInfo.FindSystemTimeZoneById("PST") を実行したところ、該当するタイムゾーンは存在しないという例外になってしまった。興味深いことに、ASP.NET Core Web アプリケーションプロジェクトではなく、素の .NET コンソールアプリケーションでこれを実行すると、ちゃんと期待どおりの TimeZoneInfo が返ってくる。プロジェクト形式の違いでどうしてこのような違いが生まれるのか理解できていないが、とりあえず今回は深追いするのはやめておいた。その代わりに、TimeZoneNames というライブラリを使わせてもらうことにした。
このライブラリは、TimeZoneInfo.GetSystemTimeZones() が返すタイムゾーンの ID から、そのタイムゾーンの略称を取得することができる。タイムゾーンの略称から ID を直接表引きできれば良かったのだが、どうもそのような機能提供はなかったようなので、自前のコードで自分でそのような変換辞書を構築することにした。
この辞書で "PST" をキーに値を取得すると "Pacific Standard Time" という正式なタイムゾーン ID を取得できる、という寸法だ。この変換辞書を介することで、"PST" というタイムゾーンの略称から TimeZoneInfo を取得し、最終的に UTC 日時への換算を実装することができた。
まぁ、ここまでこだわらずに、PST であることを前提にして固定の TimeZonInfo で換算するように実装してもよかったかもしれないが、ちょっとだけ汎用化してみた次第。
まとめ
以上、これにて .NET Conf のセッションスケジュールを、好みのオンラインカレンダーで参照することができるようになった。
HTML のスクレイピングで実装したので、.NET Conf 公式サイトの Agenda ページの構造が変わってしまうとすぐに破綻してしまう仕組みなので、できれば公式のほうで iCal 形式での公開をサポートしてくれるに越したことはない。しかしそうはいっても、今時点でないものはないので、当面はこの Web API サイトが役に立ってくれるはずだ。
]]>
System.Text.Josn で、文字列になってる数値 "Foo":"1" を int Foo {get;set;} に逆シリアル化する方法
http://devadjust.exblog.jp/29682981/
2023-08-31T19:29:00+09:00
2023-09-01T07:47:39+09:00
2023-08-31T19:29:29+09:00
developer-adjust
.NET
お題は以下のとおり。
命題下記のような JSON 文字列が提供されたとする。
{"Foo":"123"}
この JSON 文字列を、以下の C# クラスのオブジェクトに逆シリアル化したい。
public class Bar
{
public decimal Foo { get; set; }
}
ということで、System.Text.Json 名前空間の JsonSerializer クラスを使って以下のように実装したとする。
// 変数 jsonText には上記の JSON 文字列が格納されているとする
var obj = JsonSerializer.Deserialize<Bar>(jsonText);
これを実行すると何が起きるか? JSON 文字列上、プロパティ Foo には "123" という文字列が格納されているのだが、その逆シリアル化対象である Bar クラスの Foo プロパティの型は int 型である。結果、int 型に文字列を収めることはできないということで、下記例外が発生する。
Unhandled exception. System.Text.Json.JsonException:
The JSON value could not be converted to System.Int32.
これを、JSON 文字列上の "123" を、int 型の文字列表現であると解釈して、どうにか int Foo { get; set; } に格納したい、というのが命題だ。
自分が見つけた解決方法は以下の 2 つ。
方法1. Deserialize メソッドに指定するオプションで指定するSystem.Text.Json.JsonSerialization クラスの Deserialize メソッドには、第二引数に逆シリアル化の動作を変更できるオプション指定 (JsonSerializerOptions オブジェクト) を渡すことができる。そのオプションオブジェクトの NumberHandling プロパティに JsonNumberHandling.AllowReadingFromString ("文字列からの読み取りを許す") を指定する。
var obj = JsonSerializer.Deserialize<Bar>(jsonText, options: new()
{
// このオプションを指定する
NumberHandling = JsonNumberHandling.AllowReadingFromString
});
こうすると、Bar クラスの Foo プロパティに格納する元の JSON 文字列が、"123" であっても (あるいは 123 というように数値であっても)、これを int 値であるとしてパースし、Foo プロパティにそのパース後の値を格納してくれることになる。
方法2. プロパティに JsonNumberHandling 属性を付ける逆シリアル化対象のクラスのプロパティに、JsonNumberHandling 属性を付けることで、プロパティごとに逆シリアル化の動作を変更できる。以下のように、Bar クラスの Foo プロパティに、JsonNumberHandling.AllowReadingFromString ("文字列からの読み取りを許す") を引数に指定した JsonNumberHandling 属性を付けると、
public class Bar
{
[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
public int Foo { get; set; }
}
この Foo プロパティについては、元の JSON 文字列が "123" であっても、これを int 値であるとしてパースし、Foo プロパティにそのパース後の値を格納してくれる。
まとめ以上の方法にて、元の JSON 文字列上の表現が文字列型の値になっていても、int 型のプロパティに逆シリアル化することができた。方法 1 は、逆シリアル化対象のクラス内のすべてのプロパティに作用するのに対し、方法 2 は、個々のプロパティに個別に指定できる。状況や要件に応じて使い分けたいところ。
あと、この JsonNumberHandling.AllowReadingFromString の指定は、逆シリアル化対象のプロパティの型が、int 型に限らず、byte や short、long、float、double、decimal などなど、すべての数値型で有効に機能した。ただし、プロパティの型が int 型なのに、"123.4" のように小数点以下を含む文字列を読ませようとすると、当然のことながら System.Text.Json.JsonException 例外が発生する。
以上
]]>
Azure Functions (.NET 6) のプロジェクトで、NuGet パッケージ参照のバージョンを上げたらエラー
http://devadjust.exblog.jp/29654451/
2023-07-30T17:54:00+09:00
2023-07-30T17:54:51+09:00
2023-07-30T17:54:51+09:00
developer-adjust
.NET
久しぶりにその Azure Functions プロジェクトを Visual Studio で開いたので、ついでに参照している NuGet パッケージのバージョンを確認した。すると、すっかり忘れていたのだが、ユーザーシークレットを扱うための NuGet パッケージ "Microsoft.Extensions.Configuration.UserSecrets" のバージョンが、今や Deprecated とマークされている 5.0 のままだった。
そこで "Microsoft.Extensions.Configuration.UserSecrets" NuGet パッケージの参照バージョンを現時点での最新バージョンの 7.0 に更新した。その上で動作確認のため、ひとまずこの開発環境で Function を実行してみた。
すると予期しなかったことに、下記実行時エラーが発生した。
Could not load file or assembly 'Microsoft.Extensions.Configuration.Abstractions, Version=7.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60'. The system cannot find the file specified.
アセンブリ "Microsoft.Extensions.Configuration.Abstractions" は、"Microsoft.Extensions.Configuration.UserSecrets" の依存関係で必要とされているアセンブリである。それのバージョン 7.0 が見つからない、というのだ。慌てて出力フォルダ (./bin/Debug/net6.0 ) を確認するも、アセンブリファイル "Microsoft.Extensions.Configuration.Abstractions.dll" はしっかり存在するし、そのプロパティを確認しても、ちゃんとバージョン 7.0 である。普通に考えれば "見つからない" などというはずはない。
ネットで検索したりしつつしている内に、はたと思い至った。この Azure Functions プロジェクト、"インプロセス" で実行されているのだ。
.NET による Azure Functions には、その実行形態として、"インプロセス" 実行と "分離ワーカープロセス" 実行の 2 形態がある (下記リンク先参照)。
とくに "インプロセス" 実行の場合は、まずは Azure Functions のホスト .NET プロセスが実行されている状態から、自分が作った Azure Functions プロジェクトのアセンブリが読み込まれ、そこで実装されている静的メソッドが呼び出される仕組みである。そして、どうやら、その Azure Functions のホストプロセスが、既に Microsoft.Extensions.Configuration.Abstractions アセンブリのバージョン 6.0 をプロセス内に読み込んでいる、ということらしいのだ。
いっぽうで、自分がビルドした Azure Functions プロジェクトのアセンブリは、バージョン 7.0 の Microsoft.Extensions.Configuration.Abstractions とリンクされているので、Azure Functions のホストプロセス内のアセンブリリゾルバに、同アセンブリの解決を求めることになる。
しかし当然のことながら、同じプロセス内でバージョン違いの複数のアセンブリを同時に読み込むなんてことは、アセンブリリソルバをカスタマイズしない限りできないわけで (ホストプロセスは既にバージョン 6.0 を読みこんで絶賛稼働中なわけなので)、それで件の実行時例外に至った、と、こういうことらしい。
今回、NuGet パッケージの参照バージョンを変更する前は、自分の Azure Fuctions プロジェクトは、バージョン 5.0 の Microsoft.Extensions.Configuration.Abstractions とリンクしていた訳だが、すでにホストプロセスに読み込まれている同アセンブリのバージョンがより高いバージョンであるぶんには、バージョンの転送の仕組みにより (一般的に、より高いバージョンは、より低いバージョンに対して下位互換があるので)、動作していたのだろう、と認識している (この点の自分の理解はちょっと怪しい)。
さておき、解決策としては、この Azure Functions プロジェクトを "分離ワーカープロセス" 版に作り直すか、あるいは参照している Microsoft.Extensions.Configuration.UserSecrets のバージョンを 6.0 にまで引き下げることになる。今回の Azure Functions プロジェクトでは、"分離ワーカープロセス" 版に変更する理由も自分には特に思いつかなかったので、とりあえず Microsoft.Extensions.Configuration.UserSecrets のバージョンを 6.0 に指定することで対処した。
]]>
.NET 8 SDK Preview 4 から AssemblyInformationalVersion 属性に何かハッシュが付くようになった
http://devadjust.exblog.jp/29614447/
2023-06-28T22:10:00+09:00
2023-06-28T22:10:10+09:00
2023-06-28T22:10:10+09:00
developer-adjust
.NET
.NET プログラミングの話。
自分が開発している C# プログラムや NuGet パッケージのバージョン番号の指定方法として、自分はプロジェクトファイル (.csproj) 内の "Version" MSBuild プロパティに記述するようにしている (下記例)。
<!-- 📄 ...csproj -->
<Project ...>
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Version>1.2.3.4</Version>
...
関連して、以前にこんな記事も書いた。
プレビュー版であることをバージョン番号で表現する
さてそのような .NET プログラミングにおけるバージョン番号指定だが、正式リリース前のバージョンを表現したいときに、例えば "1.2.3.4-preview.5" みたいなバージョン番号を付けたいことがある。その場合は実直にそのまま "Version" MSBuild プロパティに記述すればよい (下記例)。
<Version>1.2.3.4-preview.5</Version>
例えば Hoge.csproj ファイルに上記のように記述して "dotnet pack" を実行して NuGet パッケージを生成したら、"Hoge.1.2.3.4-preview.5.nupkg" というファイル名で NuGet パッケージファイルを生成してくれる。
自身のバージョン番号文字列を取得する方法
さてところで、こうしてプロジェクトファイル (.csproj) ファイル内で "Version" MSBuild プロパティとして設定したバージョン番号を、その .NET プログラム自身で取得したいということがままある (そのプログラムの "バージョン情報" ダイアログの実装時とか)。その場合、一般的には、以下のような C# コードで、System.Version クラスのオブジェクトでバージョン番号情報を取得可能だ。
// なんらかの手段で、その .NET プログラムの
// アセンブリの参照を取得 (下記は一例)
var assembly = this.GetType().Assembly;
// アセンブリの参照からバージョン情報 (System.Version) を取得
var version = assembly.GetName().Version;
しかしながら上記の方法だと、先に述べたような "-preview.5" みたいなバージョン番号にサフィックスを付けていた場合、そのサフィックス部分は取得できない。そもそも System.Version クラスには、そのようばバージョン番号サフィックスを格納するようなプロパティが用意されていない。
ということで、バージョン番号サフィックスも含めたバージョン番号文字列をプログラム内から取得したい場合は、次のようにする。
// なんらかの手段で、その .NET プログラムの
// アセンブリの参照を取得 (これは前記の方式と変わらず)
var assembly = this.GetType().Assembly;
// アセンブリに付与されている AssemblyInformationalVersion 属性を取得
var verInfo = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
// するとその AssemblyInformationalVersion 属性の
// InformationalVersion プロパティ (string 型) に、
// "1.2.3.4-preview.5" のようなバージョン番号文字列が入っている
verInfo.InformationalVersion;
バージョン番号サフィックスに何かハッシュ値!?
さて、上記の仕組みで自身のバージョン番号を表示するとある .NET プログラムを保守運用しており、まだ正式リリース前の .NET 8 SDK プレビュー版も先行評価のために使用してビルドしていた。
そんなあくる日、.NET 8 SDK の Preview 4 がリリースされたので、Preview 3 から Preview 4 への移行を行なって動作確認を実施。すると、その .NET プログラムのバージョン番号表示が、"1.2.3.4-preview.5+e9bd4d4...." というように、何やら末尾に何かのハッシュ値が付与されるようになってしまっていた。
どうやら .NET 8 SDK Preview 4 から、何かしらソースコードのハッシュに関する値が設定されるようになったらしい。それなりに意味のある仕様変更なのだとは思うが、取り急ぎは従来どおりの動作にしたかったため、このハッシュ値がビルド処理のどこで設定されているのかを調べてみた。
調べてみた
具体的には、"dotnet build /bl:{ファイル名}" を実行し、MSBuild のバイナリ実行ログをファイルに出力して、それを MSBuild Structured Log Viewer ( https://msbuildlog.com/ ) で開いて調べてみた。
すると幸い、.NET SDK に収録されている MSBuild スクリプトファイル
C:\Program Files\dotnet\sdk\8.0.100-preview.4.23260.5\Sdks\Microsoft.NET.Sdk\targets\Microsoft.NET.GenerateAssemblyInfo.targets
の 61 行目にある、"AddSourceRevisionToInformationalVersion" Target で、このハッシュ値の付加が行なわれていることを特定できた。かつ、この "AddSourceRevisionToInformationalVersion" Target には Condition 属性も設定されていて、そこには
'$(IncludeSourceRevisionInInformationalVersion)' == 'true'
の条件が書かれているのを見つけた。すなわち、この MSBuild プロパティ "IncludeSourceRevisionInInformationalVersion" を false に設定しておけばこの Condition が満たされず、結果、ハッシュ値の付加処理がスキップされるはずだ。
ということで、プロジェクトファイル (.csproj) 内に以下のように MSBuild プロパティを書き足して再度ビルドしたところ、
<!-- 📄 ...csproj -->
<Project ...>
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Version>1.2.3.4-preview.5</Version>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
...
当該プログラムのバージョン番号表示を "1.2.3.4-preview.5" に戻すことができた。
回避方法がわかったので、改めて MSBuild プロパティ名 "IncludeSourceRevisionInInformationalVersion" をキーワードにネットで検索してみると、このハッシュ値付加の仕組みはずいぶん昔からある模様。2018 年時点のブログ記事に言及があるのを見つけた (下記リンク先)。
https://andrewlock.net/version-vs-versionsuffix-vs-packageversion-what-do-they-all-mean/
まとめ
現時点では、どうして .NET 8 SDK Preview 4 からこの仕様になったのか、その背景事情を理解していないのだが、とりあえずは従来と同じ動作に戻すことができることがわかって一安心。引き続きはこの仕様変更の理由を確認して、理想の対応方法・この仕組みの利用方法は何なのか、理解を深めておきたいところである。
]]>
.NET アプリケーションで CPU 時間を使っている箇所を dotnet trace を使って計測する
http://devadjust.exblog.jp/29509340/
2023-02-28T21:46:00+09:00
2023-02-28T22:02:28+09:00
2023-02-28T21:46:31+09:00
developer-adjust
.NET
C# などを使った .NET アプリケーション開発における話。
先日、以前にリリースした .NET アプリケーションが、ある特定の環境 (OS は Windows) で処理が非常に遅くなる、という事態が発生した。
その処理が遅くなる現象を自分の手元の開発環境でも再現できる場合は、自分は開発環境に Visual Studio を使っているため、当該 .NET アプリケーションのソースコード/プロジェクトを Visual Studio で開いてデバッグ実行すれば、Visual Studio のデバッガ機能により、どこのメソッドがどの程度の割合、CPU 時間を消費しているのかを記録・測定、グラフィカルユーザーインターフェースで探索することができる。
ところが今回の件は、どうにも自分の手元で再現ができず、どうも実行時の環境で発生している様子。しかも自分はその現象が発生している現地・現場には行けず、かつ、なんらかの PC 画面共有ツールを活用してのリモート保守も不可能、という制約付き。
ただし代わりに、その問題現象に遭遇しているユーザーにお願いすれば、こちらから送ったデバッグビルドした版の .NET アプリケーションをインストール・実行してくれたり、スクリーンショットを撮って送ってくれたりなど、ターミナル (PowerShell) での作業を含めて、それなりの PC 操作は実施してくれる。
以上のような制約があったので、実は正直なところ、自分は経験もなく不慣れだったのだが、dotnet trace コマンドラインツールを使った、.NET アプリケーション実行時のパフォーマンス情報収集に挑戦することにした。
dotnet trace コマンドラインツール
以前にどこかで見聞きした覚えで、dotnet trace コマンドラインツールを使えば、対象とする .NET アプリケーションの実行中のパフォーマンス情報を、拡張子 .nettrace のファイルとして保存してくれるという。どうやら .NET SDK のインストールが必要となるらしいのだが、本番環境 PC に「Visual Studio 入れてください」などとお願いするのに比べれば (ライセンス的にも容量的にも手順的にもネットワーク資源的にも時間的にも) はるかに敷居が低い。
そして現場で dotnet trace コマンドを使うことができてひとたび .nettrace ファイルにパフォーマンス情報を保存成功したならば、その .nettrace ファイルをこちらに返送してもらえば、その .nettrace ファイルを Visual Studio で開くことで、どこのメソッドでどの程度の割合、CPU 時間を消費しているのかをグラフィカルユーザーインターフェースで探索できるようになるらしいのだ。
ということで現場のユーザーの支援のもと、実際に作業を進めてみた。
インストールと実行
まずは手始めに、現場のユーザーに、現象が発生している当該 PC 上に、.NET SDK をインストールしてもらった。OS は Windows (x64) だったので、公式のダウンロードサイトからしかるべきバージョンの .NET SDK のインストーラーファイルをダウンロードして実行してもらうだけだ。
.NET SDK がインストール完了し、ターミナル上で dotnet コマンドが使えるようになったら次は、本命の dotnet-trace コマンドをグローバルツールとしてインストールする。具体的には下記コマンドをターミナル上で実行すればよい。
dotnet tool install --global dotnet-trace
無事インストールが完了すると、"dotnet trace" サブコマンドが使えるようになる。そうしたらいよいよパフォーマンス情報の収集・.nettrace ファイルへの保存だ。
まずは対象となる .NET アプリケーションを実行開始する。
次にターミナル上で
dotnet trace collect -n {プロセス名}
を実行する。すると、-n オプションで指定したプロセス名の .NET アプリケーションプロセスに対し、パフォーマンス情報の収集・記録が始まる。この状態で問題現象を再現してもらい、再現も済んで充分パフォーマンス情報を記録できたと思ったら、dotnet trace collect コマンドが実行中のターミナル上でキーボードから Ctrl + C を入力して停止させる。
すると、dotnet trace コマンドを実行したカレントフォルダーに、.nettrace ファイルが生成されているのだ。
あとはこのパフォーマンス情報が保存されている .nettrace をこちらに何らかの手段で送信してもらえば OK だ。
解決できた
こうして .nettrace を手に入れたら、Visual Studio をインストール済みの開発環境 Windows PC 上で適当な作業フォルダに保存し、この .nettrace ファイルをエクスプローラー上でダブルクリックする。すると、拡張子関連付けにより Visual Studio が起動してこの .nettrace ファイルを開いてくれる。あとは Visual Studio のグラフィカルユーザーインターフェースを介して、どこのメソッドでどれだけ CPU 時間を消費しているのかを探索することができる。
こうしてどうにかこうにか CPU 時間を消費しているソースコード上の箇所を特定することができ、そこから「ああー、そういうこと!?」という環境依存であった問題原因を特定、対処して改善された新バージョンをリリースしてお届けすることができた。
ということで、自分もなかなか利用する機会がなかった dotnet trace コマンドであるが、こんな使い方・活用もできる、という共有までに。
]]>
C# で SendGrid を使ってメール送信するプログラムを書いていたが、TLS を効かせたらサーバー証明書の例外
http://devadjust.exblog.jp/29484847/
2023-01-30T20:35:00+09:00
2023-01-30T21:07:53+09:00
2023-01-30T21:07:53+09:00
developer-adjust
.NET
C# によるプログラミングにおける話。
ここ最近はメールを送信するプログラムを書く機会もめっきり無くなっていたのだが、久しぶりにそのようなプログラムを作成する機会が発生した。
メール送信のインフラストラクチャとしては、その筋では大手 (ですよね?) の SendGrid を選択。
SendGrid を使ってメール送信を行なう場合は、SendGrid が用意してくれている REST API に HTTP POST することで実装することも多いと思うが、今回は諸事情で、古典的な SMTP プロトコルを使ったメール送信を実装することとなった。
および、昨今は、C# で SMTP でメール送信をする場合は「MailKit を使いましょう」ということらしい。
「System.Net.Mail 名前空間の各種実装はもう更新されておらず、下位互換維持のために存在しているだけなので、他のモダンな実装に移行していきましょう」といったことが、下記リンク先の公式ドキュメントに記されている。
ただこちらも諸事情あって、今回は引き続き古典的な System.Net.SmtpClient クラスを一時採用することと相成った。
ということで、そのようなメール送信を行なう C# プログラム部分は下記のようになった。
using var smtpClient = new SmtpClient( host: "smtp.sendgrid.com", port: 587)
{
Credentials = new NetworkCredential(
userName: "apikey",
password: "SG....")
};
送信試験はいったんは成功したんだけど...
とりあえず簡単にメールを試験送信して無事成功することを確認。その後にもういちどソースコードを眺めていると、ふと大事な設定漏れに気づいた。EnableSsl = true の設定をしていなかったのである。ということで、危ないところであったが、EnableSsl = true の設定を追加。
using var smtpClient = new SmtpClient(...)
{
Credentials = new NetworkCredential(...), EnableSsl = true // 👈 追加!
};
どうせ開発用のキーだしそうそう心配したものではないと思いつつ、そうは言っても実際にインターネット経由で API キーを含む通信を平文で流してしまったのはそうなので、念のため API キーも再発行を済ませた。
その上で今一度メール送信を試験実行。
すると予期しないことに、実行時例外が発生してしまった。例外メッセージは下記のとおり。
Unhandled exception.
System.Security.Authentication.AuthenticationException: The remote
certificate is invalid according to the validation procedure:
RemoteCertificateNameMismatch
at CompleteHandshake(SslAuthenticationOptions)
at SslStream.ForceAuthenticationAsync[TIOAdapter](Boolean, Byte[], CancellationToken)
at SslStream.AuthenticateAsClient(SslClientAuthenticationOptions
sslClientAuthenticationOptions)
at TlsStream.AuthenticateAsClient()
at SmtpConnection.GetConnection(String host, Int32 port)
at SmtpTransport.GetConnection(String host, Int32 port)
at SmtpClient.Send(MailMessage message)
at Program.<Main>$(String[] args)
EnableSsl = true を設定する前はちゃんとメール送信できていたのに、EnableSsl = true を指定したとたん例外が発生するとはどういうこと? API キーを再発行したのが何かマズかった? それとも MailKit 使ってなかったのが敗因?? いろいろなことが頭の中を駆け巡る。
待て待て。こんなときはしっかりエラーメッセージを読むんだ。答えはそこに書いてあるはずだ。えーと、なになに、メッセージは「The remote certificate is invalid」と言っている。つまり、「リモート証明書が無効です」ということだ。ん? リモート証明書? それって SendGrid 側の証明書ってこと? それが無効ってどういうこと? SendGrid 側が何かやらかしたってこと? まさか証明書が期限切れになったのに更新してなかったみたいな初心者レベルのミスとかー!?
原因はやっぱり...
待て待て、もういちど落ち着こう。大体こういうパターンは自分が書いたコード中に原因がある。そもそも SendGrid がそんなミスしていたら今頃 Twitter 上で盛大に盛り上がっているはずなのだ。
ということでもういちどソースコードを眺めて見る。
using var smtpClient = new SmtpClient(
host: "smtp.sendgrid.com",
port: 587)
{
...
実は上記の中に一点、間違いがあるのだが、皆さんは気づいただろうか?
正しい実装は下記のとおり。
using var smtpClient = new SmtpClient(
host: "smtp.sendgrid.net",
port: 587)
{
...
そう、ホスト名のトップレベルドメインを間違っていたのだ。smtp.sendgrid.net としなければならないところを smtp.sendgrid.com としてしまっていたのだ。これはお恥ずかしい。「SendGrid への SMTP 経由での送信なんか、過去に何度も書いてたしー。ホスト名? ああ、アレでしょ、smtp で始まるやつでしょ、Gmail も smtp.gmail.com だったし」とナメくさって、ろくに公式ドキュメントを確認もせずに smtp.sendgrid.com って書いてしまったのが敗因であった。
なお、nslookup で、それぞれのホスト名 smtp.sendgrid.com と smtp.sendgrid.net を正引きしてみると、どちらも同じ IP アドレスが返る、っていうか、smtp.sendgrid.com は smtp.sendgrid.net へのエイリアス (CNAME) であった。
C:\> nslookup smtp.sendgrid.com 8.8.8.8
Server: dns.google
Address: 8.8.8.8
Non-authoritative answer:
Name: smtp.sendgrid.net
Addresses: 52.220.95.193
13.114.210.107
Aliases: smtp.sendgrid.com
C:\> nslookup smtp.sendgrid.net 8.8.8.8
Server: dns.google
Address: 8.8.8.8
Non-authoritative answer:
Name: smtp.sendgrid.net
Addresses: 13.114.210.107
52.220.95.193
EnableSsl = false の場合、非セキュア接続の場合は証明書の確認や暗号化など行なわずに平文でやりとりするだけだ。その場合、smtp.sendgrid.com でも smtp.sendgrid.net でも、どちらのホスト名を指定しても接続する先の IP エンドポイントに違いがないため、そのまま SMTP でメール送信できていたようだ。
いっぽうで EnableSsl = true としてセキュア接続を使用する場合は、通信相手のサーバーの証明書を確認する必要が出てくる。ここで証明書はその発行対象であるコモンネームを含むので、発行対象とは異なるコモンネームになるホスト名でセキュア接続を試みると、証明書の検証がエラーとなる。SendGrid としては公式ドキュメントサイトで説明しているとおり、SMTP ホスト名は「smtp.sendgrid.net を設定してください」とあるので、この SMTP サーバーの証明書は「smtp.sengrid.net」をコモンネームとして取得しているはずだ。なのに自分のプログラムは「smtp.sendgrid.com」なんていうホスト名で接続しようとしたものだから、たしかに IP アドレスは正しく解決できて TCP ポート 587 番で通信は開始したけど、結果、先の例外メッセージにある「リモート証明書が無効です」はこういう訳だったのだ。
おわりに
教訓。
自分が作ったプログラムでトラブルがあると、その原因を、接続先のサービスや、はてはクラスライブラリやコンパイラのバグに求めたくなることがあるかもしれない。でも多くの場合は、自分のプログラムに原因があることがほとんどだ (と思う。自分の身の上では。)
]]>
Azure App Service に配置した ASP.NET Core アプリで、App Service 上に設定した接続文字列を GetConnectionString で取得できない?
http://devadjust.exblog.jp/29454749/
2022-12-25T21:45:00+09:00
2022-12-25T21:52:03+09:00
2022-12-25T21:45:06+09:00
developer-adjust
.NET
先日、Qiita 上の下記投稿が目に留まった。
上記投稿内容によると、Azure App Service 上に配置した ASP.NET Core アプリ (Blazor Server) で、App Service 上で設定した接続文字列を GetConnectionString メソッドで取得できないというのだ。
自分はこれを読んで、えぇ、そんなことあるのだろうか? と非常に疑問を抱いた。というのも、自分は普段から Azure App Service 上に Blazor Server を含む各種 ASP.NET Core アプリを配置しており、App Service 上で設定した接続文字列も普通に GetConnectionString メソッドで取得できていたからだ。
ただし、確かに下記記事にあるとおり、Azure App Service 上で設定した接続文字列は環境変数として登録され、その環境変数を通して GetConnectionString メソッドで取得できるのだが、その環境変数名には、App Service 上での接続文字列設定時に選択した「種類 (Type)」に応じた "プレフィクス" が付く、という話はあるにはある。
そのように App Service 上で設定したデータベース接続文字列を含む環境変数名には上記プレフィクスが付くのに、なぜ GetConnectionString ではそのプレフィクスを気にせずに接続文字列を取得できていたのか、そういえばよく考えたこともなかった。
Azure ポータル画面から App Sevice に設定する接続文字列で、選べる「種類 (Type)」は以下の 5 つ。
- MySQL
- SQLServer
- SQLAzure
- PostgreSQL
- Custom
そこで、上記 5 種の接続文字列設定が、ちゃんと ASP.NET Core アプリから取得できるか試してみることにした。
Blazor Server アプリを書いて試してみた
動作確認用に、接続文字列と環境変数とを一覧表示する Blazor Server アプリを書いて Azure App Service 上に配置する。
@using System.Collections;
@inject IConfiguration config;
<h3>Connection Strings</h3>
<table>
<tr><td>MySQL</td><td>@connStrM</td></tr>
<tr><td>SQServer</td><td>@connStrS</td></tr>
<tr><td>SQLAzure</td><td>@connStrA</td></tr>
<tr><td>PostgreSQL</td><td>@connStrP</td></tr>
<tr><td>Custom</td><td>@connStrC</td></tr>
</table>
<h3>Environment Variables</h3>
<table>
@foreach (var env in envVals)
{
<tr><td>@env.Key</td><td>@env.Value</td></tr>
}
</table>
@code {
private string? connStrM;
private string? connStrS;
private string? connStrA;
private string? connStrP;
private string? connStrC;
private DictionaryEntry[] envVals = Array.Empty<DictionaryEntry>();
protected override void OnInitialized()
{
connStrM = config.GetConnectionString("connStrM");
connStrS = config.GetConnectionString("connStrS");
connStrA = config.GetConnectionString("connStrA");
connStrP = config.GetConnectionString("connStrP");
connStrC = config.GetConnectionString("connStrC");
envVals = Environment.GetEnvironmentVariables()
.Cast<DictionaryEntry>()
.Where(env => env.Key?.ToString()?.Contains("connStr") == true)
.ToArray();
}
}
そして appSettings.json と App Service 上との双方に上記 5 種の接続文字列を設定しておく。
{
"ConnectionStrings": {
"connStrM": "from appSettings.json (MySQL)",
"connStrS": "from appSettings.json (SQLServer)",
"connStrA": "from appSettings.json (SQLAzure)",
"connStrP": "from appSettings.json (PostgreSQL)",
"connStrC": "from appSettings.json (Custom)"
},
...
GetConnectionString で取得される接続文字列が、
appSettings.json から読み取られたものであれば
"from appSettings.json..." と表示され、
App Service 上の設定 (すなわち環境変数) から読み取られたものであれば
"from App Service..." と表示される
仕掛けだ。
App Service 上で設定した接続文字列のほうが優先度が高いため、App Service 上での設定が正しく機能していれば、この Blazor Server アプリのページ表示には、App Service で設定した接続文字列がずらっと並んで表示されるはずだ。
で、実際に実行してみたところ...
なんと、App Service 上に設定した接続文字列のうち、種類 = PostgreSQL で設定したものだけが取得できていないのだ。
これで冒頭で紹介の Qiita 投稿記事にて「Azure App Service に配置した ASP.NET Core アプリで、App Service 上に設定した接続文字列を GetConnectionString で取得できない」と書かれていた点にも合点がいく。同記事では接続文字列の種類を PostgreSQL に指定していたのだ。自分は「種類 (Type)」には「SQLAzure」しか設定したことがなく、それでこれまで、GetConnectionString メソッドで接続文字列が取得できない、といった現象に悩まされずに済んできたようであった。
理由を探る
しかし一体全体どうして 種類 = PostgreSQL のときだけ接続文字列が取得できないのだろうか??
先に書いたとおりこれまで自分は頓着していなかったものの、そもそも、どうして "MYSQLCONNSTR_~" みたいなプレフィクスが環境変数名に付いているにもかかわらず、そんなことを気にせずに GetConnectionString で接続文字列が取得できていたのだろうか?
ということで、.NET における構成プロバイダーのうち、環境変数由来の構成情報を提供するプロバイダーのソースコードを確認してみた (下記リンク先)。
そうしたところ、なんと、それら App Service 上の接続文字列設定に関するプレフィクスがソースコード上にハードコードされて、これらプレフィクスが付いた環境変数を特別に処理していたのだ。これが、App Service 上での接続文字列設定が環境変数上はその「種類 (Type)」に応じたプレフィクスが付くにも関わらず、GetConnectionString メソッドでいずれのプレフィクスが付いていても接続文字列を取得できる仕掛けだった。
そして加えて、このハードコードされたプレフィクスの中に、どういうわけか、PostgreSQL 用のプレフィクスだけが入っていないのである (下記はその抜粋)。
public class EnvironmentVariablesConfigurationProvider : ConfigurationProvider
{
private const string MySqlServerPrefix = "MYSQLCONNSTR_";
private const string SqlAzureServerPrefix = "SQLAZURECONNSTR_";
private const string SqlServerPrefix = "SQLCONNSTR_";
private const string CustomConnectionStringPrefix = "CUSTOMCONNSTR_";
// ⚠️ "POSTGRESQLCONNSTR_" についての項目だけ、存在しない!!
...
これが、「種類 (Type)」に PostgreSQL を指定した場合にのみ、GetConnectionString メソッドでその設定値を取得できない原因・理由であった。
既に3年以上前から発覚しているも修正に至らず
事情がわかった上で、改めて "EnvironmentVariablesConfigurationProvider PostgreSQL" などのキーワードでネットで検索してみると、下記 Issue を発見。
この Issue が作られたのは 2019年5月、つまりこの記事を書いている 3年よりも前に、既にこの問題は認識はされていたようだ。しかし大変残念ながら、つい先月に .NET 7 が正式リリースされた今日現在、3年以上が経過したあとでも、この不具合 (?) は解消されていない。上記 Issue を斜め読みする限り、どうも、そもそもこういうハードコードされたプレフィクス実装が好ましくないので根本的に仕組みを変更したいようなことも議論されているようだ。
あと、根本原因は前述のとおり .NET のランタイムライブラリにあるわけだが、この不具合の動作・振る舞いとしては「Azure ポータル画面で、App Service の接続文字列として、種類 = PostgreSQL で登録すると (ないしは Azure CLI で同等の設定を施したとき)、設定が取得できない」ということで、Azure CLI の GitHub リポジトリにも Issue が立てられている。
さておき、Azure App Service 上でデータベース接続文字列を設定する際に、どうして「種類 (Type)」の設定が求められるかというと、「種類 (Type)」に応じたデータベースアクセスプロバイダ名が、"~_ProviderName" のサフィックス付きのエントリに追加される仕掛けがあるためである。
例えば Azure App Service 上で、種類 = MySQL 、エントリ名 = foo、でデータベース接続文字列を設定した場合、GetConnectionString("foo") でその設定した接続文字列が返ってくることに加えて、GetConnectionString("foo_ProviderName") を実行すると "MySql.Data.MySqlClient" という文字列が返ってくるのだ。これが何の役に立つのかは本稿では割愛するが、とにかく、「種類 (Type)」の設定がどこにどう作用するのかというと、こういうことである。
回避策
ということで、とりあえずの回避策としては、たとえ接続先のデータベースが PostgreSQL であったとしても、Azure App Service でのデータベース接続文字列を設定の際は、その「種類 (Type)」として「Custom」を選んでおくのがよさそうである。そうすれば GetConnectionString メソッドでめでたく、その App Service 上に設定した接続文字列を取得できるようになる。
あるいは、冒頭で紹介した Qiita 投稿記事内で解決しているとおり、データベース接続文字列構成を使うのはあきらめて、通常のアプリケーション構成にデータベース接続文字列を設定するので良いかもしれない。Azure App Service に、通常のアプリケーション構成とは別にわざわざ "接続文字列" を設定する枠があるのは、App Service のバックアップ機能で、その接続先データベースもバックアップを取れるようにするためらしいのだが (下記リンク先参照)、もしこのバックアップ機能は使わないよ、ということであれば、通常のアプリケーション構成として PostgreSQL への接続文字列を設定しても問題ないと思われる。
以上、PostgreSQL への接続文字列を Azure App Service へ設定し、ASP.NET Core アプリ内から GetConnectionString で取得しようとする開発者各におかれては、この先躓く人が少しでも減りますようにと思わずにはいられない。
]]>
複数の .NET プロジェクトに対し、共通の1箇所で同じバージョン情報を設定する、6 つの方法
http://devadjust.exblog.jp/29404286/
2022-10-30T17:45:00+09:00
2023-06-28T22:10:26+09:00
2022-10-30T21:14:02+09:00
developer-adjust
.NET
例えばこんなフォルダ構成の .NET アプリケーションプロジェクト = AppA と AppB の 2 本があったとして、
📂Projects
├ 📄Projects.sln
├ 📂AppA
│ └ 📄AppA.csproj
└ 📂AppB
└ 📄AppB.csproj
これら AppA と AppB には、常に同じバージョン番号を設定したい、みたいな需要があったとする。
(上記は C# の例だが、VB や F# などに適宜読み替えてもらって大丈夫)
さて、.NET Core 以降、SDK スタイルの .NET プロジェクトの場合は、プロジェクトファイル中における "Version" MSBuild プロパティの指定がバージョン番号の指定になる (下記例)。
<Project>
<PropertyGroup>
...
<Version>1.2.3</Version>
...
なので、バージョン番号が変更になるたびに、各プロジェクトのプロジェクトファイル中の "<Version>~</Version>" の箇所を都度書き換えればそれでいい話である。しかしそうはいっても、同じ作業を複数回愚直に繰り返すのはなんだか愚行のような気がしてならないし、単に気持ちの問題というだけではなく、単純に設定漏れや設定違いは普通に発生する危険がある。
ということで、共通の1箇所でバージョン番号を指定することで、複数の .NET プロジェクトに対し同じバージョン番号を設定できる方法を考えてみる。
なお、以下で紹介するいずれの方式も、Microsoft の公式ドキュメントサイトやネット上の情報で既に知られているような内容だ。そのため、本記事はそれら一次情報の劣化版に留まるような気もする。とはいいつつも、実は別途このテーマで相談を受けていたこともあり、改めて自分なりに再検証の上、まとめてみる。
方法 1. Directory.Build.props を使う
.NET プロジェクトファイルのビルド開始時、実は、そのビルド対象プロジェクトファイルがあるフォルダから親フォルダへとさかのぼって、Directory.Build.props という名前のファイルがないか、探索される。そして Directory.Build.props が見つかると、その内容を MSBuild スクリプトとして追加で取り込む (インポートする) こととなっている。
例えば下記のようにプロジェクト AppA と AppB より上の階層のフォルダに Directory.Build.props を置いておき、
📂Projects
├ 📄Projects.sln
├ 📄Directory.Build.props 👈 コレ!
├ 📂AppA
│ └ 📄AppA.csproj
└ 📂AppB
└ 📄AppB.csproj
Directory.Build.props 内に Version MSBuild プロパティ指定を書いておくと、
<!-- 📄Directory.Build.props -->
<Project>
<PropertyGroup>
<Version>1.2.3</Version>
</PropertyGroup>
</Project>
この Directory.Build.props があるフォルダ以下の階層にいるすべての .NET プロジェクトに読み込まれてバージョン番号指定が反映される、という仕組みだ。バージョン番号に変更があったときは、Directory.Build.props だけを書き換えて再ビルドすればよい。
詳しい仕様は下記公式ドキュメントサイトをぜひ一読頂きたい。
方法 2. 適当な .props ファイルを作り、各アプリのプロジェクトファイルからインポートする
さて、大抵の場合は、前述の Directory.Build.props 方式で用が足りることと思う。しかし場合によっては、Directory.Build.props では効果がありすぎる場面があるかもしれない。つまり、Directory.Build.props は、その配下の階層の .NET プロジェクトすべてに自動で取り込まれてしまうからだ。場合によっては、フォルダ構成上、配下の階層にあるだけの関係の無いプロジェクト (例えばビルド支援のための専用カスタムツールのプロジェクトであったり、単体テストプロジェクトであったり) にまで適用されてしまうことが問題になることがあるかもしれない。
そのような場合は、"自動で読み込まれる" Directory.Build.props の代わりに、自分でカスタム MSBuild スクリプトを読み込むように手配するとよい。
具体的には、ファイル名は任意でよいのだが例えば VersionInfo.props というファイルを用意してこれに "Version" MSBuild プロパティを指定することとして (フォルダ構成は下記)、
📂Projects
├ 📄Projects.sln
├ 📄VersionInfo.props 👈 コレ!
├ 📂AppA
│ └ 📄AppA.csproj
└ 📂AppB
└ 📄AppB.csproj
AppA、AppB それぞれのプロジェクトファイル中から、プロジェクトファイルそれ自身を基準とした相対パス指定で、その VersionInfo.props を "取り込む" (インポートする) ようにすればよい。具体的には、各プロジェクトファイル中の冒頭に、以下のように Import ノードを記述する。
<!-- 📄AppA.csproj や 📄AppB.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../VersionInfo.props" />
...
こうすることで、明示的に VersionInfo.props をインポートしているプロジェクトでのみ、共通のバージョン情報が設定されるようになる。バージョン番号に変更があったときは、VersionInfo.props だけを書き換えて再ビルドすればよい。
方法 3. AssemblyFileVersion 属性指定を含む共通の .cs ソースコードを取り込む
実のところ、プロジェクトファイル (MSBuild スクリプト) 上の Version プロパティで指定されたバージョン情報は、アセンブリに対する AssemblyFileVersionAttribute 属性指定を行なうソースコードを自動生成するのに使われている。実際、自動生成されたソースコードは、例えば C# プロジェクトの場合、obj フォルダ以下に "{アセンブリ名}.AssemblyInfo.cs" という名前のファイルで保存されているのを確認できる。
ということで、バージョン番号の指定は、MSBuild スクリプトで指定するだけでなく、直接プログラムのソースコード中から、アセンブリに対する AssemblyFileVersionAttribute 属性を指定することでも可能だ。(というか、.NET Core より前の世代だと、この方式でバージョン番号を設定していた。)
詳細は下記公式ドキュメントサイトなどが参考になるかもしれない。
そこから次のようなやり方が可能である。
C# プロジェクトを例とするが、まず以下のようなフォルダ構成で、新たな C# ソースコードファイルを用意し (C# ソースコードファイルの名前は任意だが、ここでは AssemblyInfo.cs としてみた)、
📂Projects
├ 📄Projects.sln
├ 📄AssemblyInfo.cs 👈 コレ!
├ 📂AppA
│ └ 📄AppA.csproj
└ 📂AppB
└ 📄AppB.csproj
このソースコードに、アセンブリに対する AssemblyFileVersionAttribute 属性指定を記述する。
using System.Reflection;
[assembly: AssemblyFileVersion("1.2.3")]
あとは、AppA、AppB それぞれのプロジェクトファイル中から、プロジェクトファイルそれ自身を基準とした相対パス指定で、そのソースコードファイル (この例では AssemblyInfo.cs) を参照すればよい。具体的には、各プロジェクトファイル中に、以下のようにコンパイル対象アイテムの指定を記述する。
<!-- 📄AppA.csproj や 📄AppB.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
...
<ItemGroup>
<Compile Include="..\AssemblyInfo.cs" Link="AssemblyInfo.cs" />
</ItemGroup>
...
なお、このままビルド実行すると、前述のとおり、SDK スタイルのプロジェクトファイル (MSBuild スクリプト) によって自動生成されるコードによる AssemblyFileVersionAttribute 属性と重複指定になってしまい、下記ビルドエラーとなる。
error CS0579: Duplicate 'System.Reflection.AssemblyFileVersionAttribute' attribute
そこでプロジェクトファイルによる自動生成コードに、AssemblyFileVersionAttribute 属性指定を含めないよう追加の指示を行なう。具体的には、GenerateAssemblyFileVersionAttribute MSBuild プロパティに false を指定してやるとよい。
<!-- 📄AppA.csproj や 📄AppB.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
...
<PropertyGroup>
...
<GenerateAssemblyFileVersionAttribute>false</GenerateAssemblyFileVersionAttribute>
...
GenerateAssemblyFileVersionAttribute MSBuild プロパティをはじめ、SDK スタイルのプロジェクトファイルによって自動生成されるアセンブリ情報の制御について詳細は公式ドキュメントサイト (下記リンク先) を一読されたい。
以上で、AssemblyInfo.cs をコンパイル対象アイテムに明示指摘に指定しているプロジェクトに対し、AssemblyInfo.cs 内で指定された共通のバージョン情報が設定されるようになる。バージョン番号に変更があったときは、AssemblyInfo.cs だけを書き換えて再ビルドすればよい。
ところで、このソースコードでバージョン番号を指定する方式については、もう一つ面白い実装方法があって、AssemblyFileVersionAttribute 属性のコンストラクタ引数に指定するバージョン番号文字列を、直接即値で指定するのではなく、別のクラスの定数値として定義することもできる。下記が C# でのその実装例。
using System.Reflection;
[assembly: AssemblyFileVersion(AssemblyInfo.Version)]
internal static class AssemblyInfo
{
internal const string Version = "1.2.3";
}
こうしておくと、このソースコードファイルをコンパイル対象アイテムに参照しているプログラム内では、リフレクション技法を使わずに、自アセンブリのバージョン番号を (タイトル表示とかバージョン情報表記とかの場面で) "AssemblyInfo.Version" という文字列定数として直接利用できるのだ。もちろん、この方式も良し悪しあるのだが、簡易なツールを手早く作っているときは便利だったりするので、ご参考までに。
方法 4. リリースノートテキストファイルの記載中、1行目のバージョン番号を読み取る
例えば、リリースノートを "RELEASE-NOTES.txt" という名前のテキストファイルに、以下の書式で保存しているプロジェクト群があったとしよう。
v.1.2.3
- Lorem ipsum dolor sit amet takimata qui elitr ut
- nonummy duo et no consetetur lorem amet lorem et.
v.1.2.2
- Sanctus vero takimata et. Lorem consequat sit magna duo kasd
v.1.2.1
- gubergren erat et. Dolores at sadipscing.
- Ea consetetur eu luptatum augue magna ea lobortis justo vero.
上記例からわかるとおり、現在のバージョン番号は、このリリースノートテキストファイルの1行目に記載されることになる。および、上記 RELEASE-NOTES.txt の配置先フォルダは以下のとおりとする。
📂Projects
├ 📄Projects.sln
├ 📄RELEASE-NOTES.txt 👈 コレ!
├ 📂AppA
│ └ 📄AppA.csproj
└ 📂AppB
└ 📄AppB.csproj
さてこのリリースノートテキストファイルの1行目に記載のバージョン番号を、各アプリケーションのバージョン番号の指定とすることも可能だ。各プロジェクトファイル (MSBuild スクリプト) 内にて、このテキストファイル "RELEASE-NOTES.txt" を読み取り、正規表現検索と組み合わせて、1行目のバージョン番号部分を抜き出し、"Version" MSBuild プロパティに設定する、という作戦である。
具体的には、以下のような Target をプロジェクトファイル内に記述する。
<!-- 📄AppA.csproj や 📄AppB.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
...
<!-- ビルドの直前に実行される Target を定義 -->
<Target Name="SetupVersion" BeforeTargets="BeforeBuild">
<!-- 親フォルダにある RELEASE-NOTES.txt の内容を1行ずつ読み取って、
ReleaseNoteLines という名前の項目に、読み取った各行を格納 -->
<ReadLinesFromFile File="../RELEASE-NOTES.txt">
<Output TaskParameter="Lines" ItemName="ReleaseNoteLines" />
</ReadLinesFromFile>
<PropertyGroup>
<!-- 正規表現検索のために、読み取った各行の項目を LF 区切りで単一の
文字列に連結し、ReleaseNoteText という名前のプロパティに格納 -->
<ReleaseNoteText>@(ReleaseNoteLines, '%0a')</ReleaseNoteText>
<!-- ReleaseNoteText プロパティ値に対し正規表現検索で1行目の
バージョン番号部分を取り出し、Version プロパティ値に設定 -->
<Version>$([System.Text.RegularExpressions.Regex]::Match($(ReleaseNoteText), "^v\.([^\n]+)\n", System.Text.RegularExpressions.RegexOptions.Singleline).Groups[1].Value)</Version>
</PropertyGroup>
</Target>
</Project>
なお、若干ボリュームのある MSBuild スクリプトなので、上記 Target を方法 2 で紹介したのと同じやり方で共通の MSBuild スクリプトファイルに抜き出し、各アプリのプロジェクトファイルからはそれを Import するだけとするのがよいかもしれない。
いずれにせよ、上記 Target を仕込んであるプロジェクトに対し、RELEASE-NOTES.txt 内の1行目に記載されたバージョン情報が設定されるようになる。バージョン番号に変更があったときは、RELEASE-NOTES.txt だけを書き換えて再ビルドすればよい。
方法 5. dotnet build 時のコマンドラインで指定する
さて、ここまでの方法はいずれも、バージョン情報の指定を、MSBuild スクリプトやプログラムのソースコードなど、プログラムを構成するファイルに固定で記述する方法を紹介してきた。しかし場合によっては、そういったプログラムを構成するファイルに固定でバージョン情報を記述することは好ましくないことがあるかもしれない。
そのような場合、とりわけ、ビルドに dotnet CLI を使っている場合は、"dotnet build" コマンドによるビルド実行時に、そのコマンドライン引数にてバージョン番号を指定できる。"dotnet build" や "dotnet publish" コマンドでは、そのコマンドライン引数として、"-p:{MSBuild プロパティ名}={値}" という書式で、コマンド実行時に MSBuild プロパティを指定できる。なので、バージョン番号、すなわち、"Version" MSBuild プロパティ値も "-p:Version=1.2.3" のようにコマンドライン引数を指定することで、ビルド実行時に指定が可能だ。
例えば、ソリューションファイル Projects.sln に、AppA と AppB の両方のプロジェクトが含まれている場合は、以下の dotnet コマンドを実行するとことで、AppA、AppB の両方に、コマンドライン引数で指定された同じバージョン番号が設定されてビルドされる。
dotnet build Projects.sln -p:Version=1.2.3
方法 6. 環境変数に設定する
実は、MSBuild スクリプトにおけるプロパティ値として、ビルド実行時のプロセスの環境変数が自動で取り込まれている (下記リンク先参照)。
そのため、"dotnet build" (または "dotnet publish") コマンド実行時のコマンドライン引数に指定する以外に、環境変数を通して、同名の MSBuild プロパティ値を設定できる。例えば PwoserShell 上の場合、以下の様に環境変数 Version を設定すると、これがそのまま同名の MSBuild プロパティに設定され、結果として環境変数 Version に指定したバージョン番号で、ソリューション Projects.sln に含まれるプロジェクトがビルドされる。
PS> $env:Version="1.2.3"
PS> dotnet build Projects.sln
この方法 6: 環境変数でのバージョン番号指定、および、ひとつ前の方法 5: ビルドコマンド実行時の引数でのバージョン番号指定は、CI/CD 環境だと使い勝手がよいかもしれない。
まとめと補足
以上、複数の .NET プロジェクトに対し、共通の1箇所で同じバージョン情報を設定する、6 つの方法について紹介した。もっと他にもいろいろやり方はあるのかもしれない、何かお気づきの点があればコメントまたは Twitter 上でお知らせいただきたい。
なお、ここまでの説明では "Version" MSBuild プロパティ、すなわち、アセンブリのバージョン番号の設定についてのみ言及してきたが、もちろん、バージョン番号以外の任意のアセンブリ情報の設定、例えば Copyright であったり Description であったり、に対しても同じように共通の1箇所で複数プロジェクトに一斉適用できる。なので、バージョン番号に限らず、「同一の共通設定を繰り返し別々のプロジェクトファイルに書き散らかしたくない」という要件があれば、これら 6 つの方法のうちいくかが参考になるかもしれない。
]]>
フロント側は Next.js で構築する ASP.NET Core プロジェクト、およびそのローカル開発環境を、セキュア接続 (https) 必須で構築する
http://devadjust.exblog.jp/29320979/
2022-09-04T16:56:00+09:00
2022-09-04T17:31:15+09:00
2022-09-04T17:23:01+09:00
developer-adjust
.NET
フロントエンドを、(Next.js ではなく) 素の React で作る場合は、前回の投稿でも触れたが、Visula Studio または .NET SDK のプロジェクトテンプレートから「React での ASP.NET Core」を選んで C# プロジェクト (.csproj ファイルなど) を新規作成すればよい (詳細は下記リンク先、前回の投稿を参照)。
しかしながら、Next.js については、そのようなプロジェクトテンプレートは標準で用意されていないようだ。
そこで手作業で、フロント Next.js + バックエンド ASP.NET Core の C# プロジェクトを構築することにした。
とはいえ、Next.js は "React の上に構築された" Web アプリケーション開発フレームワークということなので、「React での ASP.NET Core」を選んで作成したプロジェクトを少し改造すれば、すぐに Next.js + ASP.NET Core の C# プロジェクトに作り替えることができるだろう、と考え、プロジェクト作成の作業を開始した。
ところが大変残念なことに、この予想は大きく裏切られ、そう一筋縄ではいかなかった。
その前に: ローカル開発環境で https を必要とするか否か?
先に進む前に、まず、本記事で構築する Next.js + ASP.NET Core 開発プロジェクトでは、ローカル開発サーバーへの接続にセキュア接続 (https) を必須とすることで進める。
「ローカル開発環境くらい、非セキュア接続 (http) でもよいのでは?」という判断もあろうかと思う。
しかしいっぽうで、様々なブラウザ機能のうちいくつか (例えば Web カメラからの動画入力を扱う navigator.mediaDevices.getUserMedia() とか) は、セキュア接続 (https) でないと使えなかったりする。
下記リンク先、Mozilla Developer Network の「安全なコンテキストに制限されている機能」のページに、どんな機能がセキュア接続 (="安全なコンテキスト") でのみ使える機能なのか、その一覧が掲載されている。
上記のような "セキュア接続でのみ使える" ブラウザ機能を使用している Web アプリケーションの場合は、その開発の最中においても、それら "セキュア接続でのみ使える機能" を使って動作確認したいことだろう。
そのような背景に鑑み、本記事で構築するアプリケーション開発環境では、セキュア接続 (https) 必須、つまり、開発中のローカル環境の当該 Web アプリの URL は「 https://localhost:xxxx:/ 」といった URL になる、という要件で構築することとする (この判断・方針が、あとあと自分を苦しめることになるのだが)。
開始地点
さてさてまずは、Visual Studio のプロジェクト新規作成画面から「React での ASP.NET Core」を選んでプロジェクトを新規作成する。あるいは .NET CLI を使うなら、下記のようにコマンドを実行すれば、カレントフォルダ内に、"NextjsApp" というプロジェクト名で、同様のプロジェクトが構築される。
$ dotnet new react -n NextjsApp -o .
こうして出来たプロジェクトファイル一式のうち、フロント側 React アプリケーションのコード類を収録している「ClientApp」フォルダをいったんバッサリを削除する。そして下記のようにコマンドを実行して、改めて Next.js プロジェクトを作り直す (下記例ではプロジェクト名を "nextjs-app" とした。"--typescript" オプションや "--use-npm" オプションを付けるかどうかはお好みで)。
$ npx create-next-app nextjs-app --typescript --use-npm
「npx create-next-app」では、プロジェクト名でサブフォルダが作成されるので、そのサブフォルダ名を「ClientApp」に名前変更し、フォルダ構成を元に戻しておく。
以上で、まずはプロジェクト構築作業の開始地点となる。
C# プロジェクト側の調整
「npx create-next-app」で作られた Next.js アプリケーションは、その開発サーバーの起動コマンドは「npm run dev」ということになっており、「React での ASP.NET Core」プロジェクトにおける「npm start」とは異なる。
そこで、この C# プロジェクトファイル (.csproj) をエディタで開き、フロント側開発サーバー起動コマンドの指定である <SpaProxyLaunchCommand> MSBuild プロパティ (「npm start」が指定されている) を「npm run dev」に書き換える (下記)。
<!-- 📜 .csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
...
<SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
...
さらには、「npm run dev」で起動する Next.js の開発サーバーは、「http://localhost:3000/ 」でリッスンするので、本当は、<SpaProxyServerUrl> MSBuild プロパティに記載するフロント側開発サーバー URL も変更する必要のが筋である。が、いったんここでは先送りする。
問題 1. Next.js 開発サーバーのプロクシ機能が自己署名証明書を拒否
さて続けて、特定の URL パスへの HTTP 要求は、Next.js の開発サーバーから ASP.NET Core サーバーへ中継するよう、プロクシを構成することとした。
素の React アプリ、というか、「npx create-react-app」で生成した開発環境の場合は、「setupProxy.js」というファイル名の JavaScript ファイルを、所定のフォルダ (この場合は ./ClientApp/src ) 内に配置し、この .js ファイル内にプロクシ構成を記述することで、これを実現できた。
いっぽう、「npx create-next-app」で生成した Next.js アプリ開発環境の場合は、生成時に同時に作成されるJavaScript ファイル「next.config.js」(このケースだと ./ClientApp フォルダ内にある) を編集し、必要な記述を書き足すことで、プロクシを構成することができる (下記リンク先は公式サイトでの説明)。
イメージとしては次のような感じだ。
// 📜 next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
...
async rewrites() {
return [
{
// 👇 この URL パスへの HTTP 要求は...
source: '/weatherforecast',
// 👇 こちら (ASP.NET Core サーバーの URL) に中継する。
destination: 'https://localhost:5001/weatherforecast',
},
]
}
}
module.exports = nextConfig
さて上記のような要領で「next.config.js」を構成し、動作確認した。
しかし残念なことに、ASP.NET Core サーバーへ中継される URL に HTTP 要求してみると、下記のサーバーエラーになってしまった。
Server Error
Error: self signed certificate
This error happened while generating the page. Any console logs will be displayed in the terminal window.
Call Stack
TLSSocket.onConnectSecure
node:_tls_wrap (1532:34)
TLSSocket.emit
node:events (527:28)
TLSSocket._finishInit
node:_tls_wrap (946:8)
TLSWrap.ssl.onhandshakedone
node:_tls_wrap (727:12)
どうやら self signed certificate、すなわち自己署名証明書によるセキュア接続は許容されないということらしい。
.NET SDK によって生成・インストールされる localhost 用の開発用証明書は自己署名証明書であり、それを使う ASP.NET Core サーバーへの、Next.js 開発サーバーからのプロクシ接続は拒否される、ということだ。
どうもこれは Next.js 開発環境における "仕様" ということらしい。
また、この件については GitHub 上で Issue が立てられたのだが (下記リンク先、2021年1月に起票)、
上記 Issue を確認するとわかるように、「It's a security bad practice to use self-signed certificates for handling https as well」、つまり「自己署名証明書の使用はセキュリティ上の悪しき慣行」という趣旨らしく、どうやら、自己署名証明書の使用には断固として対応しない、ということのようである。
なので、環境変数 NODE_TLS_REJECT_UNAUTHORIZED に "0" を設定しても、この制約は回避できず相変わらず上記エラーとなって終わり、である。
自己署名証明書は断固拒否は、趣旨はわからないでもない。とはいえ、素人考えとしては開発時くらいは迂回できるオプションくらいあっても良さそうにも感じた。まぁ、ここで愚痴っても仕方がない。
このような制約があるため、Next.js 開発サーバーと ASP.NET Core サーバーの通信くらいは、まぁ、非セキュア接続 (http) にしてこの問題を迂回してもよいかな、とも一瞬考えた。しかし ASP.NET Core サーバー側には、「非セキュア接続の要求は、セキュア接続へリダイレクトする」という実装が入っている。運用環境への配置時には、この ASP.NET Core サーバーがフロント側静的コンテンツもホストするパターンを想定すると、このセキュア接続への強制リダイレクト処理は捨てがたい。となると、ASP.NET Core サーバー側でそもそもいずれの通信であっても非セキュア接続を許容するように仕立てることはちょっと困難だ (いくつか手段がないわけではないけれども、手を出すにはちょっと厄介だ)。
ということで早々に詰んでしまった。
問題 2. Next.js の開発サーバーはセキュア接続をサポートしていない
そしてもうひとつ、そもそも Next.js の開発サーバーは、セキュア接続をサポートしていないらしいことが、公式サイトや Stackoverflow.com をうろうろしている内にわかってきた。
「npx create-react-app」で生成した React アプリの開発環境の場合は、「.env.development」や「.env.development.local」などの環境変数設定ファイルを駆使して、下記のように環境変数を設定してやれば、その開発サーバーはセキュア接続 (https) で起動するようになっている。
HTTPS=true
SSL_CRT_FILE={証明書ファイルへのパス}
SSL_KEY_FILE={秘密鍵ファイルへのパス}
しかしながら、Next.js の開発環境、開発サーバーには、そのような仕組みは提供されていないらしい。
ということで、そもそもの要件である「ローカル開発環境であっても、開発サーバーへの接続は、セキュア接続を必須とする」の段階で詰んでしまっていたのである。
作戦変更 - 前段にさらにリバースプロクシを立てる
以上のとおり、「Next.js の開発サーバーを構成して目的を達成することは不可能」ということが判明したので、作戦変更。
もう一段、前段に、別途何かしらのリバースプロクシプロセスを立ち上げ、そのリバースプロクシで以下の様にブラウザからの HTTP 要求をさばくことにした。
ASP.NET Core サーバー側で処理すべき Web API の URL パスの場合は、ASP.NET Core サーバーの URL へ中継それ以外の URL パスは、Next.js 開発サーバーへ中継
さてそのリバースプロクシ実装であるが、docker でリバースプロクシを立てるなどの方法もありえる。しかし個人的にはあまり開発環境のシステム要件を増やしたくない。つまり、.NET SDK と Node.js のみを開発環境の必須要件としたく考えた (まぁ、今どきの開発環境、逆に Docker for Desktop くらいは普通はインストール済みかなぁ、とも思うのだが)。ということで、Node.js 上で動作する、npm パッケージ化されたありもののリバースプロクシ実装を選定することとした。
問題 3. 最適な npm パッケージを見つけられない
早速 npmjs.com で「local proxy ssl」などのキーワードで検索。いくつか検索にヒットした中でもダウンロード数が頭ひとつ抜けて多い「local-ssl-proxy」という npm パッケージを見つけた。
ぱっと見た感じ、今回の目的に最適かも... と考えられた。しかしながら、改めてよく見ると、最終コミットが 7 年前であることに気づいた。セキュア接続を提供するためのコードが 7 年前というのは、脆弱性対策とかブラウザの互換性とか大丈夫なんだろうか、一抹の不安を覚えた。ということで採用を見送る。
その後も npmjs.com でリバースプロクシをいろいろ検索するも、機能や用途が合わなかったり、やはり最終コミットが古すぎたり、という感じで、最適な npm パッケージを発見できずに時間だけが過ぎていった。
さらに作戦変更 - リバースプロクシは自分で実装する
npmjs.com での探索もだんだん行き詰まってきたので、重ねての作戦変更を決断。すなわち、今回の用途のリバースプロクシは自分で実装することとした。
まぁ、実際、Node.js 上で動作する、express ベースのリバースプロクシプログラムは、今時代、大変ありがたいことに、「http-proxy-middleware」npm パッケージのおかげで、とっても簡単に実装することができる。
ひとたび自分で実装すると決まればあとは早い。
まずは「React での ASP.NET Core」プロジェクトテンプレートから別途生成したプロジェクトから、「aspnetcore-https.js」と「aspnetcore-react.js」の 2 つの JavaScript ファイルを拝借してくる。これら JavaScript プログラムは、
「aspnetcore-https.js」は .NET SDK を使って開発サーバー用サーバー証明書ファイルとその秘密鍵ファイルとを生成するプログラム、「aspnetcore-react.js」はそれらサーバー証明書ファイルと秘密鍵ファイルのパスを環境変数設定ファイル「.env.development.local」に書き込むプログラム
となっている。
JavaScript ファイル「aspnetcore-react.js」は、ファイル名に "react" が含まれているのがなんとなく気になって、深い意味はないのだが「aspnetcore-nextjs.js」にファイル名を変えておいた。
そして前述のとおり、リバースプロクシプログラムを、"Node.js 上で稼働する express を使った JavaScript プログラム" として実装する。
JavaScript ファイル名は「proxy-server.js」とした。
このリバースプロクシプログラムそれ自体のリッスンポート番号と、Next.js 開発環境のリッスンポート番号は、環境変数設定ファイル「.env.development」に記述し (下記例)、これを「proxy-server.js」内から参照することにした。
# .env.development
PORT=44450
NEXTJS_PORT=3000
ASP.NET Core サーバーのリッスンポート番号の取得は、別途「React での ASP.NET Core」プロジェクトテンプレートから生成したプロジェクト内の「setupProxy.js」の実装を参考に転記して実装した。プロクシ機能は「http-proxy-middleware」パッケージを利用して実装する。
最終的に「proxy-server.js」の実装は 50 行にも満たずに済んだ。
// 📜 proxy-server.js
const fs = require('fs');
const https = require('https');
const dotenv = require('dotenv');
const express = require('express');
const { env } = require('process');
const { createProxyMiddleware } = require('http-proxy-middleware');
dotenv.config({ path: ".env.development" })
dotenv.config({ path: ".env.development.local" })
const aspNetCoreServerUrl =
env.ASPNETCORE_HTTPS_PORT ? `https://localhost:${env.ASPNETCORE_HTTPS_PORT}` :
env.ASPNETCORE_URLS ? env.ASPNETCORE_URLS.split(';')[0] : 'https://localhost:7294';
const nextjsDevServerUrl = `http://localhost:${env.NEXTJS_PORT}`;
const app = express();
const server = https.createServer({ cert: fs.readFileSync(env.SSL_CRT_FILE), key: fs.readFileSync(env.SSL_KEY_FILE), }, app);
const context = [
"/weatherforecast",
];
// Forward to the ASP.NET Core server
app.use(createProxyMiddleware(context, {
target: aspNetCoreServerUrl,
secure: false,
headers: {
Connection: 'Keep-Alive'
}
}));
// Forward to the Next.js dev server
app.use(createProxyMiddleware({
target: nextjsDevServerUrl,
secure: false,
headers: {
Connection: 'Keep-Alive'
}
}));
server.listen(env.PORT, () => {
console.log(`Now listening on: https://localhost:${env.PORT}`);
});
仕上げ - npm-run-all パッケージを使ってプロクシと開発サーバーを並行実行
以上で必要な JavaScript プログラム類の用意は完了だ。あとは、「npm run dev」を実行したときに、
aspnetcore-http.js > aspnetcore-nextjs.js > proxy-server.js
の順で JavaScript プログラムを実行しつつ、さらに並行して Next.js の開発サーバーを起動するコマンド、
next dev
が実行されるように package.json 内の scripts ノードを構成すれば完成だ。
この辺の、package.json > scripts ノード内のスクリプトを、並列・順列に実行する制御のために、「npm-run-all」パッケージを利用した。
「npm-run-all」パッケージを使うことで、package.json の scripts 中で「run-p」「run-s」といったコマンドが使えるようになり、package.json の scripts 中に記述したスクリプトを、「run-p」なら並列実行、「run-s」なら順列実行することができるようになる。
ということで、package.json の scripts ノードは以下のとおりとなった。
// 📜 package.json
{
...
"scripts": {
"dev": "run-p nextdev proxy-server",
"nextdev": "next dev",
"proxy-server": "node aspnetcore-https && node aspnetcore-nextjs && node proxy-server",
...
完成!
これで Visual Studio から Ctrl + F5 や F5 で実行、ないしは .NET CLI なら「dotnet run」を実行することで、ASP.NET Core サーバー、Next.js 開発サーバー、自作のリバースプロクシプログラム、のそれぞれが起動するようになった。ASP.NET Core サーバーおよびリバースプロクシプログラムは、.NET SDK による開発サーバー用自己署名証明書を使ったセキュア接続 (https) で待ち受けする。
Visual Studio からの実行の場合は、さらにブラウザが起動して、リバースプロクシプログラムのリッスンポート番号の URL を開くようになっている (※厳密には、いったん ASP.NET Core サーバーの URL をブラウザで開き、その ASP.NET Core サーバーからの応答コンテンツにて、リバースプロクシプログラムの URL へリダイレクトする)。
以上の手順を再現したコミット履歴およびプロジェクトファイル一式は、下記 GitHub リポジトリで公開してある。
おまけ
自作のリバースプロクシプログラムは、今回は JavaScript で実装したが、開発環境の縛りが「.NET SDK」と「Node.js」なので、C# で "YARP" (.NET 用のリバースプロクシライブラリ) を使って書いてもよかったかもな、と考えたりしている。
]]>
スタンドアロンな Blazor WebAssembly アプリを発行、オンプレミスの IIS に配置したら HTTP 500 エラーになった件
http://devadjust.exblog.jp/29280278/
2022-07-25T22:26:00+09:00
2022-07-25T22:28:54+09:00
2022-07-25T22:26:51+09:00
developer-adjust
.NET
とあるスタンドアロンの Blazor WebAssembly アプリを、手元の Windows 11 Pro 上の IIS に配置する機会があった。
「dotnet publish -c Release」と実行して発行を行ない、生成された「./bin/Release/net6.0/publish」フォルダ内のファイル一式を C:\inetpub\wwwroot フォルダに配置して、はい完了。
...のはずだったのだが、配置完了して https://localhost/ を Web ブラウザで開いたところ、下記内容の IIS による HTTP 500 エラーページが表示されてしまった。
HTTP Error 500.19 - Internal Server Error The requested page cannot be accessed because the related configuration data for the page is invalid.
今回発行したのはスタンドアロンな Blazor WebAssembly であり、ASP.NET Core ホストされた Blazor WebAssembly ではない。つまりサーバー側プロセスがない、言ってみれば画像ファイル群などと大差ない、ただの静的コンテンツ群を配置しようとしただけである。
にも関わらず、サーバー側エラーである HTTP 500 が発生したということはどういうことか。
最初は理由がわからず、問題切り分けになるかどうかもわからないままに、何となく、この状況から、もうひとつ別の ASP.NET Core ホストされた Blazor WebAssembly プロジェクトを発行し、発行後コンテンツを C:\inetpub\wwwroot フォルダに配置してみた。
そうしたところ、こちらはさっくり動作してしまった。
同じ IIS 上の配置なのに、両者の違いは何だろうか?
エラーメッセージの中にこそ答えがあるはず
解決できないままに改めて腰を据えて、先の IIS による HTTP 500 エラーのメッセージをよく読む。絶対に答えはエラーメッセージの中にあるはずだ。心を落ち着けてよく読むと「the related configuration data for the page is invalid.」が肝心であることがわかってきた。
つまり、「当該ページ用の関連する構成データが不正である」ということで、ここでいう「構成データ」とは web.config のことに違いない。
実際、スタンドアロンな Blazor WebAssembly アプリの発行後コンテンツを見ると、web.config ファイルが含まれているのがわかる。
そこで、この発行後コンテンツに含まれている web.config の記載内容をテキストエディタで開いて覗いてみる。
web.config の冒頭は大して珍しいものはない。下記のとおり、Blazor WebAssembly ならではのファイル種別、とくに .dll とか .wasm とかが正しくブラウザに応答返信されるよう、MIME-Type を正しく再設定しているだけである。
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<staticContent>
<remove fileExtension=".blat" />
<remove fileExtension=".dat" />
<remove fileExtension=".dll" />
...
<mimeMap fileExtension=".blat" mimeType="application/octet-stream" />
<mimeMap fileExtension=".dll" mimeType="application/octet-stream" />
<mimeMap fileExtension=".dat" mimeType="application/octet-stream" />
...
しかし web.config の後ろのほうに面白いものを見つけた。下記である。
...
<rewrite>
<rules>
<rule name="Serve subdir">
<match url=".*" />
<action type="Rewrite" url="wwwroot\{R:0}" />
</rule>
<rule name="SPA fallback routing" stopProcessing="true">
<match url=".*" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
</conditions>
<action type="Rewrite" url="wwwroot\" />
</rule>
</rules>
</rewrite>
...
上記は何かというと、いわゆる「URL の書き換え (Rewrite)」についての構成だ。
特に URL 書き換えルールの 2つ目、つまり上記 XML 中の rule ノードの 2 つ目であるが、これは SPA のフォールバックページを返す仕組みを実現している仕掛けだ。
例えば Web ブラウザからの HTTP 要求が「https://localhost/counter」だとして、しかし件の Blazor WebAssembly アプリには該当する静的ファイルは存在しない。
この場合に、上記 IIS の「URL の書き換え」によって、要求を「https://localhost/」に振り向ける、というのである。
すると、続いての「既定のドキュメント」の仕組みにより、「https://localhost/index.html」の内容がブラウザに返される、という訳である。
あとは Web ブラウザ上で、そうやって読み込まれたフォールバックページの内容を元に Blazor WebAssembly アプリコンテンツがブラウザ上に読み込まれ、Blazor WebAssembly のルーティングによってめでたく「counter」ページが表示される、という寸法だ。
GitHub Pages への配置の場合などのようにサーバー側をどうにも構成のしようがない場合は、該当コンテンツが見つからない場合 (HTTP 404) に返される HTML ファイル「404.html」にフォールバックページの内容 (つまりは index.html と同じ内容) を記載したりして凌いだりする。
が、なるほど、IIS への配置の際は、Blazor WebAssembly プロジェクトの発行後コンテンツに含まれている web.config、およびその web.config 内に記載されている「URL 書き換え」ルールによって、SPA のフォールバックページ返却の仕組みが実現されていたのである。
URL 書き換えモジュール!
ここでようやくはたと気がついた。
そういえば、IIS の「URL 書き換え」の機能って、別途追加でインストールが要るんではなかったか?
インターネットで検索してみると、果たしてそのとおり。
下記 URL から「URL 書き換え」、すなわち、「URL Rewrite」モジュールをダウンロードしてインストールするべきだったのだ。
「URL 書き換え (URL Rewrite)」モジュールをインストール完了後、再度、当該スタンドアロン Blazor WebAssembly アプリの発行後コンテンツを配置しなおし、改めて「https://localhost」を Web ブラウザで開いてみると、ようやく期待どおりのページが表示された。
もちろん、Web ブラウザのアドレスバーに「https://localhost/counter」と入力したりその後再読込を実行したりしても、ちゃんと SPA フォールバックの仕組みで、期待どおり「Counter」ページが表示された。
補足
ちなみに、ASP.NET Core ホストな Blazor WebAssembly プロジェクトであれば、「URL 書き換え」モジュールが未インストールな IIS 上でも難なく動作したのは、IIS の「URL 書き換え」モジュールがやっているのと同じような SPA フォールバックページ返却の仕組みを、ホストしている ASP.NET Core サーバーが担当しているからである。
.NET SDK 標準のプロジェクトテンプレートから新規作成した ASP.NET Core ホストな Blazor WebAssembly プロジェクトであれば、ASP.NET Creo サーバー側の Program.cs で
app.MapFallbackToFile("index.html");
の1行が最後のほうにあると思うが、それこそが、スタンドアロンな Blazor WebAssembly プロジェクトを配置した際には IIS の「URL 書き換え」モジュールにやらせていた、フォールバックページ返却の仕組みである次第。
]]>
.NET 6 の ASP.NET Core Web API 新規プロジェクトでベースパスを指定したら HTTP 404
http://devadjust.exblog.jp/29253607/
2022-06-27T22:31:00+09:00
2022-06-27T22:33:38+09:00
2022-06-27T22:31:45+09:00
developer-adjust
.NET
.NET の Web アプリケーションフレームワーク ASP.NET Core プログラミングでの話。
イントラネット向けの ASP.NET Core Web アプリの場合、配置先のサーバー 1 台に複数の Web アプリを同居配置しているケースがある。Virtual Host でも実現できるが、異なるベースパスに配置することも、よく見聞きする。
つまり「https://example.com/app1」と「https://example.com/app2」という異なるベースパスにそれぞれ別々の ASP.NET Core Web アプリを配置する、というパターンだ。
コンシューマ向け・インターネット公開の案件だと、このような配置方法はあまりお目にかからない気がするが、イントラネットだと珍しくない印象で、それこそ .NET Framework の時代からまま採用してきた経験がある。
このベースパスの指定、.NET Core 世代だと、プログラムコード上で明示的に指定する必要がある。
具体的には、アプリケーション起動時の要求処理パイプラインの組み立てのところで、「UsePathBase()」拡張メソッドを呼び出すことで指定する。ベースパスを「app1」とするなら、下記のようなコードを実装すればよい。
app.UsePathBase("/app1");
「UsePathBase()」を使って ASP.NET Core Web アプリのベースパスを指定する方法については、マイクロソフトの公式ドキュメントサイトに説明がある (下記リンク先)。
.NET 6 で同じようにやってみたら、HTTP 404 !?
さてそのようなベースパスを指定しての ASP.NET Core Web アプリ開発、"イントラネットだと珍しくない" とは言いつつここ暫く手がけたことはなかったところ、久方ぶりに扱うことになった。
さて本ブログ記事投稿時点での .NET 最新 LTS バージョンは .NET 6 ということで、.NET 6 SDK を使って、新規に ASP.NET Core Web アプリを作ることにする。今回は Web API 案件だったので、dotnet CLI だと以下のコマンドで新規プロジェクト作成した。
dotnet new webapi
そうしてできた新規プロジェクトは、.NET 6 からの新スタイル、Minimal API 形式のプログラムだ。引き続き早速、「UsePathBase()」拡張メソッド呼び出しを追加する。例えば配置先ベースパスが "foo/bar" だとすると、下記のようになる。
var builder = WebApplication.CreateBuilder(args);
...
var app = builder.Build();
app.UsePathBase("/foo/bar");
app.MapGet("/hello", ()=> "Hello");
...
以上の実装で、ブラウザで「http://.../foo/bar/helo」を開いたら、"Hello" の文字列が返ってくるのが確認できるはずだ。
早速「dotnet run」で実行し、ブラウザで開いてみると...
なんと、まさかの HTTP ERROR 404 Not Found が返ってきてしまった。
.NET 5 までだとこんな現象にお目にかかったことがないのだが、はてさて。
ドキュメントをよく読もう!
どうにも困ったと首をひねりながら、もういちど公式ドキュメントに目を通してみる。
すると...ちゃんと注意書きがあるではないか! (以下引用)
注意
WebApplication を使用する場合 (ASP.NET Core 5.0 から 6.0 への移行に関する記事を参照) は、UsePathBase の後に app.UseRouting を呼び出して、ルーティング ミドルウェアがルートを照合する前に変更されたパスを確認できるようにする必要があります。 そうしない場合、ミドルウェアの順序とルーティングに関する記事で説明されているように、ルートはパスが書き直される前に UsePathBase によって照合されます。
どうやら、.NET 6 の 新規 Web API プロジェクト作成のテンプレートだと、「UseRouting()」の呼び出しが含まれておらず (Minimal API の動作的には不要)、それで「UsePathBase()」が効果を発揮しなかったらしい。
注意書き以外にも、"早い段階で UsePathBase を呼び出して..." と書いてあるあとのコード例中に、しれっと「UseRouting()」呼び出しが記載されていて (下図)、よく考えるとこれがヒントにはなっていたっぽい。
ということで「UseRouting()」の呼び出しを追加して、無事、ベースパスを指定しての実行が期待どおりに動作するようになった。
var builder = WebApplication.CreateBuilder(args);
...
var app = builder.Build();
app.UsePathBase("/foo/bar");
app.UseRouting(); // 👈 これを追加
app.MapGet("/hello", ()=> "Hello");
...
.NET 6 SDK が生成する Web API 新規プロジェクトのコードが、その前の .NET 5 までとまったく同じというわけではないので、.NET 5 までで手慣れた方法・知識だけだと躓くことがあった、という話。
公式ドキュメントはよく読もう!
]]>
https://www.excite.co.jp/
https://www.exblog.jp/
https://ssl2.excite.co.jp/