WebAPI を開発・保守していると特殊な文字(制御文字)が入力に紛れ込んでいて、その場面では問題にはならないけれども後続処理でエラーが発生する。。。なんてことがよくあります。入力から特殊文字を削除する簡単な方法としては API それぞれに対する特殊文字を削除する専用の処理を実装する、リフレクションで汎用的な処理を実装して個々の API で呼び出す、など考えられますが、中途半端で漏れも発生します。
本記事では ASP.NET Core のミドルウェアを自作し、入力された JSON から一括で制御文字を削除する手法について紹介します。
試した環境
要素 | バージョン |
---|---|
Visual Studio | 2022 (17.4.3) |
.NET SDK | 7.0.101 |
本記事に記載のコードは GitHub 上に置いてあります。
ASP.NET Core のミドルウェアとは
ASP.NET Core においてリクエストを処理する流れはパイプラインと呼ばれており、ミドルウェアとはパイプラインを構成する要素です。ミドルウェアは次のミドルウェアの処理の前後で各々の処理を実行します。
ASP.NET Core では認証、キャッシュ、レスポンス圧縮、静的ファイル配信、 HTTPS リダイレクト、例外ページ表示等の標準機能はミドルウェアとして実装されています。
Visual Studio で新規 Web プロジェクトを作成すると Program.cs
に UseHttpsRedirection
や UseAuthorization
等の呼び出しが見られます。これらが要求パイプラインに対して使用するミドルウェアを登録する処理で、リクエストに対してこの呼び出しの順番でミドルウェアが呼び出されます。
ASP.NET Core のミドルウェアを実装する
ざっくりと ASP.NET Core のミドルウェアについて説明したところで実際にカスタムミドルウェアを実装してみましょう。本記事で作成するミドルウェアの機能はざっくりと紹介すると、 HTTP リクエストのボディ部分の JSON のテキスト値から特殊文字を削除する、となります。手順は以下の四段階です。
- ミドルウェアのクラス作成
- ミドルウェアのパイプライン登録
- JSON のテキスト値から特殊文字を削除
- リクエストボディの中身を特殊文字が削除された JSON に差し替え
ミドルウェアのクラスを作成する
まず最初にミドルウェアのクラスを作成します。自作ミドルウェアに関するドキュメントはこちらにあります。
ドキュメントによるとミドルウェアとなる条件は次の2点だそうです。
- 第一引数が
RequestDelegate
のパブリックコンストラクターがあること - 名前が
Invoke
またはInvokeAsync
で第一引数がHttpContext
、返値の型がTask
のパブリックメソッドがあること
この条件さえ満たしていればクラスの名前やコンストラクター・メソッドの第二引数以降は自由で、 DI サービスに登録されている任意のコンポーネントを受け取ることができます。ただしミドルウェアのライフライクルはアプリケーションスコープ(シングルトン扱い)のため、コンストラクターで受け取れる物はアプリケーションスコープのもの、 Invoke メソッドで受け取れるものはリクエストスコープとなります。
本記事では特殊文字を削除するミドルウェアを実装するため、次のクラスを作成しました。
using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace NX.AspNetCore.Middlewares; /// <summary> /// リクエストボディ(JSON)に含まれる特殊文字を削除するミドルウェア /// </summary> public class RemoveControlCharactersMiddleware { private readonly RequestDelegate _next; private readonly ILogger<RemoveControlCharactersMiddleware> _logger; public RemoveControlCharactersMiddleware( RequestDelegate next, ILogger<RemoveControlCharactersMiddleware> logger) { _next = next ?? throw new ArgumentNullException(nameof(next)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger.LogInformation("Initialized"); } /// <summary> /// ミドルウェアの処理を実装します。 /// </summary> public async Task Invoke(HttpContext context) { // このミドルウェアの処理を記述します(前処理)。 _logger.LogInformation("Before"); // 次のミドルウェアの処理を実行します。 await _next(context); // このミドルウェアの処理を記述します(後処理)。 _logger.LogInformation("After"); } }
ミドルウェアをパイプラインに登録する
続いて作成したミドルウェアのクラスをパイプラインに登録します。直接ミドルウェアのクラスを登録せず、拡張メソッドを利用すると良いでしょう。
using Microsoft.AspNetCore.Builder; namespace NX.AspNetCore.Middlewares; public static class RemoveControlCharactersMiddlewareExtensions { /// <summary> /// 特殊文字をリクエストボディから削除するミドルウェアを使用します。 /// </summary> public static IApplicationBuilder UseRemoveControlCharacters(this IApplicationBuilder app) { app.UseMiddleware<RemoveControlCharactersMiddleware>(); return app; } }
作成した拡張メソッドを Program.cs
で使用します。この IApplicationBuilder
へのミドルウェア登録順が要求パイプラインにおけるミドルウェアの呼び出し順となるため、追加する場所(順番)には注意が必要です。
// 略 app.UseHttpsRedirection(); app.UseAuthorization(); // 追加 app.UseRemoveControlCharacters(); app.MapControllers(); app.Run();
この状態でアプリケーションを実行すると、コンストラクターに仕込んだログはアプリケーション起動時に1度だけ表示されるのに対して Invoke メソッドのログはリクエストの度に表示されるかと思います。
JSON から特殊文字を削除する
ミドルウェアのクラス作成とパイプライン登録が完了したところで、続いて制御文字を削除する処理の中身を実装します。
リクエストボディには JSON が文字列(の UTF-8 バイト配列)として含まれています。 JSON のテキスト値に含まれる制御文字はテキスト値として表現するためにエスケープ処理がされていますので、リクエストボディから単純に制御文字を削除するのは不適切です。また値ではなくキー等に含まれる制御文字は削除したくありません。これらを実現するためには JSON 文字列を JSON オブジェクトとして処理せねばなりません。本記事では .NET 3.0から導入された System.Text.Json 名前空間を使用して JSON のテキスト値部分のみを差し替えます。
System.Text.Json 名前空間には UTF-8 バイト配列の JSON を PullParser の様に読み込む Utf8JsonReader
とその逆を行う Utf8JsonWriter
が実装されています。これらを使用するととても簡単に JSON 文字列から一部のテキスト値を変更した新しい JSON 文字列を作成することができます。
それでは実際に実装してみましょう。 Utf8JsonReader
の使い方は「 Read
呼び出し ⇒ JSONの部品を表すトークンに対して何らかの処理」の繰り返しです。 Utf8JsonWriter
には Utf8JsonReader
に対応するメソッドが実装されています。今回はテキスト値のみ差し替えたいため、それ以外のトークンに対しては対応する Writer のメソッドを呼び出すだけとなります。
/// <summary> /// JSONバイト配列から制御文字を削除したJSONバイト配列を返す /// </summary> private static byte[] RemoveControlCharactersInJsonString(byte[] source) { var reader = new Utf8JsonReader(source, new JsonReaderOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Disallow, MaxDepth = 0 }); using var ms = new MemoryStream(); using var writer = new Utf8JsonWriter(ms); while (reader.Read()) { switch (reader.TokenType) { case JsonTokenType.None: // Do nothing break; case JsonTokenType.StartObject: writer.WriteStartObject(); break; case JsonTokenType.EndObject: writer.WriteEndObject(); break; case JsonTokenType.StartArray: writer.WriteStartArray(); break; case JsonTokenType.EndArray: writer.WriteEndArray(); break; case JsonTokenType.PropertyName: writer.WritePropertyName(reader.ValueSpan); break; case JsonTokenType.Comment: // Do nothing break; case JsonTokenType.String: // ここだけ特殊な処理を差し込みたい writer.WriteStringValue(reader.GetString()); break; case JsonTokenType.Number: writer.WriteNumberValue(reader.GetDecimal()); break; case JsonTokenType.True: case JsonTokenType.False: writer.WriteBooleanValue(reader.GetBoolean()); break; case JsonTokenType.Null: writer.WriteNullValue(); break; default: throw new InvalidOperationException("Unknown JsonTokenType"); } } writer.Flush(); return ms.ToArray(); }
そして文字列から制御文字(改行と横タブを除く)を削除する処理はこんな感じでしょうか。
// \r, \n, \t 以外の制御文字 private static readonly Regex ControlCharactersRegex = new ("[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u0080-\u009f]", RegexOptions.Multiline); /// <summary> /// 文字列から改行と横タブを除いた制御文字を削除した文字列にする /// </summary> private static string? RemoveControlCharacters(string? value) { return value == null ? null : ControlCharactersRegex.Replace(value, ""); }
この制御文字を削除するメソッドを JSON のテキスト値読み込み・書き込みの箇所に挟み込めば、 JSON のテキスト値から制御文字を削除した新しい JSON 文字列の UTF-8 バイト配列が生成されます。
リクエストボディを差し替える
最後に先に作成した JSON の中身を差し替えた結果で HTTP リクエストのボディを差し替えれば完成です。リクエストボディを一旦全て読み込んだ上で、新しい MemoryStream に変更しましょう。
注)リクエストボディが UTF-8 でエンコードされている前提で実装していますので、他のエンコードにも対応する場合はよしなにしてください。
/// <summary> /// リクエストボディを全て読み込んで制御文字を削除したJSONバイト配列とし、中身を差し替える /// </summary> private static async Task RemoveControlCharactersInRequestBodyAsync(HttpContext context) { using var ms = new MemoryStream(); await context.Request.Body.CopyToAsync(ms, context.RequestAborted); var bytes = RemoveControlCharactersInJsonString(ms.ToArray()); context.Request.Body = new MemoryStream(bytes); }
作成したこのメソッドをミドルウェアの Invoke で呼び出せば完成です。
/// <summary> /// ミドルウェアの処理を実装します。 /// </summary> public async Task Invoke(HttpContext context) { // リクエストのContentTypeがJSON以外の場合は何もしない if (!context.Request.HasJsonContentType()) { await _next(context); return; } try { // 特殊文字を削除したJSONデータを作成してリクエストボディのStreamを差し替える await RemoveControlCharactersInRequestBodyAsync(context); } catch (Exception e) { _logger.LogError(e, "RemoveControlCharacter failed."); throw; } // 次のミドルウェアの処理を実行します。 await _next(context); }
おわりに
入力にヒッソリと含まれている制御文字を削除するためのミドルウェアを自作してみました。 ASP.NET Core はこのような機能もサクッと実装・導入できるのも1つの利点かと思います。