AsyncOAuth - C#用の全プラットフォーム対応の非同期OAuthライブラリ

待ち望まれていたHttpClientがPortable Class Library化しました、まだBetaだけどね!というわけで、早速PCL版のHttpClientをベースにしたOAuthライブラリを仕上げてみました。ポータブルクラスライブラリなので、.NET 4.5は勿論、Windows Phone 7.5, 8, Windows Store Apps, Silverlight, それと.NET 4.0にも対応です。

前身のReactiveOAuthがTwitterでしかロクにテストしてなくてHatenaでズタボロだったことを反省し、今回はSampleにTwitterとHatenaを入れておきました&どっちでもちゃんと正常に動きます。なお、完全に上位互換なので、ReactiveOAuthはObsoleteです。それと、ライブラリのインストールはNuGet経由でのみの提供です。

PM> Install-Package AsyncOAuth -Pre

もしくはPreReleaseを表示に含めてGUIから検索してください。

AsyncOAuth is not a new library

AsyncOAuthの実態はOAuthMessageHandlerというDelegatingHandlerです。

var client = new HttpClient(new OAuthMessageHandler("consumerKey", "consumerSecret", new AccessToken("accessToken", "accessTokenSecret")));

// 上のだとnewの入れ子が面倒なので短縮形、戻り値は上のと同じ
var client = OAuthUtility.CreateOAuthClient("consumerKey", "consumerSecret", new AccessToken("accessToken", "accessTokenSecret"));

こうなっていると何がいいか、というと、全ての操作がHttpClient標準通りなのです。

// Get
var json = await client.GetStringAsync("http://api.twitter.com/1.1/statuses/home_timeline.json?count=" + count + "&page=" + page);

// Post
var content = new FormUrlEncodedContent(new[] { new KeyValuePair<string, string>("status", status) });
var response = await client.PostAsync("http://api.twitter.com/1.1/statuses/update.json", content);
var json = await response.Content.ReadAsStringAsync();

// Multi Post
var content = new MultipartFormDataContent();
content.Add(new StringContent(status), "\"status\"");
content.Add(new ByteArrayContent(media), "media[]", "\"" + fileName + "\"");

var response = await client.PostAsync("https://upload.twitter.com/1/statuses/update_with_media.json", content);
var json = await response.Content.ReadAsStringAsync();

もうおれおれクライアントのAPIを覚える必要はありません。これからの標準クライアントであるHttpClientの操作だけを覚えればいいのです。

コンセプトはHttpClientチームから掲示されているサンプルコードExtending HttpClient with OAuth to Access Twitterどおりですが、このサンプルコードは本当にただのコンセプトレベルなサンプルで、そのまんまじゃ使えないので、ちゃんと実用的なOAuthライブラリとして叩き直したのがAsyncOAuthになります。DelegatingHandlerというのは、リクエストを投げる直前をフックするものなので、そこでOAuth用の認証を作っているわけです。

イニシャライズ

使う場合は、必ず最初にHMAC-SHA1の計算関数をセットしなければなりません。何故か、というと、ポータブルクラスライブラリには現状、暗号系のライブラリが含まれていなくて、その部分は含むことができないからです。外部から差し込んでもらうことでしか対処できない、という。ご不便おかけしますが、的な何か。そのうち含まれてくれるといいなあ、って感じですねえ。それまでは、以下のコードをApp.xaml.csとかApplication_Startとか、初回の本当に最初の最初に呼ばれるところに、コピペってください。

// WinRT以外(Silverlight, Windows Phone, Consoleなどなど)
OAuthUtility.ComputeHash = (key, buffer) => { using (var hmac = new HMACSHA1(key)) { return hmac.ComputeHash(buffer); } };

// Windows Store App(めんどうくせえええええ)
AsyncOAuth.OAuthUtility.ComputeHash = (key, buffer) =>
{
    var crypt = Windows.Security.Cryptography.Core.MacAlgorithmProvider.OpenAlgorithm("HMAC_SHA1");
    var keyBuffer = Windows.Security.Cryptography.CryptographicBuffer.CreateFromByteArray(key);
    var cryptKey = crypt.CreateKey(keyBuffer);

    var dataBuffer = Windows.Security.Cryptography.CryptographicBuffer.CreateFromByteArray(buffer);
    var signBuffer = Windows.Security.Cryptography.Core.CryptographicEngine.Sign(cryptKey, dataBuffer);

    byte[] value;
    Windows.Security.Cryptography.CryptographicBuffer.CopyToByteArray(signBuffer, out value);
    return value;
};

また、使いかたの詳しいサンプルは、GitHub上のソースコードからAsyncOAuth.ConsoleAppの中にTwitter.csとHatena.csがあるので、それを見てもらえればと思います。AccessToken取得までの、認証系の説明はここには書きませんが(OAuthAuthorizerという特別に用意してあるものを使う)、その具体的な書き方が乗っています。特にHatenaの認証はTwitterに比べるとかなりメンドーくさいので、メンドーくさい系のOAuthが対象の場合は参考になるかと思います。

ストリーミング、Single vs Multiple、或いはRxの再来

勿論、TwitterのストリーミングAPIにも対応できます。以下のようなコードを書けばOK。

public async Task GetStream(Action<string> fetchAction)
{
    var client = OAuthUtility.CreateOAuthClient(consumerKey, consumerSecret, accessToken);
    client.Timeout = System.Threading.Timeout.InfiniteTimeSpan; // ストリーミングなのでTimeoutで切られないよう設定しておくこと

    using (var stream = await client.GetStreamAsync("https://userstream.twitter.com/1.1/user.json"))
    using (var sr = new StreamReader(stream))
    {
        while (!sr.EndOfStream)
        {
            var s = await sr.ReadLineAsync();
            fetchAction(s);
        }
    }
}

ほぅ、Actionですか、コールバックですか……。ダサい。使い勝手悪い。最悪。しかし、じゃあ何返せばいいんだよ!ということになる。Taskは一つしか返せない、でもストリーミングは複数。うーん、うーん、と、そこでIObservable<T>の出番です。Reactive Extensionsを参照して、以下のように書き換えましょう。

public IObservable<string> GetStream()
{
    return Observable.Create<string>(async (observer, ct) =>
    {
        try
        {
            var client = OAuthUtility.CreateOAuthClient(consumerKey, consumerSecret, accessToken);
            client.Timeout = System.Threading.Timeout.InfiniteTimeSpan; // ストリーミングなのでTimeoutで切られないよう設定しておくこと

            using (var stream = await client.GetStreamAsync("https://userstream.twitter.com/1.1/user.json"))
            using (var sr = new StreamReader(stream))
            {
                while (!sr.EndOfStream && !ct.IsCancellationRequested)
                {
                    var s = await sr.ReadLineAsync();
                    observer.OnNext(s);
                }
            }
        }
        catch (Exception ex)
        {
            observer.OnError(ex);
            return;
        }
        if (!ct.IsCancellationRequested)
        {
            observer.OnCompleted();
        }
    });
}
var client = new TwitterClient(consumerKey, consumerSecret, new AccessToken(accessTokenKey, accessTokenSecret));

// subscribe async stream
var cancel = client.GetStream()
    .Skip(1)
    .Subscribe(x => Console.WriteLine(x));

Console.ReadLine();
cancel.Dispose(); // キャンセルはDisposeで行う

といったように、自然にRxと繋げられます。コールバックのObservable化はObservable.Createで、そんなに難しくはない(ただしOnNext以外にちゃんとOnError, OnCompletedも記述してあげること)です。キャンセル対応に関しては、ちゃんとCancelleationToken付きのオーバーロードで行いましょう。そうしないと、Subscribeの解除はされていても、内部ではループが延々と動いている、といったような状態になってしまいますので。

ともあれ、asyncやCancellationTokenとRxがスムースに結合されていることは良くわかるかと思います。完璧!

こういった、単発の非同期はTaskで、複数の非同期はIObservable<T>で行う、というガイドはTPLチームからも示されています。先日のpfxteamからのスライドから引用すると(ちなみにこのスライドはTask系の落とし穴などが超丁寧に書かれているので必読!)

といった感じです。んねー。

まとめ

ReactiveOAuthはオワコン。HttpClient始まってる。Reactive Extensions自体は終わってない、むしろ始まってる。というわけで、色々と使いこなしていきましょう。

追記:リリースから一晩開けて、POST周りを中心にバグが発見されていてお恥ずかしい限りです。あらかた修正したとは思うのですが(NuGetのバージョンは随時上げています)、怪しい挙動見つけたら報告下さると嬉しいです。勿論、GitHubなのでPull Requestでも!

C#でぬるぽを回避するどうでもいい方法

どうもペチパーです。嘘です逃げないで。まあ、どうでもいいPHPの例をまずは出しませう。あ、逃げないで、PHPの話はすぐやめるんで。

// ネストしてる配列
$hoge["huga"]["hage"]["tako"] = "なのなの";

// なのなの
$v = isset($hoge["huga"]["hage"]["tako"])
    ? $hoge["huga"]["hage"]["tako"]
    : "ない";

// 途中で欠けてる配列
$hoge["huga"] = "なのなの";

// ない
$v = isset($hoge["huga"]["hage"]["tako"])
    ? $hoge["huga"]["hage"]["tako"]
    : "ない";

全体的にキモいんですが、まあ無視してもらって、何が言いたいか、と言うとisset。これはネストしてる部分も一気に評価してくれるのです。フツーの関数だと常識的に考えて評価は先に内側で行うので配列の境界外で死ぬんですが、issetは関数みたいな見た目だけど実は言語構文なのだ!キモチワルイ。ともあれ、そんなわけでネストしてるものの有無を一気にチェックできるのです。

で、PHPのこと書いてるとサイト違うので、C#の話をしませう。

C#でネストネスト

PHPは何でも連想配列なのですが、C#だったらクラスのプロパティでしょうか。以下のようなシチュエーション。

// こういうドカドカした構造があるとして
class Hoge
{
    public Huga Prop1 { get; set; }
}

class Huga
{
    public Hage Prop2 { get; set; }
}

class Hage
{
    public string Prop3 { get; set; }
}

// こっちプログラム本体
var hoge = new Hoge();

// とちゅーでヌルぽが発生すると死んじゃうんの回避が醜悪!
var prop3 = (hoge != null && hoge.Prop1 != null && hoge.Prop1.Prop2 != null && hoge.Prop1.Prop2.Prop3 != null)
    ? hoge.Prop1.Prop2.Prop3
    : null;

!=nullの連鎖が面倒くさいですぅー。なんとかしてくださいぃー。ぴーHPに負けてるんじゃないですかぁー?とか言われてないですが言われてるってことにするので、しょうがないからエレガントな解決策を探してあげました、誰にも頼まれてませんが!

Love ExpressionTree

こーいう風に書ければいいんでしょ!下のhoge.GetValueOrDefaultってとこです。

// こんなHogeがあるとして
var hoge = new Hoge();

// すっきり!
var value = hoge.GetValueOrDefault(x => x.Prop1.Prop2.Prop3);
Console.WriteLine(value == null); // true

// 中身が詰まってたら
hoge = new Hoge { Prop1 = new Huga { Prop2 = new Hage { Prop3 = "ほげ!" } } };
var value2 = hoge.GetValueOrDefault(x => x.Prop1.Prop2.Prop3);
Console.WriteLine(value2); // ほげ!

すっごくスッキリしますね!イイね!

で、どーやってるかというと、ExpressionTreeでグルグルですよ。

public static class MonyaMonyaExtensions
{
    public static TR GetValueOrDefault<T, TR>(this T value, Expression<Func<T, TR>> memberSelector)
        where T : class
    {
        var expression = memberSelector.Body;

        var memberNames = new List<string>();
        while (!(expression is ParameterExpression))
        {
            if ((expression is UnaryExpression) && (expression.NodeType == ExpressionType.Convert))
            {
                expression = ((UnaryExpression)expression).Operand;
                continue;
            }

            var memberExpression = (MemberExpression)expression;
            memberNames.Add(memberExpression.Member.Name);
            expression = memberExpression.Expression;
        }

        object value2 = value;
        for (int i = memberNames.Count - 1; i >= 0; i--)
        {
            if (value2 == null) return default(TR);
            var memberName = memberNames[i];
            dynamic info = value2.GetType().GetMember(memberName)[0];
            value2 = info.GetValue(value2);
        }

        return (TR)value2;
    }
}

はい。というわけで、一つ言えるのは、これ、あんま速くないんで実用には使わないでくださいね、あくまでネタです、ネタ。

もにゃど

それもにゃど、という人はLINQでMaybeモナドでも検索しませう。既出なので私は書きません。

連打対策などりの同時アクセス禁止機構

ゆるふわ連打対策のお時間です。連打されて無限にあーーーーーーー!という悲鳴を上げたり上げなかったりするとかしないとしても、何らかの対策したいよね!ということで、ASP.NETのお話。Application.Lock使ってSessionに、というのは複数台数あったら死ぬのでナシね(Application.Lockは当然、一台単位でのロックなので複数台数でロックは共有されてない)。そんなわけで、カジュアルな一手を打ちます。先に利用例から。

static void StandardUsage(string token)
{
    // 複数サーバーで共有されるロックもどきの取得
    using (var rock = DistributedLock.Acquire("StandardUsage-Lock-Token-" + token))
    {
        rock.ThrowIfLockAlreadyExists(); // 二重に取得された場合は即座に例外!

        // 以下、本体を書けばいい
    }
}

こんなふーに書けると、楽ですね。tokenは、まあ好きな単位で。ユーザー一人の単位だったら、認証済みなら何らかのIDを。非認証状態なら、POSTのHiddenにGUIDでも仕込んでおけばいい、と。ただの連打対策ってわけじゃなく、複数ユーザー間で同時処理されるのを抑えたければ、何らかのキーを、例えばソーシャルゲームだとチーム単位で、チームIDでかけたりとかします。

ロックもどきには↑の例ではMemcachedを使いました。単純に、Memcachedに指定キーでAddしにいく→Keyが既に存在していると上書きしないで追加に失敗→二重実行時は必ず失敗したという結果を受け取れる(bool:falseで)→Disposeで追加出来たときのみキーを必ず削除する(&保険でexpireもつけておく)

usingの部分は割と定型なので、毎回コントローラーを丸ごと囲むとかなら、属性作って、属性ペタッと貼るだけでOKみたいな形にするといいと思われます!

ド単純ですが、普通に機能して、結構幸せになれるかな?Memcachedならカジュアルに叩いても、相当耐えきれますから。あ、勿論、固定の台にリクエストが飛ぶの前提なのでノードがぐいぐい動的に追加削除されまくるよーな状況ではダメですよ、はい。あんまないでしょうが(Memcachedはクライアントサイドの分散で、複数台あってもキーが同一の場合は基本的に同じ台に飛ぶ)。

public class DistributedLockAlreadyExistsException : Exception
{
    public DistributedLockAlreadyExistsException(string key)
        : base("LockKey:" + key)
    { }
}

public class DistributedLock : IDisposable
{
    static MemcachedClient client = new MemcachedClient();
    static readonly TimeSpan DefaultExpire = TimeSpan.FromSeconds(5);

    public bool IsAcquiredLock { get; private set; }
    string key;
    bool disposed;

    private DistributedLock(string key, TimeSpan expire)
    {
        this.key = key;
        this.IsAcquiredLock = client.Store(StoreMode.Add, key, DateTime.Now.Ticks, expire);
    }

    public static DistributedLock Acquire(string key)
    {
        return Acquire(key, DefaultExpire);
    }

    public static DistributedLock Acquire(string key, TimeSpan expire)
    {
        return new DistributedLock(key, expire);
    }

    public async Task<bool> WaitAndRetry(int retryCount, TimeSpan waitTime)
    {
        var count = 0;
        while (count++ < retryCount && !IsAcquiredLock)
        {
            await Task.Delay(waitTime);
            IsAcquiredLock = client.Store(StoreMode.Add, key, DateTime.Now.Ticks, DefaultExpire);
        }
        return IsAcquiredLock;
    }

    public void ThrowIfLockAlreadyExists()
    {
        if (!IsAcquiredLock)
        {
            throw new DistributedLockAlreadyExistsException(key);
        }
    }

    public void Dispose()
    {
        if (!disposed && IsAcquiredLock)
        {
            disposed = true;
            var removeSuccess = client.Remove(key);
        }
        GC.SuppressFinalize(this);
    }

    ~DistributedLock()
    {
        Dispose();
    }
}

MemcachedのライブラリはEnyimMemcachedです。

Asyncとリトライ

取得に失敗したら、間隔おいてリトライぐらいはしたいですよね、いや、連打対策なら不要ですが、そうでないように使う場合は。でも、ベタにThread.Sleepでまったりしたくないよねえ、という、そこでasyncですよ!async!

async static Task TaskUsage(string token)
{
    using (var rock = DistributedLock.Acquire("TaskUsage-Lock-Token-" + token))
    {
        if (!rock.IsAcquiredLock)
        {
            // 200ミリ秒感覚で3回取得に挑戦する
            await rock.WaitAndRetry(3, TimeSpan.FromMilliseconds(200));
            rock.ThrowIfLockAlreadyExists(); // それでもダメなら例外投げるん
        }

        // 以下、本体を書けばいい!
    }
}

WaitAndRetryメソッドではawait Task.Delay(waitTime)によって待機させています。少し前だとまんどくせ、と思って書く気のしない処理も、C# 5.0のお陰でカジュアルに書けるようになっていいですね。

Memcachedを立てないサーバー一台の場合

サーバー一台の場合は、わざわざMemcached立てるのも馬鹿らしいので、インメモリなキャッシュを代替として使えばいいと思われます。HttpRuntime.Cacheでも、System.Runtime.Caching.MemoryCacheでも、なんでもを、client.Storeのとこに差し替えてもらえれば。ただ、MemoryCacheは何かちょっと今回試すためにもぞもぞ弄ってたんですが、Addまわりの挙動がすんごく怪しくて信用ならない気がするので私は使うのパス。大丈夫なのかなあ。

まとめ

うーん、まんま、かつ、ゆるふわ単純な話なので特にまとめる話はないかしらん。

ので、We're Hiringということで謎社のほめぱげが少しだけリニューアル、ただしリクルートページが諸事情でまだ工事中!メールフォーム入れるつもりなので、↑のような感じにC# 5.0をすぐに振り回すような最先端な環境のC#でウェブな開発がやりたい方は、是非応募してください。相当本気で人が欲しいところですねー。現状ですけれど、リリース2週間で早くもランキング3位を獲得などと、あまり細かくは言えないのですけれど、まあ非常に好調ですので、安心して&是非とも一緒に加速させましょう。

Razorで空テンプレートとセパレータテンプレート

Razorに限らずT4でもなんでもいいんですが、テンプレートで素のforeachだと、セパレータだったり空の時の代替テンプレートだったりを、どういう風に表現すればいいのかなあ、と悩ましいのです、どうなっているのでしょう実際世の中的に。

WebFormsのRepeaterだとSeparatorTemplateタグと、拡張すればEmptyTemplateなども作れますね。Smarty(PHPのテンプレート、最近ペチパーなので)には{foreachelse}で配列が空の時のテンプレートが吐かれます。カスタムの構文を定義すれば、勿論なんだってありです。

RepeaterにせよSmartyにせよ、よーするところ独自のテンプレート構文だから好き放題できますが、俺々構文って、それ自体の覚える手間もあり、あんまスッキリしないんですよねえ。RazorのIs not a new language、だからEasy to Learn。は大事。また、そういった独自拡張がないからこそ、Compact, Expressive, and Fluidが実現できる(開き@だけで閉じタグレスはやっぱ偉大)し、フルにIntelliSenseなどエディタサポートも効くわけだし。

やりたいことって、コード上のノイズが限りなく少なく、かつ、HTMLという"テキスト"を最大限コントロールの効く形で吐くこと。なわけで、その辺を損なっちゃあ、見失っちゃあ、いけないね。

で、しかしようするところ、やりたいのはforeachを拡張したい。foreachする時に空の時の出力とセパレータの時の出力を足したい。あと、どうせならインデックスも欲しい。あと、最初の値か、とか最後の値か、とかも欲しい(最初はともかく「最後」はindexがないものを列挙すると大変)

そのうえで、Razorの良さである素のC#構文(と、ほぼほぼ同じものとして扱える)というのを生かしたうえで、書きやすくするには(例えばHtmlヘルパーに拡張メソッド定義して、引数でテンプレートやラムダ渡したり、というのは閉じカッコが増えたり空ラムダが出たりして書きづらいしグチャグチャしてしまいクリーンさが消える)、と思って、考えたのが、foreachで回すアイテム自体に情報載せればいいな、と。

<table>
    @foreach (var item in source.ToLoopItem(withEmpty: true, withSeparator: true))
    {
        // empty template
        if (item.IsEmpty)
        {
        <tr>
            <td colspan="2">中身が空だよ!</td>
        </tr>
        }

        // separator
        if (item.IsSeparator)
        { 
        <tr>
            <td colspan="2">------------</td>
        </tr>
        }

        // body
        if (item.IsElement)
        {
        <tr style="@(item.IsLast ? "background-color:red" : null)">
            <td>@item.Index</td>
            <td>@item.Item</td>
        </tr>
        }
    }
</table>

何も足さない何も引かない。とはいえどっかに何か足さなきゃならない。C#として崩さないで足すんなら、単独の要素の一つ上に包んで情報を付与してやりゃあいいんだね、と。foreachで回す時にToLoopItem拡張メソッドを呼べば、情報を足してくれます。

IsEmptyは全体が空の時、IsSeparatorは要素の間の時、IsElementが本体の要素の列挙の時、を指します。Elementの時は、更にIsFirst, IsLast, Indexが取れる。item.Itemはちょっと間抜けか。ともあれ、実際にRazorで書いてみた感触としても悪くなく収まってる。

Emptyだけならばループの外で@if(!source.Any()) /* 空の時のテンプレート */ としてやればいいし、そのほうが綺麗感はある。けれど、それだとsourceがIEnumerableの時キモチワルイ(二度列挙開始が走る)とかもあるし、コレクションに関わるものはforeachのスコープ内に全部収まったほうがスッキリ感も、なくもない。

IndexとIsLastだけが欲しいなら、空テンプレートとセパレータはオプションだから、withEmpty, withSeparatorを共にfalseにすれば、全部Elementなので、if(item.IsElement)は不要になる。

それにしてもRazor V2で属性にnull渡すと属性自体を吐かないでくれる機能は素敵ですなあ。クリーンは正義!

実装はこんな感じ。

public struct LoopItem<T>
{
    public readonly bool IsEmpty;
    public readonly bool IsSeparator;
    public readonly bool IsElement;
    public readonly bool IsFirst;
    public readonly bool IsLast;
    public readonly int Index;
    public readonly T Item;

    public LoopItem(bool isEmpty = false, bool isSeparator = false, bool isElement = false, bool isFirst = false, bool isLast = false, int index = 0, T item = default(T))
    {
        this.IsEmpty = isEmpty;
        this.IsSeparator = isSeparator;
        this.IsElement = isElement;
        this.IsFirst = isFirst;
        this.IsLast = isLast;
        this.Index = index;
        this.Item = item;
    }

    public override string ToString()
    {
        return (IsEmpty) ? "Empty"
             : (IsSeparator) ? "Separator"
             : Index + ":" + Item.ToString();
    }
}

public static class LoopItemEnumerableExtensions
{
    public static IEnumerable<LoopItem<T>> ToLoopItem<T>(this IEnumerable<T> source, bool withEmpty = false, bool withSeparator = false)
    {
        if (source == null) source = Enumerable.Empty<T>();

        var index = 0;
        using (var e = source.GetEnumerator())
        {
            var hasNext = e.MoveNext();
            if (hasNext)
            {
                while (true)
                {
                    var item = e.Current;
                    hasNext = e.MoveNext();
                    if (hasNext)
                    {
                        yield return new LoopItem<T>(index: index, isElement: true, isFirst: (index == 0), item: item);
                    }
                    else
                    {
                        yield return new LoopItem<T>(index: index, isElement: true, isFirst: (index == 0), isLast: true, item: item);
                        break;
                    }

                    if (withSeparator) yield return new LoopItem<T>(index: index, isSeparator: true);
                    index++;
                }
            }
            else
            {
                if (withEmpty)
                {
                    yield return new LoopItem<T>(isEmpty: true);
                }
            }
        }
    }
}

大事なのは、IEnumerable<T>へのループは必ず一回にすること、ね。よくあるAny()で調べてから、ループ本体を廻すと、二度列挙実行が走る(Anyは最初を調べるだけですが、もしIEnumerable<T>が遅延実行の場合、そのコストは読めない)というのは、精神衛生上非常に良くない。

あとIsLastを取るために、一手先を取得してからyield returnをしなければならないので、少しゴチャついてしまいましたが、まあ、こういうのがViewの表面上に現れる苦難を思えば!

最近、イミュータブルな入れ物を作りたい時はコンストラクタにずらずら引数並べるでファイナルアンサー。と思うようになりました、一周回って。名前付き引数で書かせれば、数が多くても可読性落ちたりとかないですし、これでいいでしょう。名前付きで書かせることを強制したいけれど、それは無理なので適度に諦めるとして。

最後にユニットテストを置いておきます。例によってMSTest + Chaining Assertionで。

[TestClass]
public class LoopItemTest
{
    [TestMethod]
    public void Empty()
    {
        Enumerable.Empty<int>().ToLoopItem(withEmpty: false).Any().IsFalse();
        Enumerable.Empty<int>().ToLoopItem(withEmpty: true).Is(new LoopItem<int>(isEmpty: true));
        ((IEnumerable<int>)null).ToLoopItem(withEmpty: false).Any().IsFalse();
        ((IEnumerable<int>)null).ToLoopItem(withEmpty: true).Is(new LoopItem<int>(isEmpty: true));
    }

    [TestMethod]
    public void Separator()
    {
        Enumerable.Range(1, 3).ToLoopItem(withSeparator: false).Is(
            new LoopItem<int>(index: 0, item: 1, isFirst: true, isElement: true),
            new LoopItem<int>(index: 1, item: 2, isElement: true),
            new LoopItem<int>(index: 2, item: 3, isLast: true, isElement: true)
        );

        Enumerable.Range(1, 1).ToLoopItem(withSeparator: true).Is(
            new LoopItem<int>(index: 0, item: 1, isFirst: true, isLast: true, isElement: true)
        );

        Enumerable.Range(1, 3).ToLoopItem(withSeparator: true).Is(
            new LoopItem<int>(index: 0, item: 1, isFirst: true, isElement: true),
            new LoopItem<int>(index: 0, isSeparator: true),
            new LoopItem<int>(index: 1, item: 2, isElement: true),
            new LoopItem<int>(index: 1, isSeparator: true),
            new LoopItem<int>(index: 2, item: 3, isLast: true, isElement: true)
        );

        Enumerable.Range(1, 4).ToLoopItem(withSeparator: true).Is(
            new LoopItem<int>(index: 0, item: 1, isFirst: true, isElement: true),
            new LoopItem<int>(index: 0, isSeparator: true),
            new LoopItem<int>(index: 1, item: 2, isElement: true),
            new LoopItem<int>(index: 1, isSeparator: true),
            new LoopItem<int>(index: 2, item: 3, isLast: false, isElement: true),
            new LoopItem<int>(index: 2, isSeparator: true),
            new LoopItem<int>(index: 3, item: 4, isLast: true, isElement: true)
        );
    }
}

structだと同値比較のために何もしなくていいのが楽ですね、けれどChaining AssertionならIsStructuralEqualがあるので、もしclassでも、やっぱり楽です!

まとめ

RazorだけじゃなくT4でコレクション回す時なんかにも使えます。なにかと毎度毎度、悩みの種なんですよねー。他に、こういうやり方もいいんでないー?とかあったら教えてください。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(.NET)
April 2011
|
July 2025

X:@neuecc GitHub:neuecc

Archive