LoginSignup
5
3

More than 5 years have passed since last update.

DurableFunctions コントリビューションの学び - (1) クエリーの実装

Last updated at Posted at 2018-08-11

私は定期的に Durable Functions のリポジトリに貢献している。特に DevOps に関する機能に関して貢献している。なぜそんなことをしているか?というと、プロジェクトが気に入っているのもあるし、プロジェクトのオーナーの Chris とも仲がいいし、貢献するととても学びになる。私の前職の以前のキャリアは、大手SI のSE/PM だったり、アジャイルや、超上流のコンサルでプログラマとしては三流。どうやって、スキルを引き上げるか考えた場合、こういった OSS の貢献はガチの本番環境を体験できるし、リリースまでのすべてを学べるし、リポジトリオーナーのレビューも最高なので、大変ありがたい。

ただ、今までは、貢献しっぱなしで、特に振り返りなどをしていなかったので、大きめの貢献をしたら、学んだことを振り返っておこうと思う。

今まで貢献した内容

大きな機能でいうと2つぐらいしかまだ貢献していない。大きく分けると EventGrid にオーケストレーションのステータスを送信するようにしたものと、Http/Client 経由でインスタンスの状態を取得できるようにしたものの2つ。Kanio とか 24 commitしていて2位だけど、私は 4 commit で5位だから、もうちょっと頑張ってみたいな。スピードを上げて楽に貢献できるようになりたい。

今回の貢献内容

今回の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 メソッドにしない

AsyncAwait.JPG

クリスから上記のようなコメントがあり、どういうこと?と思ったんだけど、整理して考えると当たり前で、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件の場合、AggregateSequence 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#の言語仕様を学ぶほうがいいかも

次はパージの機能いってみよう。

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3