JwtBearer Authentication と ASP.NET Core Identity の 2 要素認証を無理やり組み合わせてみた

以前、JwtBearer Authentication の裏で ASP.NET Core Identity を使うことには成功した。

tnakamura.hatenablog.com

ASP.NET Core Identity は 2 要素認証をサポートしているので、 JwtBearer Authentication を使う場合でもなんとか組み合わせることができないものか、 いろいろ試行錯誤してみた。

結論としては、少々無理やりではあるけど、なんとか使うことができた。 以下、サンプルコード。

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;

namespace JwtTwoFactAuthSample
{
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .Build();
    }

    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.AddIdentity<ApplicationUser, ApplicationRole>()
                .AddDefaultTokenProviders();
            services.AddTransient<IUserStore<ApplicationUser>, ApplicationUserStore>();
            services.AddTransient<IRoleStore<ApplicationRole>, ApplicationRoleStore>();

            services.AddAuthentication(options =>
            {
                // JWT Bearer をデフォルトにする
                options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
            })
            .AddJwtBearer(options =>
            {
                options.SaveToken = true;
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidIssuer = Configuration["JwtIssuer"],
                    ValidAudience = Configuration["JwtIssuer"],
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["JwtKey"])),
                };
            })
            // 2 要素認証専用のスキームを登録
            .AddJwtBearer("Bearer.TwoFactor", options =>
            {
                options.SaveToken = true;
                // 2 要素認証用のトークンは 2 要素認証関連の API だけを呼び出せるように、
                // JWT のシグネチャを変えておく。
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidIssuer = Configuration["JwtIssuer"],
                    ValidAudience = Configuration["JwtIssuer"],
                    IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["JwtTwoFactorKey"])),
                };
            });

            services.AddMvc();
        }

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

            app.UseAuthentication();

            app.UseMvc();

            // データベースが無ければ作成
            context.Database.EnsureCreated();
        }
    }

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

        public DbSet<ApplicationUser> Users => Set<ApplicationUser>();
    }

    public class ApplicationUser
    {
        [Key]
        public string Id { get; set; } = Guid.NewGuid().ToString();

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

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

        public bool IsTwoFactorEnabled { get; set; } = false;

        public string AuthenticatorKey { get; set; }

        public string RecoveryCode { get; set; }
    }

    public class ApplicationRole
    {
        public string Id { get; set; } = Guid.NewGuid().ToString();

        public string RoleName { get; set; }
    }

    public class ApplicationUserStore : IUserStore<ApplicationUser>,
                                        IUserPasswordStore<ApplicationUser>,
                                        IUserTwoFactorStore<ApplicationUser>,
                                        IUserAuthenticatorKeyStore<ApplicationUser>,
                                        IUserTwoFactorRecoveryCodeStore<ApplicationUser>,
                                        IUserEmailStore<ApplicationUser>,
                                        IUserPhoneNumberStore<ApplicationUser>
    {
        readonly ApplicationDbContext context;

        public ApplicationUserStore(ApplicationDbContext context)
        {
            this.context = context;
        }

        #region IDisposable

        public void Dispose()
        {
        }

        #endregion

        #region IUserStore<ApplicationUser>

        public async Task<IdentityResult> CreateAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            context.Users.Add(user);
            await context.SaveChangesAsync(cancellationToken);
            return IdentityResult.Success;
        }

        public async Task<IdentityResult> DeleteAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            context.Users.Remove(user);
            await context.SaveChangesAsync(cancellationToken);
            return IdentityResult.Success;
        }

        public async Task<ApplicationUser> FindByIdAsync(string userId, CancellationToken cancellationToken)
        {
            return await context.Users
                .FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
        }

        public async Task<ApplicationUser> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken)
        {
            return await context.Users
                .FirstOrDefaultAsync(u => u.UserName == normalizedUserName, cancellationToken);
        }

        public Task<string> GetNormalizedUserNameAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return GetUserNameAsync(user, cancellationToken);
        }

        public Task<string> GetUserIdAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.Id);
        }

        public Task<string> GetUserNameAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.UserName);
        }

        public Task SetNormalizedUserNameAsync(ApplicationUser user, string normalizedName, CancellationToken cancellationToken)
        {
            return SetUserNameAsync(user, normalizedName, cancellationToken);
        }

        public Task SetUserNameAsync(ApplicationUser user, string userName, CancellationToken cancellationToken)
        {
            user.UserName = userName;
            return Task.CompletedTask;
        }

        public async Task<IdentityResult> UpdateAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            context.Users.Update(user);
            await context.SaveChangesAsync(cancellationToken);
            return IdentityResult.Success;
        }

        #endregion

        #region IUserPasswordStore<ApplicationUser>

        public Task<string> GetPasswordHashAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.PasswordHash);
        }

        public Task<bool> HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.PasswordHash != null);
        }

        public Task SetPasswordHashAsync(ApplicationUser user, string passwordHash, CancellationToken cancellationToken)
        {
            user.PasswordHash = passwordHash;
            return Task.CompletedTask;
        }

        #endregion

        #region IUserTwoFactorStore<ApplicationUser>

        public Task<bool> GetTwoFactorEnabledAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.IsTwoFactorEnabled);
        }

        public Task SetTwoFactorEnabledAsync(ApplicationUser user, bool enabled, CancellationToken cancellationToken)
        {
            user.IsTwoFactorEnabled = enabled;
            return Task.CompletedTask;
        }

        #endregion

        #region IUserAuthenticatorKeyStore<ApplicationUser>

        public Task SetAuthenticatorKeyAsync(ApplicationUser user, string key, CancellationToken cancellationToken)
        {
            user.AuthenticatorKey = key;
            return Task.CompletedTask;
        }

        public Task<string> GetAuthenticatorKeyAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.AuthenticatorKey);
        }

        #endregion

        #region IUserTwoFactorRecoveryCodeStore<ApplicationUser>

        public Task<int> CountCodesAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            var count = user.RecoveryCode?.Split(";").Length ?? 0;
            return Task.FromResult(count);
        }

        public async Task<bool> RedeemCodeAsync(ApplicationUser user, string code, CancellationToken cancellationToken)
        {
            var codes = user.RecoveryCode.Split(";");
            if (codes.Contains(code))
            {
                var updatedCodes = codes.Where(c => c != code).ToList();
                await ReplaceCodesAsync(user, updatedCodes, cancellationToken);
                return true;
            }
            else
            {
                return false;
            }
        }

        public Task ReplaceCodesAsync(ApplicationUser user, IEnumerable<string> recoveryCodes, CancellationToken cancellationToken)
        {
            user.RecoveryCode = string.Join(";", recoveryCodes);
            return Task.CompletedTask;
        }

        #endregion

        #region IUserEmailStore<ApplicationUser>

        public Task<ApplicationUser> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken)
        {
            return FindByNameAsync(normalizedEmail, cancellationToken);
        }

        public Task<string> GetEmailAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(user.UserName);
        }

        public Task<bool> GetEmailConfirmedAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(true);
        }

        public Task<string> GetNormalizedEmailAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return GetEmailAsync(user, cancellationToken);
        }

        public Task SetEmailAsync(ApplicationUser user, string email, CancellationToken cancellationToken)
        {
            user.UserName = email;
            return Task.CompletedTask;
        }

        public Task SetEmailConfirmedAsync(ApplicationUser user, bool confirmed, CancellationToken cancellationToken)
        {
            return Task.CompletedTask;
        }

        public Task SetNormalizedEmailAsync(ApplicationUser user, string normalizedEmail, CancellationToken cancellationToken)
        {
            return SetEmailAsync(user, normalizedEmail, cancellationToken);
        }

        #endregion

        #region IUserPhoneNumberStore<ApplicationUser>

        public Task<string> GetPhoneNumberAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(default(string));
        }

        public Task<bool> GetPhoneNumberConfirmedAsync(ApplicationUser user, CancellationToken cancellationToken)
        {
            return Task.FromResult(false);
        }

        public Task SetPhoneNumberAsync(ApplicationUser user, string phoneNumber, CancellationToken cancellationToken)
        {
            return Task.CompletedTask;
        }

        public Task SetPhoneNumberConfirmedAsync(ApplicationUser user, bool confirmed, CancellationToken cancellationToken)
        {
            return Task.CompletedTask;
        }

        #endregion
    }

    public class ApplicationRoleStore : IRoleStore<ApplicationRole>
    {
        public Task<IdentityResult> CreateAsync(ApplicationRole role, CancellationToken cancellationToken) =>
            throw new NotImplementedException();

        public Task<IdentityResult> DeleteAsync(ApplicationRole role, CancellationToken cancellationToken) =>
            throw new NotImplementedException();

        public void Dispose() { }

        public Task<ApplicationRole> FindByIdAsync(string roleId, CancellationToken cancellationToken) =>
            throw new NotImplementedException();

        public Task<ApplicationRole> FindByNameAsync(string normalizedRoleName, CancellationToken cancellationToken) =>
            throw new NotImplementedException();

        public Task<string> GetNormalizedRoleNameAsync(ApplicationRole role, CancellationToken cancellationToken) =>
            throw new NotImplementedException();

        public Task<IdentityResult> UpdateAsync(ApplicationRole role, CancellationToken cancellationToken) =>
            throw new NotImplementedException();

        public Task<string> GetRoleIdAsync(ApplicationRole role, CancellationToken cancellationToken) =>
            Task.FromResult(role.Id);

        public Task<string> GetRoleNameAsync(ApplicationRole role, CancellationToken cancellationToken) =>
            Task.FromResult(role.RoleName);

        public Task SetNormalizedRoleNameAsync(ApplicationRole role, string normalizedName, CancellationToken cancellationToken) =>
            SetRoleNameAsync(role, normalizedName, cancellationToken);

        public Task SetRoleNameAsync(ApplicationRole role, string roleName, CancellationToken cancellationToken)
        {
            role.RoleName = roleName;
            return Task.CompletedTask;
        }
    }

    [Route("[controller]/[action]")]
    public class UserController : Controller
    {
        const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
        readonly UserManager<ApplicationUser> userManager;
        readonly SignInManager<ApplicationUser> signInManager;
        readonly IConfiguration configuration;
        readonly UrlEncoder urlEncoder;

        public UserController(
            SignInManager<ApplicationUser> signInManager,
            UserManager<ApplicationUser> userManager,
            IConfiguration configuration,
            UrlEncoder urlEncoder)
        {
            this.signInManager = signInManager;
            this.userManager = userManager;
            this.configuration = configuration;
            this.urlEncoder = urlEncoder;
        }

        [HttpPost]
        public async Task<IActionResult> Register([FromBody] RegisterViewModel model)
        {
            if (ModelState.IsValid)
            {
                var user = new ApplicationUser()
                {
                    UserName = model.UserName,
                };
                var result = await userManager.CreateAsync(user, model.Password);
                if (result.Succeeded)
                {
                    await signInManager.SignInAsync(user, false);
                    return Ok();
                }
            }
            return BadRequest(ModelState);
        }

        [HttpPost]
        public async Task<IActionResult> Login([FromBody]LoginViewModel model)
        {
            if (ModelState.IsValid)
            {
                var result = await signInManager.PasswordSignInAsync(model.UserName, model.Password, true, false);
                if (result.Succeeded)
                {
                    var user = await userManager.FindByNameAsync(model.UserName);

                    // 2 要素認証が OFF のときは通常の JWT を返す
                    var token = await GenerateJwtTokenAsync(user);
                    return Ok(token);
                }
                else if (result.RequiresTwoFactor)
                {
                    var user = await userManager.FindByNameAsync(model.UserName);

                    // 2 要素認証が ON のときは 2 要素認証関連 API だけ利用できる JWT を返す
                    var token = await GenerateJwtTokenAsync(user, true);
                    return Ok(token);
                }
            }
            return BadRequest(ModelState);
        }

        // 2 要素認証関連 API は認証スキームを別にしておく
        [HttpPost]
        [Authorize(AuthenticationSchemes = "Bearer.TwoFactor")]
        public async Task<IActionResult> LoginWith2fa([FromBody]LoginWith2faViewModel model)
        {
            if (ModelState.IsValid)
            {
                var user = await userManager.GetUserAsync(User);
                var code = model.Code.Replace(" ", "").Replace("-", "");
                var result = await userManager.VerifyTwoFactorTokenAsync(user, TokenOptions.DefaultAuthenticatorProvider, code);
                if (result)
                {
                    var token = await GenerateJwtTokenAsync(user);
                    return Ok(token);
                }
            }
            return BadRequest(ModelState);
        }

        // 2 要素認証関連 API は認証スキームを別にしておく
        [HttpPost]
        [Authorize(AuthenticationSchemes = "Bearer.TwoFactor")]
        public async Task<IActionResult> LoginWithRecoveryCode([FromBody]LoginWithRecoveryCodeViewModel model)
        {
            if (ModelState.IsValid)
            {
                var user = await userManager.GetUserAsync(User);
                var code = model.RecoveryCode.Replace(" ", "");
                var result = await userManager.RedeemTwoFactorRecoveryCodeAsync(user, code);
                if (result.Succeeded)
                {
                    var token = await GenerateJwtTokenAsync(user);
                    return Ok(token);
                }
            }
            return BadRequest(ModelState);
        }

        async Task<TokenViewModel> GenerateJwtTokenAsync(ApplicationUser user, bool requiresTwoFactor = false)
        {
            var principal = await signInManager.CreateUserPrincipalAsync(user);

            var claims = new List<Claim>(principal.Claims);
            claims.Add(new Claim(JwtRegisteredClaimNames.Sub, user.UserName));
            claims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()));

            SymmetricSecurityKey key;
            if (requiresTwoFactor)
            {
                // 2 要素認証関連 API のみ利用できる JWT を作成
                claims.Add(new Claim(ClaimTypes.AuthenticationMethod, IdentityConstants.TwoFactorUserIdScheme));
                key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JwtTwoFactorKey"]));
            }
            else
            {
                key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JwtKey"]));
            }

            var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
            var expires = DateTime.Now.AddDays(7);

            var token = new JwtSecurityToken(
                configuration["JwtIssuer"],
                configuration["JwtIssuer"],
                claims,
                expires: expires,
                signingCredentials: credentials
            );

            var jwt = new JwtSecurityTokenHandler().WriteToken(token);
            return new TokenViewModel()
            {
                Token = jwt,
                RequiresTwoFactor = requiresTwoFactor,
            };
        }

        [HttpGet]
        [Authorize]
        public async Task<IActionResult> Current()
        {
            var user = await userManager.GetUserAsync(User);
            return Ok(user);
        }

        [HttpGet]
        [Authorize]
        public async Task<IActionResult> EnableAuthenticator()
        {
            var user = await userManager.GetUserAsync(User);
            if (user == null)
            {
                throw new ApplicationException($"Unable to load user with ID '{userManager.GetUserId(User)}'.");
            }

            var model = new EnableAuthenticatorViewModel();
            await LoadSharedKeyAndQrCodeUriAsync(user, model);

            return Ok(model);
        }

        [HttpPost]
        [Authorize]
        public async Task<IActionResult> EnableAuthenticator([FromBody] EnableAuthenticatorViewModel model)
        {
            var user = await userManager.GetUserAsync(User);
            if (user == null)
            {
                throw new ApplicationException(
                    $"Unable to load user with ID '{userManager.GetUserId(User)}'.");
            }

            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            // 半角スペースとハイフンを除去
            var verificationCode = model.Code
                .Replace(" ", string.Empty)
                .Replace("-", string.Empty);

            var is2faTokenValid = await userManager.VerifyTwoFactorTokenAsync(
                user, userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);

            if (!is2faTokenValid)
            {
                ModelState.AddModelError("Code", "Verification code is invalid.");
                return BadRequest(ModelState);
            }

            await userManager.SetTwoFactorEnabledAsync(user, true);

            var recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
            var viewModel = new ShowRecoveryCodesViewModel
            {
                RecoveryCodes = recoveryCodes.ToArray()
            };
            return Ok(viewModel);
        }

        [HttpPost]
        [Authorize]
        public async Task<IActionResult> Disable2fa()
        {
            var user = await userManager.GetUserAsync(User);
            if (user == null)
            {
                throw new ApplicationException(
                    $"Unable to load user with ID '{userManager.GetUserId(User)}'.");
            }

            var disable2faResult = await userManager.SetTwoFactorEnabledAsync(user, false);
            if (!disable2faResult.Succeeded)
            {
                throw new ApplicationException($"Unexpected error occured disabling 2FA for user with ID '{user.Id}'.");
            }

            return Ok();
        }

        [HttpPost]
        [Authorize]
        public async Task<IActionResult> ResetAuthenticator()
        {
            var user = await userManager.GetUserAsync(User);
            if (user == null)
            {
                throw new ApplicationException(
                    $"Unable to load user with ID '{userManager.GetUserId(User)}'.");
            }
            await userManager.SetTwoFactorEnabledAsync(user, false);
            await userManager.ResetAuthenticatorKeyAsync(user);
            return Ok();
        }


        #region Helpers

        private void AddErrors(IdentityResult result)
        {
            foreach (var error in result.Errors)
            {
                ModelState.AddModelError(string.Empty, error.Description);
            }
        }

        private string FormatKey(string unformattedKey)
        {
            var result = new StringBuilder();
            int currentPosition = 0;
            while (currentPosition + 4 < unformattedKey.Length)
            {
                result.Append(unformattedKey.Substring(currentPosition, 4)).Append(" ");
                currentPosition += 4;
            }
            if (currentPosition < unformattedKey.Length)
            {
                result.Append(unformattedKey.Substring(currentPosition));
            }

            return result.ToString().ToLowerInvariant();
        }

        private string GenerateQrCodeUri(string email, string unformattedKey)
        {
            return string.Format(
                AuthenticatorUriFormat,
                urlEncoder.Encode("JwtTwoFactAuthSample"),
                urlEncoder.Encode(email),
                unformattedKey);
        }

        private async Task LoadSharedKeyAndQrCodeUriAsync(ApplicationUser user, EnableAuthenticatorViewModel model)
        {
            var unformattedKey = await userManager.GetAuthenticatorKeyAsync(user);
            if (string.IsNullOrEmpty(unformattedKey))
            {
                await userManager.ResetAuthenticatorKeyAsync(user);
                unformattedKey = await userManager.GetAuthenticatorKeyAsync(user);
            }

            model.SharedKey = FormatKey(unformattedKey);
            model.AuthenticatorUri = GenerateQrCodeUri(user.UserName, unformattedKey);
        }

        #endregion
    }

    // ユーザー登録用
    public class RegisterViewModel
    {
        [Required]
        public string UserName { get; set; }

        [Required]
        [DataType(DataType.Password)]
        public string Password { get; set; }
    }

    // ログイン用
    public class LoginViewModel
    {
        [Required]
        public string UserName { get; set; }

        [Required]
        [DataType(DataType.Password)]
        public string Password { get; set; }
    }

    public class EnableAuthenticatorViewModel
    {
        [Required]
        [StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
        [DataType(DataType.Text)]
        [Display(Name = "Verification Code")]
        public string Code { get; set; }

        [BindNever]
        public string SharedKey { get; set; }

        [BindNever]
        public string AuthenticatorUri { get; set; }
    }

    // 2 要素認証の認証コードのバインド用
    public class LoginWith2faViewModel
    {
        [Required]
        public string Code { get; set; }
    }

    public class LoginWithRecoveryCodeViewModel
    {
        [Required]
        public string RecoveryCode { get; set; }
    }

    public class ShowRecoveryCodesViewModel
    {
        public string[] RecoveryCodes { get; set; }
    }

    // JWT をレスポンスで返すとき用
    public class TokenViewModel
    {
        public string Token { get; set; }

        public bool RequiresTwoFactor { get; set; }
    }
}

Visual StudioASP.NET Core Identity を使う Web アプリケーションプロジェクトを作成すると、2 要素認証を行うコードまで作成される。ただし作成されたコードは Cookie を使うこと前提で、JwtBearer Authentication と組み合わせる際にそのままでは使えなかった。

そのため、内部で呼び出されているメソッドをASP.NET Core Identity のソースコードで調べ、そのメソッドを直接呼び出すことで無理やり実現している。呼び出しているのはどれも public なメソッドだし、内部 API というようなものでも無さそうなので、今のところ問題は起きていない。あまりオススメのやり方では無いのは重々承知。

『からかい上手の高木さん(9)』を読んだ

最近はほとんど、西片くんが高木さんに勝負を挑み、返り討ちに合うという展開。 色々策を講じるものの、結局は高木さんに上を行かれてしまうわけで。 もはや様式美だな。

それはそうと西片くん、何としても高木さんに勝ちたいのか、 いよいよ手段を選ばなくなってきた。

姑息。あまりにも姑息。 それで勝ったところで虚しくならないんだろうか。 まぁ結局勝てないので余計な心配でしかないんだけど。

高木さんの方も、既成事実を作りつつ、 西片くんの外堀を順調に埋めていて、 攻略は完了一歩手前という感じ。

あと、気になっていたサブキャラの北条さんに恋の予感。 男の方は浜口だっけ。 帰る方向が同じということなので、幼馴染とかだったりするのかも。

北条さんはまんざらでもなさそうな表情だったので、高木さんと西片くんを差し置いて、 作中2組目のカップル誕生なるか。

北条さんのスピンオフが出たらいいな。

『ぼくたちは勉強ができない(7)』を読んだ

夏休みが終わってあしゅみー先輩の出番が減るかと思いきや、そんなことは全くなく7巻でも健在。それにしても、夏休み&予備校も終わったというのに、成幸はまだハイステージに通ってるのか。

ここのところ、誰か1人がメインではなく、3人娘全員が同じくらい登場するような回が増えてきた印象。そういった回は結局、最後にあしゅみー先輩や真冬先生が持っていってしまうんだけど。

その真冬先生、プライベートではポンコツを晒しているが仕事では完璧。…といつから錯覚していた?話の都合上仕方ないとはいえ、成幸無しでよく今まで社会人やってこれたもんだ。

7巻ではうるかがかなり積極的に攻めていた。成幸に手料理を振る舞ったのは彼女くらいだし、他に好きな人がいると勘違いされながらも着実に進んでいる印象。ただ、2人を応援していた方が先にくっつくとは予想してなかったな。

文乃は3人娘の中において最初の出遅れを一気に挽回して、その勢いに陰りは見えない。女心の師匠と姉弟ごっこを経て、なんだかんだで成幸と文乃は良いコンビになってきた。父親回も残っているし、メインヒロイン扱いっぽくなってきた気がする。

理珠は再度イメチェンを図ったりと作者の苦労が滲み出てる気がする。メイン回少ないし。ポテンシャルは高いだけに勿体ない。やはり、自分の気持ちに気付いてからが勝負だろう。そうなると、理珠も重要エピソードを残してることになるな。

ブコメだから最終的にはヒロインの誰かと結ばれるんだろうけど、その相手がまったく予想できない。みんな可能性ありそう。途中からヒロインに加わったあしゅみー先輩や真冬先生はさすがに難しいとは思うが、先生推しの自分としては前例にない事をやって欲しいとも思ってる。

gRPC vs. WCF

はじめに

仕事で開発に携わっているアプリでは通信に WCF を使っている。 一方で去年から Microservices やゲームのバックエンドとして gRPC が盛り上がっている。 gRPC は通信に HTTP/2 を使っていて、HTTP/2 はクライアントとサーバー間の通信がバイナリベースで行われているし、 さらに gRPC ではシリアライズに ProtocolBuffers を使っている。

「こいつはもしかしたら、WCF の NetTcpBinding と同じくらい速いのでは?」 と思ったので、ベンチマークをとって比較してみた。

NuGet パッケージをインストール

gRPC を使うために必要なパッケージは、 公式のものが NuGet に公開されているのでインストールする。

service.proto ファイル作成

まずは gRPC 側から取り掛かる。 service.proto ファイルを作成し、サービスのインタフェースを定義。

syntax = "proto3";

package grpcVsWcf.grpc;

service BookService {
  rpc GetBooks(BooksRequest) returns (BooksReply);
}

message Book {
    string id = 1;
    string title = 2;
    string description = 3;
    string author = 4;
    int32 price = 5;
    string publishedAt = 6;
}

message BooksRequest {
    int32 count = 1;
}

message BooksReply {
    repeated Book books = 1;
}

service.proto からコードを生成

NuGet でインストールした Grpc.Tools にコード生成のためのツールが含まれているので、 コマンドプロンプトから直接実行する。 Visual Studio にコマンドでも追加してくれれば楽なんだけどね。

packages\Grpc.Tools.1.13.0\tools\windows_x86\protoc.exe --csharp_out . --grpc_out service.proto --plugin=protoc-gen-grpc=packages\Grpc.Tools.1.13.0\tools\windows_x86\grpc_csharp_plugin.exe

次のようなサービスのコードが出力される。

// <auto-generated>
//     Generated by the protocol buffer compiler.  DO NOT EDIT!
//     source: service.proto
// </auto-generated>
#pragma warning disable 0414, 1591
#region Designer generated code

using grpc = global::Grpc.Core;

namespace GrpcVsWcf.Grpc {
  public static partial class BookService
  {
    static readonly string __ServiceName = "grpcVsWcf.grpc.BookService";

    static readonly grpc::Marshaller<global::GrpcVsWcf.Grpc.BooksRequest> __Marshaller_BooksRequest = grpc::Marshallers.Create((arg) => global::Google.Protobuf.MessageExtensions.ToByteArray(arg), global::GrpcVsWcf.Grpc.BooksRequest.Parser.ParseFrom);
    static readonly grpc::Marshaller<global::GrpcVsWcf.Grpc.BooksReply> __Marshaller_BooksReply = grpc::Marshallers.Create((arg) => global::Google.Protobuf.MessageExtensions.ToByteArray(arg), global::GrpcVsWcf.Grpc.BooksReply.Parser.ParseFrom);

    static readonly grpc::Method<global::GrpcVsWcf.Grpc.BooksRequest, global::GrpcVsWcf.Grpc.BooksReply> __Method_GetBooks = new grpc::Method<global::GrpcVsWcf.Grpc.BooksRequest, global::GrpcVsWcf.Grpc.BooksReply>(
        grpc::MethodType.Unary,
        __ServiceName,
        "GetBooks",
        __Marshaller_BooksRequest,
        __Marshaller_BooksReply);

    /// <summary>Service descriptor</summary>
    public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor
    {
      get { return global::GrpcVsWcf.Grpc.ServiceReflection.Descriptor.Services[0]; }
    }

    /// <summary>Base class for server-side implementations of BookService</summary>
    public abstract partial class BookServiceBase
    {
      public virtual global::System.Threading.Tasks.Task<global::GrpcVsWcf.Grpc.BooksReply> GetBooks(global::GrpcVsWcf.Grpc.BooksRequest request, grpc::ServerCallContext context)
      {
        throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
      }

    }

    /// <summary>Client for BookService</summary>
    public partial class BookServiceClient : grpc::ClientBase<BookServiceClient>
    {
      /// <summary>Creates a new client for BookService</summary>
      /// <param name="channel">The channel to use to make remote calls.</param>
      public BookServiceClient(grpc::Channel channel) : base(channel)
      {
      }
      /// <summary>Creates a new client for BookService that uses a custom <c>CallInvoker</c>.</summary>
      /// <param name="callInvoker">The callInvoker to use to make remote calls.</param>
      public BookServiceClient(grpc::CallInvoker callInvoker) : base(callInvoker)
      {
      }
      /// <summary>Protected parameterless constructor to allow creation of test doubles.</summary>
      protected BookServiceClient() : base()
      {
      }
      /// <summary>Protected constructor to allow creation of configured clients.</summary>
      /// <param name="configuration">The client configuration.</param>
      protected BookServiceClient(ClientBaseConfiguration configuration) : base(configuration)
      {
      }

      public virtual global::GrpcVsWcf.Grpc.BooksReply GetBooks(global::GrpcVsWcf.Grpc.BooksRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
      {
        return GetBooks(request, new grpc::CallOptions(headers, deadline, cancellationToken));
      }
      public virtual global::GrpcVsWcf.Grpc.BooksReply GetBooks(global::GrpcVsWcf.Grpc.BooksRequest request, grpc::CallOptions options)
      {
        return CallInvoker.BlockingUnaryCall(__Method_GetBooks, null, options, request);
      }
      public virtual grpc::AsyncUnaryCall<global::GrpcVsWcf.Grpc.BooksReply> GetBooksAsync(global::GrpcVsWcf.Grpc.BooksRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
      {
        return GetBooksAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
      }
      public virtual grpc::AsyncUnaryCall<global::GrpcVsWcf.Grpc.BooksReply> GetBooksAsync(global::GrpcVsWcf.Grpc.BooksRequest request, grpc::CallOptions options)
      {
        return CallInvoker.AsyncUnaryCall(__Method_GetBooks, null, options, request);
      }
      /// <summary>Creates a new instance of client from given <c>ClientBaseConfiguration</c>.</summary>
      protected override BookServiceClient NewInstance(ClientBaseConfiguration configuration)
      {
        return new BookServiceClient(configuration);
      }
    }

    /// <summary>Creates service definition that can be registered with a server</summary>
    /// <param name="serviceImpl">An object implementing the server-side handling logic.</param>
    public static grpc::ServerServiceDefinition BindService(BookServiceBase serviceImpl)
    {
      return grpc::ServerServiceDefinition.CreateBuilder()
          .AddMethod(__Method_GetBooks, serviceImpl.GetBooks).Build();
    }

  }
}
#endregion

Book, BookRequest, BookReply のコードも出力されたけど、570行もあるので省略。 出力されたコードは、プロジェクトにリンクとして追加しておく。

gRPC のサービスを実装

ツールを実行して出力されたコードは、リクエストとレスポンスのクラス、それにサービスの抽象クラス。 抽象クラスを継承して、サービス本体の処理を実装しないといけない。

using Grpc.Core;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace GrpcVsWcf.Grpc
{
    public class BookServiceImpl : BookService.BookServiceBase
    {
        static readonly Book[] _books;

        static BookServiceImpl()
        {
            _books = Enumerable.Range(0, 100)
                .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 override Task<BooksReply> GetBooks(BooksRequest request, ServerCallContext context)
        {
            var result = new BooksReply();
            result.Books.AddRange(_books.Take(request.Count));
            return Task.FromResult(result);
        }
    }
}

ベンチマークが目的なので、サービスの実装は単純に、 リクエストで指定された件数分のデータを返すだけにしておく。

WCF のサービスコントラクトとデータコントラクトを定義

gRPC と比較するので、サービスコントラクトとデータコントラクトは service.proto から生成したものに極力近づける。

using System.Collections.Generic;
using System.Runtime.Serialization;
using System.ServiceModel;
using System.Threading.Tasks;

namespace GrpcVsWcf.Wcf
{
    [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; }
    }

    [DataContract]
    public class BooksReply
    {
        [DataMember]
        public List<Book> Books { get; set; } = new List<Book>();
    }

    [DataContract]
    public class BooksRequest
    {
        [DataMember]
        public int Count { get; set; }
    }

    [ServiceContract]
    public interface IBookService
    {
        [OperationContract]
        Task<BooksReply> GetBooks(BooksRequest request);
    }
}

WCF サービス実装

WCF サービス本体を実装。 こちらも gRPC のサービスに実装を極力近づけておく。

using System;
using System.Linq;
using System.Threading.Tasks;

namespace GrpcVsWcf.Wcf
{
    public class BookService : IBookService
    {
        static readonly Book[] _books;

        static BookService()
        {
            _books = Enumerable.Range(0, 100)
                .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 Task<BooksReply> GetBooks(BooksRequest request)
        {
            var result = new BooksReply();
            result.Books.AddRange(_books.Take(request.Count));
            return Task.FromResult(result);
        }
    }
}

gRPC と WCF のホストを実装

クライアントとサービスは別プロセスで動かしてベンチマークを行いたいので、 サービスをホストするプログラムを作成。 gRPC と WCF のサービスは同じプロセスで動かす。

using Grpc.Core;
using GrpcVsWcf.Grpc;
using GrpcVsWcf.Wcf;
using System;
using System.ServiceModel;

namespace GrpcVsWcf
{
    class Program
    {
        static void Main(string[] args)
        {
            var tcpHost = StartTcpWcfService();
            var namedPipeHost = StartNamedPipeWcfService();
            var grpcServer = StartGrpcServer();

            Console.WriteLine("Enter で終了します。");
            Console.ReadLine();

            grpcServer.ShutdownAsync().GetAwaiter().GetResult();
            tcpHost.Close();
            namedPipeHost.Close();
        }

        static ServiceHost StartTcpWcfService()
        {
            var host = new ServiceHost(typeof(Wcf.BookService));
            host.AddServiceEndpoint(
                typeof(IBookService),
                new NetTcpBinding(),
                "net.tcp://localhost:8080/BookService");
            host.Open();
            return host;
        }

        static ServiceHost StartNamedPipeWcfService()
        {
            var host = new ServiceHost(typeof(Wcf.BookService));
            host.AddServiceEndpoint(
                typeof(IBookService),
                new NetNamedPipeBinding(),
                "net.pipe://localhost/BookService");
            host.Open();
            return host;
        }

        static Server StartGrpcServer()
        {
            var server = new Server()
            {
                Services =
                {
                    Grpc.BookService.BindService(new BookServiceImpl()),
                },
                Ports =
                {
                    new ServerPort("localhost", 8090, ServerCredentials.Insecure),
                }
            };
            server.Start();
            return server;
        }
    }
}

ベンチマークを作成

gRPC と WCF のサービスを呼び出すクライアントのプログラムを作成。 ベンチマークもこのプログラムで行う。 なお、ベンチマークには BenchmarkDotNet を使っている。

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Grpc.Core;
using GrpcVsWcf.Wcf;
using System;
using System.ServiceModel;
using System.ServiceModel.Channels;

namespace GrpcVsWcf
{
    public class GrpcVsWcfBenchmark
    {
        Channel channel;
        Grpc.BookService.BookServiceClient client;
        IBookService wcfTcpChannel;
        IBookService wcfNamedPipeChannel;

        [GlobalSetup]
        public void GlobalSetup()
        {
            channel = new Channel("127.0.0.1:8090", ChannelCredentials.Insecure);
            client = new Grpc.BookService.BookServiceClient(channel);

            wcfTcpChannel = ChannelFactory<IBookService>.CreateChannel(
                new NetTcpBinding(),
                new EndpointAddress("net.tcp://localhost:8080/BookService"));

            wcfNamedPipeChannel = ChannelFactory<IBookService>.CreateChannel(
                new NetNamedPipeBinding(),
                new EndpointAddress("net.pipe://localhost/BookService"));
        }

        [GlobalCleanup]
        public void GlobalCleanup()
        {
            channel.ShutdownAsync().GetAwaiter().GetResult();
            ((IChannel)wcfTcpChannel).Close();
            ((IChannel)wcfNamedPipeChannel).Close();
        }

        [Benchmark]
        [Arguments(1)]
        [Arguments(10)]
        [Arguments(50)]
        [Arguments(100)]
        public Grpc.BooksReply Grpc(int count)
        {
            return client.GetBooksAsync(new Grpc.BooksRequest() { Count = count })
                .GetAwaiter().GetResult();
        }

        [Benchmark]
        [Arguments(1)]
        [Arguments(10)]
        [Arguments(50)]
        [Arguments(100)]
        public BooksReply WcfTcp(int count)
        {
            return wcfTcpChannel.GetBooks(new BooksRequest() { Count = count })
                .GetAwaiter().GetResult();
        }

        [Benchmark]
        [Arguments(1)]
        [Arguments(10)]
        [Arguments(50)]
        [Arguments(100)]
        public BooksReply WcfNamedPipe(int count)
        {
            return wcfNamedPipeChannel.GetBooks(new BooksRequest() { Count = count })
                .GetAwaiter().GetResult();
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            BenchmarkRunner.Run<GrpcVsWcfBenchmark>();

            Console.ReadLine();
        }
    }
}

ベンチマーク結果

f:id:griefworker:20180706100859p:plain

おわりに

ベンチマークをとる前は「WCF の NetTcpBinding と gRPC は同等の速度が出たらいいな」 なんて思っていたけど、とんでもない。 gRPC の方が速かった。圧倒的に。 NetNamedPipeBinding よりも速かったのは完全に予想外。 WCF と比べてごめんなさいするレベルだ。

これはもう積極的に WCF を選ぶ理由は無いな。 CustomBinding 使えば WCF の方はもう少し速くなる可能性はある。 でも、そこに労力使うならその分 gRPC に労力使いたいし、 それでも gRPC を超えそうには思えない。 WCF .NET Core でクライアントのみサポートされている状況で、 未来も無さそうだし。

gRPC がファーストチョイスでしょ。

『WEB+DB PRESS Vol.105』を読んだ

特集1:実践サーバーレス

awsgi を使うことで Python + Flask で実装したアプリを Lambda で動かせるのは衝撃だった。 これは Lambda の敷居がかなり下がったんじゃないか? WSGI に対応していればいいのなら、 Django すら動かせるかもしれないけど、さすがに起動が遅くて実用的ではないか。bottle や Flask が現実的。 Node よりも開発しやすいと思う。

それにしても awsgi って良いパッケージ名だな。

特集2:Firebase大活用

モバイルアプリ開発において、 多くの場合でバックエンドは必要。 Firebase はデータの保存に Realtime Database を、さらには Firebase Authentication で認証まで提供してくれて、ここまで至れり尽くせりだったとは。

ビジネスロジックをクライアント側に実装するなら、 React Native や Xamarin といったクロスプラットフォームと組み合わせれば、iOSAndroid のアプリをスピーディに開発できるんじゃないだろうか。 個人的にはネイティブの薄いラッパーを提供している Xamarin と組み合わせるといい気がする。 .NET Standard もあるからビジネスロジックやビューモデルなど結構な部分を共有できるし。

特集3:はじめてのスマートスピーカー

スマートスピーカーがある生活は未来感あって、生活が変わりそうな予感がする。 さらに Alexa スキルや Google Assistant アプリを JavaScript で開発できる点は、開発者を一気に集めることができそうで、両陣営わかってるなぁという印象。実際、既に結構なスキルやアプリが存在するみたいだし。

ただなぁ、IoT にはイマイチ食指が動かないんだよなぁ。 ミニマリスト志向で、 スマートフォンと PC 以外のガジェットを 欲しいと思わないのが問題なんだろうけど。

WEB+DB PRESS Vol.105

WEB+DB PRESS Vol.105

  • 作者: 小笠原みつき,西村公宏,柳佳音,志甫侑紀,池田友洋,木村涼平,?橋優介,大塚雅和,飯塚直,吉川竜太,末永恭正,久保田祐史,浜田真成,穴井宏幸,大島一将,桑原仁雄,牧大輔,池田拓司,はまちや2,竹原,WEB+DB PRESS編集部
  • 出版社/メーカー: 技術評論社
  • 発売日: 2018/06/23
  • メディア: 単行本
  • この商品を含むブログを見る

クロッカンシュー ザクザク

天神地下街に3月にオープンした『クロッカンシュー ザクザク』の行列が、6月になってだいぶ落ち着いたので、ようやく食べる機会ができた。

想像していたのより長くて、1本で結構なボリューム。中のカスタードクリームはサッパリしていて、余裕で1本食べられた。飲めるカスタードクリームだと思う。もう1本はいけたな。外側のクロッカンシューは確かにザクザクしてたけど、購入から時間が経っていたせいか、若干しんなりしてしまった。購入してすぐ、カスタードクリーム詰めたてを食べないと、本当のクロッカンシューを食べたことにはならないかもしれない。

関連ランキング:スイーツ(その他) | 西鉄福岡駅(天神)天神駅天神南駅

プログラマー定年

とうとうプログラマー定年と呼ばれていた年齢になってしまった。ただ、定年どころか今でもフツーにコードを書いてる。周囲を見ても、自分より年上の人たちが当たり前のようにコードを書いている。

流石にマネージャーともなると、ミーティングに時間を取られることが多くなっているようだが、マネージャーはリーダークラスで、そんな肩書きの人は社内で数人。まぁ中小企業なのでそんなものだろう。プログラマー定年なんて SIer の世界の話だ。自分にとっては御伽噺だな。