はじめに
タイトルの通り、ASP.NET Coreで作ったWebAPIサーバで、クエリパラメータを使うにはどうしたらいいのかという事です。
クエリパラメータという言葉が正しいかいまいちよくわかりませんが、よく見る以下のようなやつです。
https://hogehoge.co.jp/api/Customer?fields=name,address&orderby=name,pageno=1,pagesize=30
URLパラメータに取得したいフィールド名やソート順、ページングの情報などを混ぜて、必要な情報だけをサーバーから返してもらうようなAPIをよく見ます。
Micorsoftの公式を見るとODataなるものを使用する方法が記載されています。
正直言って「なんか難しそう」「パラメータ名が$filter、$selectとか何で$がつくの?」「そもそもOData使うと色々根本から変えないといけないの?」とかもやもやしてしまいます。
多少の実装は止む無しで色々探していて出会ったのが「Dynamic LINQ」なるライブラリーです。
Dynamic LINQって?
公式サイトは以下です。
その名の通りですが、LINQをダイナミックに扱えるようにするライブラリーで、nugetでも提供されているので、プロジェクトへの導入も簡単です。
通常LINQはモデルのプロパティを利用して抽出条件やソート条件などを指定しますが、Dynamic LINQを使用すると、文字列で各条件を定義することができます。
という事は、URLパラメータで指定された抽出条件やソート条件などをそのままLINQに適用してしまえば簡単なのでは?と思い、実装してみました。
#実装方法
環境
OS:Windows 10 Pro
開発ツール:Microsoft Visual Studio Community 2019
プロジェクト作成
以下のような感じでプロジェクトを作成します。
プロジェクトテンプレート:ASP.NET Core Web API
プロジェクト名:適当(サンプルはTestWebApiとしています)
ターゲットフレームワーク:.NET 5.0
他は適当で。
Dynamic LINQを追加
NuGetで「System.Linq.Dynamic.Core」をプロジェクトに適用します。これがDynamic LINQになります。
実装
サンプルとして、既存の「WeatherForecastController.Get()」にクエリパラメータでの条件指定を受け付けたいと思います。
クエリパラメータのモデル作成
上記Getメソッドに直接パラメータを書いてもいいのですが、まとめたほうがきれいだと思うので、以下のモデルを作成します。
プロジェクト直下にModelsフォルダを作成してその中にQueryParameter.csを作成します。
namespace TestWebApi.Models
{
/// <summary>
/// クエリパラメータ管理モデル
/// </summary>
public class QueryParameter
{
/// <summary>
/// 取得したいフィールド名をカンマ区切りで指定(SQLのSELECTに相当)
/// </summary>
public string Fields { get; set; }
/// <summary>
/// 抽出条件を指定(SQLのWHEREに相当)
/// </summary>
public string Filter { get; set; }
/// <summary>
/// ソート条件を指定(SQLのOrderByに相当)
/// </summary>
public string OrderBy { get; set; }
/// <summary>
/// 取得レコードのページ番号を指定
/// </summary>
public int? PageNo { get; set; }
/// <summary>
/// 取得レコードのレコード数(1ページのレコード数)を設定
/// </summary>
public int? PageSize { get; set; }
}
}
このモデルをURLパラメータの取得に使用します。
コントローラ修正
既存のWeatherForecastControllerのGetメソッドを以下の通り修正し、新たにAddQueryParameterメソッドを追加します。
// 上部でusingに以下のネームスペースを追加してください。
// using System.Linq.Dynamic.Core;
// using TestWebApi.Models;
[HttpGet]
[ProducesDefaultResponseType(typeof(WeatherForecast))] // 追加
public IEnumerable<dynamic> Get([FromQuery] QueryParameter param) // WeatherForecast → dynamic(objectでも可)
{
var rng = new Random();
var query = Enumerable.Range(1, 5).Select(index => new WeatherForecast // 変数に格納するよう修正
{
Date = DateTime.Now.AddDays(index),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
})
.AsQueryable(); // ToArray → AsQueryable
return AddQueryParameter(query, param).ToDynamicArray(); // 追加
}
/// <summary>
/// クエリにクエリパラメータのクエリを追加
/// </summary>
/// <typeparam name="T">元のクエリで扱うモデルの型</typeparam>
/// <param name="query">元のクエリ</param>
/// <param name="param">クエリパラメータ</param>
/// <returns>クエリパラメータが追加されたクエリ</returns>
public IQueryable AddQueryParameter<T>(IQueryable<T> query, QueryParameter param)
{
if(!string.IsNullOrWhiteSpace(param.Filter))
{
// Filterが設定されている場合は、クエリに追加
query = query.Where(param.Filter);
}
if(!string.IsNullOrWhiteSpace(param.OrderBy))
{
// OrderByが設定されている場合は、クエリに追加
query = query.OrderBy(param.OrderBy);
}
// ページングは未設定の場合でもデフォルト値を設定して追加
var pageNo = param.PageNo ?? 1; // デフォルト1ページ目
var pageSize = param.PageSize ?? 50; // デフォルト50レコード
query = query.Page(pageNo, pageSize);
if(!string.IsNullOrWhiteSpace(param.Fields))
{
// Fieldsが設定されている場合は、クエリに追加して返却
return query.Select($"new({param.Fields})");
}
// Fieldsが未設定の場合はここで返却
return query;
}
何となく解説
[ProducesDefaultResponseType(typeof(WeatherForecast))]
public IEnumerable<dynamic> Get([FromQuery] QueryParameter param)
Getメソッドの戻り値の型がIEnumerable<WeatherForecast>
からIEnumerable<dynamic>
に変更されています。これはFieldsパラメータで取得フィールドを指定してしまうと、元の型とは別の型を返却することになるためです。
ProducesDefaultResponseType
属性は本来返却するはずの型を指定します。これはないといけないというものではないのですが、プロジェクトを作成する際に「OpenAPIサポートを有効にする」を選択していた場合、Swaggerが使用されますが、それが見たりするようです。型を指定しないとSwagger上でレスポンスの型が不明になります。
var query = Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
})
.AsQueryable();
もともとのソースコードはこれを直接returnしていましたが、処理を追加するために変数に格納しています。
また、Enumerable.Range(..).Select(..)
の戻り値をもとはToArrayしていましたが、AsQueryableに変更しました。これはDynamicLINQがIQueryableを拡張して作られているものなので、その都合上のこともありますが、IQueryableで扱うことはこの場合非常に重要です(後述)。
public IQueryable AddQueryParameter<T>(IQueryable<T> query, QueryParameter param)
{
...
}
今回の処理のメインどころはAddQueryParameter
メソッドですが、ただクエリを設定しているだけで何も難しいことをしていないので説明は割愛します。
強いて特筆するところがあるとすれば、
if(!string.IsNullOrWhiteSpace(param.Fields))
{
// Fieldsが設定されている場合は、クエリに追加して返却
return query.Select($"new({param.Fields})");
}
// Fieldsが未設定の場合はここで返却
return query;
ここですね。
まず、Selectに渡している文字列ですがnew(date,summary)
みたいなものになります。これはDynamicLINQの仕様なので、公式のリファレンスを参照してください。多分普通のLINQのでもSelectの中でオブジェクトをnewするので、それを意識した書式かなと思います。
あと、returnが2か所にあって気持ち悪い気もします。FieldsやOrderByでやっているようにqueryに格納したほうがきれいに見えますが、これには理由がありまして。。。
このメソッドで使用されているqueryはIQueryable<T>
型ですが、query.Select(...)
の戻り値はIQueryable
型なので、queryに格納することができません。前述しましたが、Selectして取得フィールドを絞ると型の保証がなくなってしまうので、このようになります。
動作検証!
実装が終わったら、実行してみましょう。
プロジェクト作成時に「OpenAPIサポートを有効にする」を選択していた場合はSwaggerが起動し、選択しなかった場合はブラウザで「weatherforecast」のAPIがコールされていると思います。
Swaggerが立ち上がった場合でも、ブラウザのアドレスバーのURLを少しいじって「https://localhost:xxxxx/weatherforecast
」にしてみてください。(xxxxxの部分はそのままいじらないようにしてください。)
すると、
こんな画面になって、WebAPIが無事コールされていることがわかります。
しかし見辛い。。。
見やすくしたい場合は、Startup.csに以下を追加してください。
public void ConfigureServices(IServiceCollection services)
{
// 以下のコードを追加。それ以外はそのまま。
services.AddMvc()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.WriteIndented = true;
});
}
では、気を取り直してQueryParameterの動作検証です。
まずFieldsを追加してみます。
https://localhost:xxxxx/weatherforecast?fields=date,summary
いい感じ。
OrderByを試してみる。
https://localhost:44332/weatherforecast?fields=date,summary&orderby=date desc
ちゃんとdateフィールドの降順になった。
次はFilter。
https://localhost:44332/weatherforecast?fields=date,summary&orderby=date desc&filter=date<="2021-10-23T23:59:59"
いいね!
最後はPageNoとPageSize
https://localhost:44332/weatherforecast?fields=date,summary&orderby=date desc&filter=date<="22021-10-23T23:59:59"&pageno=2&pagesize=2
レコード数が少ないのでわかりづらいですが、ちゃんと動いている。
ただ、WeatherForecast.Getはランダムで値を返してくるので、ちょっと確認が面倒です。
DynamicLINQを使用すると、結構簡単に実装することができます。
それぞれに渡せるパラメータは公式のリファレンスを参照してください。
もちろんLINQ to SQLにも適用できますよ!
注意すること
###IQueryableで扱うのがキモです!
特にLINQ to SQLを使用する際に重要ですが、LINQを扱うインターフェスにIList<T>
、IQueryable<T>
などがあります。
何となく扱いが似ているので実装時にあまり意識はしないかもしれませんが、この2つの違いを意識しないとひどい目にあいます。
LINQ to SQLであるテーブルの全レコードの中からQueryParamegerを使用して抽出条件などを指定する場合、
using var context = new DbContext();
var query = context.Customers.AsQuery(); // Customer全件抽出するクエリを作成。
var customersQuery = AddQueryParameter(query, param); // クエリにパラメータを追加
var customers = customersQuery.ToList(); // SQL実行
using var context = new DbContext();
var query = context.Customers.ToList(); // Customer全件抽出するSQLを実行。
var customersQuery = AddQueryParameter(query.AsQueryable, param); // 抽出結果をクエリに変換して条件追加
var customers = customersQuery.ToList(); // メモリ内でクエリ
上記に2つは実行結果は同じなのですが、処理工程が全く異なります。
いい例の方は、すべてがデータベース上で処理されるのに対して、悪い例は、とりあえずデータベースからCustomerテーブルの全レコードを取得して、メモリ内で必要なレコードのみを抽出するようなイメージです。
IQueryable<T>
やIQueryable
のままでいられる内はクエリは実行されず、SQLが構築されているような状態になりますが、ToList()やforeachで処理された瞬間にその段階のSQLが実行され、値を持つようになります。
上記例では、Customerテーブルにさほどレコードが存在しなければ影響は少ないですが、100万レコードとかあった場合は、処理速度に雲泥の差が出ることになります。
最後に
色々方法を調査していましたが、これといった解決策はなく、Dynamic LINQを使用した方法に落ち着きました。
実装は特に難しくないので、方法を検討していた時間の方が長かった気がします。
よろしければ参考にしてみてください。