vaguely

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

null 許容参照型を試す

はじめに

気になりつつも試せていなかった、 null 許容参照型 (nullable reference types) を試してみます。

プロジェクトは前回のものを使います。

Environments

準備

null 許容参照型を使うにはプロジェクトの C# バージョンを 8.0 以上にすること、また Nullable を有効にする必要があります。

CodeFirstSample.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <LangVersion>preview</LangVersion>
    <Nullable>enable</Nullable>
  </PropertyGroup>
...

Nullable を有効にすると、大量の Warning が出てきます。

null 許容参照型について

C# 8 以前、 null 許容にできるのは値型だけでした。

int nonNullNumber = 0;
nonNullNumber = null; // <- compile error
int? nullableNumber = 0;
nullableNumber = null; // <- OK

// null 許容の値型を通常の値型に代入するにはキャストが必要
nonNullNumber = (int)nullableNumber;

c# 8 以降は参照型についても null になりうるか、そうでないかを区別できるようになりました。

using System;
using Models;

namespace CSharpEightSample
{
    class Program
    {
        static void Main(string[] args)
        {
            // Nullable instance
            User? nullableUser = null;  // <- OK
            // Non-nullable instance
            User user = null;           // <- Warning
        }
    }
}

残念ながら(後方互換性のため)コンパイルエラーではなく Warning のみではありますが。

IL

さて、この null 許容型と非 null 許容型のインスタンスですが、実行時の違いはあるのでしょうか。

ILSpy Visual Studio Code Extension( https://github.com/icsharpcode/ilspy-vscode )を使って IL を覗いてみます。

.class /* 02000005 */ private auto ansi beforefieldinit CSharpEightSample.Program
    extends [System.Runtime]System.Object
{
    // Methods
    .method /* 06000005 */ private hidebysig static 
        void Main (
            string[] args
        ) cil managed 
    {
        .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
            01 00 01 00 00
        )
        // Method begins at RVA 0x2094
        // Code size 6 (0x6)
        .maxstack 1
        .entrypoint
        .locals /* 11000001 */ init (
            [0] class Models.User,
            [1] class Models.User
        )

        IL_0000: nop
        IL_0001: ldnull
        IL_0002: stloc.0
        IL_0003: ldnull
        IL_0004: stloc.1
        IL_0005: ret
    } // end of method Program::Main
...

ローカル変数においては特に違いがなさそうです。

プロパティはどうでしょうか。

namespace CSharpEightSample
{
    public class Sample
    {
        public string Name { get; set; } = "";
        public string? NullableName {get; set; }
    }
    class Program
    {
        static void Main(string[] args)
        {
        }
    }
}
.class /* 02000005 */ public auto ansi beforefieldinit CSharpEightSample.Sample
    extends [System.Runtime]System.Object
{
    .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
        01 00 01 00 00
    )
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = (
        01 00 00 00 00
    )
    // Fields
    .field /* 04000003 */ private string '<Name>k__BackingField'
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
        01 00 00 00
    )
    .custom instance void [System.Runtime]System.Diagnostics.DebuggerBrowsableAttribute::.ctor(valuetype [System.Runtime]System.Diagnostics.DebuggerBrowsableState) = (
        01 00 00 00 00 00 00 00
    )
    .field /* 04000004 */ private string '<NullableName>k__BackingField'
    .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = (
        01 00 02 00 00
    )
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
        01 00 00 00
    )
    .custom instance void [System.Runtime]System.Diagnostics.DebuggerBrowsableAttribute::.ctor(valuetype [System.Runtime]System.Diagnostics.DebuggerBrowsableState) = (
        01 00 00 00 00 00 00 00
    )

    // Methods
    .method /* 06000005 */ public hidebysig specialname 
        instance string get_Name () cil managed 
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
            01 00 00 00
        )
        // Method begins at RVA 0x2092
        // Code size 7 (0x7)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldfld string CSharpEightSample.Sample::'<Name>k__BackingField' /* 04000003 */
        IL_0006: ret
    } // end of method Sample::get_Name

    .method /* 06000006 */ public hidebysig specialname 
        instance void set_Name (
            string 'value'
        ) cil managed 
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
            01 00 00 00
        )
        // Method begins at RVA 0x209a
        // Code size 8 (0x8)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldarg.1
        IL_0002: stfld string CSharpEightSample.Sample::'<Name>k__BackingField' /* 04000003 */
        IL_0007: ret
    } // end of method Sample::set_Name

    .method /* 06000007 */ public hidebysig specialname 
        instance string get_NullableName () cil managed 
    {
        .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
            01 00 02 00 00
        )
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
            01 00 00 00
        )
        // Method begins at RVA 0x20a3
        // Code size 7 (0x7)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldfld string CSharpEightSample.Sample::'<NullableName>k__BackingField' /* 04000004 */
        IL_0006: ret
    } // end of method Sample::get_NullableName

    .method /* 06000008 */ public hidebysig specialname 
        instance void set_NullableName (
            string 'value'
        ) cil managed 
    {
        .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
            01 00 02 00 00
        )
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
            01 00 00 00
        )
        // Method begins at RVA 0x20ab
        // Code size 8 (0x8)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldarg.1
        IL_0002: stfld string CSharpEightSample.Sample::'<NullableName>k__BackingField' /* 04000004 */
        IL_0007: ret
    } // end of method Sample::set_NullableName

    .method /* 06000009 */ public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        // Method begins at RVA 0x20b4
        // Code size 19 (0x13)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldstr "" /* 70000001 */
        IL_0006: stfld string CSharpEightSample.Sample::'<Name>k__BackingField' /* 04000003 */
        IL_000b: ldarg.0
        IL_000c: call instance void [System.Runtime]System.Object::.ctor() /* 0A00000F */
        IL_0011: nop
        IL_0012: ret
    } // end of method Sample::.ctor

    // Properties
    .property /* 17000001 */ instance string Name()
    {
        .get instance string CSharpEightSample.Sample::get_Name()
        .set instance void CSharpEightSample.Sample::set_Name(string)
    }
    .property /* 17000002 */ instance string NullableName()
    {
        .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = (
            01 00 02 00 00
        )
        .get instance string CSharpEightSample.Sample::get_NullableName()
        .set instance void CSharpEightSample.Sample::set_NullableName(string)
    }

} // end of class CSharpEightSample.Sample

「NullableName」の方には「System.Runtime.CompilerServices.NullableAttribute」が付与されていますね。

default!

例えば非 null 許容の string 型のインスタンスがあるとして、デフォルト値として空文字を設定するか、「default!」を渡すことで Warning を回避できます。

string sample = ""; // <- OK
string sample2 = default! // <- OK

さて、「sample2」にはデフォルトで何が入っているのか?というと、やっぱり null でした。

ということで、「default!」はただ Warning を抑えるためだけに使える、といった印象を受けました。

Generics

Generics はどうでしょうか。

まず、下記のように書くことはできません。

...
    public class Sample<T> where T: class
    {
        public T Name { get; set; } = default!;
        public T? NullableName {get; set; } // <- compile error
    }
...

「T」に対して class または struct の制約がつけられるのであれば、「T?」を使うことができます。

...
    public class Sample<T> where T: class
    {
        public T Name { get; set; } = default!;
        public T? NullableName {get; set; } // <- OK
    }
...

また「T」に null 許容の型を指定したい場合は「class ?」を使うことができます。

...
    public class Sample<T> where T: class ?
    {
        public T Name { get; set; } = default!;
        public T NullableName {get; set; } = default!;
    }
    class Program
    {
        static void Main(string[] args)
        {
            var s = new Sample<string?>(); // <- OK
        }
    }
...

またプロパティやメソッドに対しては属性を使う、といった方法もあります。

...
    public class Sample<T>
    {
        public T Name { get; set; } = default!;
        [AllowNull]
        public T NullableName {get; set; } = default!;
    }
    class Program
    {
        static void Main(string[] args)
        {
            var s = new Sample<string>();
            s.NullableName = null;          // <- OK
        }
...
...
    public class Sample<T>
    {
        public T Name { get; set; } = default!;
        [AllowNull]
        public T NullableName {get; set; } = default!;
        [return: MaybeNull]
        public T GetNull()
        {
            return default(T);
        }
    }
    class Program
    {
        static void Main(string[] args)
        {
            var s = new Sample<string>();
            s.NullableName = null;
            Console.WriteLine((s.GetNull() == null));          // <- true
        }
...

Entity FrameWork Core

DbContext

DbContext では、「DbSet<T>」のインスタンスに対して 「Set<T>()」を使い、 null 許容を避けることができます。

using Microsoft.EntityFrameworkCore;

namespace Models
{
    public class CodeFirstSampleContext: DbContext
    {
        public CodeFirstSampleContext(DbContextOptions<CodeFirstSampleContext> options)
            : base(options)
        {

        }
        public DbSet<Workflow> Workflows => Set<Workflow>();
        public DbSet<WorkflowReader> WorkflowReaders => Set<WorkflowReader>();
    }
}

Model

ではモデルクラスはどうでしょうか。

こちらは特に null 許容を避けるメソッドなどが用意されているわけではないようなので、 Database のテーブルなどから null 許容にすべきかどうかを判断する、という必要があります。

null 許容・非 null 許容参照型はマイグレーションファイルに影響を与えるか

答えはイエスです。

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace Models
{
    public class Sample
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int Id { get; set; }
        public string? Name { get; set; } = "hello";
    }
}

これでマイグレーションファイルを作ると、デフォルト値を設定していても「Name」カラムは null 許容となります。

...
        protected override void Up(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.CreateTable(
                name: "Samples",
                columns: table => new
                {
                    Id = table.Column<int>(type: "integer", nullable: false)
                        .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
                    Name = table.Column<string>(type: "text", nullable: true)
                },
                constraints: table =>
                {
                    table.PrimaryKey("PK_Samples", x => x.Id);
                });
        }
...

今のところ、できる限り全プロパティを非 null 許容にすべきか?というところはわかっていないのですが、まずはできる限り null 許容を避けてみようかな、とは思っています。

参照

【ASP.NET Core】【Entity Framework Core】Code first / DB first ってみる

はじめに

Entity Framework Core でコードからデータベースのテーブルを生成する Code first、データベースからコードを生成する DB first をやってみたよというお話。

Environments

Microsoft.EntityFrameworkCore.Abstractions と Microsoft.EntityFrameworkCore.Relational は Microsoft.EntityFrameworkCore (だったと思う)が依存しているとリストア時にエラーを出したので追加しました。

※個人的な興味から .NET 5 を使ってますが、現状(2020-07-24 時点)では .NET Core 3.1 と書き方などほとんど変わっていないと思います。

Code first

ではまず Code first から。

プロジェクトを作る

空で ASP.NET Core のプロジェクトを作ります。

dotnet new empty -n CodeFiirstSample

NuGet でパッケージを追加しておきます。

Samples

接続文字列やテーブルに対応するクラスなどを追加します。

appsettings.Development.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "ConnectionStrings": "Host=localhost;Port=5432;Database=WorkflowSample;Username=postgres;Password=XXX"
}

Models/Workflow.cs

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace Models
{
    [Table("Workflow")]
    public class Workflow
    {
        // 主キー
        [Key]
        // Auto increament
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int Id { get; set;}
        // not null
        [Required]
        public int ProductId { get; set; }
        [Required]
        [Column("CreateUserMail", TypeName="text")]
        public string CreateUserMail { get; set;}
        [Column(TypeName="timestamp with time zone")]
        public DateTime? CirculationLimit { get; set; }
        [Column(TypeName="timestamp with time zone")]
        // 更新のたびに現在時刻を入れたい
        [DatabaseGenerated(DatabaseGeneratedOption.Computed)]
        public DateTime LastUpdateDate { get; set; }

        public List<WorkflowReader> Readers {get; set; }
    }
}

Models/WorkflowReader.cs

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace Models
{
    public class WorkflowReader
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int Id { get; set; }
        // 外部キー
        [ForeignKey("Workflow")]
        [Required]
        public int WorkflowId { get; set; }
        [Required]
        public string Name{ get; set; }

        public Workflow Workflow {get; set; }
    }
}

Models/CodeFirstSampleContext.cs

using Microsoft.EntityFrameworkCore;

namespace Models
{
    public class CodeFirstSampleContext: DbContext
    {
        public CodeFirstSampleContext(DbContextOptions<CodeFirstSampleContext> options)
            : base(options)
        {

        }
        public DbSet<Workflow> Workflows { get; set; }
        public DbSet<WorkflowReader> WorkflowReaders { get; set; }
    }
}

Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Models;
using Workflow;

namespace CodeFirstSample
{
    public class Startup
    {
        private readonly IConfiguration configuration;
        public Startup(IHostEnvironment env)
        {
            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)
        {
            services.AddControllers();
            services.AddDbContext<ProofreadingWorkflowContext>(options =>
                options.UseNpgsql(configuration["ConnectionStrings"]));
            services.AddScoped<IWorkflowService, WorkflowService>();
        }

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

            app.UseRouting();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }
    }
}

最初のマイグレーションファイルを生成する

さてさて準備ができたところでマイグレーションファイルを生成しますよ。

dotnet tool update --global dotnet-ef --version 5.0.0-preview.7.20365.15

※.NET 5 で試す場合、バージョン指定しないと最新の安定板がインストールされ、コマンド実行時にエラーが出るので注意が必要です。

ドキュメントによると、「dotnet add package Microsoft.EntityFrameworkCore.Design」で「Microsoft.EntityFrameworkCore.Design」をインストール、ということなのですが、これでインストールされるのは安定板 (3.1.6) なので、 NuGet でインストールする必要があります。
(VSCode の NuGet Package Manager 便利ですね)

前置きが長くなりましたが、マイグレーションファイルを作成します。

dotnet ef migrations add InitialCreate

コマンドを実行すると、 Migrations というフォルダ以下に下記が出力されます。

  • 20200719144103_InitialCreate.cs
  • 20200719144103_InitialCreate.Designer.cs
  • WorkflowSampleContextModelSnapshot.cs

コマンドを実行してデータベースに反映します。

dotnet ef database update

すでに同名のテーブルが存在する場合

普通にエラーになります。

ということで、 Code first の場合はデータベースを空で作成する必要があります。
(それが難しい場合は後述の DB first で進めるのが良さそうです)

マイグレーションの取り消し

データベースに反映したマイグレーションを取り消したい場合。
例えば上記のあと何らかのマイグレーションを実行し、それを取り消したい場合は下記のようにします。

dotnet ef database update InitialCreate

直前の処理をなかったことにする、というよりは、戻したい状態を指定してそこに戻る、という方法はちょっと Git を思い出させます。

ただ、特にデータを挿入する前にテーブル名間違えたりしていた場合は、マイグレーションファイルを作って~とするより、マイグレーションファイル、テーブルを削除 -> 作り直して再度生成、とした方が楽かもしれませんね。

デフォルト値の設定

ここまでを実行すると、とりあえずテーブルは生成されます。

が、データを追加しようとすると、「Workflow」テーブルで「LastUpdateDate」が Null だと怒られます。

というのも、「DatabaseGenerated」がつけられている要素は Entity Framework が生成する INSERT 文から外されるから + デフォルト値が設定されていないから です。

INSERT INTO "Workflow" ("CirculationLimit", "CreateUserMail", "ModelName", "ProductId", "SizaiCode")
      VALUES (@p4285, @p4286, @p4287, @p4288, @p4289)
      RETURNING "Id", "LastUpdateDate";

なお「Id」は(おそらく) データベースでは「serial」型として扱われるため、自動でインクリメントされた値が挿入されます。

最初「DatabaseGenerated」をつければ自動で値をセットしてくれるのかな?と思い込んでいたため、ちょっとハマりました。

デフォルト値の設定は Context クラスでできます。

Models/CodeFirstSampleContext.cs

using Microsoft.EntityFrameworkCore;

namespace Models
{
    public class CodeFirstSampleContext: DbContext
    {
...
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Workflow>()
                .Property(w => w.LastUpdateDate)
                .HasDefaultValueSql("CURRENT_TIMESTAMP");
        }
...
    }
}

現在時刻の設定を、ついつい DateTime.Now とかしたくなりますが、その場合「マイグレーションファイルを作った時間」がデフォルト値になってしまうので要注意です。

DB first

では、先ほど作ったデータベース、テーブルを使って DB first も試してみます。

dotnet new empty -n DbFiirstSample

NuGet で Code first と同じパッケージを入れておきます。

で、コマンドを実行します。

dotnet ef dbcontext scaffold "Host=localhost;Port=5432;Database=WorkflowSample;Username=postgres;Password=XXX" Npgsql.EntityFrameworkCore.PostgreSQL -d -o Models -n Models

コマンドのオプションについて

DB first でコードを生成するコマンドのオプションはいくつかありますが、その中でも下記 3 つは特に必要かな~、と思いました。

  • -d(--data-annotations): 生成するモデルクラスの要素にアノテーション([Key]とか)を付けます。
  • -o(--output-dir): デフォルトだとプロジェクト直下にファイルが生成されるので、これを「Models」というフォルダー以下に生成されるよう変更しています。
  • -n(--namespace): 生成されるクラスの namespace を指定しています。デフォルトでは「DbFiirstSample.Models」となります。

生成されたクラス

Models/Workflow.cs

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

namespace Models
{
    public partial class Workflow
    {
        public Workflow()
        {
            WorkflowReaders = new HashSet<WorkflowReaders>();
        }
        [Key]
        public int Id { get; set; }
        public int ProductId { get; set; }
        [Required]
        public string CreateUserMail { get; set; }
        [Column(TypeName = "timestamp with time zone")]
        public DateTime? CirculationLimit { get; set; }
        [Column(TypeName = "timestamp with time zone")]
        public DateTime LastUpdateDate { get; set; }
        [InverseProperty("Workflow")]
        public virtual ICollection<WorkflowReader> WorkflowReaders { get; set; }
    }
}

Models/WorkflowReaders.cs

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

namespace Models
{
    [Index(nameof(WorkflowId))]
    public partial class WorkflowReaders
    {
        [Key]
        public int Id { get; set; }
        public int WorkflowId { get; set; }
        [Required]
        public string Name{ get; set; }
        [ForeignKey(nameof(WorkflowId))]
        [InverseProperty("WorkflowReaders")]
        public virtual Workflow Workflow { get; set; }
    }
}

Models/WorkflowSampleContext.cs

using Microsoft.EntityFrameworkCore;

namespace Models
{
    public partial class WorkflowSampleContext : DbContext
    {
        public WorkflowSampleContext()
        {
        }

        public WorkflowSampleContext(DbContextOptions<WorkflowSampleContext> options)
            : base(options)
        {
        }

        public virtual DbSet<WorkflowReaders> WorkflowReaders { get; set; }
        public virtual DbSet<Workflow> Workflow { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            if (!optionsBuilder.IsConfigured)
            {
#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see http://go.microsoft.com/fwlink/?LinkId=723263.
                optionsBuilder.UseNpgsql("Host=localhost;Port=5432;Database=WorkflowSample;Username=postgres;Password=XXX");
            }
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            OnModelCreatingPartial(modelBuilder);
        }

        partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
    }
}

Code first で生成されるコードとはやはり異なっていますね。

特に「DatabaseGenerated」については反映されていないようです。
(別のオプション、もしくは別のファイルで表現されている?)

処理に失敗したときに早期リターンしたい話

はじめに

C# でコードを書く時に、下記のような処理をよく書きます。

private async Task ExecuteExampleAsync()
{
    var result1 = await DoSomethingAsync1();
    if (result1 == null)
    {
        return;
    }
    var result2 = await DoSomethingAsync2();
    if (result2 == null)
    {
        return;
    }
    return await DoSomethingAsync3();
}

例えば一つ目の処理に失敗した場合はそこで処理をストップする、という話なのですが、これを RxJS でやるとどうなるの?というのが前から気になっていたので試してみました。

Environments

  • Angular ver.10.1.0-next.1
  • RxJS ver.6.6

empty, throwError, throw new Error

今回調べてみたなかで、実現できそうな方法は3つありました。

サンプルコード(ベースだけ)

今回は下記の ngOnInit からメソッドを呼ぶ、という形で試すことにしました。

workflow-page.component.ts

import { Component, OnInit } from '@angular/core';
import { Observable, of, empty, throwError } from 'rxjs';
import { flatMap, catchError } from 'rxjs/operators';

@Component({
  selector: 'app-workflow-page',
  templateUrl: './workflow-page.component.html',
  styleUrls: ['./workflow-page.component.css']
})
export class WorkflowPageComponent implements OnInit {

  constructor() { }

  ngOnInit(): void {
      // call methods
  }
}

empty

「empty」が呼ばれると、subscribe の「complete」ハンドラーが呼ばれて処理が完了します。

...
  ngOnInit(): void {
    console.log('-- Throw empty from first call --');
    this.executeEmpty(4);
    console.log('-- Throw empty from second call --');
    this.executeEmpty(3);
    console.log('-- End --');
  }
  private executeEmpty(startValue: number) {
    this.getEmpty(startValue)
      .pipe(
        flatMap(result => {
          console.log(`2nd execution: ${result}`);
          return this.getEmpty(result);
        }),
        catchError(error => {
          console.error(`catch: ${error}`);
          return of(error);
        })
      )
      .subscribe(result => console.log(`next: ${result}`),
        error => console.error(`error: ${error}`),
        () => console.log('complete'));
  }
  private getEmpty(lastValue: number): Observable<number> {
    if (lastValue > 3) {
      return empty();
    }
    return of(lastValue + 1);
  }

結果

-- Throw empty from first call --
complete
-- Throw empty from second call --
2nd execution: 4
complete
-- End --

以上から、「empty」を使う場合、処理の結果(今回は null が返ってきた)は呼び出し先である「getEmpty」がハンドリングする必要があります。

呼び出し元には「complete」ハンドラーが呼ばれたことしか返ってこないからですね。 f:id:mslGt:20200718144847p:plain

throwError

...
  ngOnInit(): void {
    console.log('-- Throw throwError from first call --');
    this.executeThrowError(4);
    console.log('-- Throw throwError from second call --');
    this.executeThrowError(3);
    
    console.log('-- Throw throwError with catchError from first call --');
    this.executeThrowErrorWithCatchError(4);
    console.log('-- Throw throwError with catchError from second call --');
    this.executeThrowErrorWithCatchError(3);
    console.log('-- End --');
  }
  private executeThrowError(startValue: number) {
    this.getThrowError(startValue)
    .pipe(
      flatMap(result => {
        console.log(`2nd execution: ${result}`);
        return this.getThrowError(result);
      })
    )
    .subscribe(result => console.log(`next: ${result}`),
        error => console.error(`error: ${error}`),
        () => console.log('complete'));
  }
  private executeThrowErrorWithCatchError(startValue: number) {
    this.getThrowError(startValue)
    .pipe(
      flatMap(result => {
        console.log(`2nd execution: ${result}`);
        return this.getThrowError(result);
      }),
      catchError(error => {
        console.error(`catch: ${error}`);
        return of(error);
      })
    )
    .subscribe(result => console.log(`next: ${result}`),
        error => console.error(`error: ${error}`),
        () => console.log('complete'));
  }
  public getThrowError(lastValue: number): Observable<number> {
    if (lastValue > 3) {
      return throwError('Error from throwError');
    }
    return of(lastValue + 1);
  }

結果

-- Throw throwError from first call --
error: Error from throwError
...
-- Throw throwError from second call --
2nd execution: 4
error: Error from throwError
...
-- Throw throwError with catchError from first call --
catch: Error from throwError
...
next: Error from throwError
complete
-- Throw throwError with catchError from second call --
2nd execution: 4
catch: Error from throwError
...
next: Error from throwError
complete
-- End --
  • 「catchError」を使っていない場合はsubscribeの「error」ハンドラーが呼ばれます。 f:id:mslGt:20200718144926p:plain

  • 「catchError」を使っている場合、「catchError」で「of(error)」を返すと通常と同じく「next」と「complete」が呼ばれます。 f:id:mslGt:20200718144947p:plain

ということで、処理に失敗したことを subscribe でエラーとしてハンドリングしたい場合は「catchError」無しで「throwError」を使うか、「catchError」からさらに「throwError」を実行する、といった書き方になりそうです。

throw new Error

「throw new Error」で Error を投げる、または呼び出したメソッド内で Error が発生した場合はどうでしょうか。

...
  ngOnInit(): void {
    console.log('-- Throw new Error from first call --');
    this.executeThrowNewError(4);
    console.log('-- Throw new Error from second call --');
    this.executeThrowNewError(3);
  }
  private executeThrowNewError(startValue: number) {
    this.getThrowNewError(startValue)
      .pipe(
        flatMap(result => {
          console.log(`2nd execution: ${result}`);
          return this.getThrowNewError(result);
        })
      )
      .subscribe(result => console.log(`next: ${result}`),
        error => console.error(`error: ${error}`),
        () => console.log('complete'));
  }
  // throw new Error
  public getThrowNewError(lastValue: number): Observable<number> {
    if (lastValue > 3) {
      throw new Error('Error from new Error()');
    }
    return of(lastValue + 1);
  }

結果

-- Throw new Error from first call --
ERROR Error: Error from new Error()

最初の結果しか出力されない。。。(´・ω・`)

というのも、 pipe でつながれていない、最初の処理で Error が発生してしまうと「catchError」や subscribe の「error」では受け取ることができず、処理が止まってしまうからですね。 f:id:mslGt:20200718145020p:plain

引数を2回目の実行時に Error を投げることになる 3 に変更すると、下記のような結果になります。

-- Throw new Error from first call --
2nd execution: 4
error: Error: Error from new Error()
...

f:id:mslGt:20200718145049p:plain

ということで、 Observable を返すメソッド(関数)では「throw new Error」は使わない方が良さそうです。

また、メソッド内で Error が発生する場合も呼び出し元には「throwError」として返す必要があるかと。

処理失敗の通知に「throwError」を使うべきか

今回試した「empty」、「throwError」、「throw new Error」の中では「throwError」を使うのがよさそうです。

が、まだ処理失敗の通知に「throwError」を使うべきか、というのはよくわかっておりません。

もう少しサンプルなど漁った方がよさそうですね。

TypeOrm を追加する

はじめに

今回は TypeOrm を追加して Database にアクセスできるようにしますよ。

Angular 10 + NgRx + Nest.js でプロジェクトを作る

TypeOrm を使う

Install

前回と同じくいくつかパッケージをインストールします。

Nest.js のプロジェクトに統合するためのパッケージもあるので合わせてインストールします。

npm install --save @nestjs/typeorm typeorm pg

Configuration

前回は「ormconfig.json」を使って TypeOrm の設定( Database の接続文字列など)を行っていました。

Nest.js のプロジェクトでは、「app.module.ts」の中で設定することもできます。

server/app.module.ts


import { Module } from '@nestjs/common';
import { AngularUniversalModule } from '@nestjs/ng-universal';
import { TypeOrmModule } from '@nestjs/typeorm';
import { join } from 'path';
import { Workflow } from './entities/workflow.entity';

@Module({
  imports: [
    AngularUniversalModule.forRoot({
      viewsPath: join(process.cwd(), 'dist/browser'),
      bundle: require('../server/main'),
      liveReload: false
    }),
    TypeOrmModule.forRoot({
      type: "postgres",
      host: "localhost",
      port: 5432,
      username: "test",
      password: "test",
      database: "Example",
      entities: [Workflow],
      synchronize: true,
      logging: true,
      cache: false
    })
  ],
  controllers: [],
  providers: []
})
export class ApplicationModule {}

結局 JSON ファイルを選択した

結局、前回同様「ormconfig.json」を使うことにしました。

理由は、マイグレーションファイルを使った Database 更新をしたい場合、 TypeOrm のコマンドを使うのですが、この時コマンドからは「app.module.ts」の内容が参照できません(多分)。

ということで、いずれにしても「ormconfig.json」が必要になるわけですね。

ormconfig.json


{
    "type": "postgres",
    "host": "localhost",
    "port": 5432,
    "username": "test",
    "password": "test",
    "database": "Example",
    "entities": ["dist/**/entities/*.entity{.ts,.js}"],
    "migrations": ["server/migrations/*.{.ts,.js}"],
    "synchronize": true,
    "logging": true,
    "cache": false,
    "cli": {
        "entitiesDir": "server/entities",
        "migrationsDir": "server/migrations"
    }
}

なお「ormconfig.json」を使う場合の「app.module.ts」はこんな感じです。

server/app.module.ts


~省略~
@Module({
  imports: [
    ~省略~
    TypeOrmModule.forRoot()
  ],
  controllers: [],
  providers: []
})
export class ApplicationModule {}

Entity クラスを追加する

server/entities/workflow.entity.ts


import {Entity, PrimaryGeneratedColumn, Column, OneToMany, UpdateDateColumn} from "typeorm";

@Entity("Workflow")
export class Workflow {
    @PrimaryGeneratedColumn()
    id: number = -1;
    @Column({ type: 'text', nullable: false })
    createUserMail: string = '';
    @Column({ type: 'timestamp with time zone', nullable: true })
    circulationLimit: Date|null = null;
    @Column({ type: 'timestamp with time zone', nullable: false })
    lastUpdateDate: Date = new Date();
    
    public create(createUserMail: string, circulationLimit: Date|null) {
        if (createUserMail == null ||
            createUserMail.length <= 0) {
            throw new Error("Can not set createUserMail empty");
        }
        this.createUserMail = createUserMail;
        this.update(circulationLimit);
    }
    public update(circulationLimit: Date|null) {
        this.circulationLimit = circulationLimit;
        this.lastUpdateDate = new Date();
    }
}

「nest generate」を使う

TypeOrm のクラスにアクセスするため、いくつか Nest.js のプロジェクトにクラスを追加していきます。

が、「nest generate ~」を実行すると、ファイルが「src」ディレクトリにできてしまう。。。(´・ω・`)
サーバー側のファイルを置いている「server」ディレクトリ以下にファイルを出力するにはどうすれば・・・?

結果としては、プロジェクト直下に「nest-cli.json」を置くことで解決しました。

nest-cli.json


{
    "collection": "@nestjs/schematics",
    "sourceRoot": "server"
}

「@nestjs/testing」の追加

Controller や Service などのクラスを追加したところ、エラー発生。
どうも一緒に追加されたテストクラスが必要としている、「@nestjs/testing」が足りない模様。

ということでインストールしました。

npm install --save-dev @nestjs/testing

Repository クラスから Database にアクセスする

Database にアクセスする(失敗)

まず Database にアクセスするための Controller や Service を追加します。

server/workflow/workflow.controller.ts


import { Controller, Get } from '@nestjs/common';
import { WorkflowService } from './workflow.service';
import { Workflow } from '../entities/workflow.entity';

@Controller('workflow')
export class WorkflowController {
    constructor(private readonly service: WorkflowService) {
    }
    @Get("search")
    public async getWorkflowItems(): Promise<Workflow[]> {
        return await this.service.findAll();
    }
}

server/workflow/workflow.service.ts


import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Workflow } from '../entities/workflow.entity';
import { Repository } from 'typeorm';
import { of } from 'rxjs';

@Injectable()
export class WorkflowService {
    constructor(
        @InjectRepository(Workflow)
        private workflowRepositiory: Repository<Workflow>
    ){}
    public findAll(): Promise<Workflow[]> {
        return this.workflowRepositiory.find();
    }
}

server/app.module.ts


...
import { WorkflowController } from './workflow/workflow.controller';
import { WorkflowService } from './workflow/workflow.service';
import { Workflow } from './entities/workflow.entity';

@Module({
  imports: [
...
  ],
  controllers: [WorkflowController],
  providers: [WorkflowService]
})
export class ApplicationModule {}

OK。では実行してみます。

…エラーが発生しました(´・ω・`)

...
[Nest] 17376   - 07/11/2020, 8:22:27 PM   [ExceptionHandler] Nest can't resolve dependencies of the WorkflowService (?). Please make sure that the argument WorkflowRepository at index [0] is available in the ApplicationModule context.

Potential solutions:
- If WorkflowRepository is a provider, is it part of the current ApplicationModule?
- If WorkflowRepository is exported from a separate @Module, is that module imported within ApplicationModule?
  @Module({
    imports: [ /* the Module containing WorkflowRepository */ ]
  })
 +570ms
Error: Nest can't resolve dependencies of the WorkflowService (?). Please make sure that the argument WorkflowRepository at index [0] is available in the ApplicationModule context.

Potential solutions:
- If WorkflowRepository is a provider, is it part of the current ApplicationModule?
- If WorkflowRepository is exported from a separate @Module, is that module imported within ApplicationModule?
  @Module({
    imports: [ /* the Module containing WorkflowRepository */ ]
  })

    at Injector.lookupComponentInParentModules (C:\Users\example\Documents\workspace\proofreading-workflow\node_modules\@nestjs\core\injector\injector.js:190:19)
    at processTicksAndRejections (internal/process/task_queues.js:97:5)
[nodemon] app crashed - waiting for file changes before starting...

依存を解決する

どうも Repository クラスが DI できてないようです。

結局「app.module.ts」にモジュールを追加しました。

server/app.module.ts


...
import { Workflow } from './entities/workflow.entity';

@Module({
  imports: [
...
    TypeOrmModule.forRoot(),
    TypeOrmModule.forFeature([Workflow])
  ],
  controllers: [ProofreaderController, WorkflowController],
  providers: [ProofreaderService, WorkflowService]
})
export class ApplicationModule {}

ようやく Database にアクセスできるようになりました :).

参照

Angular 10 + NgRx + Nest.js でプロジェクトを作る

はじめに

今回はサーバー側のプログラムも追加してみます。

今回は TypeScript 好きである、ということもあって Nest.js を選びました。

Environments

  • Angular ver.10.0.2
  • @ngrx/store ver.9.2.0
  • @ngrx/effects ver.9.2.0
  • @nestjs/ng-universal ver.2.0.1

Angular ver.10のプロジェクトに @ngrx/store が追加できない

前回同様「npx ng add @ngrx/store」で NgRx を追加しようとするとエラーが発生。

Installing packages for tooling via npm.
Installed packages for tooling via npm.
The package that you are trying to add does not support schematics. You can try using a different version of the package or contact the package author to add ng-add support.

下記の Issue によると、「@latest」をつける必要があるとのこと。

npx ng add @ngrx/store@latest

「@latest」をつけない場合、 1.2.0 のような古いバージョンのものがインストールされているようでした。

cannot use ng add to install ngrx store · Issue #2604 · ngrx/platform · GitHub

「@ngrx/effects」については「@latest」が必要かどうかはわかりませんでしたが、とりあえずつけてインストールしました。

npx ng add @ngrx/effects@latest

Nest.js のプロジェクトを追加する

「@nestjs/ng-universal」を使って Nest.js のプロジェクトを追加します。

GitHub - nestjs/ng-universal: Angular Universal module for Nest framework (node.js) 🌷

元々は Server-Side Rendering のためのものではありますが。

「ng add」コマンドを実行すると、プロジェクト名を聞かれるので先に作成した Angular のプロジェクト名を入力します。

npx ng add @nestjs/ng-universal

プロジェクトを実行する(失敗)

「npm run serve」で実行し、「localhost:4200」にアクセスするわけですが、ここでエラーが発生しました。

...
[Nest] 12736   - 07/08/2020, 1:04:45 AM   [ExceptionsHandler] Cannot read property 'indexOf' of undefined +28479ms
TypeError: Cannot read property 'indexOf' of undefined
...

下記の Issue によると、「liveReload」を false にする必要があるとのこと。

TypeError: Cannot read property 'indexOf' of undefined · Issue #188 · nestjs/ng-universal · GitHub

server/app.module.ts


import { Module } from '@nestjs/common';
import { AngularUniversalModule } from '@nestjs/ng-universal';
import { join } from 'path';

@Module({
  imports: [
    AngularUniversalModule.forRoot({
      viewsPath: join(process.cwd(), 'dist/browser'),
      bundle: require('../server/main'),
      liveReload: false
    })
  ]
})
export class ApplicationModule {}

白いページが表示される

「liveReload」自体も気になるところですが、とにあれこれでエラーなしでページを表示できました。

が、そこにあるのはただ真っ白のページ。Angular のページではありません。

プロジェクトの README.md にある「bootstrap」は(恐らく)仕様変更のため「AngularUniversalOptions」に存在せず、サンプルコードをそのままコピペすることはできないようです。

ただ、「localhost:4200/index.html」にアクセスすると、正しく Angular のページが表示できました。 f:id:mslGt:20200708021636j:plain

ということで、 デフォルトの Static file が設定されていないようだ、ということはわかりました。

またまた README.md を見ると「templatePath」というプロパティがあったため、追加してみたのですが動作しないようです。

server/app.module.ts


import { Module } from '@nestjs/common';
import { AngularUniversalModule } from '@nestjs/ng-universal';
import { join } from 'path';

@Module({
  imports: [
    AngularUniversalModule.forRoot({
      viewsPath: join(process.cwd(), 'dist/browser'),
      bundle: require('../server/main'),
      liveReload: false,
      templatePath: join(process.cwd(), 'dist/browser/index.html'),
    })
  ]
})
export class ApplicationModule {}

「@nestjs/serve-static」を使ったり、ルーティングで index.html を表示させる、といった処理が必要なのでしょうか。。。?

2020-07-08 追記

@nestjs/serve-static 追加

今回は Nest.js で静的ファイルを扱うことができるようにする「@nestjs/serve-static」を使って解決することにしました。

npm install --save @nestjs/serve-static

server/main.ts


import { NestFactory } from '@nestjs/core';
import { ApplicationModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';

async function bootstrap() {
  const app = await NestFactory.create(ApplicationModule);
  app.setGlobalPrefix('api');
  app.useStaticAssets(join(__dirname, '..', 'browser'));
  await app.listen(4200);
}
bootstrap();

NgRx に触れてみる 1

はじめに

前から気になっていた NgRx に触れてみます。

NgRX は Redux のように状態を管理するためのものです。
今回は Redux のチュートリアルにある tic-tac-toe をなんとなく真似してみることにします。

環境

  • Angular: 10.0.2
  • @ngrx/store: 9.2.0
  • @ngrx/effects: 9.2.0 (Effects で使用)

Install

npx ng add @ngrx/store

元のプロジェクト

まずは NgRx を使用しないプロジェクトを用意しますよ( Effects に関連するところは後で)。

app.component.html

<app-board></app-board>

board.component.html


<div class="board">
<app-square
    *ngFor="let s of state.squares; let i = index"
    [props]="s" (onClick)="updateSquare(i)"></app-square>
</div>

board.component.ts


import { Component, OnInit } from '@angular/core';
import { BoardState } from './board-state';

@Component({
  selector: 'app-board',
  templateUrl: './board.component.html',
  styleUrls: ['./board.component.css']
})
export class BoardComponent implements OnInit {
  public state: BoardState;
  constructor() {
    this.state = this.initialState();
  }

  ngOnInit(): void {
  }
  public updateSquare(index: number) {
    const squares = this.state.squares.slice();
    squares[index] = (this.state.nextIsX)? '✕': '◯';
    this.state = {
        nextIsX: ! this.state.nextIsX,
        squares
    };
  }
  private initialState(): BoardState {
    return {
        nextIsX: true,
        squares: Array(9).fill(null),
    };
  }
}

board.component.css


.board {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
    height: auto;
    width: 21vw;
}

board-state.ts


import { SquareValue } from './square/square-value';

export type BoardState = {
    nextIsX: boolean,
    squares: Array<SquareValue>
};

square.component.html


<button class="square" (click)="click()">{{props}}</button>

square.component.ts


import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { SquareValue } from './square-value';

@Component({
  selector: 'app-square',
  templateUrl: './square.component.html',
  styleUrls: ['./square.component.css']
})
export class SquareComponent implements OnInit {
  @Input() props: SquareValue;
  @Output() onClick: EventEmitter<void> = new EventEmitter();
  constructor() { }

  ngOnInit(): void {
  }
  public click() {
    this.onClick.emit();
  }
}

square.component.css


.square{
    height: 7vw;
    width: 7vw;
}

実行すると 9 個のボタンが表示され、クリックすると順に「◯」「✕」が表示されます。

f:id:mslGt:20200704165550j:plain

Action, Reducer, Store を追加する

Action を作る

「何が起こったか」(今回はボタンがクリックされた) を表現する Action を作ります。

game.actions.ts


import { createAction, props } from '@ngrx/store';

export const updateSquare = createAction('[Game] updateSquare',
    props<{index: number}>());

Action を作る上で必須になるのは第一引数である「type」のみです。
ですが、今回はクリックされたボタンの配列番号が欲しかっったので「props」を第二引数として追加しています。

Reducer を作る

次は「Action によって状態がどう変わるか」(今回は「◯」または「✕」が入る)を表現する Reducer を追加します。

game.reducer.ts


import { createReducer, on, Action } from "@ngrx/store";
import { BoardState } from './board/board-state';
import { updateSquare } from './game.actions';

function initialState(): BoardState {
    return {
        nextIsX: true,
        squares: Array(9).fill(null),
    };
}
function getUpdatedState(lastState: BoardState, index: number): BoardState {
    const squares = lastState.squares.slice();
    squares[index] = (lastState.nextIsX)? '✕': '◯';
    return {
        nextIsX: ! lastState.nextIsX,
        squares
    };
}
const _gameReducer = createReducer(initialState(),
    on(updateSquare, (state, {index}) => getUpdatedState(state, index)));

export function reducer(state: BoardState | undefined, action: Action) {
    return _gameReducer(state, action);
}

今回は Action を一つだけ登録していますが、二つ以上の場合は下記のように追加していきます。


const _gameReducer = createReducer(initialState(),
    on(updateSquare, (state, {index}) => getUpdatedState(state, index)),
    on(resetSqure, (state) => initialState()));

StoreModule に Reducer を登録する

Reducer を有効にし、状態管理が行われるようにするため StoreModule に登録します。

app.module.ts


import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { StoreModule } from '@ngrx/store';
import { BoardComponent } from './tic-tac-toe/board/board.component';
import { SquareComponent } from './tic-tac-toe/board/square/square.component';
import * as gameReducer from './tic-tac-toe/game.reducer';
@NgModule({
  declarations: [
    AppComponent,
    BoardComponent,
    SquareComponent
  ],
  imports: [
    BrowserModule,
    StoreModule.forRoot({game: gameReducer.reducer})
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Component に接続する

「Board」 Component で今の状態の取得、 Action 、 Reducer を使って状態の更新をできるようにします。

board.component.ts


import { Component, OnInit } from '@angular/core';
import { BoardState } from './board-state';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { updateSquare } from '../game.actions';

@Component({
  selector: 'app-board',
  templateUrl: './board.component.html',
  styleUrls: ['./board.component.css']
})
export class BoardComponent implements OnInit {
  // receive current state
  public state$: Observable<BoardState>;

  constructor(private store: Store<{game: BoardState}>) {
    this.state$ = store.pipe(select('game'));
  }
  ngOnInit(): void {
  }
  public updateSquare(index: number) {
    // call the reducer to update the state
    this.store.dispatch(updateSquare({index}));
  }
}

ここで重要だと思うのは「select('game')」です。
引数の「game」は app.module.ts で登録したものと合わせる必要があります。

ここが違っているとエラーは発生しませんが状態を受け取ることができない、という問題が発生します(ちょっとハマりました)。

board.component.html


<div class="board">
<app-square
    *ngFor="let s of (state$|async)?.squares; let i = index"
    [props]="s" (onClick)="updateSquare(i)"></app-square>
</div>

現状

ここまでで下記のような流れができています。

f:id:mslGt:20200704165630j:plain

Effects を使う

次は Effects(@ngrx/effects) を使ってみます。

名前からすると「side-effects (副作用)」なのかな?と思いきや、ネットワークリクエストなど、外部リソースにアクセスする場合に使われるようです。

Reducer のように、「store.dispatch」を使って Action を発行することで処理を呼び出します。

流れとしてはこんな感じ。。。のはず。

f:id:mslGt:20200704165653j:plain

Install

npx ng add @ngrx/effects

元のプロジェクトに Service を追加する

今回のサンプルでは、ボタンがクリックされ、状態が変化したタイミングでゲームの勝敗がついたかを確認する処理を追加します。
簡易化のためサーバーにアクセスなどはしてません。

check-game-finished.ts


import { SquareValue } from './board/square/square-value';
import { GameResult } from './game-result';

type CheckTarget = {
    index: number,
    match: readonly CheckTarget[],
    unmatch: readonly CheckTarget[]
};
const emptyTarget = {
    match: [],
    unmatch: []
};
const checkTargets: CheckTarget = {
    index: 0,
    match: [{
        index: 1,
        match: [{
            index:2,
            ...emptyTarget
        }],
        unmatch: []
    }, {
        index: 3,
        match: [{
            index: 6,
            ...emptyTarget
        }],
        unmatch: []
    }, {
        index: 4,
        match: [{
            index: 8,
            ...emptyTarget
        }],
        unmatch: []
    }],
    unmatch: [{
        index: 4,
        match: [{
            index: 1,
            match: [{
                index: 7,
                ...emptyTarget
            }, {
                index: 3,
                match: [{
                    index: 5,
                    ...emptyTarget
                }],
                unmatch: []
            }],
            unmatch: []
        }],
        unmatch:[{
            index: 8,
            match: [{
                index: 2,
                ...emptyTarget
            },{
                index: 6,
                ...emptyTarget
            }],
            unmatch: []
        }]
    }]
};
function getTargetIndices(squares: readonly SquareValue[]): { circles: number[], crosses: number[]} {
    const circles = new Array<number>();
    const crosses = new Array<number>();
    for(let i = 0; i < squares.length; i++) {
        if (squares[i] == '◯') {
            circles.push(i);
        } else if(squares[i] == '✕') {
            crosses.push(i);
        }
    }
    return { circles, crosses };
}
function check(targetIndices: readonly number[],
        nextTarget: CheckTarget): boolean {
    if (targetIndices.some(i => nextTarget.index == i)) {
        if (nextTarget.match.length <= 0) {
            return true;
        }
        if (nextTarget.match.some(t => check(targetIndices, t))) {
            return true;
        }
    } else {
        if (nextTarget.unmatch.length <= 0) {
            return false;
        }
        if (nextTarget.unmatch.some(t => check(targetIndices, t))) {
            return true;
        }
    }
    return false;
}
export function checkGameFinished(squares: readonly SquareValue[]): GameResult {
    console.log("check");
    const targets = getTargetIndices(squares);
    if (check(targets.circles, checkTargets)) {
        return { finished: true, winner: '◯' };
    } else if (check(targets.crosses, checkTargets)) {
        return { finished: true, winner: '✕' };
    }
    return { finished: false, winner: null };
}

ゲームの勝敗確認の処理はもう少し簡略化したい。。。

game-score.service.ts


import { Injectable } from '@angular/core';
import { SquareValue } from './board/square/square-value';
import { Observable, of } from 'rxjs';
import { GameResult } from './game-result';
import * as GameChecker from './check-game-finished';

@Injectable({
  providedIn: 'root'
})
export class GameScoreService {

  constructor() { }
  public checkResult(squares: readonly SquareValue[]): Observable<GameResult> {
    return of(GameChecker.checkGameFinished(squares));
  }
}

Effects を追加する

game-result.effects.ts


import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { map, mergeMap, catchError } from 'rxjs/operators';
import { GameScoreService } from './game-score.service';
import { checkScore, gameFinished } from './game.actions';
import { EMPTY } from 'rxjs';
@Injectable()
export class GameResultEffects {
    /* this would be fired by dispatching "checkScore" action. */
    checkResult$ = createEffect(() => this.actions$.pipe(
        ofType(checkScore),
        /* call service */
        mergeMap(action => this.gameScoreService.checkResult(action.squares)
            .pipe(
                /* make an action fired */
                map(result => gameFinished({ result })),
                catchError(error => {
                    console.error(error);
                    return EMPTY;
                })
            ))));
    constructor(
        private actions$: Actions,
        private gameScoreService: GameScoreService
      ) {}
}

game.action.ts


import { createAction, props } from '@ngrx/store';
import { SquareValue } from './board/square/square-value';
import { GameResult } from './game-result';

...
export const checkScore = createAction('[Score] checkScore',
    props<{ squares: readonly SquareValue[]}>());
export const gameFinished = createAction('[Game] finished',
    props<{ result: GameResult }>());

Effects を登録する

Reducer と同じように app.module.ts に Effects を追加します。

app.module.ts


import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { StoreModule } from '@ngrx/store';
import { BoardComponent } from './tic-tac-toe/board/board.component';
import { SquareComponent } from './tic-tac-toe/board/square/square.component';
import * as gameReducer from './tic-tac-toe/game.reducer';
import { EffectsModule } from '@ngrx/effects';
import { GameResultEffects } from './tic-tac-toe/game-result.effects';
@NgModule({
  declarations: [
    AppComponent,
    BoardComponent,
    SquareComponent
  ],
  imports: [
    BrowserModule,
    StoreModule.forRoot({game: gameReducer.reducer}),
    EffectsModule.forRoot([GameResultEffects])
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Component に接続する(失敗)

こちらも Reducer のときと同じように Component に状態受け取りと Action 発行の処理を追加します。

board.component.ts


import { Component, OnInit } from '@angular/core';
import { BoardState } from './board-state';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { updateSquare, checkScore } from '../game.actions';
import { GameResult } from '../game-result';
import { SquareValue } from './square/square-value';

@Component({
  selector: 'app-board',
  templateUrl: './board.component.html',
  styleUrls: ['./board.component.css']
})
export class BoardComponent implements OnInit {
  public state$: Observable<BoardState>;
  public result$: Observable<GameResult>;

  constructor(private store: Store<{game: BoardState, result: GameResult }>) {
    this.state$ = store.pipe(select('game'));
    this.state$.subscribe(s => this.onValueChanged(s.squares));
    this.result$ = store.select(state => state.result);
    this.result$.subscribe(r => console.log(r));
  }

  ngOnInit(): void {
  }
  public updateSquare(index: number) {
    this.store.dispatch(updateSquare({index}));
  }
  private onValueChanged(squares: readonly SquareValue[]) {
    this.store.dispatch(checkScore({squares}));
  }
}

課題と対策

「gameScoreService.checkResult」の処理実行などは問題ないにもかかわらず、「this.result$.subscribe(r => console.log(r));」が実行されません。

結局 Effects から発行されている「gameFinished」の Action をハンドリングする Reducer がいない、ということが原因でした。

Effects から Action を発行すると、良い感じに StoreModule が状態をさばいてくれる、とかいう都合の良い展開にはならない、ということですね。

ということで Reducer を追加します。

game-result.reducer.ts


import { GameResult } from './game-result';
import { createReducer, on, Action } from '@ngrx/store';
import { gameFinished } from './game.actions';

function initialState(): GameResult {
    return {
        finished: false,
        winner: null
    };
}
const _resultReducer = createReducer(initialState(),
    on(gameFinished, (state, {result}) => {
        if (result.finished) {
            return result;
        }
        return state;
    }));
export function reducer(state: GameResult | undefined, action: Action) {
    return _resultReducer(state, action);
}

app.module.ts


...
import * as gameResultReducer from './tic-tac-toe/game-result.reducer';
import { EffectsModule } from '@ngrx/effects';
import { GameResultEffects } from './tic-tac-toe/game-result.effects';
@NgModule({
...
  imports: [
    BrowserModule,
    StoreModule.forRoot({game: gameReducer.reducer, result: gameResultReducer.reducer}),
    EffectsModule.forRoot([GameResultEffects])
  ],
...

これで result$ の変化も受け取ることができました。

今回は何もしていませんが、 Service で例外が発生した場合 Effects から Action として発行することができるため、実運用ではこちらも Reducer で処理することになると思います。

次回に続く。。。はず

TypeORMに触れてみる 2

はじめに

今回はマイグレーションと外部キーの設定について。

Migration

まずマイグレーションから。 TypeORM のコマンドでファイルを作ります。

npx typeorm migration:create -n AddUpdateDate

生成されたファイルは src/migration に出力されます。

1591186678545-AddUpdateDate.ts

import {MigrationInterface, QueryRunner} from "typeorm";
export class AddUpdateDate1591186678545 implements MigrationInterface {
    public async up(queryRunner: QueryRunner): Promise<void> {
    }
    public async down(queryRunner: QueryRunner): Promise<void> {
    }
}

そのままだと何も実行されないので、処理を追加します。 今回は "updateDate" カラムを "SampleUser" に追加します。

1591186678545-AddUpdateDate.ts

import {MigrationInterface, QueryRunner} from "typeorm";
export class AddUpdateDate1591186678545 implements MigrationInterface {
    public async up(queryRunner: QueryRunner): Promise<void> {
      await queryRunner.query(`ALTER TABLE "SampleUser" ADD COLUMN "updateDate" date DEFAULT current_timestamp NOT NULL`);
    }
    public async down(queryRunner: QueryRunner): Promise<void> {
      await queryRunner.query(`ALTER TABLE "SampleUser" DROP COLUMN "updateDate"`);
    }
}

重要(多分)な点として、 SQL ではデータ型で何もしないと Nullable になる、ということです。
Entity クラスはデフォルトで Not Null なので、実行するとエラーになって??になる、という。

マイグレーション実行(失敗)

"migration:run" コマンドを実行すると処理が反映されるはず。

npx typeorm migration:run

しかし実際はエラーになります。

import {Entity, PrimaryGeneratedColumn, Column} from "typeorm";
^^^^^^

SyntaxError: Cannot use import statement outside a module
    at wrapSafe (internal/modules/cjs/loader.js:1101:16)
    at Module._compile (internal/modules/cjs/loader.js:1149:27)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1205:10)
    at Module.load (internal/modules/cjs/loader.js:1034:32)
    at Function.Module._load (internal/modules/cjs/loader.js:923:14)
    at Module.require (internal/modules/cjs/loader.js:1074:19)
    at require (internal/modules/cjs/helpers.js:72:18)
    at Function.PlatformTools.load (C:\Users\example\Documents\workspace\gen-typeorm-sample\node_modules\typeorm\platform\PlatformTools.js:114:28)
    at C:\Users\example\Documents\workspace\gen-typeorm-sample\node_modules\typeorm\util\DirectoryExportedClassesLoader.js:39:69
    at Array.map (<anonymous>)

マイグレーションを実行する(成功)

これは、今回 "ts-node" を使用していることが原因のようです。
下記のように実行すれば OK です。

npx ts-node ./node_modules/typeorm/cli.js migration:run

また、処理を取り消したい場合は下記のようにすれば OK です。

npx ts-node ./node_modules/typeorm/cli.js migration:revert

"migration:revert" を実行すると、最後に実行した処理が取り戻されます。

"migration" テーブル

"migration:run" を最初に実行すると、"migration" というテーブルが追加されます。 f:id:mslGt:20200607163135j:plain

反映された処理はこのテーブルに記録されているため、下記のような操作をすると、最後の処理が無視されます。

  1. マイグレーションファイルによってテーブルを作る。
  2. PgAdmin などを使って直接 1.のテーブルを削除する。
  3. "migration:run" で 1.を再実行する。

いつ新しい ID が発行されるか

たとえば2つのテーブル(テーブル A 、B とする)に一度に追加する場合。
B が A の ID を参照しているとします。
これを実現しようとすると、 A にデータを追加したあと、その ID を B に渡す必要があります。

では、A の ID はいつ発行されるでしょうか。
というのを試してみます。

index.ts

import "reflect-metadata";
import {createConnection } from "typeorm";
import { SampleUser } from "./entity/sample-user";

createConnection().then(async connection => {
   const queryRunner = connection.createQueryRunner();
   await queryRunner.startTransaction();
   try{
      const thirdUser = new SampleUser();
      thirdUser.firstName = 'Hello4';
      thirdUser.lastName = 'World4';
      thirdUser.age = 43;

      console.log("Before saving");
      console.log(thirdUser);

      await queryRunner.manager.save(thirdUser);
   
      console.log("Before committing");
      console.log(thirdUser);
      
      queryRunner.commitTransaction();
   
      console.log("After");
      console.log(thirdUser);
    }catch(error) {
        await queryRunner.rollbackTransaction();
    }
    finally {
        await queryRunner.release();
    }
}).catch(error => console.log(error));

結果

Before saving
SampleUser { id: -1, firstName: 'Hello4', lastName: 'World4', age: 43 }
Before committing
SampleUser { id: 6, firstName: 'Hello4', lastName: 'World4', age: 43 }
After
SampleUser { id: 6, firstName: 'Hello4', lastName: 'World4', age: 43 }

ということで、 "queryRunner.manager.save" 実行後に ID が取得できます。

外部キー

"SampleUser" の "id" を外部キーとして設定するにはどうすれば良いでしょうか。

@ManyToOne(), @OneToMany(), @OneToOne(), @JoinColumn() を使って実現できます。

sample-user.ts

import {Entity, PrimaryGeneratedColumn, Column, OneToMany, UpdateDateColumn} from "typeorm";
import { Post } from "./post";

@Entity("SampleUser")
export class SampleUser {
    @PrimaryGeneratedColumn()
    id: number = -1;

    @Column({ type: 'text' })
    firstName: string = '';

    @Column({ type: 'text' })
    lastName: string = '';

    @Column()
    age: number = -1;

    @UpdateDateColumn({ type: 'timestamptz' })
    updateDate: Date = new Date();

    @OneToMany(type => Post, post => post.user)
    posts: Post[]|null = null;

}

post.ts

import {Entity, PrimaryGeneratedColumn, Column, ManyToOne, UpdateDateColumn, OneToOne, JoinColumn} from "typeorm";
import { SampleUser } from "./sample-user";
import { Category } from "./category";

@Entity("Post")
export class Post {
    @PrimaryGeneratedColumn()
    id: number = -1;

    @ManyToOne(type => SampleUser, user => user.posts)
    user: SampleUser = new SampleUser();

    @OneToOne(() => Category)
    @JoinColumn([{
        name: 'categoryId',
        referencedColumnName: 'id'
    }])
    category: Category = new Category();

    @Column({ type: 'text' })
    title: string = '';

    @Column({ type: 'text' })
    article: string = '';

    @UpdateDateColumn({ type: 'timestamptz' })
    updateDate: Date = new Date();
}

category.ts

import {Entity, PrimaryGeneratedColumn, Column, UpdateDateColumn} from "typeorm";

@Entity("Category")
export class Category {
    @PrimaryGeneratedColumn()
    id: number = -1;

    @Column({ type: 'text' })
    name: string = '';

    @UpdateDateColumn({ type: 'timestamptz' })
    updateDate: Date = new Date();
}

テーブル生成を synchronize: true ですべきかマイグレーションファイルですべきか

これまで、 ormconfig.json で "synchronize" を true に設定して実行していました。
そのため、実行したときにテーブルが存在していなければ自動で生成されていました。

しかし、テーブルの追加はマイグレーションファイルに "CREATE TABLE" を書いても実現できます。

1591186678544-CreateSampleUserTable.ts

import {MigrationInterface, QueryRunner} from "typeorm";

export class AddCreateSampleUserTable1591186678544 implements MigrationInterface {
    public async up(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(`CREATE TABLE "SampleUser" ( id serial PRIMARY KEY, "firstName" text NOT NULL, "lastName" text NOT NULL, "age" integer NOT NULL)`);
    }
    public async down(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(`DROP TABLE "SampleUser"`);
    }
}

どちらを使うべきでしょうか。

という答えはまだ見つけられていないのですが、少なくともマスタテーブル(基本的に更新せず、他のテーブルから参照するためのデータを持つテーブル)については "synchronize" では解決できないので、マイグレーションファイルからテーブル生成も行おうかと思います。

1591356501422-AddCategories.ts

import {MigrationInterface, QueryRunner} from "typeorm";
import { Category } from "../entity/category";
export class AddCategories1591356501422 implements MigrationInterface {
    public async up(queryRunner: QueryRunner): Promise<void> {
        const programming = new Category();
        programming.name = 'Programming';
        await queryRunner.manager.save(programming);
        const book = new Category();
        book.name = 'Book';
        await queryRunner.manager.save(book);
    }
    public async down(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(`DELETE FROM "Category"`);
    }
}