ASP.NET Core で GraphQL API

GraphQL API が必要になったので、ASP.NET Core で実装できるか試してみた。ライブラリは現状 GraphQL 一択。

www.nuget.org

GraphiQL っていう、GraphQL を試すのに便利な Web UI があるので、それも利用した。ASP.NET Core のミドルウェアを使えば組み込むのは簡単。

www.nuget.org

必要になったのはクエリだけで、ミューテーションはまだ。なので今回は、ASP.NET Core MVC で GraphQL のエンドポイントを用意し、Entity Framework Core と組み合わせてデータベースに保存されたデータを取得するところまでをやってみた。

using GraphiQl;
using GraphQL;
using GraphQL.Types;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace HelloGraphQL
{
    //============================================
    // 1. Entity Framework Core で使うクラスを定義
    //============================================

    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();
            }
        }
    }

    //==============================
    // 2. GraphQL で使うクラスを定義
    //==============================

    // Player に対応する GraphQL の型
    public class PlayerType : ObjectGraphType<Player>
    {
        public PlayerType()
        {
            Field(x => x.Id);
            Field(x => x.Name);
            Field(x => x.Position);
            Field(x => x.Number);
            Field(x => x.TeamId);
        }
    }

    // Team に対応する GraphQL の型
    public class TeamType : ObjectGraphType<Team>
    {
        public TeamType()
        {
            Field(x => x.Id);
            Field(x => x.Name);
            Field<ListGraphType<PlayerType>>("players");
        }
    }

    // クエリを定義
    public class HelloGraphQLQuery : ObjectGraphType
    {
        public HelloGraphQLQuery(ApplicationDbContext context)
        {
            Field<ListGraphType<TeamType>>(
                "teams",
                arguments: new QueryArguments(
                    new QueryArgument<IntGraphType>
                    {
                        Name = "id",
                    }),
                resolve: ctx =>
                {
                    var query = context.Teams.AsQueryable();
                    if (ctx.HasArgument("id"))
                    {
                        var id = ctx.GetArgument<int>("id");
                        query = query.Where(b => b.Id == id);
                    }
                    var teams = query.Include(b => b.Players)
                        .ToList();
                    return teams;
                });
        }
    }

    // GraphQL のスキーマを定義
    public class HelloGraphQLSchema : Schema
    {
        public HelloGraphQLSchema(IDependencyResolver dependencyResolver)
            : base(dependencyResolver)
        {
            // 今回はクエリだけ。ミューテーションは後で。
            Query = dependencyResolver.Resolve<HelloGraphQLQuery>();
        }
    }

    //====================================================
    // 3. ASP.NET Core MVC で GraphQL エンドポイントを実装
    //====================================================

    // クライアントから送信されてきた GraphQL をバインドするモデル
    public class GraphQLModel
    {
        public string OperationName { get; set; }

        public string Query { get; set; }
    }

    [Route("graphql")]
    [ApiController]
    public class GraphQLController : ControllerBase
    {
        readonly IDocumentExecuter documentExecuter;

        readonly ISchema schema;

        public GraphQLController(IDocumentExecuter documentExecuter, ISchema schema)
        {
            this.documentExecuter = documentExecuter;
            this.schema = schema;
        }

        [HttpPost]
        public async Task<IActionResult> Post([FromBody] GraphQLModel model)
        {
            var result = await documentExecuter.ExecuteAsync(new ExecutionOptions
            {
                Schema = schema,
                OperationName = model.OperationName,
                Query = model.Query
            });
            return Ok(result);
        }
    }

    //==============================================================
    // 4. いつものように ASP.NET Core 用の Startup と Program を作成
    //==============================================================

    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")));

            services.AddMvc()
                .SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

            //--------------------
            // ここから GraphQL 用
            //--------------------

            services.AddTransient<IDocumentExecuter, DocumentExecuter>();
            services.AddTransient<ISchema, HelloGraphQLSchema>();
            services.AddTransient<HelloGraphQLQuery>();

            // IDependencyResolver が使うために GraphQL 用のクラスを
            // DI コンテナに登録しておく必要があるみたい。
            services.AddTransient<TeamType>();
            services.AddTransient<PlayerType>();
            var sp = services.BuildServiceProvider();
            services.AddSingleton<IDependencyResolver>(x => new FuncDependencyResolver(sp.GetService));
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env, ApplicationDbContext context)
        {
            // GraphiQL をホストして手軽に GraphQL を試せるようにする
            app.UseGraphiQl();

            app.UseMvc();

            // テスト用データベースが無ければ作る
            context.Database.EnsureDeleted();
            context.Database.EnsureCreated();
            context.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 に Web ブラウザでアクセスすると、GraphiQL の UI が表示される。全チーム取得するクエリを実行してみると、ちゃんとデータベースからデータを引っ張ってこれた。余談だけど、GraphiQL は補完が効いて、これを使わないなんてとんでもない、ってレベル。

f:id:griefworker:20181101152856p:plain

次に、指定した id に該当するチームだけ取得するクエリを実行してみると、こちらも上手く動いた。

f:id:griefworker:20181101152916p:plain

クエリの解析は GraphQL.NET がやってくれるけど、データベースからデータを引っ張ってくるところは自分で書く必要があって、なかなかツラミがあった。

あと、今回みたいな簡単なクエリを実装するだけでも結構苦労した。GraphQL 自体の仕様だけでなく、ライブラリの使い方も知る必要があったし、何より、REST API と GraphQL API では考え方が違いすぎて、切り替えるのが大変。GraphQL 脳にならないといけないな。

GraphQL API だと URL を考える必要は無くなったけど、どんなクエリやミューテーションを提供するか考える必要があるので、API 提供側の苦労はさほど変わらない印象だ。

ASP.NET Core のキャッチオールルートパラメーターでハマった

例えば、はてなブログのカスタム URL みたいな、entry/ 以下にすべてマッチするルートを定義する場合、ASP.NET Core MVC だと下記のように書く。

[HttpGet("entry/{*path}")]
public async Task<IActionResult> Details(string path)
{
    // IIS Express は %2F を / に戻してくれるが、
    // Kestrel は戻してくれないので、自分で戻す必要がある。
    path = Uri.UnescapeDataString(path);

    // path を使って何かする
}

path ルートパラメーターの値が %2F を含んでいるとき、デバッグ実行だと %2F/ に変換してくれるのに、ビルドしたものを dotnet コマンドで起動すると変換してくれなくて嵌った。どうも、Kestrel はやってくれないみたいだ。一方、IIS Express はやってくれる。デバッグ実行では IIS Express で動かしていて、dotnet コマンドでは Kestrel で動かしていたため遭遇した問題でしたとさ。

『五等分の花嫁(1)〜(6)』を読んだ

週刊少年マガジンで連載中の『五等分の花嫁』を1巻から6巻まで読んだ。

[まとめ買い] 五等分の花嫁

借金を抱えた貧乏な秀才・上杉風太郎が、一花・二乃・三玖・四葉・五月ら五つ子の家庭教師になって、落第を回避し卒業に導くというストーリー。正直な話、ジャンプのぼく勉と被ってると思った。ただ、絵は違う方向性でどちらも上手いし、登場人物はみんな魅力的だし、ストーリーも面白いしで、そんな難癖をつける気は速攻で無くなった。

五月は五つ子の中で一番真面目。そして頑固。性格的に風太郎と似たところがあるのか、よく衝突している。貧乏な家のカレーをおかわりするメンタルの強さは見習いたい。

四葉は最初から風太郎の味方。五つ子の中で一番素直で、頼まれると断れないお人好し。気持ちのいい性格をしている。そんなキャラクターなので、ラブコメのコメ担当、マスコット的な扱いで、ラブからは遠い。そういうのが案外、秘密兵器になったりするかも。最後まで秘密かもしれないが。

三玖は最初何考えているかわからないミステリアスな印象だった。それが、風太郎のおかげで自信がついてきてからはどんどん積極的になって、最初の頃からは想像できない姿勢を見せるまでに成長した。健気なところがあるので報われて欲しいと思う。

二乃は五つ子の中で一番女子力が高い。風太郎を一番最後まで敵視していて、ツンデレというにはデレが少なくツンが強烈。でも、その行動理由の多くは姉妹のためで、風太郎にツラくあたっても憎めない。むしろデレたら物足りなくなりそうだ。

一花は長女で、風太郎に対して何かとお姉さん風を吹かせてくる。あと、風太郎をからかったりも。大抵の場合、長女らしくしっかりしているんだけど、たまにドジなときがあって、そのギャップが良い。姉妹のために頑張ってたりするから、一番応援しているヒロインかなぁ。

風太郎は貧乏な家だからお金への執着心が強く、勉強ができるゆえにイヤミ、かと思いきや全くそうでもなかった。天上天下唯我独尊ではなく、意外と人の気持ちに立てる。そもそも勉強をするようになった理由が誰かの役に立つためで、五つ子と関わって成長した部分もあるだろうけど、本人の素質も間違いなくあるだろう。向けられる好意に対して鈍感なのは、ラブコメ主人公のお約束。

結婚式の場面から物語が始まったため、五人のうち誰かと結婚するのは確定。その相手は誰かは最後まで判らないんだろう。ただ、一花か三玖のどちらかな気がする。最初に出会ったのは五月だが、最初に出会ったからといって結ばれるとは限らないし。それとも、秘密兵器・四葉か?誰が相手になるのか気になる。先が楽しみ。

兼虎の辛辛魚

兼虎が天神に移転してきてから、自分の中で空前の兼虎ブームが巻き起こっている。赤坂のときは滅多に行けないから、いつも濃厚つけ麺を食べていたが、天神ならいつでも行ける。今こそ他のメニューを食べるチャンスと思い、また行ってきた。お目当は『辛辛魚』。

スープの濃度が凄くて、とてもラーメンとは思えない。つけ麺のつけ汁並だ。太麺との絡みは抜群。辛さ耐性に自信がなかったので辛さ控えめにしてみたが、それでも結構クル。唇がヒリつく辛さ。基本以上だと完食無理だったかも。クセになりそうな旨さだった。

兼虎

食べログ 兼虎

IdentityServer4 の認証を差し替える方法

はじめに

外部に公開する REST API の認証・認可に OpenID Connect を選択した場合、ASP.NET Core での実装には IdentityServer4 を使うことになると思う。IdentityServer4 は認証のバックエンドに ASP.NET Core Identity をサポートしているので、新規の開発ならそれを使えばいいかもしれない。

github.com

ただ、独自の認証機能を既に実装している場合、今更 ASP.NET Core Identity に移行するのは困難。そんなときは、IdentityServer4 のカスタム認証バックエンドを作ればいいはず。既に IdentityServer4.AspNetIdentity というお手本があるので、それを参考に挑戦してみた。

IdentityServer4 のホストを作成

IdentityServer4.AspNetIdentity のソースコードを見た感じ、認証をカスタマイズするには IProfileService と IResourceOwnerPasswordValidator を実装して、差し替えれば良さそうだった。

using IdentityModel;
using IdentityServer4.Extensions;
using IdentityServer4.Models;
using IdentityServer4.Services;
using IdentityServer4.Validation;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;

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

        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseUrls("http://localhost:8000");
    }

    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddIdentityServer()
                .AddDeveloperSigningCredential()
                .AddInMemoryApiResources(Config.GetApiResources())
                .AddInMemoryClients(Config.GetClients())
                .AddProfileService<MyProfileService>()
                .AddResourceOwnerValidator<MyResourceOwnerPasswordValidator>();

            services.AddScoped<MyUserManager>();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseIdentityServer();

            app.Run(async (context) =>
            {
                await context.Response.WriteAsync("Hello IdentityServer4!");
            });
        }
    }

    static class Config
    {
        public static IEnumerable<ApiResource> GetApiResources()
        {
            return new List<ApiResource>
            {
                new ApiResource("api1", "My API")
            };
        }

        public static IEnumerable<Client> GetClients()
        {
            return new List<Client>
            {
                new Client
                {
                    ClientId = "ro.client",
                    AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
                    ClientSecrets =
                    {
                        new Secret("secret".Sha256())
                    },
                    AllowedScopes =
                    {
                        "api1",
                    }
                }
            };
        }
    }

    // 独自のユーザー
    public class MyUser
    {
        public string Id { get; set; } = Guid.NewGuid().ToString();

        public string UserName { get; set; }

        public string Password { get; set; }
    }

    // 独自のユーザーを管理
    public class MyUserManager
    {
        static readonly List<MyUser> users = new List<MyUser>
        {
            new MyUser
            {
                UserName = "kagawa",
                Password = "password",
            }
        };

        // 実際に利用するときは非同期メソッドになるだろうから、
        // このサンプルでは必要ないけど非同期メソッドにしておく。

        public Task<MyUser> FindByNameAsync(string userName)
        {
            var user = users.FirstOrDefault(u => u.UserName == userName);
            return Task.FromResult(user);
        }

        public Task<MyUser> FindByIdAsync(string userId)
        {
            var user = users.FirstOrDefault(u => u.Id == userId);
            return Task.FromResult(user);
        }

        public Task<bool> CheckPasswordAsync(MyUser user, string password)
        {
            return Task.FromResult(user.Password == password);
        }

        public Task<IEnumerable<Claim>> GetClaimsAsync(MyUser user)
        {
            var claims = new List<Claim>();
            claims.Add(new Claim(JwtClaimTypes.PreferredUserName, user.UserName));
            claims.Add(new Claim(JwtClaimTypes.Name, user.UserName));
            return Task.FromResult<IEnumerable<Claim>>(claims);
        }
    }

    // ユーザーを取得するサービス
    class MyProfileService : IProfileService
    {
        readonly MyUserManager userManager;

        public MyProfileService(MyUserManager userManager)
        {
            this.userManager = userManager;
        }

        public async Task GetProfileDataAsync(ProfileDataRequestContext context)
        {
            var sub = context.Subject.GetSubjectId();
            var user = await userManager.FindByIdAsync(sub);
            var claims = await userManager.GetClaimsAsync(user);

            // ユーザーの情報をクレームで登録する
            context.IssuedClaims.AddRange(claims);
        }

        public async Task IsActiveAsync(IsActiveContext context)
        {
            var sub = context.Subject.GetSubjectId();
            var user = await userManager.FindByIdAsync(sub);
            context.IsActive = user != null;
        }
    }

    // ユーザーのパスワードを検証するバリデーター
    class MyResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator
    {
        readonly MyUserManager userManager;

        public MyResourceOwnerPasswordValidator(MyUserManager userManager)
        {
            this.userManager = userManager;
        }

        public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
        {
            var user = await userManager.FindByNameAsync(context.UserName);
            if (user != null)
            {
                var result = await userManager.CheckPasswordAsync(user, context.Password);
                if (result)
                {
                    context.Result = new GrantValidationResult(
                        user.Id,
                        OidcConstants.AuthenticationMethods.Password);
                    return;
                }
            }
            context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
        }
    }
}

保護対象の Web API を作成

アクセスしてきたユーザーのクレームを返すだけの Web API を実装した。 IdentityServer4 チュートリアルのまんま。

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Linq;

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

        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseUrls("http://localhost:8001");
    }

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

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc()
                .SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

            services.AddAuthentication();
            services.AddAuthentication("Bearer")
                .AddIdentityServerAuthentication(options =>
                {
                    options.Authority = "http://localhost:8000";
                    options.RequireHttpsMetadata = false;
                    options.ApiName = "api1";
                });
        }

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

            app.UseMvc();
        }
    }

    [Authorize]
    [ApiController]
    [Route("identity")]
    public class IdentityController : ControllerBase
    {
        [HttpGet]
        public IActionResult Get()
        {
            return new JsonResult(
                from c in User.Claims
                select new
                {
                    c.Type,
                    c.Value
                });
        }
    }
}

Web API を呼び出すクライアントを作成

OpenID Connect/OAuth クライアントには IdentityModel を使う。

www.nuget.org

アクセストークンを取得できたら、あとは HttpClient を使って Web API を呼び出す。

using IdentityModel.Client;
using System;
using System.Net.Http;
using System.Threading.Tasks;

namespace ConsoleClient
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.Title = "ConsoleClient";

            MainAsync().GetAwaiter().GetResult();

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

        static async Task MainAsync()
        {
            // メタデータからエンドポイントを取得する
            var disco = await DiscoveryClient.GetAsync("http://localhost:8000");
            if (disco.IsError)
            {
                Console.WriteLine(disco.Error);
                return;
            }

            var tokenClient = new TokenClient(
                address: disco.TokenEndpoint,
                clientId: "ro.client",
                clientSecret: "secret");

            var tokenResponse = await tokenClient.RequestResourceOwnerPasswordAsync(
                userName: "kagawa",
                password: "password",
                scope: "api1");

            if (tokenResponse.IsError)
            {
                Console.WriteLine(tokenResponse.Error);
                return;
            }

            Console.WriteLine(tokenResponse.Json);

            // SampleWebApi を呼び出す
            var client = new HttpClient();
            client.SetBearerToken(tokenResponse.AccessToken);

            var response = await client.GetAsync("http://localhost:8001/identity");
            if (!response.IsSuccessStatusCode)
            {
                Console.WriteLine(response.StatusCode);
            }
            else
            {
                var content = await response.Content.ReadAsStringAsync();
                Console.WriteLine(content);
            }
        }
    }
}

実行結果

アクセストークンの取得と、Web API の呼び出しに成功した。

f:id:griefworker:20181002112110p:plain

おわりに

IdentityServer4.AspNetIdentity を参考に、 IdentityServer4 で使う認証を独自のものに差し替えることができた。今回必要に迫られたので試してみたけど、できれば避けたいことにはかわりない。

ASP.NET Core MVC vs. WCF

はじめに

以前 WCF と gRPC のベンチマークを比較して、結果 gRPC の圧勝だった。

tnakamura.hatenablog.com

ASP.NET Core MVC(Web API) と WCF ではどっちが速いか気になったので試してみた。WCF のサービスは今のところ .NET Framework で動かすしかないので、公平のために ASP.NET Core MVC .NET Framework で動かした。

ASP.NET Core MVC で Web API を作成

gRPC のときと同様に、書籍の一覧を取得する API を用意。

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Linq;

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

        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseUrls("http://localhost:8000");
    }

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

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc()
                .SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
        }

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

    public class Book
    {
        public string Id { get; set; }

        public string Title { get; set; }

        public string Description { get; set; }

        public string Author { get; set; }

        public int Price { get; set; }

        public string PublishedAt { get; set; }
    }

    [Route("api/[controller]")]
    [ApiController]
    public class BooksController : ControllerBase
    {
        static readonly Book[] books;

        static BooksController()
        {
            books = Enumerable.Range(0, 50)
                .Select(i => new Book()
                {
                    Id = Guid.NewGuid().ToString(),
                    Title = $"Book{i}",
                    Author = $"Author{i}",
                    Description = $"Description{i}",
                    PublishedAt = DateTime.Today.ToString(),
                    Price = 2000,
                })
                .ToArray();
        }

        [HttpGet]
        public ActionResult<IEnumerable<Book>> Get()
        {
            return books;
        }
    }
}

WCF のサービスを作成

WCF の方でも書籍の一覧を取得するオペレーションを用意した。WCF を使うとしたら TCP か名前付きパイプくらいなので、今回はバインディングTCP を選択。

using System;
using System.Linq;
using System.Runtime.Serialization;
using System.ServiceModel;

namespace AspNetCoreVsWcf.Wcf
{
    class Program
    {
        static void Main(string[] args)
        {
            var host = new ServiceHost(typeof(BookService));
            host.AddServiceEndpoint(
                typeof(IBookService),
                new NetTcpBinding(),
                "net.tcp://localhost:8001/BookService");
            host.Open();

            Console.WriteLine("Enter で終了");
            Console.ReadLine();

            host.Close();
        }
    }

    [DataContract]
    public class Book
    {
        [DataMember]
        public string Id { get; set; }

        [DataMember]
        public string Title { get; set; }

        [DataMember]
        public string Description { get; set; }

        [DataMember]
        public string Author { get; set; }

        [DataMember]
        public int Price { get; set; }

        [DataMember]
        public string PublishedAt { get; set; }
    }

    [ServiceContract]
    public interface IBookService
    {
        [OperationContract]
        Book[] GetBooks();
    }

    public class BookService : IBookService
    {
        static readonly Book[] books;

        static BookService()
        {
            books = Enumerable.Range(0, 50)
                .Select(i => new Book()
                {
                    Id = Guid.NewGuid().ToString(),
                    Title = $"Book{i}",
                    Author = $"Author{i}",
                    Description = $"Description{i}",
                    PublishedAt = DateTime.Today.ToString(),
                    Price = 2000,
                })
                .ToArray();
        }

        public Book[] GetBooks()
        {
            return books;
        }
    }

}

ベンチマークを作成

BenchmarnDotNet を使ってベンチマークを作成。WCFASP.NET Core MVC で公平にするため、ASP.NET Core MVC 側は取得したレスポンスをデシリアライズするところまでを計測対象にした。

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Newtonsoft.Json;
using System;
using System.IO;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Net.Http;
using System.Runtime.Serialization;
using System.ServiceModel;

namespace AspNetCoreVsWcf.Benchmark
{
    class Program
    {
        static void Main(string[] args)
        {
            BenchmarkRunner.Run<AspNetCoreVsWcfBenchmark>();

            Console.ReadLine();
        }
    }

    [DataContract]
    public class Book
    {
        [DataMember]
        public string Id { get; set; }

        [DataMember]
        public string Title { get; set; }

        [DataMember]
        public string Description { get; set; }

        [DataMember]
        public string Author { get; set; }

        [DataMember]
        public int Price { get; set; }

        [DataMember]
        public string PublishedAt { get; set; }
    }

    [ServiceContract]
    public interface IBookService
    {
        [OperationContract]
        Book[] GetBooks();
    }

    public class AspNetCoreVsWcfBenchmark
    {
        IBookService tcpChannel;
        HttpClient httpClient;
        JsonSerializer jsonSerializer;

        [GlobalSetup]
        public void GlobalSetup()
        {
            tcpChannel = ChannelFactory<IBookService>.CreateChannel(
                new NetTcpBinding(),
                new EndpointAddress("net.tcp://localhost:8001/BookService"));
            httpClient = new HttpClient();
            jsonSerializer = JsonSerializer.CreateDefault();
        }

        [GlobalCleanup]
        public void GlobalCleanup()
        {
            ((IClientChannel)tcpChannel).Close();
            httpClient.Dispose();
        }

        [Benchmark]
        public Book[] WcfTcp()
        {
            return tcpChannel.GetBooks();
        }

        [Benchmark]
        public IEnumerable<Book> AspNetCore()
        {
            //var json = httpClient.GetStringAsync("http://localhost:8000/api/books")
            //    .GetAwaiter().GetResult(); ;
            //return JsonConvert.DeserializeObject<IEnumerable<Book>>(json);
            return GetBooksAsync().GetAwaiter().GetResult();
        }

        async Task<IEnumerable<Book>> GetBooksAsync()
        {
            var response = await httpClient.GetAsync("http://localhost:8000/api/books");
            var stream = await response.Content.ReadAsStreamAsync();
            using (var streamReader = new StreamReader(stream))
            using (var jsonReader = new JsonTextReader(streamReader))
            {
                return jsonSerializer.Deserialize<IEnumerable<Book>>(jsonReader);
            }
        }
    }
}

ベンチマーク結果

WCF/TCP の方が断然速かった。

f:id:griefworker:20181015133719p:plain

おわりに

ASP.NET Core MVC は HTTP2 ではないし、WCF の方が速いんだろうなとは予想していた。ただ、ここまで差が開くとは思わなかったな。素の ASP.NET Core ならもっと差は縮まりそうだけど、実際の開発では ASP.NET Core MVC を使うことがほとんどだから、やっても参考にはならないか。Kestrel と HttpClient が HTTP2 に対応するので、正式版が出たらベンチマークを取り直すかもしれない。

シリウス

BUMP OF CHECKENの『シリウス』がiTunesで配信されているのに気付いたのでポチッた。TVアニメ『 重神機パンドーラ』の主題歌に提供と発表されてからだいぶ経ったな。アニメは見てないけど、ロボットアニメの主題歌らしい、激しい戦闘シーンが浮かんでくるような曲だった。

シリウス

シリウス