私は定期的に Durable Functions のリポジトリに貢献している。特に DevOps に関する機能に関して貢献している。なぜそんなことをしているか?というと、プロジェクトが気に入っているのもあるし、プロジェクトのオーナーの Chris とも仲がいいし、貢献するととても学びになる。私の前職の以前のキャリアは、大手SI のSE/PM だったり、アジャイルや、超上流のコンサルでプログラマとしては三流。どうやって、スキルを引き上げるか考えた場合、こういった OSS の貢献はガチの本番環境を体験できるし、リリースまでのすべてを学べるし、リポジトリオーナーのレビューも最高なので、大変ありがたい。
ただ、今までは、貢献しっぱなしで、特に振り返りなどをしていなかったので、大きめの貢献をしたら、学んだことを振り返っておこうと思う。
今まで貢献した内容
大きな機能でいうと2つぐらいしかまだ貢献していない。大きく分けると EventGrid にオーケストレーションのステータスを送信するようにしたものと、Http/Client 経由でインスタンスの状態を取得できるようにしたものの2つ。Kanio とか 24 commitしていて2位だけど、私は 4 commit で5位だから、もうちょっと頑張ってみたいな。スピードを上げて楽に貢献できるようになりたい。
- Post orchestration lifecycle events to Azure Event Grid
- fix: Event Grid data payload should use lowercase letters.
- Durable Functions Automatic Event Grid publishing documentation #38541
- Change custom Event Grid eventType property from an int to a string #294
- Change custom Event Grid eventType property from an int to a string #40239
- DurableTask.AzureStorage API to enumerate instances #187
- APIs to enumerate all orchestration instances #323
- Durable Functions Get All orchestration instance statues. #42887
今回の貢献内容
今回のPR は、Get all instances status enhancements #358に対応したもの。前に、ステータスの取得を全件行うAPIを作成したけど、その当時からわかっていたことだけど、検索条件を付けたり、古いデータを削除したりということは当然必要になることはわかっていた。しかし、一気にインプリすると負荷も大きいので、プルリクエストを分けて実施することにした。
今回は、条件検索のコードを追加することにした。むっちゃ簡単そうだ(実際そんなに難しくない)だが、ガチのプロダクトに貢献するとなると、そうは簡単にいかない。今回は2つのリポジトリを修正する必要がある。
Durable Functions の本体は後者だが、DurableTask は、Durable Functions の内部で使われているライブラリで、Azure Storage へのアクセスを担っている。Durable Task はロングランニングな永続性のあるワークフローをサポートするライブラリで、Durable だけでなく、マイクロソフトの他のプロジェクトでも使われているもの。今回は、インスタンステーブルという、Durable Functions のオーケストレーションインスタンスの状態を CQRSパターンで保存する部分へのクエリを書くので、こちらのライブラリの修正が必要となる。Durable 側の実装は大したことなくて、今回のPRの最大のポイントはクエリーの組み立てと、そのデザイン部分と思う。だから、今回はクエリーをラップするクエリーオブジェクトというものをTDDで実装してみた。パラメータを雑に渡すと、TableQuery を組み立てて返してくれるので、それを単に実行すればいいようにしてくれる。もともとの発想の元は、J2EE パターンかなんかのQueryObject というパターンがあったので、それを参考にしている。
コードは最終的にこんな感じになった。あとのロジックは、大体委譲なのだけど、Durable の HTTP API のインターフェイスからのパラメータのパースの部分が存在する。
Query オブジェクト
// ----------------------------------------------------------------------------------
// Copyright Microsoft Corporation
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ----------------------------------------------------------------------------------
namespace DurableTask.AzureStorage.Tracking
{
using DurableTask.Core;
using Microsoft.WindowsAzure.Storage.Table;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Xml.Linq;
/// <summary>
/// OrchestrationInstanceStatusQueryBuilder is a builder to create a StorageTable Query
/// </summary>
internal class OrchestrationInstanceStatusQueryCondition
{
public IEnumerable<OrchestrationStatus> RuntimeStatus { get; set; }
public DateTime CreatedTimeFrom { get; set; }
public DateTime CreatedTimeTo { get; set; }
public TableQuery<T> ToTableQuery<T>()
where T : TableEntity, new()
{
var query = new TableQuery<T>();
if (!((RuntimeStatus == null || (!RuntimeStatus.Any())) && CreatedTimeFrom == default(DateTime) && CreatedTimeTo == default(DateTime)))
{
query.Where(
GetConditions()
);
}
return query;
}
private string GetConditions()
{
var conditions = new List<string>();
if (default(DateTime) != this.CreatedTimeFrom)
{
conditions.Add(TableQuery.GenerateFilterConditionForDate("CreatedTime", QueryComparisons.GreaterThanOrEqual, new DateTimeOffset(this.CreatedTimeFrom)));
}
if (default(DateTime) != this.CreatedTimeTo)
{
conditions.Add(TableQuery.GenerateFilterConditionForDate("CreatedTime", QueryComparisons.LessThanOrEqual, new DateTimeOffset(this.CreatedTimeTo)));
}
if (this.RuntimeStatus != null && this.RuntimeStatus.Any())
{
var runtimeCondition = this.RuntimeStatus.Select(x => TableQuery.GenerateFilterCondition("RuntimeStatus", QueryComparisons.Equal, x.ToString()))
.Aggregate((a, b) => TableQuery.CombineFilters(a, TableOperators.Or, b));
if (runtimeCondition.Count() != 0)
{
conditions.Add(runtimeCondition);
}
}
if (conditions.Count == 1)
{
return conditions[0];
}
else
{
return conditions.Aggregate((a, b) => TableQuery.CombineFilters(a, TableOperators.And, b));
}
}
/// <summary>
/// Parse is a factory method of the OrchestrationInstanceStatusConditionQuery
/// </summary>
/// <param name="createdTimeFrom">CreatedTimeFrom</param>
/// <param name="createdTimeTo">CreatedTimeTo</param>
/// <param name="runtimeStatus">RuntimeStatus</param>
/// <returns></returns>
public static OrchestrationInstanceStatusQueryCondition Parse(DateTime createdTimeFrom, DateTime? createdTimeTo, IEnumerable<OrchestrationStatus> runtimeStatus)
{
var condition = new OrchestrationInstanceStatusQueryCondition();
condition.CreatedTimeFrom = createdTimeFrom;
condition.CreatedTimeTo = (createdTimeTo != null) ? (DateTime)createdTimeTo : default(DateTime);
condition.RuntimeStatus = runtimeStatus;
return condition;
}
}
}
Httpのパース部分
private async Task<HttpResponseMessage> HandleGetStatusRequestAsync(
HttpRequestMessage request)
{
DurableOrchestrationClientBase client = this.GetClient(request);
var queryNameValuePairs = request.GetQueryNameValuePairs();
var createdTimeFrom = GetDateTimeQueryParameterValue(queryNameValuePairs, CreatedTimeFromParameter, default(DateTime));
var createdTimeTo = GetDateTimeQueryParameterValue(queryNameValuePairs, CreatedTimeToParameter, default(DateTime));
var runtimeStatus = GetIEnumerableQueryParameterValue(queryNameValuePairs, RuntimeStatusParameter);
// TODO Step-by-step. After fixing the parameter change, I'll implement multiple parameters.
IList<DurableOrchestrationStatus> statusForAllInstances = await client.GetStatusAsync(createdTimeFrom, createdTimeTo, runtimeStatus);
var results = new List<StatusResponsePayload>(statusForAllInstances.Count);
foreach (var state in statusForAllInstances)
{
results.Add(this.ConvertFrom(state));
}
return request.CreateResponse(HttpStatusCode.OK, results);
}
:
private static IEnumerable<OrchestrationRuntimeStatus> GetIEnumerableQueryParameterValue(NameValueCollection queryStringNameValueCollection, string queryParameterName)
{
var results = new List<OrchestrationRuntimeStatus>();
var parameters = queryStringNameValueCollection.GetValues(queryParameterName) ?? new string[] { };
foreach (var value in parameters.SelectMany(x => x.Split(',')))
{
if (Enum.TryParse<OrchestrationRuntimeStatus>(value, out OrchestrationRuntimeStatus result))
{
results.Add(result);
}
}
return results;
}
private static DateTime GetDateTimeQueryParameterValue(NameValueCollection queryStringNameValueCollection, string queryParameterName, DateTime defaultDateTime)
{
var value = queryStringNameValueCollection[queryParameterName];
return DateTime.TryParse(value, out DateTime dateTime) ? dateTime : defaultDateTime;
}
Queryのテスト
// ----------------------------------------------------------------------------------
// Copyright Microsoft Corporation
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ----------------------------------------------------------------------------------
namespace DurableTask.AzureStorage.Tests
{
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using DurableTask.AzureStorage.Tracking;
using DurableTask.Core;
using DurableTask.ServiceBus.Tracking;
using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class OrchestrationInstanceStatusQueryConditionTest
{
[TestMethod]
public void OrchestrationInstanceQuery_RuntimeStatus()
{
var runtimeStatus = new OrchestrationStatus[] { OrchestrationStatus.Running };
var condition = new OrchestrationInstanceStatusQueryCondition
{
RuntimeStatus = runtimeStatus
};
var query = condition.ToTableQuery<OrchestrationInstanceStatus>();
Assert.AreEqual("RuntimeStatus eq 'Running'", query.FilterString);
}
[TestMethod]
public void OrchestrationInstanceQuery_CreatedTime()
{
var condition = new OrchestrationInstanceStatusQueryCondition
{
CreatedTimeFrom = new DateTime(2018, 1, 10, 10, 10, 10),
CreatedTimeTo = new DateTime(2018, 1, 10, 10, 10, 50)
};
var result = condition.ToTableQuery<OrchestrationInstanceStatus>().FilterString;
Assert.AreEqual("(CreatedTime ge datetime'2018-01-10T01:10:10.0000000Z') and (CreatedTime le datetime'2018-01-10T01:10:50.0000000Z')", condition.ToTableQuery<OrchestrationInstanceStatus>().FilterString);
}
[TestMethod]
public void OrchestrationInstanceQuery_CreatedTimeOnly()
{
var condition = new OrchestrationInstanceStatusQueryCondition
{
CreatedTimeFrom = new DateTime(2018, 1, 10, 10, 10, 10),
CreatedTimeTo = default(DateTime),
RuntimeStatus = new List<OrchestrationStatus>(),
};
var result = condition.ToTableQuery<OrchestrationInstanceStatus>().FilterString;
Assert.AreEqual("CreatedTime ge datetime'2018-01-10T01:10:10.0000000Z'", condition.ToTableQuery<OrchestrationInstanceStatus>().FilterString);
}
[TestMethod]
public void OrchestrationInstanceQuery_CreatedTimeVariations()
{
var condition = new OrchestrationInstanceStatusQueryCondition
{
CreatedTimeFrom = new DateTime(2018, 1, 10, 10, 10, 10)
};
Assert.AreEqual("CreatedTime ge datetime'2018-01-10T01:10:10.0000000Z'", condition.ToTableQuery<OrchestrationInstanceStatus>().FilterString);
condition = new OrchestrationInstanceStatusQueryCondition
{
CreatedTimeTo = new DateTime(2018, 1, 10, 10, 10, 50)
};
Assert.AreEqual("CreatedTime le datetime'2018-01-10T01:10:50.0000000Z'", condition.ToTableQuery<OrchestrationInstanceStatus>().FilterString);
}
[TestMethod]
public void OrchestrationInstanceQuery_Combination()
{
var runtimeStatus = new OrchestrationStatus[] { OrchestrationStatus.Running };
var condition = new OrchestrationInstanceStatusQueryCondition
{
RuntimeStatus = runtimeStatus,
CreatedTimeFrom = new DateTime(2018, 1, 10, 10, 10, 10),
CreatedTimeTo = new DateTime(2018, 1, 10, 10, 10, 50)
};
Assert.AreEqual("((CreatedTime ge datetime'2018-01-10T01:10:10.0000000Z') and (CreatedTime le datetime'2018-01-10T01:10:50.0000000Z')) and (RuntimeStatus eq 'Running')", condition.ToTableQuery<OrchestrationInstanceStatus>().FilterString);
}
[TestMethod]
public void OrchestrationInstanceQuery_NoParameter()
{
var condition = new OrchestrationInstanceStatusQueryCondition();
var query = condition.ToTableQuery<OrchestrationInstanceStatus>();
Assert.IsNull(query.Expression);
}
[TestMethod]
public void OrchestrationInstanceQuery_MultipleRuntimeStatus()
{
var runtimeStatus = new OrchestrationStatus[] { OrchestrationStatus.Running , OrchestrationStatus.Completed };
var condition = new OrchestrationInstanceStatusQueryCondition
{
RuntimeStatus = runtimeStatus,
CreatedTimeFrom = new DateTime(2018, 1, 10, 10, 10, 10),
CreatedTimeTo = new DateTime(2018, 1, 10, 10, 10, 50)
};
Assert.AreEqual("((CreatedTime ge datetime'2018-01-10T01:10:10.0000000Z') and (CreatedTime le datetime'2018-01-10T01:10:50.0000000Z')) and ((RuntimeStatus eq 'Running') or (RuntimeStatus eq 'Completed'))", condition.ToTableQuery<OrchestrationInstanceStatus>().FilterString);
}
[TestMethod]
public void OrchestrationInstanceQuery_Parse()
{
var runtimeStatus = new List<OrchestrationStatus>();
runtimeStatus.Add(OrchestrationStatus.Running);
var condition = OrchestrationInstanceStatusQueryCondition.Parse(new DateTime(2018, 1, 10, 10, 10, 10), new DateTime(2018, 1, 10, 10, 10, 50), runtimeStatus);
Assert.AreEqual("((CreatedTime ge datetime'2018-01-10T01:10:10.0000000Z') and (CreatedTime le datetime'2018-01-10T01:10:50.0000000Z')) and (RuntimeStatus eq 'Running')", condition.ToTableQuery<OrchestrationInstanceStatus>().FilterString);
}
[TestMethod]
public void OrchestrationInstanceQuery_ParseOptional()
{
var runtimeStatus = new List<OrchestrationStatus>();
runtimeStatus.Add(OrchestrationStatus.Running);
var condition = OrchestrationInstanceStatusQueryCondition.Parse(default(DateTime), null, runtimeStatus);
var query = condition.ToTableQuery<OrchestrationInstanceStatus>();
Assert.AreEqual("RuntimeStatus eq 'Running'", query.FilterString);
}
}
}
Httpのテスト
[Fact]
public async Task GetAllStatus_is_Success()
{
var list = (IList<DurableOrchestrationStatus>)new List<DurableOrchestrationStatus>
{
new DurableOrchestrationStatus
{
InstanceId = "01",
RuntimeStatus = OrchestrationRuntimeStatus.Running
},
new DurableOrchestrationStatus
{
InstanceId = "02",
RuntimeStatus = OrchestrationRuntimeStatus.Completed
},
};
var clientMock = new Mock<DurableOrchestrationClientBase>();
clientMock
.Setup(x => x.GetStatusAsync(default(DateTime), default(DateTime), new List<OrchestrationRuntimeStatus>(), It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(list));
var httpApiHandler = new ExtendedHttpApiHandler(clientMock.Object);
var getStatusRequestUriBuilder = new UriBuilder(TestConstants.NotificationUrl);
getStatusRequestUriBuilder.Path += $"/Instances/";
var responseMessage = await httpApiHandler.HandleRequestAsync(
new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = getStatusRequestUriBuilder.Uri,
});
Assert.Equal(HttpStatusCode.OK, responseMessage.StatusCode);
var actual = JsonConvert.DeserializeObject<IList<StatusResponsePayload>>(await responseMessage.Content.ReadAsStringAsync());
Assert.Equal("01", actual[0].InstanceId);
Assert.Equal("Running", actual[0].RuntimeStatus);
Assert.Equal("02", actual[1].InstanceId);
Assert.Equal("Completed", actual[1].RuntimeStatus);
}
[Fact]
public async Task GetQueryStatus_is_Success()
{
var list = (IList<DurableOrchestrationStatus>)new List<DurableOrchestrationStatus>
{
new DurableOrchestrationStatus
{
InstanceId = "01",
CreatedTime = new DateTime(2018, 3, 10, 10, 10, 10),
RuntimeStatus = OrchestrationRuntimeStatus.Running,
},
new DurableOrchestrationStatus
{
InstanceId = "02",
CreatedTime = new DateTime(2018, 3, 10, 10, 6, 10),
RuntimeStatus = OrchestrationRuntimeStatus.Running,
},
};
var createdTimeFrom = new DateTime(2018, 3, 10, 10, 1, 0);
var createdTimeTo = new DateTime(2018, 3, 10, 10, 23, 59);
var runtimeStatus = new List<OrchestrationRuntimeStatus>();
runtimeStatus.Add(OrchestrationRuntimeStatus.Running);
var runtimeStatusString = OrchestrationRuntimeStatus.Running.ToString();
var clientMock = new Mock<DurableOrchestrationClientBase>();
clientMock
.Setup(x => x.GetStatusAsync(createdTimeFrom, createdTimeTo, runtimeStatus, It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(list));
var httpApiHandler = new ExtendedHttpApiHandler(clientMock.Object);
var getStatusRequestUriBuilder = new UriBuilder(TestConstants.NotificationUrl);
getStatusRequestUriBuilder.Path += $"/Instances/";
getStatusRequestUriBuilder.Query = $"createdTimeFrom={WebUtility.UrlEncode(createdTimeFrom.ToString())}&createdTimeTo={System.Web.HttpUtility.UrlEncode(createdTimeTo.ToString())}&runtimeStatus={runtimeStatusString}";
var responseMessage = await httpApiHandler.HandleRequestAsync(
new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = getStatusRequestUriBuilder.Uri,
});
Assert.Equal(HttpStatusCode.OK, responseMessage.StatusCode);
var actual = JsonConvert.DeserializeObject<IList<StatusResponsePayload>>(await responseMessage.Content.ReadAsStringAsync());
clientMock.Verify(x => x.GetStatusAsync(createdTimeFrom, createdTimeTo, runtimeStatus, It.IsAny<CancellationToken>()));
Assert.Equal("01", actual[0].InstanceId);
Assert.Equal("Running", actual[0].RuntimeStatus);
Assert.Equal("02", actual[1].InstanceId);
Assert.Equal("Running", actual[1].RuntimeStatus);
}
[Fact]
public async Task GetQueryMultipleRuntimeStatus_is_Success()
{
var list = (IList<DurableOrchestrationStatus>)new List<DurableOrchestrationStatus>
{
new DurableOrchestrationStatus
{
InstanceId = "01",
CreatedTime = new DateTime(2018, 3, 10, 10, 10, 10),
RuntimeStatus = OrchestrationRuntimeStatus.Running,
},
new DurableOrchestrationStatus
{
InstanceId = "02",
CreatedTime = new DateTime(2018, 3, 10, 10, 6, 10),
RuntimeStatus = OrchestrationRuntimeStatus.Completed,
},
};
var createdTimeFrom = new DateTime(2018, 3, 10, 10, 1, 0);
var createdTimeTo = new DateTime(2018, 3, 10, 10, 23, 59);
var runtimeStatus = new List<OrchestrationRuntimeStatus>();
runtimeStatus.Add(OrchestrationRuntimeStatus.Running);
runtimeStatus.Add(OrchestrationRuntimeStatus.Completed);
var runtimeStatusRunningString = OrchestrationRuntimeStatus.Running.ToString();
var runtimeStatusCompletedString = OrchestrationRuntimeStatus.Completed.ToString();
var clientMock = new Mock<DurableOrchestrationClientBase>();
clientMock
.Setup(x => x.GetStatusAsync(createdTimeFrom, createdTimeTo, runtimeStatus, It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(list));
var httpApiHandler = new ExtendedHttpApiHandler(clientMock.Object);
var getStatusRequestUriBuilder = new UriBuilder(TestConstants.NotificationUrl);
getStatusRequestUriBuilder.Path += $"/Instances/";
getStatusRequestUriBuilder.Query = $"createdTimeFrom={WebUtility.UrlEncode(createdTimeFrom.ToString())}&createdTimeTo={WebUtility.UrlEncode(createdTimeTo.ToString())}&runtimeStatus={runtimeStatusRunningString},{runtimeStatusCompletedString}";
var responseMessage = await httpApiHandler.HandleRequestAsync(
new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = getStatusRequestUriBuilder.Uri,
});
Assert.Equal(HttpStatusCode.OK, responseMessage.StatusCode);
var actual = JsonConvert.DeserializeObject<IList<StatusResponsePayload>>(await responseMessage.Content.ReadAsStringAsync());
clientMock.Verify(x => x.GetStatusAsync(createdTimeFrom, createdTimeTo, runtimeStatus, It.IsAny<CancellationToken>()));
Assert.Equal("01", actual[0].InstanceId);
Assert.Equal("Running", actual[0].RuntimeStatus);
Assert.Equal("02", actual[1].InstanceId);
Assert.Equal("Completed", actual[1].RuntimeStatus);
}
[Fact]
public async Task GetQueryWithoutRuntimeStatus_is_Success()
{
var list = (IList<DurableOrchestrationStatus>)new List<DurableOrchestrationStatus>
{
new DurableOrchestrationStatus
{
InstanceId = "01",
CreatedTime = new DateTime(2018, 3, 10, 10, 10, 10),
RuntimeStatus = OrchestrationRuntimeStatus.Running,
},
new DurableOrchestrationStatus
{
InstanceId = "02",
CreatedTime = new DateTime(2018, 3, 10, 10, 6, 10),
RuntimeStatus = OrchestrationRuntimeStatus.Completed,
},
};
var createdTimeFrom = new DateTime(2018, 3, 10, 10, 1, 0);
var clientMock = new Mock<DurableOrchestrationClientBase>();
clientMock
.Setup(x => x.GetStatusAsync(createdTimeFrom, default(DateTime), new List<OrchestrationRuntimeStatus>(), It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(list));
var httpApiHandler = new ExtendedHttpApiHandler(clientMock.Object);
var getStatusRequestUriBuilder = new UriBuilder(TestConstants.NotificationUrl);
getStatusRequestUriBuilder.Path += $"/Instances/";
getStatusRequestUriBuilder.Query = $"createdTimeFrom={WebUtility.UrlEncode(createdTimeFrom.ToString())}";
var responseMessage = await httpApiHandler.HandleRequestAsync(
new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = getStatusRequestUriBuilder.Uri,
});
Assert.Equal(HttpStatusCode.OK, responseMessage.StatusCode);
var actual = JsonConvert.DeserializeObject<IList<StatusResponsePayload>>(await responseMessage.Content.ReadAsStringAsync());
clientMock.Verify(x => x.GetStatusAsync(createdTimeFrom, default(DateTime), new List<OrchestrationRuntimeStatus>(), It.IsAny<CancellationToken>()));
Assert.Equal("01", actual[0].InstanceId);
Assert.Equal("Running", actual[0].RuntimeStatus);
Assert.Equal("02", actual[1].InstanceId);
Assert.Equal("Completed", actual[1].RuntimeStatus);
}
こうして振り返ると全然たいしたことないPRなのに、なんでこんなに時間がかかってるんだよ!というのがこのブログを書いて解明したいポイントではある。
師匠の皆様からのコメントと調べたこと
さて、この作成過程でかずき師匠や、Chris からいろいろ良いレビューをいただいたので、その辺を再度見直して、なぜ起こったのか、次回以降の対策を考えていきたい。
StorageTableのサンプル
最初に困ったのが、StorageTable のサンプル探しだ。そんなのとても簡単そうに思えるのだが、今のStorageTableは、SDKがかわっていて、コードの書き方が全部違っている。そして、今はなんと、Namespace が CosmosDBになっている。
これが必要になってくる。
using Microsoft.Azure; // Namespace for CloudConfigurationManager
using Microsoft.Azure.Storage; // Namespace for StorageAccounts
using Microsoft.Azure.CosmosDB.Table; // Namespace for Table storage types
ただ、Durable Functions の場合Microsoft.WindowsAzure.Storage.Table 7.2.1
を使っているので現在見れるサンプルとはコードの書き方が異なる。仕方がないので自分でスパイクしてサンプルを作ってみた。ここには、Queryオブジェクトの原型のプロトタイプがある。レビュー前なので、現在のと比べると面白い。
ちなみに、Durable Functions の instance テーブルのデータを大量につくる簡単なサンプルも書いておいた
これは古いサンプルを基に実装している。
* How to write to and read from Windows Azure tables within Web Applications
* CloudStorageAccount Class
Linq クエリが使えない
どうせならLinq かっこよく書きたいところだが、調べると、.NET Coreでは、下記の Linq に書いているこのような書き方ができない。
var query = from entity in dataServiceContext.CreateQuery<SampleEntity>(tableName)
where entity.PartitionKey == "MyPartitionKey"
select new { entity.RowKey };
上記にある、CreateQuery
メソッドが.NetCoreでは使えないからだ。なぜかというと、.NetCore用のライブラリ では、sync オペレーションがサポートされていないからの様子。
According to this question: Missing syncronous methods for dotnet core?,NetCore/Netstandard support does not yet include Sync implementation of the APIs.
となれば、Linq 無しで書くことになる。こんな感じ。
// Retrieve the storage account from the connection string
CloudStorageAccount storageAccount = CloudStorageAccount.Parse(
CloudConfigurationManager.GetSetting("StorageConnectionString"));
// Create the table client
CloudTableClient tableClient = storageAccount.CreateCloudTableClient();
// Create the CloudTable object that represents the "items" table
CloudTable table = client.GetTableReference("items");
TableQuery<Item> itemStockQuery = new TableQuery<Item>().Where(
TableQuery.CombineFilters(
TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, "RawMaterial"),
TableOperators.And,
TableQuery.GenerateFilterConditionForInt("Stock-in-hand", QueryComparisons.GreaterThan, 0)));
var rawMtlStock = table.ExecuteQuery(itemStockQuery);
if (rawMtlStock.Any())
{
foreach (ItemEntity item in rawMtlStock)
{
Console.WriteLine("Item: {0} as {1} items in stock", item.Name, item.Stock-in-hand);
}
}
クラスの解析
このクラスには、TaskHubなど、Httpリクエストで使っているパラメータが全部ではないが格納されている。しかし、このクラスはキャッシュされるので、頻繁に変わるものに対しては使えないので、HttpApiHandler.csでパースの処理を実装。HttpManagementPayloadもなにか、URL系の値をもっているが、こちらは、インスタンスを作成したら取得できる3るのURLを保持するためのクラスなので、今回の修正には関係がない。
APIのデザイン
Http API
API のデザインも、クリスからアドバイスをもらった。最初に考えていたデザインは次の感じ。
http://localhost:7071/runtime/webhooks/DurableTaskExtension/instances/?taskHub={TaskHubName}&connection=Storage&code={SystemKey}&createdTimeFrom=2018-08-10T06%3A44%3A49Z&createdTimeTo=2018-08-10T07%3A44%3A49&runtimeStatus[]=Completed&runtimeStatus[]=Running
クリス曰く、こんなデザインはみたことないし、こっちのほうがいいというコメント。たしかにこっちのほうがずっといい。ちなみに上の書き方は、RFCで定義されていないことを確認してから、日本語のブログを検索して採用したんだけど、やっぱり慎重にMicrosoft の他の REST API の書き方を勉強したほうが良かったのかもしれない。RESTAPIブラウザーを見ても1項目複数指定できるクエリーは見つけられなかった。
http://localhost:7071/runtime/webhooks/DurableTaskExtension/instances/?taskHub={TaskHubName}&connection=Storage&code={SystemKey}&createdTimeFrom=2018-08-10T06%3A44%3A49Z&createdTimeTo=2018-08-10T07%3A44%3A49&runtimeStatus=Completed,Running
OrchestrationClient
他の項目としても、OrchestrationClient に実装されるメソッドのなまえとして、GetStatus
にするのか、QueryStatus
にするのかでも迷った。スタイルに合わせるということで、GetStatus
になったが、Azure の API を見ても、Getか、List しかなくて、Queryというネーミングにはだれもしていないので、妥当と思われる。
最初の API デザイン
最初はこのような感じだったが、createdTimeTo をオプショナルにしたいという話になった。
public override async Task<IList<DurableOrchestrationStatus>> GetStatusAsync(DateTime createdTimeFrom, DateTime createdTimeTo, string runtimeStatus, CancellationToken cancellationToken = default(CancellationToken))
ちなみに、本当はクエリーオブジェクトは、すべてオプショナルでも動くようになっているので、問題ないのだが、API としては、作者の「意図」を明示的に示したいのだろう。勉強になる。
public override async Task<IList<DurableOrchestrationStatus>> GetStatusAsync(DateTime createdTimeFrom, DateTime? createdTimeTo, string runtimeStatus, CancellationToken cancellationToken = default(CancellationToken))
さらに、runtimeStatus が複数指定できるようにとのことになった。
IList より IEnumerable
status の複数形は、status/statuses のどちらでもいいので、同じ形を採用。しかし、IList はいまいちとのことで、IEnumerable に変更。最初は、配列にするか迷ったのだが、C# の場合は、Enumerable が良さげ。Linq も使える。が、IList だと要素の増減ができてしまうので、クエリーの戻り値としてはこちらが良さげ。
かずき師匠にきいても次のような回答。
私は特に理由がなければ引数も戻り値も複数件のときはIEnumerableですよ
public override async Task<IList<DurableOrchestrationStatus>> GetStatusAsync(DateTime createdTimeFrom, DateTime createdTimeTo, IList<string> runtimeStatus, CancellationToken cancellationToken = default(CancellationToken))
こんな感じに
public override async Task<IList<DurableOrchestrationStatus>> GetStatusAsync(DateTime createdTimeFrom, DateTime createdTimeTo, IEnumerable<string> runtimeStatus, CancellationToken cancellationToken = default(CancellationToken))
最終的に、string はいまいちだろうということで、
public override async Task<IList<DurableOrchestrationStatus>> GetStatusAsync(DateTime createdTimeFrom, DateTime? createdTimeTo, IEnumerable<OrchestrationRuntimeStatus> runtimeStatus, CancellationToken cancellationToken = default(CancellationToken))
になった。こちらのほうが型が明確になっている。
System.Web.HttpUtility.UrlEncode より、WebUtility.UrlEncode
クリス曰く
System.Web.HttpUtility.UrlEncodeを使わないでください。WebUtility.UrlEncodeを使ったほうがいいです。
とのこと。
これを読むと挙動の違いはわかるけど、なぜかは不明。聞いてみる。
追記:facebookで尋ねてみると
Web配下は ASP.NET に依存していて事実上Webプロジェクトじゃないと使えないからだったはず。
System.webには色々ありすぎだからエンコードするだけにはちょっと的な?
そですね。System配下のやつができるまでコンソールアプリでUri使うの大変でした
なるほど。ASP.NET 依存だったのですね。
Enum の型変換
今回学んだことの一つとして、DurableTask と、Durable Functions 側でとても似たようなEnumがあった。意味はほぼ同等といってよくて、実装されている場所が違う程度。OrchestrationRuntimeStatus
と、DurableTask.Core で実装されているOrchestrationStatus
もっている項目も同じ。当初は自分で変換しないといけないと思っていたのだが、なんとキャストでいけるようす。
OrchestrationStatus status = (OrchestrationStatus)orchestrationRuntimeStatus;
みたいな感じ。これにはびっくり。
最後でのみ await するメソッドは、async メソッドにしない
クリスから上記のようなコメントがあり、どういうこと?と思ったんだけど、整理して考えると当たり前で、async/await は、なぜ待ち合わせできるか?というと、Taskを戻しているから。このコードの場合、途中でawait を使っている箇所がなくて、最後の戻りのところで、ほかのasync メソッドを呼び出して待ち受けをしている。でもそれだと、ここは、Task返ってくるだけなので、ここで、特にタスクの待ち受けをしても時間がかかるだけで、ここからTaskを返却してあげると、await の分だけ待たなくても、このメソッドを使う元で、待ち合わせがかかるので問題ないという仕組み。
internal の使用とユニットテスト
当初私は、Queryオブジェクトを、public で実装していたが、クリスの指摘を受けて、internal に変更した。なぜ私がpublic にしたかというと、Unit Testing をしたかったからなのだが、C# の機能で、internal はアセンブリ単位だけど、それを超えたい場合は、AssemblyInfo.cs クラスに参照可能なネームスペースを指定しておけば見れるようになる。
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("WebJobs.Extensions.DurableTask.Tests")]
Coun() != 0 のかわりにAny()
下記も指摘をうけた。this.RuntimeStatus.Any()
というメソッドがあり、これがあれば、要素があるか否かを検査してくれる。こっちのほうが素晴らしい。ちなみに、要素がなければというメソッドは存在しなかった。ちゃんと基本的な APIはスピードをあげるためには、一通りどんなメソッドがあるのか読んでおくとよさそう。
if(this.RuntimeStatus.Count() != 0)
:
flatMap 相当の C# 実装
最終的にAPI で、runtimeStatus=foo,bar
を実装することになった。Request のパラメータは、string[]
になっているので、ワンライナーで書くために、Scala でいうflatMapを使いたくなった。もちろんリンクにもあってSelectManyだった。まだ改良できそうだけど、Split()
でカンマ区切りをコレクションに変えて、SelectMany で階層をフラットにする。
private static IEnumerable<OrchestrationRuntimeStatus> GetIEnumerableQueryParameterValue(NameValueCollection queryStringNameValueCollection, string queryParameterName)
{
var results = new List<OrchestrationRuntimeStatus>();
var parameters = queryStringNameValueCollection.GetValues(queryParameterName) ?? new string[] { };
foreach (var value in parameters.SelectMany(x => x.Split(',')))
{
if (Enum.TryParse<OrchestrationRuntimeStatus>(value, out OrchestrationRuntimeStatus result))
{
results.Add(result);
}
}
return results;
}
エラーの対処
いろいろ起こったエラーのメッセージと対処方法
Object is not defined in an assembly that is not referenced.
最初に実行しようとした出たエラー。実際のメッセージはちょっと違うが、DurableTask.Core の2.0.0.6 で PublicKeyTokenがnullのものが無いというエラーだった。普通のものはトークンがあるので、DurableTaskでPack を実行すると、DurableTask.Coreのpackageも作成されるので、そちらを使えばよい。おそらく、この方式だと、publicKeyTokenがnullのものができるのだろう。
Error CS0012 The type 'Object' is defined in an assembly that is not referenced. You must add a reference to assembly 'netstandard, Version=2.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51'
Table QueryのWhere句がnull の場合のエラー
[05/08/2018 07:43:19] Executed 'Query' (Failed, Id=31af8789-cd6b-4734-aa69-93d8a88dd4f3)
[05/08/2018 07:43:19] System.Private.CoreLib: Exception while executing function: Query. System.Linq: Sequence contains no elements.
NameValueCollection に複数キー入力可能か?
可能
var collection = new NameValueCollection();
collection.Add("name", "ushio");
collection.Add("name", "yamazaki");
var values = collection.GetValues("name");
DurableのHttp APIで 500 エラー
エラーがでると、ログがないので解析不可能。どうやったかというと、再現テストを、Durable Functions 側、DurableTask側で書いてみて、検証した。同じパラメータを、テストとして渡してみる。Durable側では問題ないので、Mock先だとわかる。今回は、DurableTask側のエラーだった。FunctionAppにデプロイしてもエラーは見ることができない。
今回の問題は、Aggregateの箇所。ここで、RuntimeStatus.Select(...) が0件の場合、Aggregate
はSequence Contains no element.
エラーになる。
var runtimeCondition = this.RuntimeStatus.Select(x => TableQuery.GenerateFilterCondition("RuntimeStatus", QueryComparisons.Equal, x.ToString()))
.Aggregate((a, b) => TableQuery.CombineFilters(a, TableOperators.Or, b));
今回はこの箇所の、件数0件チェックをAny
で行うので不要になったが、Aggregateで解決したい場合は
.Aggregate("", (a, b) => ...
といった具合に初期値を設定するとよい。ただ、今回は初期値を設定すると、それも、OR
の対象になったので、外した。
クソコードの消臭
クソコードがあって、そこも、いろいろ相談してみました。
null を判断してデフォルト値を返す
condition.CreatedTimeTo = (createdTimeTo != null) ? (DateTime)createdTimeTo : default(DateTime);
みたいなものは、師匠曰く
nullだったら何か別の値ってパターンは
createdTimeTo ?? default(DateTime) みたいにかけますよ
なるほど、、、
var runtimeStatus = new List<string>();
runtimeStatus.Add("Running");
var condition = new OrchestrationInstanceStatusQueryCondition
{
RuntimeStatus = runtimeStatus,
CreatedTimeFrom = new DateTime(2018, 1, 10, 10, 10, 10),
CreatedTimeTo = new DateTime(2018, 1, 10, 10, 10, 50)
};
:
このクソコードですが
とのこと。string は IEnumerableなので、
var condition = new OrchestrationInstanceStatusQueryCondition
{
RuntimeStatus = new string[] {"Running"},
CreatedTimeFrom = new DateTime(2018, 1, 10, 10, 10, 10),
CreatedTimeTo = new DateTime(2018, 1, 10, 10, 10, 50)
};
public static OrchestrationInstanceStatusQueryCondition Parse(DateTime createdTimeFrom, DateTime? createdTimeTo, IEnumerable<string> runtimeStatus)
{
var condition = new OrchestrationInstanceStatusQueryCondition();
condition.CreatedTimeFrom = createdTimeFrom;
condition.CreatedTimeTo = (createdTimeTo != null) ? (DateTime)createdTimeTo : default(DateTime);
condition.RuntimeStatus = runtimeStatus;
return condition;
}
あとは、どうかなぁ。IEをparams string[] にして可変長引数対応のオーバーロードも作ってもいいかもですねぇ
Parse メソッドはオーバーロード追加してから、オブジェクト初期化子と??演算子で書くとすっきりするかもですね
あとは1ステートメントなので、メソッド本文を=>で書けますね
可変の書き方はためしてないけど、こんな感じでいいのかな。
public static OrchestrationInstanceStatusQueryCondition Parse(DateTime createdTimeFrom, DateTime? createdTimeTo, IEnumerable<string> runtimeStatus) => new OrchestrationInstanceStatusQueryCondition {
condition.CreatedTimeFrom = createdTimeFrom,
condition.CreatedTimeTo = (DateTime) (createdTimeTo ?? default(DateTime), condition.RuntimeStatus = runtimeStatus
}
今回の成果
まとめ
次回以降スピードアップするために、学んだことを記録してみたが、ブログを書くのに3時間ぐらいかかってしまった。もっとこまめにアウトプットするほうがよさそう。
今回全体像として学んだのはこういうところ
- 代表的なクラスライブラリのインターフェイスは一通りなめておくこと
- Httpや、ライブラリのインターフェイスの決定は既存のライブラリを参考にするのが良い。普通のブログだとオレオレの記事もあるので、注意
- 見直しをする(すぐ完成にしちゃうけど、しっかりリファクタリング)
- 既存のリポジトリのコードを参考にする。時間をかけて読むほうが結局速いかも(スタイルを合わせるのが重要)
- 技術要素に関してスパイクしておいて、高速に書けるまで練習する
- C#の言語仕様を学ぶほうがいいかも
次はパージの機能いってみよう。