ASP .NET Core 3.1 + EntityFramework Core を使って、一対多のリレーションがあるデータをレスポンスしてみた

ASP.NET Core 3.1 + EntityFramework Coreを使ってアプリを書いた際、1モデルのデータを返す方法はチュートリアルにありました。

 

一方、一対多のリレーションがあるデータをどうやってレスポンスすれば良いか、最初のうちは分かりませんでした。

そのため、色々と試したことをメモしておきます。

 

目次

 

環境

 

環境構築

まずは、MacASP.NET Core プロジェクトを作るまでの環境構築です。

 

.NET Core SDKのインストール

.NET Core SDKをHomebrewで管理しようと調べてみたところ、caskにありました。

$ brew search dotnet-sdk
==> Casks
dotnet-sdk

 

そのため、caskを使ってインストールしました。

$ brew cask install dotnet-sdk
...
==> Linking Binary 'dotnet' to '/usr/local/bin/dotnet'.
🍺  dotnet-sdk was successfully installed!

 

これで dotnet コマンドが使えるようになりました。

$ dotnet --version
3.1.100

 
なお、SDKのバージョンアップする場合は、以下のコマンドで行います。

$ brew cask upgrade dotnet-sdk
...
==> Linking Binary 'dotnet' to '/usr/local/bin/dotnet'.
==> Purging files for version 3.0.101,1b9f265d-ba27-4f0c-8b4d-1fd42bd8448b:2bbd64abddeeea91149df3aa39d049ae of Cask dotnet-sdk
🍺  dotnet-sdk was successfully upgraded!

 

ソリューションとプロジェクトを作る

今回は、dotnetコマンドを使って作成します。

 

ソリューションファイルの作成
$ dotnet new sln

 

プロジェクトファイルの作成

今回は、 EFCoreRelationSample というプロジェクトを、Web APIテンプレートを使って作成します。

 

まずは用意されているテンプレートを確認します。今回は webapi を使えば良さそうです。

$ dotnet new --list
...

Templates                                         Short Name                                 
------------------------------------------------------------                            
ASP.NET Core Web App (Model-View-Controller)      mvc                              
...
ASP.NET Core Web API                              webapi
...

 

プロジェクトを作成し、ソリューションに追加します。

# プロジェクトを作成
$ dotnet new webapi -o EFCoreRelationSample

# ソリューションに追加
dotnet sln add ./EFCoreRelationSample

 

EntityFramework CoreまわりのNuGetパッケージを追加

まずはSDKのグローバルに、EntityFramework Core (以下、 EF Core )まわりのコマンドを追加します。
EF Core ツールリファレンス (.NET CLI)-EF Core | Microsoft Docs

$ dotnet tool install --global dotnet-ef
次のコマンドを使用してツールを呼び出せます。dotnet-ef
ツール 'dotnet-ef' (バージョン '3.1.0') が正常にインストールされました。

なお、 dotnet ef コマンドは .NET Core SDKに同梱されていません。
Announcing Entity Framework Core 3.1 and Entity Framework 6.4 | .NET Blog

 

続いて、EF Core関連のパッケージを追加します。

# プロジェクトのあるディレクトリへ移動
$ cd EFCoreRelationSample/

# 今回SQLiteを使うため、SQLiteパッケージを追加
$ dotnet add package Microsoft.EntityFrameworkCore.SQLite

# マイグレーションなどのために追加
$ dotnet add package Microsoft.EntityFrameworkCore.Design

 
なお、 Microsoft.EntityFrameworkCore.SQLite の制限内容はこちら。 SQLite Database Provider - Limitations - EF Core | Microsoft Docs

 
また、Microsoft.EntityFrameworkCore.Design を追加する目的はこちら。
c# - Purpose of package "Microsoft.EntityFrameworkCore.Design" - Stack Overflow

 

ここまでで、ASP.NET Core & EF Coreの環境ができました。

 

モデルまわりの作成

今回使用するモデルまわりを作成します。

 

モデルの作成

今回作成する一対多のリレーションを持つモデルとして、BlogとPostを使います。公式チュートリアルとほぼ同じ型です。
リレーションシップ-EF Core | Microsoft Docs

 

テンプレートにはモデルを入れるディレクトリがないため、

  • Controllersと同列に Models というディレクトリを作成
  • Modelsの中にモデルファイル BlogPostOfFk.cs を作成

とします。

 

2つのモデルのリレーションについて、今回はナビゲーションプロパティを使って定義します。

EF Coreの規則として、親側にだけ定義すれば、自動でリレーションが貼られるようです。
https://docs.microsoft.com/ja-jp/ef/core/modeling/relationships?tabs=data-annotations#single-navigation-property

 
ただ、後述のControllerにて親から子をたぐる処理を書く際、ナビゲーションプロパティが定義されていない状態ではコンパイルが通りませんでした。

var blog = await _context.BlogOfFks
    .Include(p => p.PostsOfFk)  // この p.PostOfFk が参照できなくてエラー
    .ToListAsync();
return Ok(blog);

 
そのため、以下のように明示的に定義します。なお、外部キーについては定義しなくても動作するようです。

using System.Collections.Generic;

namespace EFCoreRelationSample.Models
{
    public class BlogOfFk
    {
        public int Id { get; set; }
        public string Url { get; set; }
        
        public List<PostOfFk> PostsOfFk { get; set; }
    }

    public class PostOfFk
    {
        public int Id { get; set; }
        public string Content { get; set; }

        public BlogOfFk BlogOfFk { get; set; }
    }
}

 

DbContextの作成

モデルができたので、次はDBと接続するためのDbContextを作成します。

DbContextの適切な置き場が分からなかったため、今回は Models の下に MyContext.cs として作成しました。

DbContextを継承した MyContext クラスを作成し、

  • コンストラク
  • DbSet型のpublicプロパティ

を用意します。

using Microsoft.EntityFrameworkCore;

namespace EFCoreRelationSample.Models
{
    public class MyContext : DbContext
    {
        public MyContext(DbContextOptions<MyContext> options) : base(options) {}
        
        // モデルクラスへのアクセス (DbSet<TModel>) 型のpublicプロパティ
        public DbSet<BlogOfFk> BlogOfFks { get; set; }
        public DbSet<PostOfFk> PostOfFks { get; set; }
    }
}

 

接続文字列の作成 (appsettings.json)

次に、SQLiteへの接続するための接続文字列を用意します。

今回は、プロジェクトルート直下にある appsettings.json の中に記載します。

接続するSQLiteは、プロジェクトルート直下の WebApplication.db とします。

{
  // ...
  "AllowedHosts": "*",
  
  // 以下を追加
  "ConnectionStrings": {
    "MyContext": "Data Source=./WebApplication.db"
  }
}

なお、SQLiteファイルはEF Coreのマイグレーションにより自動生成されます。

 

Startup.csに、DbContextと接続文字列を追加

今回使用するDbContextと接続文字列を追加します。

SQLiteへ接続するため、 UseSqlite() を使います。

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    // 以下を追加
    services.AddDbContext<MyContext>(options => options.UseSqlite(
        Configuration.GetConnectionString("MyContext")));
}

 

マイグレーションの作成

モデルまわりができたため、EF Coreによるマイグレーションを作成します。

 

マイグレーションファイルの作成

以下のコマンドを実行すると、 Migrations ディレクトリの中にマイグレーションファイルが作成されます。

$ dotnet ef migrations add InitialCreate

今回は

  • MyContextModelSnapshot.cs
  • <タイムスタンプ>_InitialCreate.cs

の2ファイルが作成されました。

 

データベースへの反映

マイグレーションファイルの内容をSQLiteへ反映します。

$ dotnet ef database update

 
実行時のログより、BlogOfFk・PostOfFk モデルを元に、2つのテーブルを作成するSQLが発行されたことが分かります。

BlogOfFk

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "BlogOfFks" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_BlogOfFks" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NULL
      );

 

PostOfFk

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "PostOfFks" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_PostOfFks" PRIMARY KEY AUTOINCREMENT,
          "Content" TEXT NULL,
          "BlogOfFkId" INTEGER NULL,
          CONSTRAINT "FK_PostOfFks_BlogOfFks_BlogOfFkId" FOREIGN KEY ("BlogOfFkId") 
              REFERENCES "BlogOfFks" ("Id") ON DELETE RESTRICT
      );

モデルにはナビゲーションプロパティだけ定義したものの、子モデルの外部キー BlogOfFkId が自動生成されました。

一方、モデルに定義した

  • コレクションナビゲーションプロパティ (BlogOfFk)
  • 参照ナビゲーションプロパティ (PostsOfFk)

は、テーブルのフィールドとして作成されませんでした。

 

初期データの投入

初期データを投入するため、データのシード処理を実装します。
データシード処理-EF Core | Microsoft Docs

投入方法はいくつかあるようですが、今回はDbContextの中で実装します。

OnModelCreating() メソッドをオーバーライドして実装します。

namespace EFCoreRelationSample.Models
{
    public class MyContext : DbContext
    {
        // ...
        
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // データのシードを定義
            // 親データ
            modelBuilder.Entity<BlogOfFk>().HasData(
                new {Id = 1, Url = "https://example.com/foo"},
                new {Id = 2, Url = "https://example.com/bar"}
            );
            
            // 子データ
            modelBuilder.Entity<PostOfFk>().HasData(
                new {Id = 1, Content = "ふー", BlogOfFkId = 1},
                new {Id = 2, Content = "ばー", BlogOfFkId = 1}
            );
        }
    }
}

 

初期データを投入するため、

  • 先ほど作成したマイグレーションファイル (Migrations/ の下) を削除
  • SQLite (WebApplication.db) を削除
  • dotnet ef migrations add InitialCreate
  • dotnet ef database update

を実行したところ

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      INSERT INTO "BlogOfFks" ("Id", "Url")
      VALUES (1, 'https://example.com/foo');
      
...

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      INSERT INTO "PostOfFks" ("Id", "BlogOfFkId", "Content")
      VALUES (1, 1, 'ふー');

などのログが表示され、シード処理が行われていることが確認できました。

 
Rider IDEで確認しても、以下のようにデータが存在しています。

BlogOfFk

f:id:thinkAmi:20191217221650p:plain:w400

 

PostOfFk

f:id:thinkAmi:20191217221704p:plain:w400

 

EF Coreから発行したSQLをコンソールへ出力

コントローラを作成する前に、EF Coreで発行したSQLをコンソールで確認する設定を追加しておきます。

基本的には以下の記事の通りですが、.NET Core 3.1版では少々変更がありました。
【C#】Entity Framework Core の SQL の実行ログを確認する - Qiita

 

NuGetパッケージを追加

コンソールへ出力するためのNuGetパッケージを追加します。

$ dotnet add package Microsoft.Extensions.Logging.Console

 

DbContextへ追加

DbContextへ

  • staticなreadonlyフィールドを追加
  • OnConfiguring()をオーバーライドして実装

を追加します。

ただ、.NET Core 3.1ではLoggerFactoryの追加方法が変わったようです。

 

そのため

  • LoggerFactory.Create()で生成
  • AddFilter()で、 DbLoggerCategory.Database.Command.Name を対象にする
  • AddConsole()で、コンソールへ出力

とします。

static readonly ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
{
    builder
        .AddFilter(DbLoggerCategory.Database.Command.Name, LogLevel.Information)
        .AddConsole();
});

// UseLoggerFactory で上記のロガーファクトリを登録する。
// Insert 文の値をロギングするために EnableSensitiveDataLogging を許可する。
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
    optionsBuilder
        .EnableSensitiveDataLogging()
        .UseLoggerFactory(loggerFactory);

 

コントローラの作成

あとはコントローラを作成すれば完成です。

 

失敗:Include()を使用したリレーションデータのレスポンス

コントローラに必要な

  • DbContextフィールド
  • コンストラクタで、DbContextフィールドへの設定
  • HTTP GETでアクセスした時のメソッド

を実装します。

以下は、 /BlogPost/p2c にアクセスした時に、BlogOfFkモデルからPostOfFkモデルをたぐって表示する例となります。

namespace EFCoreRelationSample.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class BlogPostController : ControllerBase
    {
        private readonly MyContext _context;

        public BlogPostController(MyContext context)
        {
            this._context = context;
        }

        // 1 -> N の順でたぐる場合
        [HttpGet("p2c", Name = "Parent2Child")]
        public async Task<IActionResult> P2C()
        {
            var blog = await _context.BlogOfFks
                .Include(p => p.PostsOfFk)
                .ToListAsync();
            return Ok(blog);
        }
    }
}

 

Rider IDEで実行し、ブラウザで https://localhost5000/api/BlogPost/p2c へアクセスすると

An unhandled exception occurred while processing the request.
JsonException: A possible object cycle was detected which is not supported. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 32.

というエラーが表示されました。

 
おそらく、モデルに

  • コレクションナビゲーションプロパティ (BlogOfFk)
  • 参照ナビゲーションプロパティ (PostsOfFk)

の2つが実装されているため、循環してしまったものと考えられました。

 

対応ですが、ASP.NET Core 2系では

のように、 SerializerSettings.ReferenceLoopHandling を設定すれば良いようでした。

 
ただ、ASP.NET Core 3系では、JSONライブラリに変更が入りました。

その影響で、SerializerSettings.ReferenceLoopHandling を設定するには、NuGetパッケージ Microsoft.AspNetCore.Mvc.NewtonsoftJson を追加する必要がありそうでした。

 
NewtonsoftJson の追加も考えましたが、今回は AutoMapper を使うことで対応することにしました。

 

AutoMapperを使った、リレーションデータのレスポンス

AutoMapperを使うにあたり、以下の記事が参考になりました。
ASP.NET CoreでAutoMapperを使う - ryuichi111stdの技術日記

ただ、ASP.NET Core3.1では少々変更されていました。

そのため、上記記事とstackoverflowを参考に

  • NuGetパッケージの追加
  • DTOクラスを追加
  • Profileクラスを追加
  • Startup.csへの追加
  • コントローラへの追加

を行いました。
c# - How to set up Automapper in ASP.NET Core - Stack Overflow

 

NuGetパッケージの追加

必要なNuGetパッケージを追加します。

# AutoMapper本体を追加
dotnet add package AutoMapper

# 拡張も入れる
dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection

 

DTOクラスを追加

ブラウザ側へ返す時のデータ型となるDTOクラスを追加します。

今回は DTO/BlogPostOfFKDTO.cs として追加しました。

また、DTOのプロパティ名はモデルと同一としました。

using System.Collections.Generic;
using EFCoreRelationSample.Models;

namespace EFCoreRelationSample.DTO
{
    public class PostOfFkDTO
    {
        // Postのフィールド
        public int Id { get; set; }
        public string Content { get; set; }
        
        // FKのフィールド
        public BlogOfFk BlogOfFk { get; set; }
    }

    public class BlogOfFkDTO
    {
        // Blogのフィールド
        public int Id { get; set; }
        public string Url { get; set; }

        // FK(Post)のフィールド
        public List<PostOfFk> PostsOfFk { get; set; }
    }   
}

 

Profileクラスを追加

次に、モデルとDTOを結びつけるProfileクラスを追加します。

置き場所が分からなかったため、今回は DTO/MyProfile.cs として作成しました。

Profileクラスでは CreateMap() を使って、モデルとDTOを結びつけます。

using AutoMapper;
using EFCoreRelationSample.Models;

namespace EFCoreRelationSample.DTO
{
    public class MyProfile : Profile
    {
        public MyProfile()
        {
            CreateMap<BlogOfFk, BlogOfFkDTO>();
            CreateMap<PostOfFk, PostOfFkDTO>();
        }
    }
}

 

Startup.csへの追加

ConfigureServices() に、 AddAutoMapper() を追加します。

public void ConfigureServices(IServiceCollection services)
{
    ...
    // 【追加】AutoMapperを追加
    services.AddAutoMapper(typeof(MyProfile));
}

 

コントローラへの追加

まず、

  • IMapper型のフィールドを追加
  • コンストラクタに IMapper 型の引数を追加

を行います。

public class BlogPostController : ControllerBase
{

    private readonly IMapper _mapper;

    public BlogPostController(MyContext context, IMapper mapper)
    {
        this._context = context;
        this._mapper = mapper;
    }
    ...
}

 

続いてコントローラのメソッドを書き換えます。

ただ、今回はAutoMapper有無を比較できるよう P2CWithAutoMapper() を新しく作成してみました。

AutoMapperを使う場合は、 ProjectTo() を追加することで、DTOへの変換を行っています。

[HttpGet("p2c-auto", Name = "Parent2ChildWithAutoMapper")]
public async Task<IActionResult> P2CWithAutoMapper()
{
    var blogs = await _context.BlogOfFks
        .Include(p => p.PostsOfFk)
        .ProjectTo<BlogOfFkDTO>(_mapper.ConfigurationProvider)  // 追加
        .ToListAsync();
    return Ok(blogs);
}

ちなみに、同じような機能に Map() もあります。 ProjectTo() との違いは、こちらが参考になりました。
LINQ to Entities の遅延評価と AutoMapper が便利という話 - Qiita  

Rider IDEASP.NET Coreを起動した時の動作を変更

あとは確認するだけです。

ただ、Rider IDEASP.NET Coreを起動した時の動作を変更したいため、 Properties/launchSettings.json を修正します。

  • launchBrowser で、起動した時にブラウザも自動で開くか
  • launchUrl で、ブラウザで最初にアクセスするURLを指定

などができます。

"EFCoreRelationSample": {
  "commandName": "Project",
  "launchBrowser": true,
  "launchUrl": "api/BlogPost",
  "applicationUrl": "https://localhost:5001;http://localhost:5000",
  "environmentVariables": {
    "ASPNETCORE_ENVIRONMENT": "Development"
  }
}

 

動作確認

ASP.NET Coreアプリを起動し、ブラウザで https://localhost5000/api/BlogPost/p2c-auto を開くと、リレーションデータのJSONが表示されました。

f:id:thinkAmi:20191217221737p:plain

 

ソースコード

GitHubに上げました。
https://github.com/thinkAmi-sandbox/ASP_Net_Core-sample

なお、マイグレーションファイルは自動生成されたものなので、GitHubには含めていません。

Pythonで、unittest.mock.patchを使って定数を差し替える

小ネタです。

以前、 unittest.mock.patch を使ってデコレータを差し替えたことがありました。
Pythonで、unittest.mock.patchを使ってデコレータを差し替える - メモ的な思考的な

 
今回は、unittest.mock.patch を使って定数を差し替えてみます。

 
目次

 

環境

  • Python 3.7.5
  • pytest 5.3.1
    • テストランナーとしてのみ、使用

 

定数の差し替え

デコレータの時と同様、今回の定数の差し替えでも unittest.mock.patch() を使います。
https://docs.python.org/ja/3/library/unittest.mock.html#unittest.mock.patch

 
デコレータや関数の場合は、 side_effectreturn_value を使いました。

ただ、定数の場合は単にオブジェクトの差し替えだけのため、第2引数(もしくは new キーワード引数)で、差し替え後の値を渡してあげます。

 
たとえば、 target.py というファイルに、以下のようなプロダクションコードがあるとします。

DEBUG = True

def get_mode():
    return 'デバッグ' if DEBUG else 'プロダクション'

これに対し、定数 DEBUGFalse に差し替えたテストコードを書きたいとします。

 
この場合、

from target import get_mode
from unittest.mock import patch


@patch('target.DEBUG', False)
def test_production():
    actual = get_mode()
    assert actual == 'プロダクション'


@patch('target.DEBUG', new=False)
def test_production_with_new_keyword():
    actual = get_mode()
    assert actual == 'プロダクション'

とすることで、定数 DEBUG の値が TrueからFalseへと差し替わります。

 
また、もしテストの一時期だけDEBUGの値を差し替えたいという場合には、patchをwith文とともに使います。

def test_patch_using_with():
    # with文の中では、定数の値がFalseになる
    with patch('target.DEBUG', False) as p:
        actual1 = get_mode()
        assert actual1 == 'プロダクション'

    # with文を抜けると、定数の値がTrueに戻る
    actual2 = get_mode()
    assert actual2 == 'デバッグ'

 

その他参考

unit testing - How to patch a constant in python - Stack Overflow

 

ソースコード

Githubに上げました。ディレクトe.g._patch_constant が今回のものです。
https://github.com/thinkAmi-sandbox/python_mock-sample

ついカッとなって、飲み会座席のくじ引きアプリを作った話

この記事は、 JSL (日本システム技研) Advent Calendar 2019 - Qiita 12/2の記事です。

昨年同様、今年もJSLメンバーによるアドベントカレンダーをお届けします。

 
今回の記事は、ついカッとなって作成したアプリについて、ゆるく書いていきます。

目次

 

作ったもの

歓迎会の前日の雑談で「自分はなぜか最近の飲み会で役付きの方の隣が多いですー」という話題になりました。

そこで、ついカッとなって、翌朝までに、飲み会座席のくじ引きアプリを作ることにしました。

 

作成するまでの流れ

まず、欲しい機能としては

  • 自分の社員番号を入力したら、席番号を表示する
  • ランダムで席番号を割り当てる
  • 一回表示した社員番号の場合は、同じ席番号を表示する
  • 存在しない社員番号の場合は、エラーを表示する

があれば良さそうでした。

 
そこで、Python & Python製WebフレームワークのDjangoを使うことにより、各機能を実現できそうでした。

 

一方、最低限動けばいいだろうと考え、

あたりは、がんばらない方針としました。

 
機能と実現方法さえ決まれば後は実装するだけなので、夕食後に実装し始めて22時半くらいには完成しました。

ただ、翌朝までという時間制限を加えることで、ハッカソン的になったことから

と、作るだけで満足してしまいました。。。

 

アプリの画像

トップ

f:id:thinkAmi:20191202184442p:plain:w200

 

座席決定

f:id:thinkAmi:20191202184457p:plain:w200

 

存在しない場合

f:id:thinkAmi:20191202184514p:plain:w200

 

社内への公開から運用へ

満足したとはいえ、お蔵入りするのもなーと迷う気持ちがありました。

社内Slackでは分報を運用しているため、気軽に

最近の飲み会、くじ引きから自由席になった結果、なぜか役付きの人の近くにしか行けず。。。 ランダム性がほしいと思って、昨夜ついカッとなってくじ引きアプリを作ったけど、アプリ作ったら満足してしまった。。。

と書いてみました。

するとありがたいことに、@_sin_tanaka より

一旦ランダムでやってみる感じでいいと思います!せっかく作ったものなので!

と後押しがありました。

そこで、社内の雑談チャンネルにて

f:id:thinkAmi:20191202183005p:plain:w450

と書いたところ、ありがたいことに @chinoppy0727 の手により運用してもらえることになりました。

 

とはいえ、上記の通り、かなり機能を絞ったまま公開してしまったため

  • アプリ仕様や使い方のドキュメントが無い
  • 社員場号だけが使われるので、社員番号が不明だと、誰か分からない
  • Django adminによる管理画面作ってなかった
    • fixtureは用意していた
  • 「実際の物理席に、座席番号を書いて置く必要がある」という制約

など、かなり運用はつらい感じだったと思います。。。

 

運用に乗ってみて

ランダムで座席を決めているはずなのに、役付の人のまわりには変わり映えしないメンバーが集まるという、現実不具合がありました*1

そのため、歓迎会が始まるまではつらい気持ちになりました*2

 
とはいえ、自分が作ったものが使われると嬉しかったこともあり、カッとなって作るというのもたまには良いなーと感じました。

あと、今年度は開発合宿もあるので、このようなものがまた作れるかもしれないのも楽しみです。

 

ソースコード

GitHubに上げました。
https://github.com/thinkAmi/nomikai_kuji

*1:自分はおすそわけできた

*2:歓迎会で飲み始めたら忘れました

AWS CDKで、cdk deployしたらエラー「NoSuchBucket: The specified bucket does not exist」

先日、S3に不要なバケットがたまっていたため、バケットを全削除をするスクリプトを作成・実行しました。

その後、AWS CDK + Pythonで、 cdk deploy したところ、

 ❌  <your_backet_name> failed: NoSuchBucket: The specified bucket does not exist
The specified bucket does not exist

とエラーになり、AWS CDKを使ったデプロイができなくなりました。

 
この時の対応でしばらく悩んだため、メモを残します。

 

目次

 

環境

 

調査と対応

前述の通りバケットを全削除してしまったため、必要なバケット <your_backet_name> が無いせいかと考えました。

そこで、手動でエラーが出ている名称のバケットを作成したものの、エラーは変わりませんでした。

これにより、他にも消してはマズいバケットを消してしまったのかなと考えました。

 

CloudFormationのスタックを見たところ、 CDKToolkit という気になるスタックがありました。

スタックの説明には

The CDK Toolkit Stack. It was created by `cdk bootstrap` and manages resources necessary for managing your Cloud Applications with AWS CDK.

とありました。

Getting Startedページに書かれていないため忘れていましたが、AWS CDKでデプロイする前に、 cdk bootstrap をやっていたことを思い出しました。
Getting Started With the AWS CDK - AWS Cloud Development Kit (AWS CDK)

 

そこで、改めて cdk bootstrap したところ、エラーが変わりました。

$ cdk bootstrap
 ⏳  Bootstrapping environment aws://<account>/<region>...
CDKToolkit: creating CloudFormation changeset...
 0/2 | 8:22:48 PM | UPDATE_IN_PROGRESS   | AWS::S3::Bucket | StagingBucket 
0/2 Currently in progress: StagingBucket
 1/2 | 8:24:34 PM | UPDATE_FAILED        | AWS::S3::Bucket | StagingBucket The specified bucket does not exist (Service: Amazon S3; Status Code: 404; Error Code: NoSuchBucket; Request ID: xxx; S3 Extended Request ID: xxx)
 1/2 | 8:24:34 PM | UPDATE_ROLLBACK_IN_P | AWS::CloudFormation::Stack | CDKToolkit The following resource(s) failed to update: [StagingBucket]. 
 ❌  Environment aws://<account>>/<region> failed bootstrapping: Error: The stack named CDKToolkit is in a failed state: UPDATE_ROLLBACK_COMPLETE
The stack named CDKToolkit is in a failed state: UPDATE_ROLLBACK_COMPLETE

 

ただ、新規で cdk bootstrap を行ったつもりが、ログには UPDATE_IN_PROGRESS と出ていました。

そのため、もしかしたら、スタック CDKToolkit が存在しているせいかもしれないと考えました。

スタック CDKToolkit について、AWSドキュメントの記載は以下です。

Bootstrapping your AWS Environment
Before you can use the AWS CDK you must bootstrap your AWS environment to create the infrastructure that the AWS CDK CLI needs to deploy your AWS CDK app. Currently the bootstrap command creates only an Amazon S3 bucket.

You incur any charges for what the AWS CDK stores in the bucket. Because the AWS CDK does not remove any objects from the bucket, the bucket can accumulate objects as you use the AWS CDK. You can get rid of the bucket by deleting the CDKToolkit stack from your account.

https://docs.aws.amazon.com/cdk/latest/guide/tools.html

 

そこで、スタック CDKToolkit を削除した後、 cdk bootstrap を行ったところ、成功しました。

$ cdk bootstrap
 ⏳  Bootstrapping environment aws://<account>/<region>...
CDKToolkit: creating CloudFormation changeset...
 0/2 | 8:31:37 PM | CREATE_IN_PROGRESS   | AWS::S3::Bucket | StagingBucket 
 0/2 | 8:31:39 PM | CREATE_IN_PROGRESS   | AWS::S3::Bucket | StagingBucket Resource creation Initiated
 1/2 | 8:32:01 PM | CREATE_COMPLETE      | AWS::S3::Bucket | StagingBucket 
 2/2 | 8:32:03 PM | CREATE_COMPLETE      | AWS::CloudFormation::Stack | CDKToolkit 
 ✅  Environment aws://<account>/<region> bootstrapped.

 

次に、 cdk deploy してみたところ、デプロイが成功しました。

$ cdk deploy
<your_stack>: deploying...
<your_stack>: creating CloudFormation changeset...
 0/3 | 8:33:12 PM | CREATE_IN_PROGRESS   | AWS::IAM::User     | xxx (xxx) 
 0/3 | 8:33:12 PM | CREATE_IN_PROGRESS   | AWS::CDK::Metadata | CDKMetadata 
 0/3 | 8:33:13 PM | CREATE_IN_PROGRESS   | AWS::IAM::User     | xxx (xxx) Resource creation Initiated
 0/3 | 8:33:14 PM | CREATE_IN_PROGRESS   | AWS::CDK::Metadata | CDKMetadata Resource creation Initiated
 1/3 | 8:33:14 PM | CREATE_COMPLETE      | AWS::CDK::Metadata | CDKMetadata 

 ✅  <your_stack>

 

これより、AWS CDKを使っている場合は、S3のバケット CDKToolkit を削除してはいけないことが分かりました。

DjangoのListViewで、対象データがない場合は404ページを表示する

DjangoでListViewを使う場合、デフォルトではモデルにデータがない時は HTTP200 で、データがない状態で表示されます。

f:id:thinkAmi:20191029062825p:plain:w400

 
ただ、モデルにデータがない場合に HTTP404 を表示したい時はどうするか、調べたことをメモしておきます。

 

目次

 

環境

 

調査

公式ドキュメントにより、ListViewが継承している MultipleObjectMixin が持つ allow_empty を使えば良さそうでした。

 
stackoverflowにも、同じような回答がありました。
Django: get_object_or_404 for ListView - Stack Overflow  
 
そこで、以下のような

  • urls.py
  • models.py
  • todo_list.html

を用意し、 allow_empty の挙動を試してみます。

urls.py

urlpatterns = [
    path('', NotFoundListView.as_view(), name='todo_list'),
]

models.py

from django.db import models


class Todo(models.Model):
    title = models.CharField(max_length=100)
    content = models.CharField(max_length=255)

todo_list.html

<body>
<main class="container">
    <table>
        <thead>
        <tr>
            <th>ID</th>
            <th>タイトル</th>
            <th>内容</th>
        </tr>
        </thead>
        <tbody>
        {% for todo in todo_list %}
            <tr>
                <td>{{ todo.id }}</td>
                <td>{{ todo.title }}</td>
                <td>{{ todo.content }}</td>
        </tbody>
    </table>
    {% endfor %}
</main>
</body>

 

allow_empty=Trueの場合

使うListViewはこんな感じです。

from django.views.generic import ListView
from listview404.models import Todo


class NotFoundListView(ListView):
    allow_empty = True  # デフォルトのまま
    model = Todo

 

結果はこちら。HTTP200です。

f:id:thinkAmi:20191029062825p:plain:w400

 

allow_empty=Falseの場合

allow_empty のみ変更します。

from django.views.generic import ListView
from listview404.models import Todo


class NotFoundListView(ListView):
    allow_empty = False  # 変更
    model = Todo

 

HTTP404 が表示されました。

f:id:thinkAmi:20191029062859p:plain:w400

 

ソースコード

Githubに上げました。 listview404 が今回のアプリです。
https://github.com/thinkAmi-sandbox/Django22_generic_view-sample

AWS CDKで、cdk deployしたら「Unable to resolve AWS account to use」エラー

AWS CDKを使って cdk deploy したところ

$ cdk deploy
Unable to resolve AWS account to use. It must be either configured when you define your CDK or through the environment

というエラーが発生したため、対応した時のメモ。

 

環境

 

原因と対応

CDK CLIdoctor コマンドを使って確認したところ

$ cdk doctor
ℹ️ CDK Version: 1.9.0 (build 30f158a)
ℹ️ AWS environment variables:
  - AWS_SECRET_ACCESS_KEY = <redacted>
  - AWS_REGION = us-east-1
  - AWS_ACCESS_KEY_ID = AK<redacted>
  - AWS_S3_GATEWAY = http://http://s3.amazonaws.com
ℹ️ No CDK environment variables

と、自分のAWS CLIのconfigとは異なる値(AWS_REGION)や、設定していない値(AWS_S3_GATEWAY)が出てきました。

 
何か環境変数を指定したかなと思い確認したところ、

$ export -p
...
declare -x AWS_ACCESS_KEY_ID="AK***"
declare -x AWS_REGION="us-east-1"
declare -x AWS_S3_GATEWAY="http://http://s3.amazonaws.com"
declare -x AWS_SECRET_ACCESS_KEY="***"

と、別のところで使った時の環境変数を消し忘れているようでした。

 
そのため、 cdk doctor に出てきた環境変数を消しました。

$ unset AWS_S3_GATEWAY
$ unset AWS_REGION
$ unset AWS_SECRET_ACCESS_KEY
$ unset AWS_ACCESS_KEY_ID

 
再度実行したところ、問題なくデプロイできました。

$ cdk deploy
step-functions: deploying...
step-functions: creating CloudFormation changeset...
 0/2 | 8:12:22 PM | UPDATE_IN_PROGRESS   | AWS::CDK::Metadata               | CDKMetadata 

 ✅  step-functions

AWS CDK + Pythonで、ネストした AWS StepFunctions のワークフローを作ってみた

今年の7月にAWS CDK (Cloud Development Kit) がGAとなりました。
AWS クラウド開発キット (CDK) – TypeScript と Python 用がご利用可能に | Amazon Web Services ブログ

APIリファレンスも公開されているため、これでPythonを使ってAWSのリソースを作成することができるようになりました。
API Reference · AWS CDK

 
ただ、ドキュメントではTypeScriptの書き方がメインであり、Pythonでどう書くのかイマイチ分かりませんでした。

そこで、AWS CDK + Pythonで、ネストした AWS StepFunctions のワークフローを作ってみることにしました。

 
なお、記事の末尾にもあるように、ソースコードは公開しています。この記事はそのソースコードの解説です。

 

目次

 

環境

 
今回作成するStepFunctionsのワークフローです。

メインのStateMachineを実行します。サブのStateMachineを3パラレルで起動します。

f:id:thinkAmi:20190929163040p:plain:w300

 
サブの方は、各タスクにエラーハンドリングが付いています。

f:id:thinkAmi:20190929163143p:plain:w250

 
各StateMachineの機能です。

  • メイン
    • LambdaからS3へファイルを保存
    • サブのStateMachineを3パラレルで起動
  • サブ
    • Lambdaを実行

 

環境構築

Getting Startedに従い、環境を構築します。
Getting Started With the AWS CDK - AWS Cloud Development Kit (AWS CDK)

AWS CDKのCLIをインストールします。

$ npm install -g aws-cdk

$ cdk --version
1.9.0 (build 30f158a)

$ mkdir step_functions

$ cd step_functions/

 

cdk initPythonを使ったCDK環境を作成します。

$ cdk init --language python
Applying project template app for python
Executing Creating virtualenv...


# ちなみに、ディレクトリの中に何かファイルがあるとエラー
$ cdk init --language python
`cdk init` cannot be run in a non-empty directory!

 
Pythonの仮想環境が準備されるため、activate後にインストールします。

$ source .env/bin/activate

$ pip install -r requirements.txt

 
次にAWS CDKで使うリソースに対するモジュールを追加でインストールします。

どのモジュールが必要なのかは、APIリファレンスのトップに記載されています。*1

 
まずは、StepFunctionまわりで必要なモジュールをインストールします。

$ pip install aws_cdk.aws_stepfunctions

$ pip install aws_cdk.aws_stepfunctions_tasks

 
次にLambdaまわり。

$ pip install aws_cdk.aws_lambda

 
S3バケットにファイルを保存するため、S3のモジュールも必要です。

$ pip install aws_cdk.aws_s3

 
あとは、S3バケットにファイルを保存するために、IAMロールも必要になります。

$ pip install aws_cdk.aws_iam

 

作成順について

AWS CDKでは、下位のリソースから順に作成します。

今回は

  1. S3
  2. IAM Managed Policy
  3. IAM Role
  4. Lambda
  5. サブのStatemMachine (Step Functions)
  6. メインのStatemMachine (Step Functions)

の順で作成します。

 
Pythonでは、 core.Stack を継承したクラスの __init__() にて、作成したいリソースのオブジェクトを生成します。

class StepFunctionsStack(core.Stack):
    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        # S3バケットを生成
        self.bucket = self.create_s3_bucket()
        # 管理ポリシーを生成
        self.managed_policy = self.create_managed_policy()
        ...

 

S3バケットの作成

ドキュメントに従い、S3バケットを新規作成します。
class Bucket (construct) · AWS CDK

 
ドキュメントはTypeScript形式で書かれています。

new Bucket(scope: Construct, id: string, props?: Bucket<wbr>Props)

Pythonに読み替えた時の書き方です。

def create_s3_bucket(self):
    return Bucket(
        self,  # scope
        'S3 Bucket',  # id
        bucket_name=f'sfn-bucket-by-aws-cdk',  # propsのbucketName
    )

Pythonで読み替える時のポイントは以下です。

  • scopeは self で読み替え
  • 引数名は、snake_caseで読み替え
  • props にて指定可能なキーと値は、ドキュメントの Construct Props に記載

 
ちなみに、もし既存のS3バケットを利用したい場合は

  • fromBucketArn()
  • fromBucketAttributes()
  • fromBucketName()

などの静的メソッドを使います。

これにより、既存のS3バケットのオブジェクトが取得できるので、他のリソースでの指定が可能になります。

 

IAM Managed Policy (管理ポリシー)の作成

今回は、S3バケットにアクセスする管理ポリシーを作成します*2
class ManagedPolicy (construct) · AWS CDK

 
Managed Policyを作成するには、

  1. PolicyStatement
  2. ManagedPolicy

の順でオブジェクトを作成します。

 

PolicyStatementの作成

ドキュメントに従い作成します。
class PolicyStatement · AWS CDK

なお、resourcesでは、上記で作成したBucketのARNを参照する必要があります。

ドキュメントにあるように、Bucketオブジェクトのプロパティ bucket_arn でARNを参照します。
https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-s3.Bucket.html#properties

def create_managed_policy(self):
    statement = PolicyStatement(
        effect=Effect.ALLOW,
        actions=[
            "s3:PutObject",
        ],
        resources=[
            f'{self.bucket.bucket_arn}/*',
        ]
    )

 

ManagedPolicyの作成

こちらもドキュメント通りです。
class ManagedPolicy (construct) · AWS CDK

return ManagedPolicy(
    self,
    'Managed Policy',
    managed_policy_name='sfn_lambda_policy',
    statements=[statement],
)

 

IAMロールを作成

次に、LambdaからS3バケットへアクセスするためのIAMロールを作成します。

まずは、 ServicePrincipal オブジェクトでLambdaを指定します。
class ServicePrincipal · AWS CDK

def create_role(self):
    service_principal = ServicePrincipal('lambda.amazonaws.com')

 
次に、 Role オブジェクトを作成します。
class Role (construct) · AWS CDK

ポイントは以下です。

  • assumed_by に、ServicePrincipalオブジェクトを指定
  • managed_policies に、作成したManaged Policyを指定
return Role(
    self,
    'Role',
    assumed_by=service_principal,
    role_name='sfn_lambda_role',
    managed_policies=[self.managed_policy],
)

 

Lambdaの作成

今回は4つのLambdaを作成します。

Lambda名 用途
sfn_first_lambda S3にファイルを保存
sfn_second_lambda サブStateMachineの1番目のLambda
sfn_third_lambda サブStateMachineの2番目のLambda
sfn_error_lambda sfn_second_lambda・sfn_third_lambdaでエラーが起きた時に実行されるLambda

 
なお、CDKで使うLambda本体は、どこかのディレクトリに入れておけばOKです。

CDKのFunctionオブジェクトを生成する際に、そのディレクトリパスを指定することで、CDKがzip化・アップロードまで面倒を見てくれます。

 

S3にファイルを保存するLambdaを作成
Lambda本体

lambda_function/first/lambda_function.py として作成します。

ポイントは以下です。

  • boto3でS3へアップロードする
  • NumPyを使って値を取得する
  • 環境変数 BUCKET_NAME で、保存先のS3バケット名を受け取る
  • Lambdaのパラメータとして message が渡されてくる
    • StateMachineのInputより渡されることを想定
  • 戻り地は、 bodymessage を持つdict
import os
import boto3
from numpy.random import rand


def lambda_handler(event, context):
    body = f'{event["message"]} \n value: {rand()}'
    client = boto3.client('s3')
    client.put_object(
        Bucket=os.environ['BUCKET_NAME'],
        Key='sfn_first.txt',
        Body=body,
    )

    return {
        'body': body,
        'message': event['message'],
    }

 

CDKでLambdaリソース(Function)を作成

まずは、Lambda本体のあるファイルパスを使って、AssetCodeオブジェクトを生成します。
class AssetCode · AWS CDK

function_path = str(self.lambda_path_base.joinpath('first'))
code = AssetCode(function_path)

 
次にNumPyを用意します。

Lambdaでは、NumPyのようによく使われるモジュールは Lambda Layers として用意されていますので、今回もこちらを利用します。
新機能 – AWS Lambda :あらゆるプログラム言語への対応と一般的なコンポーネントの共有 | Amazon Web Services ブログ

 
LayerVersionオブジェクトにて、既存のLambda Layersを扱えます。
class LayerVersion (construct) · AWS CDK

今回はARNを指定してNumPyのLayerを取得するため、静的メソッド from_layer_version_arn() を使います。

scipy_layer = LayerVersion.from_layer_version_arn(
    self, f'sfn_scipy_layer_for_first', AWS_SCIPY_ARN)

なお、Lambda LayersのARNについては、AWSアカウントごとに異なるようです。

そのため、一度、Lambda Consoleにて、該当LayerのARNを確認する必要があります。

 
最後に、FunctionオブジェクトでLambdaリソースを生成します。
class Function (construct) · AWS CDK

return Function(
    self,
    f'id_first',
    # Lambda本体のソースコードがあるディレクトリを指定
    code=code,
    # Lambda本体のハンドラ名を指定
    handler='lambda_function.lambda_handler',
    # ランタイムの指定
    runtime=Runtime.PYTHON_3_7,
    # 環境変数の設定
    environment={'BUCKET_NAME': self.bucket.bucket_name},
    function_name='sfn_first_lambda',
    layers=[scipy_layer],
    memory_size=128,
    role=self.role,
    timeout=core.Duration.seconds(10),
)

 
ちなみに、Layerを自分で作成したい場合は以下のようにします。

Codeオブジェクトのリファレンスは以下です。
class Code · AWS CDK

LayerVersion(
    self,
    'layer_id',
    code=Code.from_asset('your_zip_filepath'),
    compatible_runtimes=[Runtime.PYTHON_3_7],
    layer_version_name='layer_version_name',
)

 

残りのLambda
2番目のLambda本体

エラーハンドリングしたいため、パラレル番号が偶数の場合はエラーとしています。

また、 result_path で結果をわかりやすく表示したいため、戻り値も絞っています。

def lambda_handler(event, context):
    if event['parallel_no'] % 2 == 0:
        raise Exception('偶数です')

    return {
        'message': event['message'],
        'const_value': event['const_value']
    }

 

3番目のLambda本体

こちらもエラーハンドリングしたいため、パラレル番号が1の場合はエラーとしています。

また、最後は文字列を返すようにしました。

def lambda_handler(event, context):
    if event['parallel_no'] == 1:
        raise Exception('強制的にエラーとします')

    return 'only 3rd message.'

 

エラーハンドリングのLambda本体

タスクでエラーが発生した場合は

{
  "resource": "arn:aws:lambda:region:id:function:sfn_error_lambda",
  "input": {
    "Error": "Exception",
    "Cause": "{\"errorMessage\": \"\\u5076\\u6570\\u3067\\u3059\",
               \"errorType\": \"Exception\",
               \"stackTrace\": [\"  File \\\"/var/task/lambda_function.py\\\", line 5,
                  in lambda_handler\\n    raise Exception('\\u5076\\u6570\\u3067\\u3059')
              \\n\"]}"
  },
  "timeoutInSeconds": null
}

という値が渡されてきます。

この中の CauseJSON文字列のため、Lambdaで見えるようにして返します。

def lambda_handler(event, context):
    return {
        # JSONをPythonオブジェクト化することで、文字化けを直す
        'error_message': json.loads(event['Cause']),
    }

 

Functionオブジェクト

ほぼ同じ内容なので一つのメソッドで生成しています。

def create_other_lambda(self, function_name):
    function_path = str(self.lambda_path_base.joinpath(function_name))

    return Function(
        self,
        f'id_{function_name}',
        code=AssetCode(function_path),
        handler='lambda_function.lambda_handler',
        runtime=Runtime.PYTHON_3_7,
        function_name=f'sfn_{function_name}_lambda',
        memory_size=128,
        timeout=core.Duration.seconds(10),
    )
    
# 使う時
self.second_lambda = self.create_other_lambda('second')
self.third_lambda = self.create_other_lambda('third')
self.error_lambda = self.create_other_lambda('error')

 

サブのStateMachine (Step Functions) 作成

概要 (InputPath・OutputPath・ResultPathを試す)

ここまででパラレル実行する、サブのStateMachineのリソースが用意できました。

それらを組み合わせて、サブのStateMachineを作成していきます。

 
StateMachineでは、タスクという単位で処理を定義します。
タスク - AWS Step Functions

サブのStateMachineでは3つのタスクを用意します。

今回、InputPath・OutputPath・ResultPathを試そうと考えました。

 
そこで、こんな感じの設定にしました。

タスク名 Lambda InputPath ResultPath OutputPath
Second Task sfn_second_lambda $['first_result', 'parallel_no', 'message', 'context_name', 'const_value] $.second_result' ['second_result', 'parallel_no']
Third Task sfn_third_lambda $ (Lambdaの戻り値で上書き)
Error Task sfn_error_lambda

 
Second Taskでは、

  • InputPath
    • 前のタスクの結果から 'first_result', 'parallel_no', 'message', 'context_name', 'const_value だけ受け取って処理する
  • OutPath
    • Lambdaの戻り値を、入力値に second_result という項目を追加する
  • ResultPath
    • 次のタスクには 'second_result', 'parallel_no' のみ渡す

とします。

 
また、Third Taskでは、

  • ResultPath
    • 入力値をすべて捨て、Lambdaの戻り値だけを出力する

とします。

 

実際のソースコード

まず、複数タスクで使うため、1つのエラータスクを作成します。

Lambdaを起動するためにはInvokeFunctionクラスを使います。引数としてLambdaオブジェクトを設定します。
class InvokeFunction · AWS CDK

error_task = Task(
    self,
    'Error Task',
    task=InvokeFunction(self.error_lambda),
)

 
次に、Second Taskを生成します。

InputPath・OutputPath・ResultPathに対応する引数があるため、それぞれ指定します。

second_task = Task(
    self,
    'Second Task',
    task=InvokeFunction(self.second_lambda),

    # 渡されてきた項目を絞ってLambdaに渡す
    input_path="$['first_result', 'parallel_no', 'message', 'context_name', 'const_value]",

    # 結果は second_result という項目に入れる
    result_path='$.second_result',

    # 次のタスクに渡す項目は絞る
    output_path="$['second_result', 'parallel_no']"
)

 
Second Taskの中でエラーが発生した場合のハンドリングをするため、 add_catch でエラーが発生した時のタスクを追加します。
エラー処理 - AWS Step Functions

second_task.add_catch(error_task, errors=['States.ALL'])

 
Third Taskを作成します。こちらもエラーハンドリングを追加します。

third_task = Task(
    self,
    'Third Task',
    task=InvokeFunction(self.third_lambda),

    # third_lambdaの結果だけに差し替え
    result_path='$',
)
# こちらもエラーハンドリングを追加
third_task.add_catch(error_task, errors=['States.ALL'])

 
次に、Second Taskの後にThird Taskを実行できるよう、 next() にて設定します。

definition = second_task.next(third_task)

 
最後に、StateMachineを作成します。

return StateMachine(
    self,
    'Sub StateMachine',
    definition=definition,
    state_machine_name='sfn_sub_state_machine',
)

 

メインのStateMachine (Step Functions) の作成

最後のリソースとして、メインのStateMachineを作成します。

 
まずは First Task を作成します。ここは先程と同じです。

first_task = Task(
    self,
    'S3 Lambda Task',
    task=InvokeFunction(self.first_lambda, payload={'message': 'Hello world'}),
    comment='Main StateMachine',
)

 
次のタスクがパラレル実行するサブのStateMachineです。

最初にParallelオブジェクトを生成します。
class Parallel (construct) · AWS CDK

parallel_task = Parallel(
    self,
    'Parallel Task',
)

 
次に、パラレル実行の設定を行います。流れは以下となります。

  1. StartExecution オブジェクトで、使用するStateMachineやInputなどを指定します。
    class StartExecution · AWS CDK

  2. StartExecutionTask に渡します。

  3. TaskParallel.branch() に渡します。

for i in range(1, 4):
    # 1.
    sub_task = StartExecution(
        self.sub_state_machine,
        input={
            'parallel_no': i,
            'first_result.$': '$',

            # first_taskのレスポンスにある、messageをセット
            'message.$': '$.message',

            # コンテキストオブジェクトの名前をセット
            'context_name.$': '$$.State.Name',
            # 固定値を2つ追加(ただ、Taskのinputでignore_valueは無視)
            'const_value': 'ham',
            'ignore_value': 'ignore',
        },
    )

    # 2.
    invoke_sub_task = Task(
        self,
        f'Sub Task {i}',
        task=sub_task,
    )

    # 3.
    parallel_task.branch(invoke_sub_task)

 

以上でStepFunctionsが完成しました。

 

$と$$について

上記例では、

  • 'message.$': '$.message'
  • 'context_name.$': '$$.State.Name'

としている部分がありました。

$$$ については、それぞれ以下の意味となります。

$ について

パスを使用して値を選択するキーと値のペアの場合、キーの名前は .$ で終わる必要があります。 InputPath およびパラメータ - AWS Step Functions

 
$$ について

コンテキストオブジェクトにアクセスするには、パスを使用して状態の入力を選択したときと同様に、.$ を末尾に追加したパラメータ名をまず指定します。次に、入力の代わりにコンテキストオブジェクトデータにアクセスするには、$$. をパスの先頭に追加します。

コンテキストオブジェクト - AWS Step Functions

 

実行結果

ではStepFunctionsのConsoleより実行してみます。

 

メインのStateMachine

f:id:thinkAmi:20190929170656p:plain:w300

S3 Lambda TaskのLambdaFunctionScheduled

{
  "resource": "arn:aws:lambda:region:account_id:function:sfn_first_lambda",
  "input": {
    "message": "Hello world"
  },
  "timeoutInSeconds": null
}

S3 Lambda TaskのTaskStateExited

{
  "name": "S3 Lambda Task",
  "output": {
    "body": "Hello world \n value: 0.035671270119142284",
    "message": "Hello world"
  }
}

 

サブのStateMachine

3パターンあるため、それぞれ記載します。

  • すべて成功
  • Second Taskでエラー
  • Third Taskでエラー
すべて成功

f:id:thinkAmi:20190929171108p:plain:w300

Second TaskのLambdaFunctionScheduledでは、設定した

input_path="$['first_result', 'parallel_no', 'message', 'context_name', 'const_value']",

の通りのinputとなっています。

{
  "resource": "arn:aws:lambda:region:account_id:function:sfn_second_lambda",
  "input": {
    "first_result": {
      "body": "Hello world \n value: 0.035671270119142284",
      "message": "Hello world"
    },
    "parallel_no": 3,
    "message": "Hello world",
    "context_name": "Sub Task 3",
    "const_value": "ham"
  },
  "timeoutInSeconds": null
}

 
Second TaskのTaskStateExitedでも、

result_path='$.second_result',
output_path="$['second_result', 'parallel_no']"

の通りのoutputです。

{
  "name": "Second Task",
  "output": {
    "second_result": {
      "message": "Hello world",
      "const_value": "ham"
    },
    "parallel_no": 3
  }
}

 

Third TaskのLambdaFunctionScheduledはこんな感じ。Secondのoutputを引き継いでいます。

{
  "resource": "arn:aws:lambda:region:account_id:function:sfn_third_lambda",
  "input": {
    "second_result": {
      "message": "Hello world",
      "const_value": "ham"
    },
    "parallel_no": 3
  },
  "timeoutInSeconds": null
}

 

Third TaskのTaskStateExited

result_path='$',通り、outputはLambdaの戻り値の文字列だけになっています。

{
  "name": "Third Task",
  "output": "only 3rd message."
}

 

Second Taskでエラー

f:id:thinkAmi:20190929172018p:plain:w300

Second TaskのLambdaFunctionScheduledは以下の通り(抜粋)。

{
  "input": {
    "parallel_no": 2,
    "context_name": "Sub Task 2",
  },
}

 
Error TaskのTaskStateExitedを確認します。日本語がそのまま表示されています。

{
  "name": "Error Task",
  "output": {
    "error_message": {
      "errorMessage": "偶数です",
      "errorType": "Exception",
      "stackTrace": [
        "  File \"/var/task/lambda_function.py\", line 3, in lambda_handler\n    raise Exception('偶数です')\n"
      ]
    }
  }
}

 

Third Taskでエラー

f:id:thinkAmi:20190929172322p:plain:w300

Third TaskのLambdaFunctionScheduledは以下の通り(抜粋)。

{
    "parallel_no": 1
  },
}

 
Error TaskのTaskStateExitedを確認します。日本語がそのまま表示されています。

{
  "output": {
    "error_message": {
      "errorMessage": "強制的にエラーとします",
      "errorType": "Exception",
      "stackTrace": [
        "  File \"/var/task/lambda_function.py\", line 3, in lambda_handler\n    raise Exception('強制的にエラーとします')\n"
      ]
    }
  }
}

 

削除

cdk destroy にて削除できます。

$ cdk destroy
Are you sure you want to delete: step-functions (y/n)? y
...
 ✅  step-functions: destroyed

 

ただ、上記の方法で作成したS3は、removalpolicyの値により削除されません。
https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-s3.Bucket.html#removalpolicy

そのため、手動で削除するか、作成する時に以下のような設定を行います。
AWS CloudFormationでStackを削除したときにリソースを消さない設定 | DevelopersIO

 

動的並列処理について

先日、StepFunctionsでの動的並列処理がサポートされました。
AWS Step Functions がワークフローでの動的並列処理のサポートを追加

 
CDKでは、以下のissueがcloseとなれば利用できそうです。

 

ソースコード

GitHubに上げました。 step_functions ディレクトリが今回のファイルです。
https://github.com/thinkAmi-sandbox/AWS_CDK-sample

*1:例えばLambdaの場合は、 aws_cdk.aws_lambda が必要です:https://docs.aws.amazon.com/cdk/api/latest/docs/aws-lambda-readme.html

*2:もし、ポリシーを使う場合はこちら:https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-iam.Policy.html