Entity Framework Core で値オブジェクトを実装する

Entity Framework Core 2.0 の頃から既に値オブジェクトを実装する資料は存在した。

docs.microsoft.com

ただ、自分がやりたかったのは「エンティティの ID を値オブジェクトにすること」だったので、 ちょっと違う。 Entity Framework Core 2.1 から追加された「値の変換」機能を使えば、 やりたかったことが実現できるかもと思ったので試してみた。

using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace ValueObjectSample
{
    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                MainAsync(args).GetAwaiter().GetResult();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }
            Console.WriteLine("Press enter key");
            Console.ReadLine();
        }

        static async Task MainAsync(string[] args)
        {
            using (var context = new ApplicationDbContext())
            {
                await context.Database.EnsureDeletedAsync();
                await context.Database.EnsureCreatedAsync();
            }

            using (var context = new ApplicationDbContext())
            {
                var author = new Author()
                {
                    Name = "荒川 弘"
                };
                context.Authors.Add(author);

                context.Books.Add(new Book()
                {
                    Title = "銀の匙 Silver Spoon",
                    AuthorId = author.Id,
                });
                context.Books.Add(new Book()
                {
                    Title = "アルスラーン戦記",
                    AuthorId = author.Id,
                });

                await context.SaveChangesAsync();
            }

            using (var context = new ApplicationDbContext())
            {
                var books = await context.Books
                    .Include(b => b.Author)
                    .ToListAsync();
                foreach (var book in books)
                {
                    Console.WriteLine($"Id : {book.Id}");
                    Console.WriteLine($"Title : {book.Title}");
                    Console.WriteLine($"AuthorName : {book.Author.Name}");
                    Console.WriteLine();
                }
            }

            using (var context = new ApplicationDbContext())
            {
                var authors = await context.Authors
                    .Include(a => a.Books)
                    .ToListAsync();
                foreach (var author in authors)
                {
                    Console.WriteLine($"Id : {author.Id}");
                    Console.WriteLine($"Name : {author.Name}");
                    for (int i = 0; i < author.Books.Count; i++)
                    {
                        Console.WriteLine($"BookTitle{i} : {author.Books[i].Title}");
                    }
                    Console.WriteLine();
                }
            }
        }
    }

    public class ApplicationDbContext : DbContext
    {
        public DbSet<Author> Authors => Set<Author>();

        public DbSet<Book> Books => Set<Book>();

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlServer(
                "Server=(local);Database=ValueObjectSample;Integrated Security=SSPI;");
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Author>(b =>
            {
                b.Property(e => e.Id)
                    .IsRequired()
                    .HasConversion( // データベースには文字列で保存する
                        id => id.Value,
                        value => AuthorId.Parse(value));
                b.Property(e => e.Name)
                    .IsRequired();

                b.HasKey(e => e.Id);
                b.HasMany(e => e.Books);
            });

            modelBuilder.Entity<Book>(b =>
            {
                b.Property(e => e.Id)
                    .IsRequired()
                    .HasConversion( // データベースには文字列で保存する
                        id => id.Value,
                        value => BookId.Parse(value));
                b.Property(e => e.Title)
                    .IsRequired();
                b.Property(e => e.AuthorId)
                    .IsRequired()
                    .HasConversion( // データベースには文字列で保存する
                        id => id.Value,
                        value => AuthorId.Parse(value));

                b.HasKey(e => e.Id);
                b.HasOne(e => e.Author);
            });
        }
    }

    public class Author
    {
        public AuthorId Id { get; set; } = AuthorId.NewAuthorId();

        public string Name { get; set; }

        public IList<Book> Books { get; set; }
    }

    public class Book
    {
        public BookId Id { get; set; } = BookId.NewBookId();

        public string Title { get; set; }

        public AuthorId AuthorId { get; set; }

        public Author Author { get; set; }
    }

    // Author の ID を ValueObject にする
    public class AuthorId
    {
        AuthorId(string value)
        {
            Value = value;
        }

        public string Value { get; }

        public static AuthorId NewAuthorId() =>
            new AuthorId(Guid.NewGuid().ToString());

        public static AuthorId Parse(string value) =>
            new AuthorId(value);

        public override int GetHashCode() =>
            Value.GetHashCode();

        public override bool Equals(object obj)
        {
            if (obj == null || GetType() != obj.GetType())
                return false;
            if (object.ReferenceEquals(this, obj))
                return true;
            if (obj is AuthorId other)
                return Value.Equals(other.Value);
            return false;
        }

        public override string ToString() =>
            $"AuthorId({Value})";
    }

    // Book の ID を ValueObject にする
    public class BookId
    {
        BookId(string value)
        {
            Value = value;
        }

        public string Value { get; }

        public static BookId NewBookId() =>
            new BookId(Guid.NewGuid().ToString());

        public static BookId Parse(string value) =>
            new BookId(value);

        public override int GetHashCode() =>
            Value.GetHashCode();

        public override bool Equals(object obj)
        {
            if (obj == null || GetType() != obj.GetType())
                return false;
            if (object.ReferenceEquals(this, obj))
                return true;
            if (obj is BookId other)
                return Value.Equals(other.Value);
            return false;
        }

        public override string ToString() =>
            $"BookId({Value})";
    }
}

実行結果がこちら。 ID をデータベースには文字列として保存しつつ、 エンティティで扱うときは値オブジェクト化できている。 リレーションシップも OK。

f:id:griefworker:20180606145237p:plain

ID は上手くいくと思っていたけど、 リレーションシップの方は上手くいくかどうか半信半疑だったので一安心。

JwtBearer Authentication の裏で ASP.NET Core Identity を使う

認証に ASP.NET Core Identity を使うけど、アプリ向けに JwtBearer の認証もサポートしたい。JwtBearer でトークンを発行するときは、ユーザーを認証するコードを書くことになるわけなので、そこで Identity の SignInManager や UserManager を使えばいいかもしれない。早速試してみた。

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.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Mvc;
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.Security.Claims;
using System.Text;
using System.Threading.Tasks;

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

        public static IWebHost BuildWebHost(string[] args)
        {
            // 構成にはメモリ内プロバイダーを使う
            var dict = new Dictionary<string, string>
            {
                ["ConnectionStrings:DefaultConnection"] = "Server=(localdb)\\mssqllocaldb;Database=aspnet-IdentityJwtSample-550CDEDB-AB81-4711-8A92-0988E2505FD1;Trusted_Connection=True;MultipleActiveResultSets=true",
                ["JwtKey"] = "9AC93E2B-8C4F-46C3-B9E5-F7E7CB37A73A",
                ["JwtIssuer"] = "http://yourdomain.com",
            };
            var builder = new ConfigurationBuilder();
            builder.AddInMemoryCollection(dict);

            return WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseConfiguration(builder.Build())
                .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, IdentityRole>()
                .AddEntityFrameworkStores<ApplicationDbContext>()
                .AddDefaultTokenProviders();

            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"])),
                };
            });

            services.AddMvc();
        }

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

            app.UseAuthentication();

            app.UseMvc();

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

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

        protected override void OnModelCreating(ModelBuilder builder)
        {
            base.OnModelCreating(builder);
        }
    }

    // アプリケーション用ユーザー
    public class ApplicationUser : IdentityUser
    {
    }

    // ログインに使うパラメーターを格納
    public class LoginViewModel
    {
        [Required]
        [EmailAddress]
        public string Email { get; set; }

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

    // ユーザー登録に使うパラメーターを格納
    public class RegisterViewModel
    {
        [Required]
        [EmailAddress]
        [Display(Name = "Email")]
        public string Email { get; set; }

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

        [DataType(DataType.Password)]
        [Display(Name = "Confirm password")]
        [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
        public string ConfirmPassword { get; set; }
    }

    // 発行したアクセストークンを格納
    public class TokenViewModel
    {
        public string Token { get; set; }
    }

    // API で取得できるユーザー情報を格納
    public class UserViewModel
    {
        public string Id { get; set; }

        public string UserName { get; set; }

        public string Email { get; set; }
    }

    [Route("api/user")]
    public class AccountController : Controller
    {
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly SignInManager<ApplicationUser> _signInManager;
        private readonly IConfiguration _configuration;

        public AccountController(
            UserManager<ApplicationUser> userManager,
            SignInManager<ApplicationUser> signInManager,
            IConfiguration configuration)
        {
            _userManager = userManager;
            _signInManager = signInManager;
            _configuration = configuration;
        }

        // ユーザーを新規登録してアクセストークンを取得
        [HttpPost("register")]
        public async Task<IActionResult> Register([FromBody]RegisterViewModel model)
        {
            if (ModelState.IsValid)
            {
                var user = new ApplicationUser()
                {
                    UserName = model.Email,
                    Email = model.Email
                };
                var result = await _userManager.CreateAsync(user, model.Password);
                if (result.Succeeded)
                {
                    await _signInManager.SignInAsync(user, isPersistent: false);

                    var token = await GenerateJwtTokenAsync(user);
                    return Ok(token);
                }
                AddErrors(result);
            }

            return BadRequest(ModelState);
        }

        // メールアドレスとパスワードでログインしてアクセストークンを取得
        [HttpPost("login")]
        public async Task<IActionResult> Login([FromBody]LoginViewModel model)
        {
            if (ModelState.IsValid)
            {
                var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, false, lockoutOnFailure: false);
                if (result.Succeeded)
                {
                    var user = await _userManager.FindByEmailAsync(model.Email);
                    var token = await GenerateJwtTokenAsync(user);
                    return Ok(token);
                }
            }

            return BadRequest(ModelState);
        }

        // アクセストークンを生成
        async Task<TokenViewModel> GenerateJwtTokenAsync(ApplicationUser user)
        {
            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()));

            var 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
            };
        }

        // ログインユーザーの情報を取得
        [HttpGet("current")]
        [Authorize]
        public async Task<IActionResult> Current()
        {
            var user = await _userManager.GetUserAsync(User);
            var viewModel = new UserViewModel()
            {
                Id = user.Id,
                UserName = user.UserName,
                Email = user.Email,
            };
            return Ok(viewModel);
        }

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

curl で動作確認してみる。 まずはユーザー登録。

f:id:griefworker:20180604133613p:plain

ユーザーの登録に成功し、アクセストークンが返ってきた。

次はログイン。

f:id:griefworker:20180604133632p:plain

ログインに成功すればアクセストークンが返ってくる。

そのアクセストークンを使って、認証必須の API を呼び出せるかテスト。

f:id:griefworker:20180604133655p:plain

ログインユーザーの情報が返ってきた。テスト成功。

『アオアシ(13)』を読んだ

プレミアリーグ柏大商業戦で葦人がベンチ入りできたと思ったら、アクシデントによりまさかの途中出場とは。 まだプレミアリーグのレベルにないので、相手から集中的に狙われてしまうわけだが。

このまま無惨に散るのかと思いきや、 ユースに入ったばかりの頃と比べて成長していたので、同じ轍は踏まなかった。 実力不足を認め、できないならどうするか、考えることをやめない葦人の姿勢には学びがある。

あと、敵対していた阿久津と協力して(?)守備するところは見どころ。 監督の手前仕方なくって感じで、 認められたわけではないけど。 阿久津の指示の意図を理解して動いたところと、最後のカウンターにはシビレた。

福岡アンパンマンこどもミュージアムinモール

f:id:griefworker:20180601214052p:plain

子供の誕生日に有休とって『福岡アンパンマンこどもミュージアムinモール』に行ってきた。平日だからか、予想よりもかなり人が少なくて、ステージはすべて最前列で見ることができた。凄く混雑した写真を見ていてビビってたけど、あれは休日だからだろうな。

子供はステージの他には砂場がたいそう気に入ったみたいで、滞在時間の大半を砂場で費やした。手にほとんど粒がつかないほどサラサラで、夢中になるのも分かる。大人でも少し童心に返ってしまった。 ただ、砂場とステージ以外はそれほど遊べるものがなくて、他のミュージアム&モールも同じくらいの内容なんだろうか。期待値が高すぎたのかもしれない。

www.fukuoka-anpanman.jp

タカサキハンバーグ

飯倉から七隈方面に向かう福大通沿いに、気になる店構えのハンバーグ屋があった。名前は『タカサキハンバーグ』。 食べログや Retty によると、かなりの人気店らしい。行列覚悟で行ってみたら案の定、一時間待ちだった。

席に案内されたころには 14 時を過ぎていた。もう空腹の絶頂。今ならハンバーグ 2 個ぐらい余裕で食べれそうだったので、挑戦してみた。

サラダはドレッシングが美味。評判が良いのか、ドレッシングの販売もしているようだった。

スープと味噌汁のどちらか選べたので、味噌汁を選択。ご飯は1回だけおかわりできる。

そしてお待ちかねのハンバーグ。 1個が結構大きい。運ばれてきたときは、2個完食できるか不安になった。 箸を入れると閉じ込められていた肉汁が溢れ出てくる。ふっくらとしていてジューシーだった。 ソースはジンジャーソース。 オニオンとはまた違った辛味と爽やかな後味は新鮮で、お店イチオシなのも納得。

空腹 MAX だったのもあって、無事に完食できた。ご飯のおかわりまでしてしまった。一時間待った甲斐はあったと思う。自分の中のハンバーグランキングトップ5には入るな。

タンメン笑盛

アサデス。の朝メシ前の昼メシ情報で紹介されていた『博多辛味噌タンメン 笑盛』がずっと気になっていたので、昼休みに行ってみた。わらもり、ではなく、えもー。エモい。

辛味噌タンメンといえば、東京の蒙古タンメン中本が思い浮かぶ。笑盛は中本とは関係ないらしいので、中本インスパイア系とでも言うべきか。辛いのは好きだけど、強いわけではないので、『笑盛タンメン(780円)』を注文した。

中本を食べたことがあるのでつい比べてしまうが、中本の野菜はクタクタなのに対し、笑盛は食感がしっかり残ってる。野菜は食感ある方が好み。麻婆餡を溶かしながら麺をすすっていくと、辛さと旨味がじわっと広がってきて、中盤以降は額に汗がにじんだ。 中本は東京に行かないと食べられないので、辛いタンメンを食べたい欲求を満たしたい場合には、足を運ぶとよさそう。

タンメン笑盛 天神南店

食べログタンメン笑盛 天神南店

めんちゃんこ亭

福岡で麺といえばラーメンとうどんだけど、『めんちゃんこ』なるものがあることをグルメ本で知った。鍋のシメに麺を入れることは定番だが、それを最初から味わってしまおうという代物。

休日で時間があったので、これは本店に行くしかない。藤崎駅出てすぐにある『めんちゃんこ亭 本店』に行ってきた。

注文したのは定番の『めんちゃんこ』。 ベースはちゃんこなので、野菜もしっかりとれて滋養ありそう。 麺はちゃんぽん並みの太麺で、 なるほど、たしかに鍋のシメを食べてる気分だ。 白くて丸い物体は最初卵かと思ったら餅だった。 麺と餅の組み合わせは初めてかも。 1杯でかなりのボリュームがある。

今回はうっかりスープを飲み干してしまった。 雑炊にしても良さそう。 そうなるとシメのさらにシメってことになるな。 背徳感ある。

関連ランキング:ラーメン | 藤崎駅室見駅