ODataHttpClient
February 20, 2024•558 words
ODataHttpClientというライブラリを作って公開しています。First Commitから随分立つので、ここらへんで一旦どうしてこれを作ろうと思い立ったか、どう使いたくて、どう使ってもらいたくて、実際作って使ってみてどうだったかをまとめておこうかと思います。
ホントはリポジトリに英語で書いたほうがいいんですが、とりあえず下書きでここに母語で書きます。
OData
ODataHttpClientはその名の通りODataのクライアントなのですが、ODataとは何でしょうか。そこから整理をはじめてみます。
ODataはOASISで標準仕様が策定されているプロトコルです。OASISでは他にAMQPなどが策定されています。
https://www.oasis-open.org/committees/tc_home.php?wg_abbrev=odata
ODataの策定員会ではMicrosoftやSAP、IBMの方々が中心となって進めています。またこれらの企業がスポンサードしています。
私がODataの存在を知って触りだしたのは、所属する会社に入社してからで、時代的にはXML RPCはある程度廃れて、gRPCが出てくる前のRESTful全盛期でした。
ODataはURLに()
があるのが気にはなったもの、DbContextからエンドポイントが自動生成されて、強力なfilterやexpandの表現を持ち、メタデータからクライアントが自動生成される機能からとても魅力的に映りました。当時はOpenAPIはまだswaggerで標準化されていませんでしたし、私はSOAPに勢いのあった後の時代にプログラミングを勉強したので、サーバとクライアントでinterfaceを共有するSOAPの思想はその時初めて目にしました。
アプリケーション指向とリソース指向
ここでアプリケーション指向とリソース指向という用語を導入しようと思います。
アプリケーション指向なAPIとは、そのAPIが提供する機能がよりアプリケーション操作的なものであり、リソース指向なAPIとは、そのAPIが提供する機能がよりリソース操作的なものを指すこととします。
※ アプリケーション指向、リソース指向という名前でAPIを区分することは、この後の説明を行いやすくするためであり、一般的にそのような区分があるかどうか、その意味がどのようなものであるかは考慮しません。あくまで私的な表現です。
また、APIがApplciation Protocol Interfaceなのにアプリケーション指向じゃないことはないのではないのかという考えも浮かびますが、ここでは、File I/O操作(Win32 APIやPOSIXを思い浮かべてください)のようなものをリソース指向よりであると定義します。
あるAPIがアプリケーション指向かリソース指向かということをきっちり二分することは難しく、その境界はグラデーションであることが多いでしょう。
例えば、Stripeのような決済プロバイダのAPIを考えるときに、カードの与信APIはアプリケーション指向ですが、ユーザのカード管理APIはよりリソース指向です。
このように、多くのAPIはアプリケーション指向もリソース指向も両方持ち合わせていることが多いでしょう。
アプリケーション指向とリソース指向を実装方法の観点から見てみましょう。これはあくまで私の勝手な主張ですが、RESTfulなAPIはリソース指向になりがちで、RPCはアプリケーション指向になりがちだと考えます。
もちろん、RESTfulでアプリケーション指向を、RPCでリソース指向は十分実装可能です。
実際先に上げた、File I/O操作は、プロシージャコール(リモートではない)ですが、リソース指向ですし、決済プロバイダの与信APIならば、与信をかけるというアプリケーション操作を与信要求の登録という考え方に変換することでリソース指向で表現が可能です。
しかしながら、そのRESTfulのすべてをリソース(URI)の決められた操作(HTTP Methods)に当てはめて表現するという手法が、APIをよりリソース指向的なものに向かうバイアスになると考えています。
そして、そのリソース的な指向を極めた仕様を持つAPI仕様の一つがODataであると、私はここで主張します。(OData Action/Function operationsについてはいったん無視して考えます。)
これは、MicrosoftのODataクライアントの実装から見ても納得のいく主張かと思います。
Microsoftが主導して作成しているODataクライアントであるDataServiceClientは、同じくMicrosoftが主導して作成しているORMのEntity FrameworkにそっくりのI/Fになっています。大まかな使用感としてはLinqのExpressionTreeが最終的にURIになるか、SQLになるか程度の違いにとどまっています。
アプリケーション指向 vs リソース指向
リソース指向はよくない、とりわけ、外部に公開するAPIとしては不適切であるという主張があります。以下は、MS純正のODataを含むWCFライブラリに意を唱えて作られた(かつかなりの市民権を得た)ServiceStackの主張です。
Tight Coupling of Internals
This service also requires knowledge of the internal structure of Netflix's DB schema to know what table and columns to query, more importantly once an OData API for your data model is published and has clients binded to it in production, the DB schema effectively becomes frozen since the OData query-space can reference any table and any column that was exposed.
https://docs.servicestack.net/autoquery/why-not-odata#out-of-band-knowledge-in-free-form-expressions
彼らは、ODataは対象の内部構造自体の深い理解を必要とする、そして、内部構造が公開されるがゆえにその構造を変えることができなくなり、プロバイダ側、コンシューマ側の双方に不利益であると述べています。
私は、この主張に完全に同意しています。実際ODataを提供するとこの問題に直面します。DataServiceClientにはモデルチェックの機能がデフォルトでONになっているため、モデルを変更すると既存のクライアントが例外を吐き停止する可能性があります。そのため、プロバイダは/v1/odata.svc、/v2/odata.svcのようにモデルのバージョンでセグメントを切りリリースすることを強制されます。また、これはつまり、過去のモデルも一定の期間維持し続ける必要があることを意味します。
そうであれば、このようなもの早く捨ててしまえばいいの思われるかもしれませんが、
SAPのようなユーザが定義したリソースに対するアクセスをAPIで提供するような機能を構築する際にはODataと同様なものがどうしても必要になってきますし、
Microsoftのような、大量のコンシューマを抱えていて多くの要件を満たそうとすると、ODataのような多彩なクエリが必要になることでしょう。(個人的な推測です)
APIを提供したいがどう使われるかを想定できない(=アプリケーションを規定できない)場合に、リソース指向は万能ナイフなのです。
ODataHttpClient
さて前置きが長くなりましたが、自ライブラリのODataHttpClientについてです。
DataServiceClientがEFライクなクライアントライブラリでそれが使い心地良くないなと思ったのが作った動機の1つです。
例えば、以下のようなメタデータで、Itemエンティティがあったとします。
<edmx:Edmx xmlns:edmx="http://schemas.microsoft.com/ado/2007/06/edmx" Version="1.0">
<edmx:DataServices xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" m:DataServiceVersion="1.0" m:MaxDataServiceVersion="3.0">
<Schema xmlns="http://schemas.microsoft.com/ado/2009/11/edm" Namespace="MyModels">
<EntityType Name="Item">
<Key>
<PropertyRef Name="Id" />
</Key>
<Property Name="Id" Type="Edm.String" Nullable="false" Unicode="true" />
<Property Name="PropA" Type="Edm.String" Nullable="false" Unicode="true" />
<Property Name="PropB" Type="Edm.String" Nullable="false" Unicode="true" />
<Property Name="PropC" Type="Edm.String" Nullable="false" Unicode="true" />
</EntityType>
<EntityContainer Name="MyDbContext" m:IsDefaultEntityContainer="true">
<EntitySet Name="Items" EntityType="MyModels.Item" />
</EntityContainer>
</Schema>
</edmx:DataServices>
</edmx:Edmx>
このItemエンティティの1つ(Id="target")のPropAの値を変更したいとき、単にHTTPリクエストで行うと以下のような形式で変更可能です。
PATCH /path/to/the/svc/Items('target') HTTP/1.1
Host: <host>
Content-Type: application/json
{ "@odata.type": "MyModels.Item", "PropA": "update value" }
これを、DataServiceClinetで書くと以下のようになります。
var item = context.Items.Where(o => o.Id == "target").First();
item.PropA = "update value";
context.UpdateObject(item);
context.SaveChanges();
しかし、上記のコードで書くと、更新のHTTP リクエストは以下のようになります。
GET /path/to/the/svc/Items('target') HTTP/1.1
Host: <host>
Accept: application/atom+xml
MERGE /path/to/the/svc/Items('target') HTTP/1.1
Host: <host>
Content-Type: application/atom+xml
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<entry xmlns:d="http://schemas.microsoft.com/ado/2007/08/dataservices" xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" xmlns="http://www.w3.org/2005/Atom">
<category scheme="http://schemas.microsoft.com/ado/2007/08/dataservices/scheme" term="MyModels.Item" />
<title />
<author>
<name />
</author>
<updated>2024-02-20T05:30:37.3670583Z</updated>
<id><host>/path/to/the/svc/Items('target')</id>
<content type="application/xml">
<m:properties>
<d:Id>target</d:Id>
<d:PropA>updated value</d:PropA>
<d:PropB>b value</d:PropB>
<d:PropC>c value</d:PropC>
</m:properties>
</content>
</entry>
更新するために一度対象をGETしなくてはならず、また、更新する際もすべてのプロパティを送信しています。
以下のようにすれば、最初のGETは必要ありませんが、すべてのプロパティの値を知っている必要があります。
var item = new Item { Id = "target", PropA = "updated value", PropB = "b value", PropC = "c value" };
context.AttachTo("Items", item);
context.UpdateObject(item);
context.SaveChanges();
実際は、OData Client for .NET 6.2.0からは、PATCHが使えるようになっており、プロパティレベルで更新のあったものだけを更新できますが、一度対象をGETすることは変わりません。
https://devblogs.microsoft.com/odata/tutorial-sample-client-property-tracking-for-patch/
また、GETを回避するために、状態プロパティを変更するのはEFと同じ煩雑さがあります。
さらに、この一度対象をGETしてエンティティオブジェクトを作るというのも曲者で、NotifyChangesでトラッキング可能にするには、すべてのプロパティを落としてくる書き方になります。
var obj1 = context.Items.Select(o => new { o.Id, o.PropA }).First(); // この書き方は可能
obj1.PropA = "" // anonymous typeなので更新できないし
UpdateObject(obj1) // 型がエンティティタイプじゃないのでできない
Item obj2 = context.Items.First()
obj2.PropA = "";
UpdateObject(obj2);
これは、小さなデータでは問題になりませんが、PropA以外のプロパティに大きな値があるときに無駄なデータのやり取りが発生して問題になります。
前述のとおり、それを回避するコードの書き方がやけに煩雑なのがこのEFライクなライブラリの問題点でした。
ODataの仕様自体は、RESTにのるプロトコルとして、HTTPでのやり取りの仕方のみが記載されています。したがって、ODataの仕様を読めば読むほど、それをDataServiceClientでどうやってリクエストすればいいのかわからないというインピーダンスミスマッチにやきもきするのです。
そして、ある時点で悟るようになります。ODataはOData。C#じゃない。SQLがSQLでC#ではないのと同じように。 変にC#らしさを求めると、ODataが消えていき一見使いやすそうに見えますが、それは皮を被っているだけであって、あるとき例外として牙をむきます。C#ではValidなのに、Sum,Max,Min,GroupByは使えないとかね。どんなにC#らしくラップしたところで開発者はODataを叩いてることは忘れられない。それなら、最初からODataで会話したほうが無駄な変換が必要なくて結果的にラクなのだ。
そんなこんなで私はODataを素のHttpClientで使い始めました。その後、バッチリクエストやlong、decimal、DateTimeの扱い易くするための便利関数を作り始めます。
それをwrapしてできたのがODataHttpClientです。
サンプルコードを見れば、その薄さが1目でわかります。
var odata = new ODataClient(httpClient);
var res = await odata.SendAsync(Request.Patch("<host>/path/to/the/svc/Items(@Id)", new { Id = "target" }, new {
PropA = "updated value"
}));
ほとんど、HttpClient.SendAsyncをwrapしただけ感がにじみ出てますね。実際その通りです。
https://learn.microsoft.com/ja-jp/dotnet/api/system.net.http.httpclient.sendasync?view=net-8.0#system-net-http-httpclient-sendasync(system-net-http-httprequestmessage)
ODataHttpClientはアプリケーション指向APIクライアントを作るために使うべし
そんなC#上でもODataはODataとしてしゃべろうよっというライブラリなわけですが、その薄さゆえに便利に使うにはコツがあります。
ODataHttpClientはODataを中に閉じ込めたアプリケーション指向のIFを持ったクライアントライブラリを作るために使いましょう。
public class MyApi
{
private readonly ODataClient _client;
public MyClient(...) { ... }
public Task<UseCase1Result> UseCase1() {...}
public Task<UseCase22Result> UseCase2() {...}
public Task UseCase3(string p1, int p2) {...}
public Task UseCase4(Op2Arg arg) {...}
}
単体テストで差し替え可能にするためにリポジトリパターンを採用するのであれば、この MyApi
をインターフェースに切ったIMyApi
を作成するか、各メソッド virtual
にします。
ポイントは、以下のようなリポジトリパターンクラスを作成しないことです。
public class ItemRepository : IRepository<Item>
{
public Task<Item> Find(...) {...}
public Task<Item> List(...) {...}
public Task<Item> Add(Item obj) {...}
public Task Update(Item obj) {...}
public Task Remove(Item obj) {...}
}
このようなものを作るということは、リソース指向をそのまま上のレイヤーにもっていくことであり、アプリケーション指向ではありませんし、
リソース指向と考えても、このような1エンティティセット1リポジトリの設計は、バッチリクエストと相性が悪いです。robconeryという方が良いこと書いていたので詳細の説明は先達に任せたいと思います。https://robconery.com/databases/repositories-on-top-unitofwork-are-not-a-good-idea/
つまるところ、リポジトリパターンの優位性は差し替え可能であることにつきると思います。
リポジトリパターンを使うことによって、データの所在を隠蔽し、単体テストと開発ではRAMで行うが、デプロイ環境ではDBに保存するなどができるようになります。
これは、リポジトリに依存する上のレイヤーのアプリケーションロジックがシステム全体で見たときの主体であるときにはうまく機能します。
しかし、仮に、作成しているアプリケーションの主体が、リポジトリの先に存在していたらどうでしょうか。
具体例を出しましょう。SalesForceのカスタムアプリケーションを作成している場合、SalesForceへのアクセスをリポジトリに隠蔽して、SalesForce以外に差し替え可能にすることにどれだけに意味があるでしょうか。おそらくほとんどないでしょう。単体テストでは役に立つかもしれませんが、それは、IFもしくはvirtualで実装が上書きできるようになっていればいいだけで抽象化はそれほど意味をなさないのではないでしょうか。
何が言いたいかまとめると、ドメインロジックがそのリポジトリパターンを挟んでどちら側にあるかでリポジトリパターンの有用性がぐっと変わるということです。
私は、システムを担うメインアプリケーションを「主(星)アプリケーション」、それを補佐するアプリケーションを「衛星アプリケーション」と呼び分けてます(アプリケーションとプラグインと同じような関係)が、「主アプリケーション」の実装にリポジトリパターンは役に立ちますが、「衛星アプリケーション」の実装にはそれほど有効じゃないという考えます。
そして、私がODataを触るとき、ODataは主アプリケーション側に生えてると認識することがはるかに多いです。 (もちろん例外はありますが。例えば、Azure Storage Tableとか)
ですので、私がODataを目の前にしたときは、やることは、エンティティとエンティティセットに向き合うのではなく、アプリケーションとしてのユースケースを考えることです。
そして、そのユースケースをメソッドに落とし込みます。
public class MyApi
{
private readonly ODataClient _client;
public MyClient(...) { ... }
public virtual Task<UseCase1Result> UseCase1() {...}
public virtual Task<UseCase22Result> UseCase2() {...}
public virtual Task UseCase3(string p1, int p2) {...}
public virtual Task UseCase4(Op2Arg arg) {...}
}
そうすると臭いものに蓋ではないですが、いつまでも、リソースのメンタルモデルを上のレイヤーに引き釣り回さずに済み、いろいろと楽になります。
アンチパターン
最後によくやりがちな、アンチパターンを紹介します。
次のコードは汎用性を求めて書いたであろうSearchItemsというメソッドが存在します。
public MyApi
{
private readonly ODataClient _client;
public MyClient(...) { ... }
public Task<IEnumerable<Item>> SearchItems(string filter, string select) {...}
}
これは、リポジトリパターンが形を変えただけです。それも問題なのですが、それに加えて、filterを外からもらっていることと、selectがあるのに戻り値がエンティティそのままなことも問題です。
filterを外からもらうのは、おそらく、ユースケースを洗い出す前に、このクラスの実装を開始してしまっています。こう使うかもしれない、ああ使うかもしれないから、ということが見て取れます。まずはアプリケーションのユースケースに向き合うことが大事だと思います。
selectをするということは、指定したプロパティ以外は返ってきません。メタデータから自動生成したクラスで返すと、selectしてないからnullなのか、それとも、値がないからnullなのかコンテキストを理解していないとわからなくなってしまいます。そうならないように、selectしたプロパティだけを持った型を使用するべきです。
つまり、以上を直すとこうなります。
public MyApi
{
private readonly ODataClient _client;
public MyClient(...) { ... }
public Task<IEnumerable<UseCase1Item>> SearchItemsForUseCase1(string propA) {...}
public Task<IEnumerable<UseCase2Item>> SearchItemsForUseCase2() {...}
}
嘘だろって思うかもしれませんが、衛星アプリケーションを作るうえでは、これが非常に有効なのです。騙されたと思って試してみてください。
そして、その際は、ODataプロバイダへのやさしさとして、ぜひ、selectで欲しいプロパティだけを指定するようにしてください。
もし、UseCase1Item
に相当するクラスを作るのが面倒だなと思う方は、そのようなクラス作成を手軽にできるジェネレータを作成していますので使ってみてください。
https://github.com/iwate/ODataHttpClient.Generators