GraphQL.NET の DataLoader

GraphQL でネストしたデータを取得するクエリを発行するとき、 N+1 問題を回避するために、 ネストしたデータの先読み込みか遅延読み込みを行う必要がある。

先読み込みの場合は、クエリのリゾルバ内で Entity Framework Core の Include を使って実装できるけど、 せっかく取得しても使わず無駄になってしまうことがありそう。 使うかどうかわからないデータを先読みするのは非効率か。

まとめて遅延読み込みできれば効率的。 遅延読み込みの場合は、DataLoader という仕組みを使う。 GraphQL.NET は DataLoader の機能を提供しているので、追加のライブラリは不要。 早速サンプルを書いてみた。

using System;
using System.Collections.Generic;
using System.Linq;
using GraphQL;
using GraphQL.DataLoader;
using GraphQL.Server;
using GraphQL.Server.Ui.Playground;
using GraphQL.Types;
using GraphQL.Types.Relay.DataObjects;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace DataLoaderSample
{
    public class Team
    {
        public int Id { get; set; }
        public string Name { get; set; }

        public IList<Player> Players { get; set; }
    }

    public class Player
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Position { get; set; }
        public int Number { get; set; }
        public int TeamId { get; set; }

        public Team Team { get; set; }
    }

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

        public DbSet<Team> Teams => Set<Team>();

        public DbSet<Player> Players => Set<Player>();

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Team>(e =>
            {
                e.Property(x => x.Id).IsRequired();
                e.Property(x => x.Name).IsRequired();
                e.HasKey(x => x.Id);
                e.HasMany(x => x.Players);
            });
            modelBuilder.Entity<Player>(e =>
            {
                e.Property(x => x.Id).IsRequired();
                e.Property(x => x.Name).IsRequired();
                e.Property(x => x.Number).IsRequired();
                e.Property(x => x.Position).IsRequired();
                e.Property(x => x.TeamId).IsRequired();
                e.HasKey(x => x.Id);
                e.HasOne(x => x.Team);
            });
        }

        // テスト用のデータを登録する
        public void EnsureSeedData()
        {
            if (!Teams.Any())
            {
                Teams.Add(new Team
                {
                    Name = "バルセロナ",
                    Players = new List<Player>
                    {
                        new Player
                        {
                            Name = "メッシ",
                            Number = 10,
                            Position = "FW",
                        },
                        new Player
                        {
                            Name = "スアレス",
                            Number = 9,
                            Position = "FW",
                        },
                    },
                });
                Teams.Add(new Team
                {
                    Name = "レアルマドリード",
                    Players = new List<Player>
                    {
                        new Player
                        {
                            Name = "ベイル",
                            Number = 11,
                            Position = "FW",
                        },
                        new Player
                        {
                            Name = "モドリッチ",
                            Number = 10,
                            Position = "MF",
                        },
                    },
                });

                SaveChanges();
            }
        }
    }

    public class TeamType : ObjectGraphType<Team>
    {
        readonly IDataLoaderContextAccessor accessor;

        public TeamType(IDataLoaderContextAccessor accessor)
            : base()
        {
            this.accessor = accessor;

            Field(x => x.Id);
            Field(x => x.Name);

            // チームに所属する選手を取得できる
            Connection<PlayerType>()
                .Name("players")
                .ResolveAsync(async context =>
                {
                    // チームの選手を取得する部分は DataLoader を使って
                    // まとめて取得できるようにする。
                    var dbContext = (ApplicationDbContext)context.UserContext;
                    var loader = accessor.Context.GetOrAddBatchLoader<int, Connection<Player>>(
                        loaderKey: "team_players",
                        fetchFunc: async (teamIdList) =>
                        {
                            // チームでグループ化し、各グループを Connection に変換
                            return await dbContext.Players
                                .Where(p => teamIdList.Contains(p.TeamId))
                                .OrderBy(p => p.TeamId)
                                .ThenBy(p => p.Id)
                                .GroupBy(p => p.TeamId)
                                .ToDictionaryAsync(
                                    keySelector: players => players.Key,
                                    elementSelector: players =>
                                    {
                                        var connection = new Connection<Player>
                                        {
                                            PageInfo = new PageInfo
                                            {
                                                StartCursor = players.FirstOrDefault()?.Id.ToString(),
                                                EndCursor = players.LastOrDefault()?.Id.ToString(),
                                            },
                                            Edges = players.Select(xx => new Edge<Player>
                                            {
                                                Cursor = xx.Id.ToString(),
                                                Node = xx,
                                            }).ToList(),
                                        };
                                        return connection;
                                    });
                        });
                    return await loader.LoadAsync(context.Source.Id);
                });
        }
    }

    public class PlayerType : ObjectGraphType<Player>
    {
        readonly IDataLoaderContextAccessor accessor;

        public PlayerType(IDataLoaderContextAccessor accessor)
            : base()
        {
            this.accessor = accessor;

            Field(x => x.Id);
            Field(x => x.Name);
            Field(x => x.Position);
            Field(x => x.Number);
            Field(x => x.TeamId);

            // 選手が所属するチームを取得できる
            FieldAsync<TeamType>(
                name: "team",
                resolve: async context =>
                {
                    // 選手が所属するチームを取得する部分も DataLoader を使って
                    // まとめて取得できるようにする。
                    var dbContext = (ApplicationDbContext)context.UserContext;
                    var loader = accessor.Context.GetOrAddBatchLoader<int, Team>(
                        loaderKey: "player_team",
                        fetchFunc: async (teamIdList) =>
                        {
                            return await dbContext.Teams
                                .Where(x => teamIdList.Contains(x.Id))
                                .ToDictionaryAsync(x => x.Id);
                        });
                    return await loader.LoadAsync(context.Source.TeamId);
                });
        }
    }

    // クエリを表す型
    public class SampleQuery : ObjectGraphType
    {
        public SampleQuery()
            : base()
        {
            Connection<TeamType>()
                .Name("teams")
                .ResolveAsync(async context =>
                {
                    // クエリのフィールドは DataLoader を使わなくてもまとめて取得できる
                    var dbContext = (ApplicationDbContext)context.UserContext;
                    var teams = await dbContext.Teams.ToListAsync();
                    return new Connection<Team>
                    {
                        PageInfo = new PageInfo
                        {
                            StartCursor = teams.FirstOrDefault()?.Id.ToString(),
                            EndCursor = teams.LastOrDefault()?.Id.ToString(),
                        },
                        Edges = teams.Select(x => new Edge<Team>
                        {
                            Cursor = x.Id.ToString(),
                            Node = x,
                        }).ToList(),
                    };
                });
            Connection<PlayerType>()
                .Name("players")
                .ResolveAsync(async context =>
                {
                    // クエリのフィールドは DataLoader を使わなくてもまとめて取得できる
                    var dbContext = (ApplicationDbContext)context.UserContext;
                    var players = await dbContext.Players.ToListAsync();
                    return new Connection<Player>
                    {
                        PageInfo = new PageInfo
                        {
                            StartCursor = players.FirstOrDefault()?.Id.ToString(),
                            EndCursor = players.LastOrDefault()?.Id.ToString(),
                        },
                        Edges = players.Select(x => new Edge<Player>
                        {
                            Cursor = x.Id.ToString(),
                            Node = x,
                        }).ToList(),
                    };
                });
        }
    }

    // サンプルの GraphQL スキーマ。
    public class SampleSchema : Schema
    {
        public SampleSchema(IDependencyResolver dependencyResolver)
            : base(dependencyResolver)
        {
            // 今回はクエリだけ。
            Query = dependencyResolver.Resolve<SampleQuery>();
        }
    }

    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

            // GraphQL Server を使用する
            services.AddGraphQL()
                .AddUserContextBuilder(httpContext =>
                {
                    return httpContext.RequestServices
                        .GetService<ApplicationDbContext>();
                })
                .AddRelayGraphTypes() // ページネーションで使う型を DI コンテナに登録
                .AddDataLoader();  // DataLoader 関連の型を DI コンテナに登録

            // ↑でDependencyResolver を登録してくれないので、自前で登録する必要がある。
            // DefaultDependencyResolver はコンストラクタインジェクションに対応していないため、
            // FuncDependencyResolver を使わないといけない。
            services.AddSingleton<IDependencyResolver>(x => new FuncDependencyResolver(x.GetService));

            // 自前で定義した GraphQL 用の型を登録
            services.AddSingleton<TeamType>();
            services.AddSingleton<PlayerType>();
            services.AddSingleton<SampleQuery>();
            services.AddSingleton<SampleSchema>();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env, ApplicationDbContext dbContext)
        {
            // GraphQL Server を使う
            app.UseGraphQL<SampleSchema>("/graphql");

            // GraphQL Playground を使う
            app.UseGraphQLPlayground(new GraphQLPlaygroundOptions
            {
                Path = "/ui/playground",
            });

            // テスト用データベースが無ければ作る
            dbContext.Database.EnsureCreated();
            dbContext.EnsureSeedData();
        }
    }

    public class Program
    {
        public static void Main(string[] args)
        {
            CreateWebHostBuilder(args).Build().Run();
        }

        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>();
    }
}

Visual Studioブレークポイントを設定した状態で、 GraphQL Playground からネストしたデータを取得するクエリを実行すると、 ネストしたデータをまとめて取得できていることが確認できた。

f:id:griefworker:20190122100019p:plain

かぐや様は告らせたい(13)

四条眞妃は友達想いかつ後輩想いでホントに良いキャラだと思う。 好意が分かりにくいのは四宮の血か。 報われないのが不憫でならない。 壁ダーンを翼に授けた会長の罪は大きいな。

そんな眞妃やかぐやの後押しを受けた石上は、 まさかの自覚なき公開告白。 無自覚なのが功を奏して、 側から見ると男らしい堂々とした告白っぷりだった。

そして、この文化祭でついに会長もかぐやに告白。 告白は告白でも、ラブな意味ではなかったけど。 どちらかというとカミングアウト的な?

予告によると、次巻でいよいよ本当に告白か。どちらが告白しても 100% 成功するだろう。恋愛は戦。好きになった方が負け。2人の恋愛頭脳戦がどんな結末を迎えるのか楽しみで仕方ない。

MangaRank(仮) を支える技術

はじめに

先日紹介記事を書いた、はてなブログのマンガ感想記事を集計して作成したランキングサイト『MangaRank(仮)』。

tnakamura.hatenablog.com

「技術的な内容について後日記事を書く」と宣言してからだいぶ時間が空いてしまったけど、 今回の開発ではかなり紆余曲折ありながら試行錯誤したので、その記録を残しておくことにする。

開発方針

Web サイトを開発するにあたって、『無料で運用できること』にとことんこだわった。 無料で運用を続けられるなら、たとえ利用者が自分しかいなくても、「とりあえずこのまま動かしておこう」と思える。

また、ユーザーが触る部分、フロントエンドの速度にもかなりこだわった。 パフォーマンスは重要。遅いのはもはやバグ。

あと、無料で運用しつつ、万が一、億が一アクセスが増えた場合でもスケールするように、一応考えて開発した。

全体の構成

MangaRank(仮)の構成は、バックエンドとフロントエンドに分かれている。

Web スクレイピングでマンガの感想記事をダウンロードしたり、Amazon からマンガの情報をダウンロードして集計するバックエンド。

そして、集計したデータを表示するフロントエンドに分かれている。フロントエンドは静的サイト。今で言うところの JAMStack。

バックエンド

.NET Core のコンソールアプリケーション

Web スクレイピングしたり、Amazon Product Advertising API を呼んだり、集計したりするアプリケーションを、 .NET Core のコンソールアプリケーションで作成した。よって言語は C#。最近のトレンドでは、この手のアプリケーションには Go を使うんだろうけど、自分が一番慣れている言語を選択したことになる。

Web スクレイピングVisual Studioデバッグ実行で確認しながらだと捗った。HTML の解析には AngleSharp を利用。

tnakamura.hatenablog.com

Web スクレイピングAmazon Product Advertising API で取得した情報は SQLite に書き込み、 集計して JSON に出力。 その JSONGoogle Cloud Storage にアップロードしている。 あと、Google Cloud Storage には SQLite データベースのバックアップもアップロードしている。

tnakamura.hatenablog.com

コンソールアプリケーションを動かすサーバー

.NET Core 製コンソールアプリケーションは Google Compute Engine の F1 micro インスタンスで動かしている。 理由は当然、F1 micro インスタンスなら無料で使い続けることができるから。

Web スクレイピングではアクセス先に負荷を与えないように適切にインターバルを入れているので、 今のところ CPU とメモリをそれほど必要としていない。 ただただ時間がかかるだけ。 そのため F1 micro インスタンスでも全然余裕。

ストレージサイズも 30 GB あるので、SQLite データベースの置き場として使っている。 データベースのサイズはまだ 100 MB いってないので、こちらも余裕だ。

ロギング

Web スクレイピング等でエラーが発生した場合に調査できるよう、ログをちゃんと仕込んでいる。 ログの保存先だけど、GCE インスタンスのストレージだと見るためにわざわざ SSH で入らなきゃいけない。 それはちょっと面倒なので、Google StackDriver Logging に保存している。

ASP.NET Core 用の公式パッケージがあったので、.NET Core コンソールアプリケーションに組み込むのは簡単だった。

tnakamura.hatenablog.com

余談

最初はデータの保存先に Firestore を使おうとした。 Web スクレイピングで集めたデータではなく、集計したデータだけど。 フロントエンドは読み取りだけの予定だったし。 ただ、途中でオーバースペックなことに気づいてしまい、Firestore を使うのはやめてしまった。

tnakamura.hatenablog.com

フロントエンド

GatsbyJS で静的サイト生成

フロントエンドの速度にこだわると決めていたので、検討の結果、 静的サイトとして書き出して CDN で配信するのが最速という結論に至った。

静的な Web サイトの作成には GatsbyJS を採用。 GatsbyJS は Blazing fast をうたっていて、爆速な Web サイトを作成できる。 React をテンプレートに使うのは違和感なかったのに対し、 そのテンプレートに渡すデータを GraphQL で取得するのは慣れるまでに時間がかかった。 今となっては、データソースを GraphQL で抽象化して統一的に扱うのは上手いやり方だと思う。 こういった風な GraphQL の使い方は覚えておきたい。

マンガの一覧と詳細の各ページは、Google Cloud Storage からダウンロードした集計データの JSON ファイルから生成している。

その他の工夫としては、一覧ページは表示する画像が多いため、 gatsby-image を使って画像の取得を遅延させている。

Web サイトは Netlify で公開

Firebase Hosting や S3 だと、自前でビルドしたものをデプロイする必要がある。 GatsbyJS のビルドはマシンパワーを結構喰うので、バックエンドを動かしている F1 micro インスタンスでは力不足。 とくにメモリが。

Netlify は Web サイトのホスティングだけでなく、ビルドまでやってくれるので、ケチケチ個人開発の強い味方。 今のところメモリ不足などのエラーでビルドが失敗したことはない。 フォーム機能も提供してくれているので、ロックイン上等で使っている。

余談2

最初、フロントエンドは Nuxt で開発していた。 というのも、開発を始めたばかりの頃 Google App Engine の Standard Environment が Node に対応したというニュースがあったためで、これは Nuxt で実装して Server Side Rendering するしかないと思った。 そして、実のところほとんど出来上がっていた。 しかし、GAE にデプロイしたら SSR で頻繁にコケて使い物にならなかった。 完成間際で、GAE 無料枠のインスタンスでは力不足というのが判明するとは…。

普通はここでインスタンスをスケールアップするところだけど、今回は無料にこだわっていたので断念。 あと、当初 Firestore を使おうとしていたので、併せて Firebase Hosting + Cloud Functions for Firebase で Nuxt の SSR も試みたが、こちらもインスタンスをスケールアップしないと使い物にならなかったので断念。

デザイン

デザインには Bootstrap 4 を使った。 GatsbyJS は React をベースにしているので Material UI とか使えるが、 ほぼ表示だけのサイトに重厚な UI フレームワークは不要と判断。 UI フレームワークを使うのは Web サイトではなく Web アプリを作るときになるだろうな。 Bootstrap は手に馴染んでいて Web サイト制作に丁度いい。 ただ Bootstrap 臭を消すのが大変で、まだまだ漂っているのでなんとかしたい。

アイコンはみんな大好き FontAwesome。 FontAwesome は svg で使えるようになっていたので嬉々として試してみたら、 Lighthouse のスコアが落ちたので、Web フォントで使っている。

まとめ

余談が結構なボリュームになってしまった気がしないでもないけど、最終的に使った技術をまとめると次の通り。

  • .NET Core
  • Google Compute Engine
  • Google Cloud Storage
  • Google Stackdriver
  • GatsbyJS
  • Bootstrap4
  • FontAwesome
  • Netlify

現在の構成に辿り着くまでに、かなり回り道をしてしまった。 Nuxt や Firebase の知見も多少溜まったので、まったくの無駄ではなかったと思う。 Firebase はアプリを開発するときにはお世話になるだろう。

今回初めて Google Cloud Platform を使ってみたが、開発体験が思いのほか良かった。 Stackdriver はログを集めるのに重宝したし、Compute Engine は Web ブラウザ上から SSHVM インスタンスにアクセスできたしと、 わかってるな〜という印象。 .NET Core は Linux で動かせるから、Microsoft Azure にこだわる理由もない。 個人開発では Firebase と Google Cloud Platform の出番が増えそうだ。

GraphQL.NET でのページネーション

GraphQL でコレクションデータの取得やページネーションを実現する場合、 Relay 由来の Connection や Edge といったインタフェースを定義するのが定石になっているみたい。

GraphQL.NET はそれらをサポートしていて、ConnectionType や EdgeType といった型を提供してくれていたので、試しにやってみた。

using System.Linq;
using GraphQL;
using GraphQL.Server;
using GraphQL.Server.Ui.Playground;
using GraphQL.Types;
using GraphQL.Types.Relay.DataObjects;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace ConnectionSample
{
    public class Player
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public int Number { get; set; }
    }

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

        public DbSet<Player> Players => Set<Player>();

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Player>(e =>
            {
                e.Property(x => x.Id).IsRequired();
                e.Property(x => x.Name).IsRequired();
                e.Property(x => x.Number).IsRequired();
                e.HasKey(x => x.Id);
            });
        }

        // テスト用のデータを登録する
        public void EnsureSeedData()
        {
            if (!Players.Any())
            {
                foreach (var x in Enumerable.Range(1, 200))
                {
                    Players.Add(new Player
                    {
                        Name = $"プレーヤー{x}",
                        Number = x,
                    });
                }
                SaveChanges();
            }
        }
    }

    public class PlayerType : ObjectGraphType<Player>
    {
        public PlayerType()
            : base()
        {
            Field(x => x.Id);
            Field(x => x.Name);
            Field(x => x.Number);
        }
    }

    public class SampleQuery : ObjectGraphType
    {
        public SampleQuery()
            : base()
        {
            Connection<PlayerType>()
                .Name("players")
                .PageSize(50)
                .Resolve(context =>
                {
                    var dbContext = (ApplicationDbContext)context.UserContext;
                    var query = dbContext.Players.AsQueryable();

                    if (!string.IsNullOrEmpty(context.After) &&
                        int.TryParse(context.After, out var lastPlayerId))
                    {
                        query = query.Where(x => x.Id > lastPlayerId);
                    }

                    if (context.First != null)
                    {
                        query = query.Take(context.First.Value);
                    }
                    else
                    {
                        query = query.Take(context.PageSize.Value);
                    }

                    var players = query.ToList();

                    return new Connection<Player>
                    {
                        PageInfo = new PageInfo
                        {
                            StartCursor = players.FirstOrDefault()?.Id.ToString(),
                            EndCursor = players.LastOrDefault()?.Id.ToString(),
                        },
                        Edges = players.Select(x => new Edge<Player>
                        {
                            Cursor = x.Id.ToString(),
                            Node = x,
                        }).ToList(),
                    };
                });
        }
    }

    public class SampleSchema : Schema
    {
        public SampleSchema(IDependencyResolver dependencyResolver)
            : base(dependencyResolver)
        {
            Query = dependencyResolver.Resolve<SampleQuery>();
        }
    }

    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

            // GraphQL Server 用
            services.AddGraphQL()
                .AddUserContextBuilder(httpContext =>
                {
                    return httpContext.RequestServices
                        .GetService<ApplicationDbContext>();
                })
                .AddRelayGraphTypes(); // ConnectionType<T>, EdgeType<T>, PageInfoType 等を登録

            // GraphQL 用
            services.AddSingleton<PlayerType>();
            services.AddSingleton<SampleQuery>();
            services.AddSingleton<ISchema, SampleSchema>();
            services.AddSingleton<IDocumentExecuter, DocumentExecuter>();
            services.AddSingleton<IDependencyResolver>(x => new FuncDependencyResolver(x.GetService));
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env, ApplicationDbContext dbContext)
        {
            // GraphQL Server を使う
            app.UseGraphQL<ISchema>("/graphql");

            // GraphQL Playground を使う
            app.UseGraphQLPlayground(new GraphQLPlaygroundOptions
            {
                Path = "/ui/playground",
            });

            // テスト用データベースが無ければ作る
            dbContext.Database.EnsureCreated();
            dbContext.EnsureSeedData();
        }
    }

    public class Program
    {
        public static void Main(string[] args)
        {
            CreateWebHostBuilder(args).Build().Run();
        }

        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>();
    }
}

GraphQL API を起動して、Web ブラウザから Playground にアクセスし、コレクションデータを取得するクエリを実行してみたのがこちら。

f:id:griefworker:20190117105455p:plain

ちゃんと動いた。次のページのデータを取得する場合は、endCursor の値を引数 after に指定したクエリを実行すればいい。

GraphQL.EntityFramework を使ったときは、リゾルバで IQueryable<T> を返してやれば良きに計らってくれたのに対し、GraphQL.NET だけだと自前で書かないといけないのは手間だな。

HTTP.sys でホストした Web API で Windows 認証と JWT Bearer 認証を共存させる

ASP.NET Core MVC で実装した Web API を HTTP.sys でホストすることで、利用に Windows 認証が必要な Web API を実現できた。

tnakamura.hatenablog.com

これでひと段落と思いきや、JWT Bearer 認証もサポートする必要が出てきたので、HTTP.sys でホストした状態で Window 認証と JWT Bearer 認証を両方使えるか試してみた。

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Server.HttpSys;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;

namespace HttpSysSample
{
    // アプリケーションの構成
    // 本来は application.json か環境変数に持たせるべきだが、
    // 今回は簡略化のために static クラスにしておく。
    static class AppConfiguration
    {
        public const string SiteUrl = "http://localhost:5000";

        // JWT の署名で使う秘密鍵
        public const string SecretKey = "<your-secret-key>";
    }

    // トークンを取得するために渡された認証情報を格納する
    public class TokenInputModel
    {
        [Required]
        public string UserName { get; set; }

        [Required]
        public string Password { get; set; }
    }

    // 生成したトークンと有効期限を格納する
    public class TokenViewModel
    {
        public string Token { get; set; }

        public DateTime Expiration { get; set; }
    }

    [ApiController]
    [Route("api/[controller]")]
    public class HomeController : ControllerBase
    {
        // 匿名で利用できる API
        [HttpGet("anonymous")]
        public IActionResult Anonymous()
        {
            var user = User.Identity;
            return Ok(new
            {
                user.Name,
                user.IsAuthenticated,
                user.AuthenticationType,
            });
        }

        // Windows 認証が必要な API
        [HttpGet("windows")]
        [Authorize]
        public IActionResult Windows()
        {
            var user = User.Identity;
            return Ok(new
            {
                user.Name,
                user.IsAuthenticated,
                user.AuthenticationType,
            });
        }

        // アクセストークンを取得するための API
        [HttpPost("token")]
        public IActionResult Token([FromBody]TokenInputModel inputModel)
        {
            if (ModelState.IsValid)
            {
                // 簡略化のためユーザー名とパスワードは固定
                if (inputModel.UserName == "admin" &&
                    inputModel.Password == "admin123")
                {
                    var token = CreateJwtSecurityToken(inputModel.UserName);
                    return Ok(new TokenViewModel
                    {
                        Token = new JwtSecurityTokenHandler().WriteToken(token),
                        Expiration = token.ValidTo,
                    });
                }
            }
            return BadRequest();
        }

        JwtSecurityToken CreateJwtSecurityToken(string userName)
        {
            // JWT に含めるクレーム
            var claims = new List<Claim>()
            {
                // JwtBearerAuthentication 用
                new Claim(JwtRegisteredClaimNames.Jti, userName),
                new Claim(JwtRegisteredClaimNames.Sub, userName),
                // User.Identity プロパティ用
                new Claim(ClaimTypes.Sid, userName),
                new Claim(ClaimTypes.Name, userName),
            };

            var token = new JwtSecurityToken(
                issuer: AppConfiguration.SiteUrl,
                audience: AppConfiguration.SiteUrl,
                claims: claims,
                expires: DateTime.UtcNow.AddDays(7),
                signingCredentials: new SigningCredentials(
                    new SymmetricSecurityKey(Encoding.UTF8.GetBytes(AppConfiguration.SecretKey)),
                    SecurityAlgorithms.HmacSha256
                )
            );

            return token;
        }

        // アクセストークンが必要な API
        [HttpGet("jwt-bearer")]
        [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
        public IActionResult JwtBearer()
        {
            var user = User.Identity;
            return Ok(new
            {
                user.Name,
                user.IsAuthenticated,
                user.AuthenticationType,
            });
        }
    }

    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            // Authorize 属性を付けたアクションだけ認証必須にしたい場合は
            // HTTP.sys 用の認証スキーマを登録しておく必要がある。
            services.AddAuthentication(HttpSysDefaults.AuthenticationScheme)
                .AddJwtBearer(options =>
                {
                    options.Audience = AppConfiguration.SiteUrl;
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidateIssuerSigningKey = true,
                        ValidateIssuer = true,
                        ValidIssuer = AppConfiguration.SiteUrl,
                        IssuerSigningKey = new SymmetricSecurityKey(
                            Encoding.UTF8.GetBytes(AppConfiguration.SecretKey))
                    };
                });

            services.AddMvc()
            .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseAuthentication();

            app.UseMvc();
        }
    }

    public class Program
    {
        public static void Main(string[] args)
        {
            CreateWebHostBuilder(args).Build().Run();
        }

        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseHttpSys(options =>
                {
                    // Windows 認証を有効にする
                    options.Authentication.Schemes = AuthenticationSchemes.NTLM | AuthenticationSchemes.Negotiate;

                    // Authorize 属性を付けたアクションだけ認証必須にしたいので、
                    // 匿名アクセスを許可する
                    options.Authentication.AllowAnonymous = true;
                });
    }
}

Web ブラウザを使って手作業で JWT Bearer 認証を試すのは面倒なので、コンソールアプリケーションを書いてみた。HttpClient で Window 認証必須の Web API を利用するには、HttpClientHandler.UseDefaultCredentials を true にすればみたいだ。

using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;

namespace SampleClient
{
    class Program
    {
        static void Main(string[] args)
        {
            MainAsync().GetAwaiter().GetResult();

            Console.WriteLine("Press Enter Key");
            Console.ReadLine();
        }

        async static Task MainAsync()
        {
            var handler = new HttpClientHandler()
            {
                UseDefaultCredentials = true,
            };
            var client = new HttpClient(handler)
            {
                BaseAddress = new Uri("http://localhost:5000"),
            };

            // 匿名アクセス可能な API を呼び出す
            Console.WriteLine(
                await client.GetStringAsync("/api/home/anonymous"));

            // Windows 認証が必要な API を呼び出す
            Console.WriteLine(
                await client.GetStringAsync("/api/home/windows"));

            // JWT Bearer 認証が必要な API を呼び出す
            // アクセストークンを Authorize ヘッダーで指定していないので、
            // 呼び出しには失敗する
            try
            {
                Console.WriteLine(
                    await client.GetStringAsync("/api/home/jwt-bearer"));
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }

            // アクセストークンを取得
            var response = await client.PostAsync(
                "/api/home/token",
                new StringContent(
                    content: @"{
                        ""userName"":""admin"",
                        ""password"":""admin123""
                    }",
                    encoding: Encoding.UTF8,
                    mediaType: "application/json"));
            var json = await response.Content.ReadAsStringAsync();
            var jObj = JObject.Parse(json);
            var token = (string)jObj["token"];

            client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

            // JWT Bearer 認証が必要な API を呼び出す
            Console.WriteLine(
                await client.GetStringAsync("/api/home/jwt-bearer"));
        }
    }
}

実行結果は次の通り。 Window 認証と JWT Bearer 認証、それぞれ期待通りに動いていることを確認できた。

f:id:griefworker:20190115144441p:plain

ASP.NET Core アプリを HTTP.sys でホストして Windows 認証を使用するメモ

外に公開する Web アプリなら、ASP.NET Core Identity や JWT Bearer 認証を採用するんだけど、社内で使う用だとユーザー管理がめんどい。Windows 認証の方が都合がいい。クライアントはほぼ全て Windows だし、Active Directory で管理してるからなおさら。

作成した Web アプリを IIS でホストするのが定石なところを、自己完結型アプリとして動かしたかったので、HTTP.sys でのホストを試してみた。コピペでデプロイできる手軽さは重要でしょ。

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Server.HttpSys;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace HttpSysSample
{
    [ApiController]
    [Route("api/[controller]")]
    public class HomeController : ControllerBase
    {
        [HttpGet("anonymous")]
        public IActionResult Anonymous()
        {
            var user = User.Identity;
            return Ok(new
            {
                user.Name,
                user.IsAuthenticated,
                user.AuthenticationType,
            });
        }

        [Authorize]
        [HttpGet("windows")]
        public IActionResult Windows()
        {
            var user = User.Identity;
            return Ok(new
            {
                user.Name,
                user.IsAuthenticated,
                user.AuthenticationType,
            });
        }
    }

    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            // Authorize 属性を付けたアクションだけ認証必須にしたい場合は
            // HTTP.sys 用の認証スキーマを登録しておく必要がある。
            services.AddAuthentication(HttpSysDefaults.AuthenticationScheme);

            services.AddMvc()
                .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseMvc();
        }
    }

    public class Program
    {
        public static void Main(string[] args)
        {
            CreateWebHostBuilder(args).Build().Run();
        }

        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseHttpSys(options =>
                {
                    // Windows 認証を有効にする
                    options.Authentication.Schemes = AuthenticationSchemes.NTLM | AuthenticationSchemes.Negotiate;

                    // Authorize 属性を付けたアクションだけ認証必須にしたいので、
                    // 匿名アクセスを許可する
                    options.Authentication.AllowAnonymous = true;
                });
    }
}

Web アプリを実行し、まずは匿名利用可能な API に Web ブラウザからアクセスしてみると、 認証されていないユーザーを表す JSON が表示された。

f:id:griefworker:20190108135356p:plain

次に Windows 認証が必要な API にアクセスすると、認証ダイアログが表示された。

f:id:griefworker:20190108135407p:plain

Window アカウントの名前とパスワードを入力して OK を押すと、認証に成功し、Window アカウントの名前を含む JSON が表示された。

f:id:griefworker:20190108135418p:plain

WEB+DB PRESS Vol.108

特集1 スキーマ駆動 Web API 開発

Swagger UI で Web ブラウザ上から手軽に REST API を試せるし、swagger-codegen でクライアントやスタブを生成できるし、REST API 開発で Swagger はもはや手放せなくなっている。ただ、本特集を読んで思ったんだが、なぜ人はスキーマを手書きしたがるのだろうか。

自分の場合 C# + ASP.NET Core になるが、サーバー側で API のインタフェースをコードで記述し、そこから Swagger の定義を生成している。本特集では、GraphQL のスキーマRuby のコードで定義していた。Swagger でも同様にしたほうがいいんじゃないかと思う。サポートするライブラリがあるならだけど。

エディタで Web API 定義の YAML を書くのはシンドかった。 Swagger Editor 使ってもそれほど軽減されなかったし。 何回か試みたけど、すべて挫折。 強靭な精神力がいるんじゃないかな。 面倒くさがりには無理だった。 そんな経験から、Swagger でもコードから動的に定義を生成したほうがいいと思うんだけどなぁ。

特集2 詳解 PostgreSQL

.NET Core で開発するようになってから、OS に Linux、データベースに PostgreSQL を選ぶ場面が増えてきた。 なので本特集で PostgreSQL の歴史・内部構造・アプリケーション開発に役立つ機能・運用に役立つ機能と、たっぷり知ることができて俺得。

集計機能を実装するのに便利な Window 関数が使えるし、パラレルクエリや JIT といった野心的な機能もある。SQL Server の Linux 版を使おうとは思わないくらい、機能が充実しているなという印象。

特集3 ZOZO 開発ノウハウ大公開

ZOZOTOWN がデータベースに SQL Server、Web サーバーに IIS、そして ASP と、Microsoft 技術で作られていたとは驚き。

今回のリプレイスで .NET Core ではなく Java を選択したことは、開発者の数・ライブラリの数・情報の数といったエコシステムの面から当然といえば当然。.NET Core と Java、どちらを選んでもフルスクラッチになるだろうし。しゃーない。

さすがにデータベースは PostgreSQL とかに移行するのはリスク高すぎるから、SQL Server は使い続けるしかないか。SQL Server の PaaS は Microsoft Azure 一択のようなものなので、クラウドMicrosoft Azure を選んだのも納得だ。

at the front 第2回 JavaScriptの呪いから解き放たれて

mizchi 氏と、最近インターネットに帰ってきたamachang 氏、 JavaScript 界の新旧スターの共演。実現する日が来るとは胸熱だ。

amachang 氏がブログを更新しなくなったのは、起業したからだったのか。まぁ確かに、起業の経験は無いけど、自分のリソースはビジネスに全て投入し、ブログに割く分が残らないのは想像できる。ただ、過程でもいいからブログに何やっているか書いておけば、宣伝になったんじゃないかなと思う。

amachang 氏にとっては、JavaScript の人から起業家へブランディングしなおす思惑もあったみたい。一方で mizchi 氏はまだ当分 JavaScript の人でいそう。一つの技術を徹底的に掘り下げていったら、おのずと普遍的なスキルが身についていくという考えには共感するところがある。設計とかメタプログラミングとか。自分の場合 C# になるが、まだしばらくは徹底的に掘り下げていきたいと思い直した。

WEB+DB PRESS Vol.108

WEB+DB PRESS Vol.108

  • 作者: 中野暁人,山本浩平,大和田純,曽根壮大,ZOZOTOWNリプレースチーム,権守健嗣,茨木暢仁,松井菜穂子,新多真琴,laiso,豊田啓介,藤原俊一郎,牧大輔,向井咲人,大島一将,上川慶,末永恭正,久保田祐史,星北斗,池田拓司,竹馬光太郎,粕谷大輔,WEB+DB PRESS編集部
  • 出版社/メーカー: 技術評論社
  • 発売日: 2018/12/22
  • メディア: 単行本
  • この商品を含むブログを見る