ASP.NET Coreでリアルタイムで接続するWebアプリケーション作ってみた


gloops AdventCalendar 9日目

gloops advent calendar 9日目です
※calendarって綴りミスりやすいですよね。

8日目はMicrosoft MVP @t_yamatoya さんの「Windows Server コンテナーのまとめ」でした。

私はWebアプリケーションエンジニアという肩書きなのでWebアプリケーションを作ってみたいと思います。
題材として実際の製品ではなく趣味アプリケーションとなりますが、作る過程や考慮する点などは現場でも有効な部分があるかと思います。

今回作るWebアプリケーション概要

「大抽選!!リアルタイムルーレット大会」と題して、
これから年末にかけて忘年会やちょっとしたイベントなどで使えるかも?

※実用性10%くらいなんでリアルタイムWebアプリケーションのサンプルくらいの位置づけになればいいかなぁと思ってます。

Github

ユースケース

設計

細かいところは置いておいてだいたい以下のような構成のイメージで設計していきたいと思います。

ベースとなるフレームワーク

弊社は.NETアプリケーションを主に扱っており、言語はC#が多いです。
WebアプリケーションにおいてもASP.NETで作ります。
ということでWebアプリケーションはASP.NETの(勉強も兼ねて)最新のASP.NET Coreにしましょう。
ベースとなる部分が決まったのでその上にどのようなライブラリを乗せるかを考えます。

データベース

ユーザや作成したイベントを識別したいのでDBを使って実現していきたいと思います。
いくつか選択肢はあるかと思いますが一番スタンダードな方法でEntifyFramework Coreを使います。

ER図

ER図を設計するタイミングは本当にその機能を実装するとき直前が多いです。
企画担当者とかがいるなら企画書を元に機能の話を説明してもらっている時などに頭の中である程度できていると実装に取り掛かるのが早くなります。

認証

認証もデフォルトのIDとパスワードを入力するタイプのものはめんどくさいので
イベントで使うことを考えると外部認証できた方が便利です。
こちらもデータベースと同様に認証を扱うスタンダードなライブラリとしてASP.NET Identityを採用します。その上で外部認証のライブラリを上乗せします。Googleアカウントで認証できれば楽そうです。(同様の仕組みでFacebookTwitterも簡単に実現できます)

リアルタイム通信

できればルーレットするときにリアルタイムで繋げたいのでWebsocket通信で同じ画面を共有できると面白いです。.NETでWebsocketのライブラリといえばSignalRです。

クライアント

クライアント側も多少インタラクティブに動かしたいのでJavascriptを使用しますが、
Typescriptで書く方が慣れているのでTypescriptを使用したいと思います。

実装

雛形の作成

まずは雛形となる部分を作っていきます。

ASP.NET Core Web Application (.NET Core)を選択する

認証の変更 -> 個別のユーザアカウントを選択することでASP.NET Identity + EntitryFramework Coreを使用することになります。

必要なパッケージのインストール

Nugetパッケージマネージャもしくはproject.jsonを編集して以下のライブラリを導入します。SignalRに関してはまだ.NET Coreで使用する場合は執筆時点でまだPreview版である部分は注意が必要です。

  • Microsoft.AspNetCore.WebSockets(0.2.0-preview1-final)
  • Microsoft.AspNetCore.SignalR.Server(0.2.0-preview2-22683)
  • Microsoft.AspNetCore.Authentication.Google(1.0.0)

ライブラリを使用するための記述

Websocket関連

現状だとHubとのDTOのマッピングがcamelCaseとPascalCaseでマッピングされないので自己定義したResolverでマッピングされるようにしてあげるとサーバ側ではPascalCaseでクライアント側ではcamelCaseでプロパティを定義できます。

    public class SignalRContractResolver : IContractResolver
    {
        private readonly Assembly _assembly;
        private readonly IContractResolver _camelCaseContractResolver;
        private readonly IContractResolver _defaultContractSerializer;

        public SignalRContractResolver()
        {
            _defaultContractSerializer = new DefaultContractResolver();
            _camelCaseContractResolver = new CamelCasePropertyNamesContractResolver();
            _assembly = typeof(Connection).GetTypeInfo().Assembly;
        }

        public JsonContract ResolveContract(Type type)
        {
            return type.GetTypeInfo().Assembly.Equals(_assembly)
                ? _defaultContractSerializer.ResolveContract(type)
                : _camelCaseContractResolver.ResolveContract(type);
        }
    }

Startup.csのConfigureServicesメソッドに以下追記

            services.AddSignalR(options =>
            {
                options.Hubs.EnableDetailedErrors = true;
                options.Hubs.RequireAuthentication();  // Hubも認証必須にする
            });
            services.Add(
                new ServiceDescriptor(
                    typeof(JsonSerializer),
                    provider => JsonSerializer.Create(new JsonSerializerSettings {ContractResolver = new SignalRContractResolver()}),
                    ServiceLifetime.Transient)
            );

Starup.csのConfigureメソッドに以下追記

            app.UseWebSockets();
            app.UseSignalR();

これでWebsocketを使用したアプリケーションを作成する準備はできました。

Google認証関連

基本的にはドキュメント記載の方法の通りですんなりと進むはずです。
GoogleAPIコンソールで認証キーとシークレットを取得してdotnetコマンドで以下を実行します
実行したローカルPC上にIDやシークレットが保存されて参照されます。

dotnet user-secrets set Authentication:Google:ClientID <client_id>
dotnet user-secrets set Authentication:Google:ClientSecret <client-secret>

Starup.csのConfigureメソッドに以下追記

            app.UseGoogleAuthentication(new GoogleOptions()
            {
                ClientId = Configuration["Authentication:Google:ClientId"],
                ClientSecret = Configuration["Authentication:Google:ClientSecret"],
            });

これでGoogle認証が可能な状態になりました。楽ちんですね。

Model部分の作成

今回はEventクラスとUserEventクラスの2つを作成したいと思います。
* Eventクラスは開催するイベント(ルーレットを回すひとつのルーム単位となる)
* イベントに参加しているユーザ。UserEventクラスはUserクラスとEventクラスの多対多の関係の中間テーブルとなるモデルです。

モデルを定義しただけではDBContextからアクセスできないので、対象のDBContext(今回はApplicationDbContext)にDbSetとしてモデルを追加します。

    public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }

        protected override void OnModelCreating(ModelBuilder builder)
        {
            base.OnModelCreating(builder);
            // Customize the ASP.NET Identity model and override the defaults if needed.
            // For example, you can rename the ASP.NET Identity table names and more.
            // Add your customizations after calling base.OnModelCreating(builder);
        }

        public DbSet<Event> Events { get; set; }
        public DbSet<UserEvent> UserEvents { get; set; }
    }

今回はLocalDBを使って開発していきますが、Code-FirstでDBを作成していきたいと思うので、Startup.csのConfigureメソッドに以下のように記述します。

            var context = app.ApplicationServices.GetService<ApplicationDbContext>();
            context.Database.EnsureCreated();

context.Database.EnsureCreated()を呼び出すことで、モデルの増分差分をアプリケーション起動時にDBに反映させることができます。開発初期など、モデルがコロコロと変わったりするケースなどで有効化もしれません。

Controller、Hub部分の作成

EventsControllerの作成

EventモデルをもとにCRUD操作できる機構がほしいのでさくっとScaffoldingしてしまいます。
Scaffoldingは指定したモデルをベースにして作成、読み取り、更新、削除の機能を一気に作成してくれる機能です。モデルBindやValidation、DBContextのInjectionなど必要最低限な機能も織り込み済をみで作ってくれるので今回のような簡単なアプリケーションの場合は出番があるかもしれません。全部が必要ではないというケースでも使い方によってはScaffoldingしてから必要な部分だけを間引くというのも有効かもしれません。

ソリューションエクスプローラのコンテキストメニューから新規スキャッフォールディングアイテムを選択しモデルを指定する

あとは、自動生成されたコードに対してビジネスロジックを少し注入していきます。

セッションユーザを取得する方法

まず前提として認証されているユーザしかアクセスさせたくないのでControllerにAuthorizeAttributeをつけます。

AuthroizeAttributeを付けるだけで認証されていないユーザはRedirectして指定したログインページ(デフォルトはAccount/Login)へ遷移します。(デフォルトのログインページを変更する方法は後述)
ちなみにメソッド単位にも設定可能です。

    [Authorize]
    public class EventsController : Controller

認証済みの場合セッションユーザは以下のようにUser.Identityで取得することができます。
User.Identity.Nameが一意な値となるのでAspNetUserテーブルから取得する場合は以下のようにして取得します。


var user = _dbContext.Users.First(x => x.UserName == User.Identity.Name);

デフォルトのログインページを変更する方法

Startup.csのConfigureServicesメソッド内で以下のように呼び出されているところがあるかと思いますが

            services.AddIdentity<ApplicationUser, IdentityRole>()
                .AddEntityFrameworkStores<ApplicationDbContext>()
                .AddDefaultTokenProviders();

以下のようにオプションで変更できます

            services.AddIdentity<ApplicationUser, IdentityRole>(options =>
                {
                    options.Cookies.ApplicationCookie.LoginPath = "/Home"; // /Account/Loginではなく/Homeに変更
                })
                .AddEntityFrameworkStores<ApplicationDbContext>()
                .AddDefaultTokenProviders();

Modelの外部キーアクセスの仕方

基本的にEntityFramework CoreではSelectに相当する値はアクセスしたDbSetのみからしか取得できません。外部キーに相当する部分をSelectしたい場合はIncludeメソッドを使って明示的に指定しなければなりません。例えば以下のような感じです。

以下は、通常アクセスする方法

            var event1 = await _context.Events
                .SingleOrDefaultAsync(m => m.Id == id);

ただし、これではevent1.CreatorはNullReferenceExceptionしてしまいます。
CreatorプロパティはUserテーブルを参照しているため、Includeメソッドを使って明示的に取得するようにしないといけません。

以下は、外部キーも含めて必要なデータを取得する方法

            var event2 = await _context.Events
                .Include(x => x.Creator)
                .SingleOrDefaultAsync(m => m.Id == id);

これで、取得したevent2データからはevent2.Creator.UserNameを取得できます。
さらにその外部キーの外部キーを取得したいという場合は、IncludeしたものにThenIncludeメソッドを呼び出しましょう。

            var event3 = await _context.Events
                .Include(x => x.UserEvents)
                .ThenInclude(x => x.User)
                .SingleOrDefaultAsync(m => m.Id == id);

Includeを使用すればもちろん発行されるクエリが変わってくるので、デフォルトでは最速パフォーマンスになるようにできるだけ無駄なデータを読み込まない配慮がされているんですね。

RouletteHubの作成

HubはSingalRでWebsocket通信をした際のControllerのようなものでWebsocket通信を受け取るサーバ部分になります。
今回はサーバ側で定義するイベントとしては以下の通りです。

  • Connect
    • 同一のイベントIDで接続した際にWebsocket接続をともにするグループにアサインします
  • Join
    • 指定したイベントIDに参加を表明します。UserEventが作成されます
  • DrawLots
    • 指定したイベントIDのUserEventの中から抽選します。

サーバが定義したイベントのコールバックをクライアント側にPushで通知することが可能です。
Hubクラスのジェネリクスを指定すればクライアントの型を定義できます。(指定しない場合はdynamic)

クライアント側のコールバックメソッドをインタフェースで定義する

    public interface IRouletteClient
    {
        void OnConnect();
        void OnJoin(UserDto user);
        void OnLeave(UserDto user);
        void OnDrawLots(UserDto user);
    }

使用するHubクラスでジェネリクス指定で継承する

public class RouletteHub : Hub<IRouletteClient>

そうすれば、Hubメソッドの中で以下のように呼び出すことができて、接続中の同一グループに通知することができます。

Clients.Group(groupName).OnConnect();

Viewの作成

基本的にはSchaffoldで作成されたViewをいじりながら構築していきます。

TypescriptでSignalRの接続

サーバ構成に合わせたjsファイルを自動生成してアクセスできるようにしてくれます。
デフォルトでは~/signalr/jsというパスなので、singalRが必要なViewでscriptタグで参照しておいてください。
jQueryプラグインとしてsingalRのクライアントライブラリにアクセスできます。

@section scripts {
    <script src="//ajax.aspnetcdn.com/ajax/signalr/jquery.signalr-2.0.3.js"></script>
    <script src="~/signalr/js"></script>
}

型定義は自前で用意しました。
最小構成でよければ使えるかもしれません。

declare namespace JQuerySignalR {
    interface ISignalR {
        hub: IHub;
    }

    interface IHub {
        start(): JQueryPromise<void>;
        server: IHubServer;
        client: IHubClient;
        on<T>(event: string, callback: (result: T) => void);
}

    interface IHubServer {
    }

    interface IHubClient {
    }
}

interface JQueryStatic {
    signalR: JQuerySignalR.ISignalR;
}

Hubに合わせてoverrideする

declare namespace JQuerySignalR {
    interface ISignalR {
        rouletteHub: IRouletteHub;
    }
    interface IRouletteHub extends IHub {
        server: IRouletteHubServer;
    }
    interface IRouletteHubServer extends IHubServer {
        connect(eventId: number): void;
        join(eventId: number): void;
        drawLots(eventId: number): void;
    }
}

Typescript側で型定義がある状態で呼び出せます。
onはクライアント側へのコールバックを監視します。
サーバからの通知が来たら指定した関数が呼び出されます。


$.signalR.hub.start()
    .then(() => {
        rouletteHub.on("onconnect", onconnect);
        rouletteHub.on("onjoin", onjoin);
        rouletteHub.on("ondrawlots", ondrawlots);
        rouletteHub.server.connect(eventId);
    });

これでリアルタイム接続できる環境が整いました。

その他Tips

  • url小文字にしたい
services.AddRouting(options =>
            {
                options.LowercaseUrls = true;
            });
  • Gravatarアイコン使いたい

まとめ

疲れてきたのでこの辺で笑
あとは、デザインをいい感じにしてもっと盛り上がる演出にしてこれからの季節に大活躍するアプリになればいいですね!
さて、アイス買いに行こう♪