# IAsyncCursor<TDocumen\>的問題之一 : 不能重複巡覽 ## 前言 * 在C#中,和mongoDB溝通的套件 **MongoDB.Driver**,是大家最常用套件之一。而在和資料庫溝通的需求中,又以「讀取」為最常用的功能。 ## 問題描述 * MongoDB.Driver提供**FindAsync()** 這個非同步方法來讀取資料庫中的資料,而其回傳的Type為**Task<IAsyncCursor\<TDocument>>** * **FindAsync()** 之定義如下 ```csharp= Task<IAsyncCursor<TProjection>> FindAsync<TProjection>(FilterDefinition<TDocument> filter, FindOptions<TDocument, TProjection> options = null, CancellationToken cancellationToken = default(CancellationToken)); ``` * 從上述定義來看,該函式會回傳一個類型為**IAsyncCursor\<TDocument>** 的物件;可以把他想像成一個「游標」,一個在文件中遊走的游標。他可以一次跳過一個item,也可以一次跳過多個。該介面的定義如下: ```csharp= // // 摘要: // Represents an asynchronous cursor. // // 類型參數: // TDocument: // The type of the document. public interface IAsyncCursor<out TDocument> : IDisposable { // // 摘要: // Gets the current batch of documents. // // 值: // The current batch of documents. IEnumerable<TDocument> Current{get;} // // 摘要: // Moves to the next batch of documents. // // 參數: // cancellationToken: // The cancellation token. // // 傳回: // Whether any more documents are available. bool MoveNext(CancellationToken cancellationToken = default(CancellationToken)); // // 摘要: // Moves to the next batch of documents. // // 參數: // cancellationToken: // The cancellation token. // // 傳回: // A Task whose result indicates whether any more documents are available. Task<bool> MoveNextAsync(CancellationToken cancellationToken = default(CancellationToken)); } ``` * 可以把**IAsyncCursor<TDocument\>** 看成**IAsyncEnumerator<IEnumerable<TDocument\>\>** :他們都可以非同步的巡覽**IEnumerable<TDocument\>** 的物件 * **MongoDB.Driver** 也提供擴充方法**ToEnumerable<TDocument\>(this IAsyncCursor<TDocument\> cursor)**,以方便使用**Linq** 相關功能。詳細定義如下: ```csharp= // // 摘要: // Wraps a cursor in an IEnumerable that can be enumerated one time. // // 參數: // cursor: // The cursor. // // cancellationToken: // The cancellation token. // // 類型參數: // TDocument: // The type of the document. // // 傳回: // An IEnumerable public static IEnumerable<TDocument> ToEnumerable<TDocument>(this IAsyncCursor<TDocument> cursor, CancellationToken cancellationToken = default(CancellationToken)) { return new AsyncCursorEnumerableOneTimeAdapter<TDocument>(cursor, cancellationToken); } ``` * 該擴充方法回傳的物件**AsyncCursorEnumerableOneTimeAdapter<TDocument\>(cursor, cancellationToken)** ,為一個實作介面**IEnumerable<TDocument\>** 之物件。 * 因為擴充方法**ToEnumerable()** 是回傳**IEnumerable**,所以外面的使用者只能得到一個**IEnumerable** 的物件。 * 類別**AsyncCursorEnumerableOneTimeAdapter<TDocument\>(cursor, cancellationToken)** ,顧名思義他只能被**列舉一次**。 * 但矛盾的是,**IEnumerable** 有提供**GetEnumerator()** 的方法,讓使用者可以重複的**取得新列舉器(Enumerator)**。因此,理論上使用者是可以對一個**IEnumeable** 之物件進行**重複列舉**。 * 綜上所述,我們發現一個矛盾點: ### 「IAsyncCursor.ToEnmerable()轉出的物件不符合介面規範」 ## 測試 * 我們寫了簡單的程式碼,以確認上述問題確定存在 * 程式碼如下: ```csharp= public static async Task Main() { var mongo = new MongoClient("mongodb://localhost:27017").GetDatabase("EnumeratorPlayground").GetCollection<MySchema>(nameof(MySchema)); var mongoEnumerable = (await mongo.FindAsync(Builders<MySchema>.Filter.Empty)).ToEnumerable(); for (int i = 0; i < 2; i++) { foreach(var iten in mongoEnumerable){} Console.WriteLine($"cursor enumeration finished...\n"); } } ``` * 取得**IAsyncCursor<MySchema\>** 物件後,先將他轉成**IEnumerable** 並對他進行兩次列舉。 * 執行結果如下圖所示: ![](https://i.imgur.com/N5WfpUw.png) * 可以發現,第二次列舉時有例外(**Exception**)產生。 ## 原因 * 當然,最主要的原因就是**ToEnumerable()** 產生的**IEnumerable** 是由類別**AsyncCursorEnumerableOneTimeAdapter<TDocument\>** 實作;而該物件不支援**重複列舉**。 * **AsyncCursorEnumerableOneTimeAdapter** 的[source code](https://github.com/mongodb/mongo-csharp-driver/blob/master/src/MongoDB.Driver.Core/Core/Operations/AsyncCursorEnumerableOneTimeAdapter.cs)如下: ```csharp= internal class AsyncCursorEnumerableOneTimeAdapter<TDocument> : IEnumerable<TDocument> { // private fields private readonly CancellationToken _cancellationToken; private readonly IAsyncCursor<TDocument> _cursor; private bool _hasBeenEnumerated; // constructors public AsyncCursorEnumerableOneTimeAdapter(IAsyncCursor<TDocument> cursor, CancellationToken cancellationToken) { _cursor = Ensure.IsNotNull(cursor, nameof(cursor)); _cancellationToken = cancellationToken; } // public methods public IEnumerator<TDocument> GetEnumerator() { if (_hasBeenEnumerated) { throw new InvalidOperationException("An IAsyncCursor can only be enumerated once."); } _hasBeenEnumerated = true; return new AsyncCursorEnumerator<TDocument>(_cursor, _cancellationToken); } IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); } } ``` * 在方法**GetEnumerator()** 中,會記錄該使用者是否已列舉過;若已列舉過就會丟出一個**Exception** 。 ## 結論 * 究竟一個interface之實作,能不能不支援某些方法呢?尤其是對於MongoDB.Driver這種使用群眾如此龐大的package? * 事實上,也有許多interface之實作不支援其中特定某些方法或屬性,並會拋出**NotSupportException** 之類的例外,以告知使用者。而且**AsyncCursorEnumerableOneTimeAdapter** 拋出的例外也非常淺顯易懂。 * 但以大型專案角度來看,這確實會造成開發和維護上的一些困擾。 * 若是小型專案,只要稍加留意,不要將該**IEnumerable** 物件重複列舉即可。 * 但若在大型專案中,該**IAsyncCursor** 被轉成**IENumerable** 後可能會被包近很多層函式。 * 若是不熟悉資料底層實作的開發人員,在接到一個**IEnumerable** 之物件後,並不一定會注意到該**IEnumerable** 只能被巡覽一次。 ## 改善方法 1. 將**IAsyncCursor<TDoucument\>** 轉成**IAsyncEnumerator<TDocument\>** 或**IAsyncEnumerator<IEnumerable<<TDocument\>\>** ,以明確定義該列舉器無法被重複列舉。 * **IAsyncCursor<TDocument\>** 有兩個方法和一個屬性。你可以將他想像成一個類型為 **IAsyncCursor<IEnumerable<TDocument\>\>**,他是一個非同步的迭代器(**IAsyncEnumerator**),其中每個element都是一個實作**IEnumerable<TDocument\>** 介面的物件。 * 以下是**IAsyncEnumerator**之定義: ```csharp= // // 摘要: // Supports a simple asynchronous iteration over a generic collection. // // 類型參數: // T: // The type of the elements in the collection. public interface IAsyncEnumerator<out T> : IAsyncDisposable { // // 摘要: // Gets the element in the collection at the current position of the enumerator. // // 傳回: // The element in the collection at the current position of the enumerator. T Current{get;} // // 摘要: // Advances the enumerator asynchronously to the next element of the collection. // // 傳回: // A System.Threading.Tasks.ValueTask`1 that will complete with a result of true // if the enumerator was successfully advanced to the next element, or false if // the enumerator has passed the end of the collection. ValueTask<bool> MoveNextAsync(); } ``` * 觀察上述**IAsyncCursor**和**IAsyncEnumerator**之定義可發現,他們倆個都有方法**MoveNextAsync()** 和屬性**Current** ,並且都在做幾近相同的事情 * 重點是,**IAsyncCursor**和**IAsyncEnumerator**最契合的地方是「都無法重複列舉」,因為缺少類似**IEnumerator** 中的方法**Reset()** 。 * 當你將**IAsyncCursor**或**IAsyncEnumerator**列舉完,你無法將他們重置並重新列舉。 2. 將**IAsyncCursor<TDoucument\>** 轉成**Array** 。 3. 實作一個**IEnumerable** 物件,並用工廠模式以便在每次重新列舉前產生新的**IAsyncCursor** 物件。 * 以下是簡單實作: ```csharp= /// <summary> /// Object that adapt <see cref="IEnumerable{TDocument}"/> from <see cref="IAsyncCursor{TDocument}"/>. /// Result od adaption can request multiple times. /// </summary> /// <typeparam name="TDocument">Type of element in <see cref="IAsyncCursor{TDocument}"/>.</typeparam> public class AsyncCursorToEnumerableMultipleTimeAdapter<TDocument> : IEnumerable<TDocument> { /// <summary> /// Factory of <see cref="IAsyncCursor{TDocument}"/>. /// </summary> private readonly Func<Task<IAsyncCursor<TDocument>>> CursorFactoryAsync; /// <summary> /// Initialize an instance of <see cref="AsyncCursorToEnumerableMultipleTimeAdapter{TDocument}"/> class. /// </summary> /// <param name="cursorFactoryAsync"></param> public AsyncCursorToEnumerableMultipleTimeAdapter(Func<Task<IAsyncCursor<TDocument>>> cursorFactoryAsync) { CursorFactoryAsync = cursorFactoryAsync; } /// <inheritdoc/> public IEnumerator<TDocument> GetEnumerator() { return CursorFactoryAsync().Result.ToEnumerable().GetEnumerator(); } /// <inheritdoc/> IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } ``` ## 參考資料 * 物件**AsyncCursorToEnumerableMultipleTimeAdapter** 之[原始碼](https://github.com/mongodb/mongo-csharp-driver/blob/master/src/MongoDB.Driver.Core/Core/Operations/AsyncCursorEnumerableOneTimeAdapter.cs) * 物件**AsyncCursorEnumerator** 之[原始碼](https://github.com/mongodb/mongo-csharp-driver/blob/master/src/MongoDB.Driver.Core/Core/Operations/AsyncCursorEnumerableOneTimeAdapter.cs)