xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP な管理人の技術ブログです。

Azure Functions + Azure SignalR Service でメッセージを Push 配信する

リアルタイムな双方向通信フレームワークである SignalR にスケーラブルな接続管理を提供する Azure SignalR ServiceGA されて半年ほどが経ちました。これと Azure Functions を利用し、いわゆるサーバーレスアーキテクチャの構成でクライアントにリアルタイムにメッセージを Push 配信することができるようになりました。この構成を作るまでの手順を残します。

以下の公式サンプルが超参考になるのでオススメです。

作りたい構成

全体像としてはザックリと下図のようなものを想定しています。Kinect とか HoloLens などでクライアント側を仮に表現していますが、これは作りたいものによって変えていただければ OK なので、チャットのようなものを想定していただいても良いかと思います。

f:id:xin9le:20190428105723p:plain

クラウド側の作り方

Step.1 : Azure Functions のインスタンスを作る

Azure Functions のインスタンスを作成します。何ということはなく、ただ作るだけです。唯一あるとすればランタイムスタックを「.NET」にすることくらいでしょうか。(ちなみに Linux 環境下では試したことがないので、できるのか確信はありません)

f:id:xin9le:20190428111517p:plain

Step.2 : Azure SignalR Service のインスタンスを作る

次に Azure SignalR Service のインスタンスを作成します。東日本にもすでに来ているのが嬉しいところですね。

f:id:xin9le:20190428112332p:plain

f:id:xin9le:20190428112428p:plain

大事なポイントは Service Mode を Serverless にする ことです。Service Mode についてはしばやんの記事が詳しいのでそっちに譲ります。

Step.3 : Azure SignalR Service の接続文字列を Azure Functions に設定する

Azure Functions から Azure SignalR Service に Push 配信したいメッセージを投げる必要があるわけですが、どの Azure SignalR Service に対して投げるべきなのか、そのエンドポイントを知る必要があります。ということで Azure SignalR Service への接続文字列を Azure Functions に設定してあげます。以下のような感じです。キー名は AzureSignalRConnectionString で固定で、値に接続文字列を入れるのがポイント!

f:id:xin9le:20190428114149p:plain

f:id:xin9le:20190428114729p:plain

ここまでで Azure 側での設定はおしまいです。簡単ですね!

Step.4 : SignalR Service Binding の利用準備

ここからは Azure Functions の実装をしていきます。Visual Studio を開き、Azure Functions の .NET Core テンプレートから開始します。

f:id:xin9le:20190428131836p:plain

Azure Functions と Azure SignalR Service の間は SignalR Service Binding を用いて行います。これは Azure Functions の拡張機能である「入出力バインディング」で実現されており、GitHub にてオープンソースとして公開されています。この NuGet Package をインストールしましょう。

f:id:xin9le:20190428134512p:plain

PM> Install-Package Microsoft.Azure.WebJobs.Extensions.SignalRService

Step.5 : Azure SignalR Service のエンドポイントを問い合わせるメソッドを実装

Push 配信でメッセージを受信したいクライアント (冒頭の図だと HoloLens なので、ここでは HoloLens として話を進めます) は、事前に Azure SignalR Service との間に Connection を張っておく必要があります。ですが、HoloLens は Azure SignalR Service のエンドポイントを知りません。なので、すでにエンドポイントを知っている Azure Functions (Step.3 で接続文字列を指定しましたよね!) に「どこに繋ぎに行ったらいいんだい?」というのを聞くことにします。以下のような感じで実装します。

[FunctionName("negotiate")]  // 'negotiate' で固定
public static SignalRConnectionInfo Negotiate
(
    [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequest _,  // POST で受ける
    [SignalRConnectionInfo(HubName = "sample")] SignalRConnectionInfo info  // 接続先情報を injection してもらう
) => info;  // それを返す

こんな短い実装ですが、ポイントがいくつかあります。これは SignalR の Client ライブラリは POST メソッドで /negotiate という固定の URL で接続先情報を取得するように実装されているためです。

  1. FunctionName 属性に negotiate を指定したメソッドを作る
  2. POST で動作する [HttpTrigger] な関数とする
  3. SignalRConnectionInfo を返す

SignalRConnectionInfo は SignalR Service Binding が提供するもので、メソッドの引数に属性を与えておくとメソッドが呼び出し時に injection してくれます。なのでそれを返すだけの実装で OK です。

Step.6 : メッセージを Push 配信する

最後に Azure Functions で受け取り、それを Azure SignalR Service に投げてメッセージを Push 配信する API を実装します。例えば以下のような感じです。

[FunctionName("broadcast")]  // 任意
public static async Task BroadcastAsync
(
    [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequest request,  // POST で投げることにする
    [SignalR(HubName = "sample")] IAsyncCollector<SignalRMessage> messages
)
{
    var data = await request.ReadAsStringAsync();  // 受け取ったデータを取り出す
    await messages.AddAsync(new SignalRMessage  // 出力バインディングを使って SignalR Service に投げ込む
    {
        Target = "Receive",  // 配信先の 'Receive' を呼び出す
        Arguments = new[] { data },  // Push 配信するデータ
    });
}

重要なポイントは IAsyncCollector<SignalRMessage> の部分で、このインスタンスに AddAsync することで (出力バインディング経由で) SignalR Service にデータを投げ込むことができます。

ここまでできたら Azure Functions にデプロイ!以上でクラウド側の実装もすべておしまいです。簡単ですね :)

クライアント側の作り方

クライアント側は、相手がサーバーレスアーキテクチャだからと言って特別何か難しいことをする必要はありません。今回はサンプルとして WPF で実装してみます。

<Window x:Class="SignalRTestApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d"
        Title="SignalRTestApp" Height="200" Width="500">
    <Grid Margin="5">
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <Label Name="StatusText" Grid.Row="0" BorderBrush="Red" BorderThickness="1" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" FontSize="30" />
        <StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,5,0,0">
            <Button Content="Connect" Width="75" Click="OnConnectClick" />
            <Button Content="Disconnect" Width="75" Margin="5,0" Click="OnDisconnectClick" />
            <Button Content="Broadcast" Width="75" Click="OnBroadcastClick" />
        </StackPanel>
    </Grid>
</Window>
using System;
using System.Text;
using System.Net.Http;
using System.Windows;
using Microsoft.AspNetCore.SignalR.Client;

namespace SignalRTestApp
{
    public partial class MainWindow : Window
    {
        private const string RootUrl = "https://signalr-test-api.azurewebsites.net";  // 作った Azure Functions の URL
        private HttpClient HttpClient { get; } = new HttpClient();
        private HubConnection Connection { get; }

        public MainWindow()
        {
            this.InitializeComponent();
            this.Connection = new HubConnectionBuilder().WithUrl($"{RootUrl}/api").Build();

            // Azure SignalR Service から Push されてきたメッセージを受信して表示
            // 最初の図の HoloLens 側
            this.Connection.On<string>("Receive", data =>
            {
                this.Dispatcher.Invoke(() => this.StatusText.Content = data);
            });
        }

        private async void OnConnectClick(object sender, RoutedEventArgs e)
        {
            // 最初の図の HoloLens 側
            await this.Connection.StartAsync();  // '/negotiate' から接続情報を取得して接続
            this.StatusText.Content = "Connected!";
        }

        private async void OnDisconnectClick(object sender, RoutedEventArgs e)
        {
            // 最初の図の HoloLens 側
            await this.Connection.StopAsync();  // 切断
            this.StatusText.Content = "Disconnected!";
        }

        private async void OnBroadcastClick(object sender, RoutedEventArgs e)
        {
            // 日付文字列を POST で Azure Functions に投げ込む
            // 最初の図の Kinect 側
            var url = $"{RootUrl}/api/broadcast";
            var now = DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss.fff");
            using (var content = new StringContent(now, Encoding.UTF8))
                await this.HttpClient.PostAsync(url, content);
        }
    }
}

これを実行すると以下のような感じになるはずです。パッと動かす分には全然難しくないですね!

f:id:xin9le:20190429235248g:plain

CloudStructures v2.1.0 released!!

前回に引き続き今回も CloudStructures ネタです。.NET Standard 対応を行ってから来たフィードバックにお応えしたのと、反省点の修正 (?) を行いました。変更点は大きく 3 点あります。

  • RedisLock 型の追加
  • 非同期メソッドとして提供しているコマンドに Async 接尾詞をつける
  • IRedisStructures から DefaultExpiry プロパティを切り出す

RedisLock 型の追加

Grani 時代に同僚のみっちぃ (@mitchydeath) から「DistributedLock を作れない」と言われました。完全に見落としてました...。

ということで、それを可能にするためのコマンド群を RedisLock として追加しました。いい感じにラップすれば C# 8.0 で提供されそうな IAsyncDisposable にも対応できるんじゃないかと思います。

命名規則の変更

これまで CloudStructures は Redis コマンド関連のメソッドに Async の接尾詞をつけていませんでした。非同期メソッドだけど!これは昔の @neuecc さんの blog に書いてありますが、「悩んだけど消した」という歴史的な理由に依ります。

ですが「やっぱり付けた方がいいよね」ということで、破壊的変更 MAX ではありますが付けて回りました。非同期メソッドには Async を付けましょう!

IRedisStructureWithExpiry を新設

RedisStringRedisLock のようなコマンド型は基底インターフェースとして IRedisStructure を持っています。これまでそこに DefaultExpiry プロパティを持っていたのですが、RedisLockRedisLua のような型では DefaultExpiry プロパティを利用していないという現実がありました。これはプロパティを持っているだけ無駄なので、DefaultExpiry プロパティを持っているインターフェースと持っていないインターフェースに分離しました。

public interface IRedisStructure
{
    RedisConnection Connection { get; }
    RedisKey Key { get; }
}

public interface IRedisStructureWithExpiry : IRedisStructure
{
    TimeSpan? DefaultExpiry { get }
}

public readonly struct RedisLock<T> : IRedisStructure{}
public readonly struct RedisString<T> : IRedisStructureWithExpiry{}

これも十分に破壊的変更ですが、あるべき姿により近づくと思って導入しました。

CloudStructures now supports .NET Standard!!

Grani 時代から Redis 操作ライブラリとして長く愛用してきた CloudStructures ですが、残念ながらこれまで .NET Core に対応していませんでした。ただ、こればっかりはさすがの @neuecc 先生もあれもこれもメンテするのは難しいので仕方ない!.NET Core 対応済みの王道ライブラリは StackExchange.Redis だと思いますが、余りにプリミティブ過ぎて使い勝手が良いとは言えません。もはや CloudStructures がないと生きていけない体にされてしまった...(罪深い

f:id:xin9le:20190228223150p:plain

と言うことで意を決して .NET Core 対応をしようと思い立ち、2018 年 6 月頃から勝手に Fork (コピー) してヒッソリと作業を始めました。半年以上プライベートで使っていたのですが、.NET Core 3.0 のリリースも近づいている今、より盛り上げるためにも本家に入れてもらえるように @neuecc 先生に話をして承認いただき、ついに正式に .NET Standard 版となりました!

主な変更点

使用感としてはそこまで変わらないのですが、以前のバージョンと比べるとかなりの破壊的変更が加わっています。リリース当時から比べると Managed な Redis でクラスター環境が提供されるようになったり、依存していたライブラリやフレームワークの環境も相当に変化していて、そういった補完していた部分だったりを今風に適応/アレンジしました。また、近年は C#/.NET もパフォーマンス最適化が命題となってきているので、その辺りにも最大限気を配ってみました。

リファクタリング系

  • RedisSettings / RedisGroupRedisConnection に一本化
  • RedisString に含まれていた Bit 関連のコマンドを RedisBit として切り出し
  • Geo 関連コマンドを RedisGeo として新設
  • RedisClass を削除
    • Grani の中でもほとんど使われてなかった
  • RedisSubject を削除
    • これは元々別パッケージとして提供されていたので一旦削除
    • 今後対応を考えるかも
  • IServerSelector を削除
    • 自前でロードバランスするのではなく Cluster 化された Managed Redis を使う方が時代に即してそう
  • TraceHelper を削除
  • app.config / web.config からの設定読み込み機能を削除
  • Glimpse サポートを削除
    • Glimpse が .NET Core に対応していないため

パフォーマンス改善系

  • RedisString などを class から struct に変更
    • ほとんどのケースでインスタンスの生存期間が非常に短くスコープも狭いため、ヒープアロケーションを避けるべきと判断
  • ラムダ式の変数キャプチャを完全に排除
  • ボックス化を完全に排除
    • 特に ValueConverter 周りの仕組みを思い切り改善

まとめ

CloudStructures を隅から隅まで全部読んで書き換えたこともあって、だいぶ理解が深まりました。パフォーマンス改善関連もすでに世の中で実現されているテクニックのいくつかを真似しているだけですが、そういったところに細心の注意を払うってすごく地道だし大変って実感しました。ほんと、すごく勉強になりました。

あとライブラリの Contributor にしていただいたので、何かあれば Pull Request などいただければ!.NET で Redis を扱うときには是非 CloudStructures を使ってみてくださいね!

平成最後なのでソーシャル就活で転職してみた

「平成最後なので」というのは何の意味もない枕詞ですが、今風な感じ (?) を出すためだけに付けてみました。それ以上の理由はありません!タイトルの通り今回ソーシャル就活というのもをやってみたのですが、どんな感じだったかを書いてみようと思います。

f:id:xin9le:20190203190632p:plain

ソーシャル就活という選択

今回退職に伴って、次の就職先をどうやって探そうかと悩んでいました。これまで転職先を探すとなったとき、大きく 3 パターンほどあったように思います。

  1. 転職/求人サイトの活用
  2. 転職エージェントに紹介してもらう
  3. 知人の会社にアプローチする

僕自身これまでの転職は「3」しか行ったことがなく、求人サイトや転職エージェントを利用したことはありませんでした。求人サイトを活用するのは重要な方法だとは思いますが、無数にある企業の中から、しかもどれも似通った募集要項の中から自分自身のスキルセット / やりたいこと / 希望年収レベルにマッチした条件を探し出す難しさはかなりのものだと思います。そして何よりも必死に探し当てた企業様が本当に自分を必要としてくれているのかどうかがどうしても分からない、という辛さがあります。(必要とされていない場合は落とされるだけだと思いますが...)

そう考えたとき「そもそも自分は市場に求められている人材なのか?」「そもそも市場価値があるのか?」という疑問でいっぱいになり、それを確認してみたいという気持ちが強くなりました。最近少しずつ #Twitter転職 というハッシュタグも見かけるようにもなりましたし、SNS を利用して転職活動中であることをアピールしてみるのもありかもしれない。そうすると自分の市場価値も分かるかもしれない。RT もいいねもされなければ拡散されないのでその点はかなり運なのですが、一切声がかからなければ市場価値なしと判断できます。そうすれば生き方 / 活動の仕方を改める必要があると分かるので、それもまたよい仮説検証だろうと前向きに捉えてやってみることにしました。

レジュメの作成

ただ単純に「仕事探してます!声掛けてください!」だけで反応してくれるとは全く思わなかったので、自分のスキルセットや経験 (= いわゆる職務経歴書) を見せて僕自身を知ってもらう必要がありました。なので GitHub にレジュメを作って公開しました。これは Grani 時代の仲間である @neuecc さんや @guitarrapc_tech さんがやっていたので、素晴らしい方法だなぁと思って真似させてもらいました。

あれもこれも文章だと読んでもらえないので、スキルセットをキャッチーにイイ感じに表現できないかなぁと考えて☆で表現してみました。自分が持っている技術とその理解度を知るだけでなく、改めて弱点を把握する時間にもなりとても有意義でした。これは職務経歴書を作るでなくてもやってみて損しないと思います。レジュメ作って本当に良かった。

反響

Twitter / Facebook の両方に「転職先探してます」の投稿をしました。Facebook は友達限定公開の投稿なのでお見せすることができませんが、Twitter は以下のような感じで投稿しました。

大変ありがたいことに多数の RT といいねをいただきまして、ツイートアクティビティは (執筆時点で) 概ね以下のような感じになっています。職務経歴書を 2640 回も見てもらえるなんて普通に就活してたら絶対無理

f:id:xin9le:20190203185447p:plain

たくさんの方の目に触れる結果となったことで、自分が把握してる範囲で大小合わせて 33 もの個人/企業様からお声がけいただきました。もし自分で会社探しをしていたとしてもこんなにたくさんの候補先を見つけることは絶対に無理でしたし、逆にこんなにも自分を必要としてくれている企業様があるんだと知って自信がつきました。僕の周りには「遠く及ばない」と思う尊敬できる方々がたくさんいますし、おかげで劣等感に苛まれることも多々あるのですが、この結果は本当に気分を上向かせてくれました。

一晩にして 30 近くの連絡があったのですが、とりあえず返信をするのにとんでもなく時間がかかりました…。A さんに返事を書き、続けて B さんに返事を書いている間にまた A さんからの返信が来るみたいな感じで対応に追われまくり、6 時間かかっても全員に返事をすることができませんでした。話をしていくと多くのケースで「一度食事でもしながらお話しませんか?」という流れになるものです。そんなこんなで 1 月中旬から 1 月末まで毎日誰かと食事するという経験したこともないものすごいスケジュールになってしまいました。飲み会以外での飲酒は一切しないので、体が持つか正直不安でしたw

余談

おまけですが、ディライトワークスさんにお声がけいただいた事が 2ch まとめに載りました。さすがの大人気ゲーム FGO*1 (= Fate/Grand Order) 効果!載っていることを友人から教えてもらったのですが、まさか拾われると思ってなかったのでさすがに笑ってしまいましたw

転職先の決定

身に余るほどたくさんの候補先が見つかりましたが、有名どころで言うと以下のようなところがありました。なんというかネームバリューだけでも圧倒されてしまいますね...。

今回僕は転職における第 1 条件として「リモートワークを許可してくれること」を掲げていたので、それが理由で多くのところが条件から外れていきました。「そういうのは最初に書けよ」と言われそうですが、希望年収 / 希望配属先 / 希望役職などと同様に求める条件のひとつでしかないと思っていたので書きませんでした。現在 3 年半ほど東京に単身赴任をしている身なのですが、この期間は家族と過ごす時間とのトレードオフとしてエンジニア人生を充実させてきました。これは悪くない選択だったと思っていますが、その分今度は家族との時間をこれまでより多く確保したいという気持ちが強くなり、それがリモートワークを求めた理由です。

そんな僕のある種のワガママのようなライフスタイル / ワークスタイルを受け入れてくれた会社さんが数社あり、その中でも以下の 3 社でかなり迷いました。必須条件をクリアしたあとは年収レベル / その会社で体得できること / 福利厚生などのバランスで決定することになりますが、どの会社さんも甲乙付けがたいくらいに素晴らしかったです。

これまでのキャリアではデスクトップアプリから Web アプリ、XR アプリまで幅広く手がけてきましたが、クラウドインフラの構築はそこまで詳しくなく (できないわけではないけれど) まだ得意と言えるほどではありません。特定のクラウド信者ではないので Azure / AWS / GCP などどれでも特に抵抗はないのですが、クラウド領域のスキル強化は今後のエンジニア人生として重要なものになると考え、今回は Azure の分野で最前線を走る会社のひとつであるシグマコンサルティングさんを選択することにしました。社長の橋本さんが僕のワークスタイルや今後の目標に共感してくれたことも、大きな決め手になりました。

長々と書いてきましたが、今回のソーシャル就活は拡散運にも恵まれつつ、個人的には非常に良い経験/結果になったと思っています。お声掛けくださった皆様、本当にありがとうございました!

おまけ : ソーシャル就活するまで 1 年間の活動

1 年前の今頃は株式会社グラニというゲーム会社に勤めていたのですが、諸般の事情で事業売却することとなり、それに伴って 2018 年 4 月より MYNET グループ配下の株式会社 GMG に承継転籍していました。そこも 2018 年 6 月で退職し、以降は知人のスタートアップ企業に勤めていました。そこではリードエンジニアとして以下のような多岐にわたる活動をしていました。

  • コアライブラリの作成
  • CI などの開発インフラの整備
  • クラウドインフラの整備
  • アプリケーション実装
  • デザイン/サービス仕様の検討
  • チームビルディング
  • 技術選定
  • etc...

このときの転職先としてスタートアップ企業を選んだ理由は、ひとり当たりに占める責任の割合の大きさを重視することでした。仕事における責任範囲とは会社規模が大きくなるにつれ明確化されていくもので、そういったある程度の規模のチームに join するとほぼ確実に個々人の能力が最大化されそうな場所に配置/配属されるものです。これは採用側として何ひとつ間違っていない采配ですが、ことスタートアップのような少人数チームとなるとそうは行かず個々人の責任範囲が必然的に広くなります。当時の僕は「自分の weak point (= 未経験な技術領域) を少しでも業務を通じて克服したい」という気持ちが強く、そのためなら投資を惜しまないという考えでした。

ここで言う投資とは時間的な投資と金銭的な投資の両方の意味を含みます。スタートアップは半年 ~ 1 年で芽を出せるかどうかが肝心です。極めて短期勝負。その期間広い責任を持ちつつ全力で突っ走ることは否応がなく短期間でスキルを伸ばすことに繋がる、と考えていました。またそのような経験を得られるのであれば金銭的にそこまで恵まれなくても、たとえ毎月赤字になろうとも、十分価値のあるものになると考えていました。実際ひとりの娘とパートをしている妻がいる身ではありますが、たとえ僕が 1 円も稼がなくても数年生きることができる程度には貯金していたので、短期的な勝負であれば金銭的にも問題ないと判断してスタートアップに join することにしました。それでスタートアップ事業が成功すれば +α で win です。

結果としてはこの思惑はほぼ計画通りに進み、僕は実質 8 ヵ月程度の在籍ながらかなりの経験を積むことができたと思います。残念にも事業の方向性がブレ続けて定まらなかったため、ずっと付き合うことが技術的にも金銭的にも自分の win にならないと判断して退職することにしました。事業の成功という +α を得ることができなかったのは残念ですが、スタートアップ企業に実際に勤めてみてその難しさを直に体感できたこともまた良い経験でした。

*1:Apple Store で年間 1000 億円売り上げたモンスタータイトル

キャリッジリターン (CR) を無視する ModelBinder を適用する

タイトルの通りです、それ以上の情報がないのですが...!下記のドキュメントを参考に、そういうものを作りました。

動機

iOS の Safari から改行を含む <textarea> のデータを POST すると勝手に CR が付与されるという問題がありました。Form の Submit イベントを JavaScript でハンドリングして CR を除外してから送信しても勝手に付与される不思議!なんでだ!

もはやクライアント側では回避不能なのではないかということになり、サーバー側で CR を削除するしかないということで対応することにしました。

実装

ASP.NET Core MVC には (ASP.NET MVC にも) ModelBinder という POST されたデータをプロパティや変数に値を詰める処理をカスタマイズする拡張ポイントがあるので、それを使っています。案外簡単ですね!

public class IgnoreCarriageReturnStringBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext == null)
            throw new ArgumentNullException(nameof(bindingContext));

        //--- 値を取り出す
        var modelName = bindingContext.ModelName;
        var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
        if (valueProviderResult == ValueProviderResult.None)
            return Task.CompletedTask;

        //--- ModelState を更新
        bindingContext.ModelState.SetModelValue(modelName, valueProviderResult);

        //--- CR を削除
        var value = valueProviderResult.FirstValue;
        if (value != null)
            value = value.Replace("\r", string.Empty);

        //--- バインディング成功
        bindingContext.Result = ModelBindingResult.Success(value);
        return Task.CompletedTask;
    }
}

あとは適用したいプロパティにピンポイントで属性を貼れば OK です。サービス全体で適用する場合は Startup.cs で処理しても良いですが、良し悪しあるので適宜判断してください。

public class HogeModel
{
    //--- textarea とマップされるプロパティ
    [ModelBinder(typeof(IgnoreCarriageReturnStringBinder))]
    public string Text { get; set; }
}