vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

ASP.NET Core + Entity Framework Core のプロジェクトを 2.2 から 3.0 にアップグレードした話

はじめに

この記事は C# Advent Calendar 2019 の二日目の記事です。

冷静に考えるとこれを C# の話として書いていいのか?と今更ながら思ったりもするのですが、プロジェクトは C# で書いてるし、 C# に関連する話もないではないから許してください(..)_

環境

  • .NET Core : ver.2.2.402 (from), ver.3.0.100 (to)
  • Microsoft.EntityFrameworkCore : ver.2.2.6 (from), ver.3.0.0 (to)
  • Npgsql.EntityFrameworkCore.PostgreSQL : ver.2.2.4 (from), ver.3.0.1 (to)
  • Windows10 : ver.1903
  • Rider : ver.2019.2.3
  • PostgreSQL : ver.12.0

元のプロジェクト

まずはアップグレードする前のプロジェクトを用意します。

UpgradeSample.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
    <PropertyGroup>
        <TargetFramework>netcoreapp2.2</TargetFramework>
        <AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
        <RuntimeIdentifier>win-x86</RuntimeIdentifier>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="Microsoft.AspNetCore.App" />
        <PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.2.0" PrivateAssets="All" />
        <PackageReference Include="Microsoft.EntityFrameworkCore" Version="2.2.6" />
        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="2.2.4" />
    </ItemGroup>
</Project>
  • RuntimeIdentifier は Self-contained app (実行環境に .NET Core が入っていなくても動作するよう出力されたアプリケーション) のために追加しています。
  • Program.cs も存在しますが、特にプロジェクト作成時から変更なく、 3.0 にアップグレード後も触らないため省略しています。

Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json.Serialization;
using UpgradeSample.Models;
using UpgradeSample.Products;

namespace UpgradeSample
{
    public class Startup
    {
        private IConfigurationRoot Configuration { get; }
        public Startup(IHostingEnvironment env)
        {
            // config ファイル読み込み.
            var builder = new ConfigurationBuilder()
                .SetBasePath(env.ContentRootPath)
                .AddJsonFile("appsettings.json", false, true)
                .AddJsonFile($"appsettings.{env.EnvironmentName}.json", false, true)
                .AddEnvironmentVariables();
            Configuration = builder.Build();
        }
        public void ConfigureServices(IServiceCollection services)
        {
            // DB Connect
            services.AddDbContext<UpgradeSampleContext>(options =>
                options.UseNpgsql(Configuration["DbConnect"]));

            // 生成される JSON のプロパティ名を大文字始まりで出力する.
            services.AddMvc()
                .AddJsonOptions(options =>
                {
                    options.SerializerSettings.ContractResolver = new DefaultContractResolver();
                });
            // DI
            services.AddScoped<IProductService, ProductService>();
        }
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseStaticFiles();
            app.UseMvc();
        }
    }
}

ApiController.cs

using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using UpgradeSample.Models;
using UpgradeSample.Products;

namespace UpgradeSample.Controllers
{
    public class ApiController: Controller
    {
        private readonly IProductService _productService;
        public ApiController(IProductService productService)
        {
            _productService = productService;
        }
        [Route("/")]
        [Route("/Home")]
        public string Index()
        {
            return "hello";
        }
        [HttpGet]
        [Route("/products")]
        public async Task<List<Product>> GetProducts([FromQuery] string[] names)
        {
            return await _productService.GetProductsAsync(names);
        }
    }
}

Product.cs

using System.ComponentModel.DataAnnotations.Schema;
using Newtonsoft.Json;

namespace UpgradeSample.Models
{
    [Table("Product")]
    public class Product
    {
        [Column("ProductId")]
        [JsonProperty("ProductId")]
        public int? ProductId { get; set; }

        [Column("ProductName")]
        [JsonProperty("ProductName")]
        public string ProductName { get; set; }
    }
}
  • JsonProperty は今回あまり役に立ってはいませんが、 ASP.NET Core プロジェクト内と JSON で名前が異なるときなどに活躍します。

UpgradeSampleContext.cs

using Microsoft.EntityFrameworkCore;

namespace UpgradeSample.Models
{
    public class UpgradeSampleContext: DbContext
    {
        public UpgradeSampleContext(DbContextOptions<UpgradeSampleContext> options)
            :base(options)
        {
        }
        public DbSet<Product> Products { get; set; }
    }
}

IProductService.cs

using System.Collections.Generic;
using System.Threading.Tasks;
using UpgradeSample.Models;

namespace UpgradeSample.Products
{
    public interface IProductService
    {
        Task<List<Product>> GetProductsAsync(string[] names);
    }
}

ProductService.cs

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using UpgradeSample.Models;

namespace UpgradeSample.Products
{
    public class ProductService: IProductService
    {
        private readonly UpgradeSampleContext _context;
        public ProductService(UpgradeSampleContext context)
        {
            _context = context;
        }
        public async Task<List<Product>> GetProductsAsync(string[] names)
        {
            return await _context.Products
              .Where(p => names.Length <= 0 ||
                  names.Any(n => p.ProductName == n))
              .ToListAsync();
        }        
    }
}

いかん。。。元のコードを並べるだけで結構スペースを取ってしまった。。。

3.0 にアップグレードする

気を取り直して早速 3.0 にアップグレードしてみますよ。

プロジェクトのバージョンを更新するには、 1 つまたは 2 つのファイルを変更する必要があります。

global.json

{
  "sdk": {
    "version": "3.0.100"
  }
}

UpgradeSample.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
    <PropertyGroup>
        <TargetFramework>netcoreapp3.0</TargetFramework>
        <RuntimeIdentifier>win-x86</RuntimeIdentifier>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.0.0" />
        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="3.0.1" />
    </ItemGroup>
</Project>
  • global.json は Rider でプロジェクトを作った場合は出力されていましたが、 Powershelldotnet new empty を実行して作った場合はありませんでした。
  • Rider ではもう一つ、 Run/Debug configuration のバージョンも上げる必要があるかもしれません。
  • これらのファイルを変更後、自動で実行されない場合は dotnet restore を実行します。

エラーを修正する

メジャーバージョンアップということで、主に下記に関連してエラーや警告が出ました。

  1. Endpoint Routing への変更
  2. Newtonsoft.Json から System.Text.Json への変更
  3. Entity Framework Core で、 Linq のクエリをクライアントで実行しない
  4. RuntimeIdentifier がロードできない

  5. Migrate from ASP.NET Core 2.2 to 3.0 - Microsoft Docs

  6. What's new in ASP.NET Core 3.0 - Microsoft Docs
  7. Comparing Startup.cs between the ASP.NET Core 3.0 templates: Exploring ASP.NET Core 3.0 - Part 2 - Andrew Lock | .NET Escapades
  8. My First Look at ASP.NET Core 3.0 - Shawn Wildermuth

1. MVC から Endpoint Routing への変更

2.2 ではルーティングを使うのに AddMvc, UseMvc を使っていましたが、 3.0 からは AddControllers, UseRouting, UseEndpoints に変わります。

Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Identity;
using Models;
using UpgradeSample.Models;
using UpgradeSample.Products;
using UpgradeSample.Users;

namespace UpgradeSample
{
    public class Startup
    {
        ...
        public void ConfigureServices(IServiceCollection services)
        {
            ...            
            // for setting JSON parameter names pascal case.
            services.AddControllers()
                .AddJsonOptions(options =>
                    options.JsonSerializerOptions.PropertyNamingPolicy = null);
            // DI
            ...
        }
        public void Configure(IApplicationBuilder app, IHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.UseStaticFiles();
            
            app.UseRouting();
            app.UseEndpoints(endpoints =>
            {
                // [Routing("")] を使ってルーティングする場合
                endpoints.MapControllers();
            });
        }
    }
}

また今回は使用していませんが、認証などを行う場合、 UseRouting の後、 UseEndpoints の前に UseAuthentication, と UseAuthorization を記載順に実行する必要があります(順序が違ったり UseAuthentication が実行されていないと実行時に例外が発生します)。

操作しようとした人が誰かを確認した後、その人がその操作を実行できるか?を確認する、ということで、考えれば順序がわかるのですが、実行時に例外が発生するという辺りはちょっとややこしいですね。

2. Newtonsoft.Json から System.Text.Json への変更

3.0 にアップグレードしたときにコンパイルエラーが発生したのは、この Json 関連だけでした。

今回のサンプルで対象となるのは 2 箇所です。

Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Identity;
using Models;
using UpgradeSample.Models;
using UpgradeSample.Products;
using UpgradeSample.Users;

namespace UpgradeSample
{
    public class Startup
    {
        ...
        public void ConfigureServices(IServiceCollection services)
        {
            ...            
            // for setting JSON parameter names pascal case.
            services.AddControllers()
                .AddJsonOptions(options =>
                    options.JsonSerializerOptions.PropertyNamingPolicy = null);
            // DI
            ...
        }
        ...

Product.cs

using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;

namespace UpgradeSample.Models
{
    [Table("Product")]
    public class Product
    {
        [Column("ProductId")]
        public int? ProductId { get; set; }
        [Column("ProductName")]
        [JsonPropertyName("ProductName")]
        public string ProductName { get; set; }
    }
}

基本的な使い方は変わらず、書き方が微妙に違っている、という感じです。

3. Entity Framework Core で、 Linq のクエリをクライアントで実行しない

個人的には一番影響が大きいのでは?と思っているところです。

Entity Framework Core で、 Linq を使ったコード( Where )が SQL に変換できない場合、例外を発生するようになりました。

これによってパフォーマンスの悪化が防げる、というメリットと、 List を Linq を使って検索するつもりで雑にアクセスしていると実行時例外を発生させまくるというデメリットがあります。

ProductService.cs

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using UpgradeSample.Models;

namespace UpgradeSample.Products
{
    public class ProductService: IProductService
    {
        ...
        public async Task<List<Product>> GetProductsAsync(string[] names)
        {
            // 実行時に例外発生
            return await _context.Products
              .Where(p => names.Length <= 0 ||
                  names.Any(n => p.ProductName == n))
              .ToListAsync();
        }        
    }
}

対策としては、 Any など SQL に変換できない条件で検索したい場合、先に AsEnumerable を実行する、というのが考えられます。

ProductService.cs

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using UpgradeSample.Models;

namespace UpgradeSample.Products
{
    public class ProductService: IProductService
    {
        ...
        public async Task<List<Product>> GetProductsAsync(string[] names)
        {
            return await Task.Run(() => 
            {
              return _context.Products
                .AsEnumerable()
                .Where(p => names.Length <= 0 ||
                  names.Any(n => p.ProductName == n))
                .ToList();
            });
        }        
    }
}

ややこしいのが、例えば Where の中身が例えば Where(p => p.ProductId == id) のような内容なら( id は引数となどで渡されているとして)わざわざ AsEnumerable を含む必要がない、というところ。

全部に AsEnumerable を含む、というのはおかしな気がするので、できるだけ Any などを使わず、 SQL に変換できるように書くのが良い。。。のかしら?

BeginTransaction

今回使用していませんが、 C# 8 で using 句に await が使えるようになった影響で、トランザクション開始時に実行する _context.Database.BeginTransaction() が await できるようになりました。

await using (var transaction = _context.Database.BeginTransaction())

public async Task SetUserNameAsync(ApplicationUser user, string userName, CancellationToken cancellationToken)
{
    await using var transaction = _context.Database.BeginTransaction();
    try
    {
      user.UserName = userName;
      _context.Users.Update(user);
      _context.SaveChanges();
      transaction.Commit();
    }
    catch (Exception e)
    {
        transaction.Rollback();
    }
}

Rider 先生曰く await using(var transaction = _context.Database.BeginTransaction()){ } より上記のように {} を使わない書き方を推奨、ということで、なぜかしら?と思っていたのですが、どうも単にネストを浅くしようとしているだけのようです。

4. RuntimeIdentifier がロードできない

3.0 にアップグレード後、 win-x86 のパッケージ?がロードできない旨の警告の後、全 C# クラスがエラーになる問題が発生してビビったという話です。

あれこれ試してみた結果、リストア -> 再ビルド で解決しました。

Single-file executable

また RuntimeIdentifier に関連して? Single-file executable というものが追加されています。

これまで Self-contained app で出力すると大量にファイルが出力されていたのですが、この設定をしておくとファイル数がぐっと少なくなります。

UpgradeSample.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
    <PropertyGroup>
        <TargetFramework>netcoreapp3.0</TargetFramework>
        <RuntimeIdentifier>win-x86</RuntimeIdentifier>
        <PublishSingleFile>true</PublishSingleFile>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="3.0.0" />
        <PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.0.0" />
        <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="3.0.1" />
    </ItemGroup>
</Project>

これで通常通り dotnet publish -c Release -r win-x86 を実行すると下記ファイルが出力されます。

おわりに

まだおっかなびっくり試しているのと、慌ててやるのはダメゼッタイというところではありますが、壊滅的な状況になることもなく 3.0 に上げることができました。

サポート期間的なこともありますが、パフォーマンスなど多くの改善点が含まれているだけに、早めに更新していきたいところです。

C# Advent Calendar, 明日は @takayoshitanaka さんです。

よろしくお願いいたします(..)_