65.9K
CodeProject 正在变化。 阅读更多。
Home

使用DLR的动态REST客户端代理

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (39投票s)

2014 年 4 月 20 日

CPOL

11分钟阅读

viewsIcon

98285

downloadIcon

120

一个易于使用的REST客户端,使用动态语言运行时。

引言

当我遇到一个有趣的 REST Web 服务,想要探索或集成到应用程序中时,首先要做的是围绕 HTTP 通信创建一系列包装类,以便可以调用服务的主要功能。这通常看起来像这样

  • 阅读 API 文档
  • 查看提供的 .NET 库(如果有)并决定它不适合剩余的编程模型,因此编写一个包装器
  • 创建一些服务类来镜像 API 的端点
  • 创建一些 POCO 对象来表示来回传递的数据
  • 为此忙活一段时间,直到数据能够流动
  • 实际对 API 做一些有趣的事情

即使有像 RestSharp Json2CSharp 这样优秀的工具,我发现自己总是在进行一些样板代码的编写,然后才能开始有趣的部分。

这个小项目源于我对样板代码的厌倦,以及探索 动态语言运行时 (DLR) 的愿望。结果是一个基于约定的、动态的 REST 客户端和透明代理,它可以使用 RestSharp 或可移植的 Microsoft HttpClient 进行传输。目标是让与 REST 服务进行交互的初始开销降至最低。

背景

基本原理是 RestProxy 是一个 DynamicObject ,它将属性和方法的调用转换为 REST 端点 URI,并允许进行基本的 HTTP 动词调用。DynamicObject 在运行时生成其成员,正是利用了这一能力来构建请求并执行它。

动态对象的一个缺点是缺乏 IntelliSense,因为 IDE 不知道对象有哪些成员或将有哪些成员。它感觉更像是 JavaScript 而不是 C#。

Using the Code

客户端约定

  • 所有通信都通过 http 或 https 进行
  • 数据传输始终是 JSON
  • 绝大多数 API 访问都可以通过 GET, PUT, POST, PATCHDELETE 来完成
  • 传递给动词调用的未命名参数将被序列化到请求正文中
  • 命名参数将作为请求参数传递(查询参数或表单编码)
  • 输出默认是动态对象,但支持序列化为静态类型
  • 所有 REST 调用都是异步且可等待的(它们始终返回一个 Task

调用约定

对动态客户端的调用都遵循以下模式

client.{optional chain of dot separated property names}.verb({optional parameter list});
  • 每个属性名都代表相对于根 URL 的 URL 段
  • 动词必须是 getputpostpatchdelete 之一
  • 传递给动词调用的未命名参数将序列化到请求正文中
  • 传递给动词调用的命名参数将添加为命名参数

因此,启动并运行一个新的服务端点只需三个步骤

  1. 创建一个 DynamicRestClient 来表示 API 根目录
  2. 将客户端对象的成员链接在一起以构建端点 URI
  3. 开始调用!

示例

那么,让我们尝试一个使用 SunLight Labs API 的简单 GET 示例

dynamic client = new DynamicRestClient("http://openstates.org/api/v1/");

dynamic result = await client.metadata.mn.get(apikey: "your_api_key_goes_here");

Assert.IsNotNull(result);
Assert.AreEqual("Minnesota", result.name);

这里发生了什么?第一行很好理解;创建 DynamicRestClient 并指定根 URI。DynamicRestClient 使用 BCL HttpClient 库进行请求和响应通信,但这在后台隐藏得很好。

第二行是所有动态内容发生的地方,但只需两行代码,就可以定义、访问 REST 端点,并将其响应反序列化并返回。

我们最终要调用的端点是:http://openstates.org/api/v1/metadata/mn/

看到了模式吗?metadata.mn 被翻译成 metadata/mn/,而 get() 定义了调用的 HTTP 请求类型。然后,方法参数 apikey: "your_api_key_goes_here",被转换为参数。如果你看一下那一行创建的 HTTP 请求,你会看到

GET http://openstates.org/api/v1/metadata/mn/?apikey=your_api_key_goes_here HTTP/1.1
Accept: application/json, text/json, text/x-json, text/javascript
Accept-Encoding: gzip, deflate
Host: openstates.org

当一个 DynamicObject 的属性被访问时,一个名为 TryGetMember 的方法被调用。正是在这里,以及 DynamicObject 的其他可重写方法中,我们创建了一个 DynamicObject 链来表示完整的端点 URI。有类似的方法用于调用动态方法或将动态对象作为委托调用。这些重写也用于帮助创建完整的端点 URI。

public override bool TryGetMember(GetMemberBinder binder, out object result)
{
    // this gets invoked when a dynamic property is accessed
    // example: proxy.locations will invoke here with a binder named locations
    // each dynamic property is treated as a url segment
    result = CreateProxyNode(this, binder.Name);

    return true;
}

等等!URL 可以包含空格和各种其他奇怪的字符!!!

这就是我们引入转义方法的地方。为了添加一个不是有效 C# 标识符的 URI 段,可以通过将其作为参数传递给动态代理对象的任何方法来转义它。转义的段可以以任何组合方式链接和混合使用属性段。

dynamic client = new DynamicRestClient("http://openstates.org/api/v1/");

var result = await client.bills.mn("2013s1")("SF 1").get(apikey: "your_api_key_goes_here");

Assert.IsNotNull(result);
Assert.IsTrue(result.id == "MNB00017167");

这还有一个额外的好处,就是允许我们添加作为数据而非代码的段。例如,端点的一部分可能由用户选择决定(如上面的示例中选择一个州而不是另一个州),或者它是从先前调用返回的值。

string billId = GetBillIdFromUser();
var result = await client.mn("2013s1")(billId).get();

段链

注意到稍微奇怪的 proxy.bills.mn("2013s1")("SF 1"). 发生了什么?

直到调用五个 HTTP 动词之一,对 DynamicRestClient 的每个方法调用或属性访问都会返回一个新的 DynamicRestClient 实例,它们被链接在一起,形成整个 URI。

  • proxy.bills 通过属性访问器 "bills" 返回一个新的代理对象
  • bills.mn("2013s1") 调用 bills 实例上的动态方法 "mn",并将 "2013s1" 作为参数传递
  • mn 和 "2013s1" 都作为代理对象实例返回
  • 最后一部分 mn("2013s1")("SF 1") 是将 2013s1 实例作为委托调用。这也返回添加到链中的另一个代理对象实例

每个客户端实例代表端点 URI 中的一个段。段名被定义为动态属性名、方法名和/或传递给动态方法调用的参数。

传递参数

命名参数使用 C# 的命名参数语法传递给动词方法。这是一个使用 Bing 位置 API 的示例

dynamic client = new DynamicRestClient("http://dev.virtualearth.net/REST/v1/");

dynamic result = await client.Locations.get
                 (postalCode: "55116", countryRegion: "US", key: "bing_key");

Assert.AreEqual(200, result.statusCode);

上面的 HTTP 请求如下所示

GET http://dev.virtualearth.net/REST/v1/Locations?postalCode=55116&countryRegion=US&key=bing_key& HTTP/1.1
Accept: application/json, text/json, text/x-json, text/javascript
Host: dev.virtualearth.net
Accept-Encoding: gzip, deflate
Connection: Keep-Alive

再次,您可以看到传递给动态方法的命名参数被转换为端点 URI 上的名称-值对参数。

转义参数名

参数名并不总是有效的 C# 标识符(尽管实际上它们大多数时候是)。由于我们对请求参数使用 C# 的命名参数语法,这就成了一个问题。以这个端点为例

congress.api.sunlightfoundation.com/bills?chamber=senate&history.house_passage_result=pass

它有一个带有 "." 的参数名。为了转义参数名,可以将它们放在一个字典中传递给调用函数。任何是 IDictionary<string, object> 的命名参数都将被添加为参数。此示例代码将生成上述 REST 请求

dynamic client = new DynamicRestClient("http://congress.api.sunlightfoundation.com");

var parameters = new Dictionar<string, object>()
{
    { "chamber", "senate" },
    { "history.house_passage_result", "pass" } 
};

dynamic result = await client.bills.get(paramList: parameters, apikey: "sunlight_key");

foreach (dynamic bill in result.results)
{
    Assert.AreEqual("senate", (string)bill.chamber);
    Assert.AreEqual("pass", (string)bill.history.house_passage_result);
}

还有一些情况,参数名与 C# 保留关键字冲突。那些也可以通过传递字典来转义,但您也可以使用 C# 参数标识符转义语法,使用 @.

dynamic client = new DynamicRestClient("http://openstates.org/api/v1/");

//escape the reserved word "long" with an @ symbol
var result = await client.legislators.geo.get
             (apikey: "sunlight_key", lat: 44.926868, @long: -93.214049);
Assert.IsNotNull(result);
Assert.IsTrue(result.Count > 0);

传递内容对象

PUT、PATCH 和 POST 操作通常需要在请求正文中包含一个对象。为了实现这一点,请将未命名参数传递给动词(命名参数和未命名参数可以一起使用,但所有未命名参数必须在命名参数之前)。

在此示例中,使用 POST 方法创建了一个新的 Google 日历。我们使用的是 ExpandoObject,因为我们不必使用静态 POCO 类型。这样,输入和输出对象都可以完全动态。也可以通过这种方式传递静态类型,并且大多数对象将被序列化为 Json。

dynamic google = new DynamicRestClient
("https://www.googleapis.com/calendar/v3/", null, async (request, cancelToken) =>
{
    // this demonstrates how t use the configuration callback to handle authentication 
    var auth = new GoogleOAuth2("email profile https://www.googleapis.com/auth/calendar");
    var token = await auth.Authenticate("", cancelToken);
    Assert.IsNotNull(token, "auth failed");

    request.Headers.Authorization = new AuthenticationHeaderValue("OAuth", token);
});

dynamic calendar = new ExpandoObject();
calendar.summary = "unit_testing";

var list = await google.calendars.post(calendar);

Assert.IsNotNull(list);
Assert.AreEqual(list.summary, "unit_testing");

在生成的 HTTP 请求中,请注意正文中的序列化对象

POST https://www.googleapis.com/calendar/v3/calendars HTTP/1.1
Authorization: OAuth xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json, text/json, text/x-json, text/javascript
Content-Type: application/json; charset=utf-8
Host: www.googleapis.com
Expect: 100-continue
Accept-Encoding: gzip, deflate
Connection: Keep-Alive
Content-Length: 26

{"summary":"unit_testing"}

默认情况下,传递给动词调用的任何未命名对象都将被序列化为 Json 并放入内容正文中。有几处例外

特定内容类型

有几种对象类型会以特定方式序列化

  • HttpContent:由于客户端内部使用可移植的 HttpClient,因此一个预配置的 HttpContent 对象将直接传递给生成的请求
  • Stream:一个 Stream 对象将被序列化为流内容类型。这对于文件上传 API 非常有用。
  • Byte array:一个 byte[] 将作为字节数组内容传递。
  • string:一个 string 将作为字符串内容传递。
  • ContentInfo StreamInfo:这些自定义类包装了一个内容对象,并允许设置特定的内容 MIME 类型和其他内容头。
  • IEnumerable<object>:一个对象集合将被发送为多部分内容,每个组成对象都将根据上述规则进行序列化

保留类型

有几种类型,当传递未命名参数且该参数是这些类型之一时,它们不会被序列化为内容,而是会在请求期间触发特定行为。

  • CancellationToken:由于所有 REST 请求都是异步的,因此可以通过传递未命名的 CancellationToken 来支持取消。
  • JsonSerializationSettings:响应内容的反序列化由 json.net 在内部处理,可以传递此类型的对象来定制内容的解序列化方式。
  • System.Type:默认情况下,内容将作为动态对象返回。要将响应反序列化为静态类型,请将所需的返回类型作为未命名参数传递(见下文)。

返回类型

默认情况下,响应将作为动态对象返回。我认为这非常易于使用,而无需拼凑静态 DTO 类型来匹配 API。但是,也支持静态类型反序列化。为了指定反序列化响应的类型,请将所需 Type 的实例作为参数传递给 REST 调用

public class Bucket
{
    public string kind { get; set; }
    public string id { get; set; }
    public string selfLink { get; set; }
    public string name { get; set; }
    public DateTime timeCreated { get; set; }
    public int metageneration { get; set; }
    public string location { get; set; }
    public string storageClass { get; set; }
    public string etag { get; set; }
}

[TestMethod]
public async Task DeserializeToStaticType()
{
    dynamic google = new DynamicRestClient("https://www.googleapis.com/");
    dynamic bucketEndPoint = google.storage.v1.b("uspto-pair");

    // by default a dynamic object is returned
    dynamic dynamicBucket = await bucketEndPoint.get();
    Assert.IsNotNull(dynamicBucket);
    Assert.AreEqual(dynamicBucket.name, "uspto-pair"); 
            
    // but if we really want a static type that works too
    Bucket staticBucket = await bucketEndPoint.get(typeof(Bucket));
    Assert.IsNotNull(staticBucket);
    Assert.AreEqual(staticBucket.name, "uspto-pair");
}

为什么不使用泛型语法?

动态对象支持泛型类型参数,使用该语法来指定返回类型会感觉更自然。这实际上是可以实现的

dynamic google = new DynamicRestClient("https://www.googleapis.com/");
dynamic bucketEndPoint = google.storage.v1.b("uspto-pair");
            
Bucket staticBucket = await bucketEndPoint.get<Bucket>();
Assert.IsNotNull(staticBucket);
Assert.AreEqual(staticBucket.name, "uspto-pair");

问题是,在实现自定义 DynamicObject 时,泛型类型参数对派生类不可见。要为动态对象提供自定义方法处理(这是该库实现其核心功能所做的),您需要重写 TryInvokeMember。此方法接收一个 InvokerMemberBinder 实例。如果您查看传递给重载的实例,您会发现它是一个 DLR 内部类型 CSharpInvokeMemberBinder。此类有一个 private 字段,其中包含泛型类型参数,并且该字段没有 public 访问器。

我不确定他们为什么选择支持泛型动态方法,但不支持 DLR 外部的类,获取类型参数的唯一方法是通过反射到那个 private 字段。我尝试过,它确实有效,但它可能会随着 DLR 实现的更新而随时中断。因此,稍微麻烦一些的 typeof 语法。

其他支持的返回类型

还可以通过传递 stringbyte[]StreamHttpResponseMessage 的类型参数来以未序列化的格式检索响应。对于 stringbyte[],内容将被读取并以这些格式的完整形式返回。返回 StreamHttpResponseMessage 要求调用者在使用后处理它们。检索 HttpResponseMessage 是另一种逃逸机制,允许检查响应本身,而不仅仅是其内容。

默认值

您还可以使用默认值对象初始化 DynamicRestClient,它允许您指定身份验证令牌、用户代理字符串和其他默认请求参数和头。这些默认值将用于使用客户端实例进行的任何请求。

在此示例中,我们首先使用一个辅助类对 Google 帐户进行身份验证并获取 OAuth 令牌。然后将其设置在 DynamicRestClientDefaults 对象中,并且后续对客户端的任何调用都将使用该令牌进行身份验证。以下代码将文件上传到 Google 云存储中的特定存储分区。

var auth = new GoogleOAuth2
("email profile https://www.googleapis.com/auth/devstorage.read_write");
var token = await auth.Authenticate("");
Assert.IsNotNull(token, "auth failed");

var defaults = new DynamicRestClientDefaults()
{
    AuthScheme = "OAuth",
    AuthToken = token
};

dynamic google = new DynamicRestClient("https://www.googleapis.com/", defaults);

using (var stream = new StreamInfo(File.OpenRead(@"D:\temp\test2.png"), "image/png"))
{
    dynamic metaData = new ExpandoObject();
    metaData.name = "test2";
    dynamic result = await google.upload.storage.v1.b.unit_tests.o.post
                     (metaData, stream, uploadType: new PostUrlParam("multipart"));
    Assert.IsNotNull(result);
}

动词调用

动词的调用会产生一个动态方法调用,最终调用 TryInvokeMember。传递给 TryInvokeMember 的参数包含有关动态方法、其名称和参数的详细信息。正是在此方法中,请求被创建、格式化、调用并反序列化其响应。几乎所有的繁重工作都在此方法中完成。

public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result)
{
    if (binder.IsVerb()) // the method name is one of our http verbs - invoke as such
    {
        var unnamedArgs = binder.GetUnnamedArgs(args);

        // filter our sentinel types out of the unnamed args to be passed on the request
        var requestArgs = unnamedArgs.Where(arg => !arg.IsOfType(_reservedTypes));

        // these are the objects that can be passed as unnamed args that 
        // we use intenrally and do not pass to the request
        var cancelToken = 
            unnamedArgs.OfType<CancellationToken>().FirstOrDefault(CancellationToken.None);
        var serializationSettings = 
            unnamedArgs.OfType<JsonSerializerSettings>().FirstOrNewInstance();

#if EXPERIMENTAL_GENERICS
        // dig the generic type argument out of the binder
        // evil exists within that method
        var returnType = binder.GetGenericTypeArguments().FirstOrDefault(); 
#else
        var returnType = unnamedArgs.OfType<Type>().FirstOrDefault();
#endif
        // if no return type argument provided there is no need for late bound method dispatch
        if (returnType == null)
        {
            // no return type argumentso return result deserialized as dynamic
            // parse out the details of the invocation and have the derived class create a Task
            result = CreateVerbAsyncTask<dynamic>(binder.Name, 
                          requestArgs, 
                          binder.GetNamedArgs(args), 
                          cancelToken, 
                          serializationSettings);
        }
        else
        {
            // we got a type argument (like this: client.get(typeof(SomeType)); )
            // make and invoke the generic implementaiton of the CreateVerbAsyncTask method
            var methodInfo = 
                this.GetType().GetTypeInfo().GetDeclaredMethod("CreateVerbAsyncTask");
            var method = methodInfo.MakeGenericMethod(returnType);
            result = method.Invoke(this, 
                        new object[] { 
                            binder.Name, 
                            requestArgs, 
                            binder.GetNamedArgs(args), 
                            cancelToken, 
                            serializationSettings });
        }
    }
    else // otherwise the method is yet another uri segment
    {
        if (args.Length != 1)
            throw new InvalidOperationException
                  ("The escape sequence can have 1 unnamed parameter");

        // this is for when we escape a url segment by passing it 
        // as an argument to a method invocation
        // example: proxy.segment1("escaped")
        // here we create two new dynamic objects, 1 for "segment1" which is the method name
        // and then we create one for the escaped segment passed as an argument 
        // - "escaped" in the example
        var tmp = CreateProxyNode(this, binder.Name);
        result = CreateProxyNode(tmp, args[0].ToString());
    }

    return true;
}

透明代理

以上所有示例都使用 DynamicRestClient 来封装 HttpClient 的通信。这会将 HTTP 对话的可配置性限制为库已实现的功能。

有一个抽象的 RestProxy 基类,它实现了动态 URI 创建和调用逻辑,但没有指定用于通信的 HTTP 客户端库。从此基类,可以创建其他 HTTP 客户端库的透明代理。附件的代码包括一个使用 RestSharp 的透明代理。该项目实际上是从 RestSharp 开始的,但由于它没有可移植版本,所以我改用了 HttpClient,这也是我最常用的版本。

使用 RestSharp 的示例

var client = new RestClient("http://openstates.org/api/v1");
client.AddDefaultHeader("X-APIKEY", "you_api_key");

dynamic proxy = new RestSharpProxy(client);

dynamic result = await proxy.metadata.mn.get();
Assert.IsNotNull(result);
Assert.IsTrue(result.name == "Minnesota");

关注点

DynamicObject 确实具有一些有趣的可能性,一旦您理解了它,使用起来就非常简单。所有上述操作实际上不需要太多代码。

单元测试

单元测试中有关于各种动词、动态 OAuth2 和其他杂项的更多示例。如果您尝试运行单元测试,请仔细查看单元测试项目中的 CredentialStore 类。它非常直接,您可以使用它来提供自己的 API 密钥,同时将它们保留在代码之外。几乎所有集成测试都需要 API 密钥才能访问它们所命中的端点,因此在没有密钥的情况下,大多数测试都会以一种糟糕的方式失败。

历史

  • 2014 年 4 月 20 日 - 初始版本
  • 2014 年 4 月 24 日 - 参数名转义机制
  • 2014 年 6 月 21 日 - 添加 HttpClient
  • 2014 年 12 月 25 日 - 重写以使用 DynamicRestClient 作为主要示例
  • 2015 年 2 月 7 日 - 添加关于返回类型的部分
© . All rights reserved.