ItemsControlを使ってWPFでGitHub風ヒートマップを描画する

WPFアプリで日々の実績を図で表現したいと思い、GitHubのContribution Graph(芝生)を参考に描画してみたのでその時の記録を残しておきます。外部データを参照し動的に描画できるように繰り返し描画に便利な ItemsControlを利用しました。xamlはWPFの素の機能だけで書いていますが、裏側はMVVMパターンで実装できるPrismというフレームワークを利用しています。

環境

使用しているプラットフォームやフレームワークは以下の通りです。

実装:xaml

メインテーマであるItemsControlはこんな感じです。

<ItemsControl ItemsSource="{Binding WeekHeatmaps}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <StackPanel Orientation="Horizontal" />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <ItemsControl.ItemTemplate>
        <DataTemplate>

            <ItemsControl ItemsSource="{Binding HeatmapElements}">
                <ItemsControl.ItemsPanel>
                    <ItemsPanelTemplate>
                        <StackPanel Orientation="Vertical" />
                    </ItemsPanelTemplate>
                </ItemsControl.ItemsPanel>
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <Grid
                            Width="15"
                            Height="15"
                            ToolTip="{Binding Comment}">
                            <Border
                                Margin="1"
                                Background="{Binding Color}"
                                CornerRadius="2" />
                        </Grid>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>

        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

ちょっと説明

ItemsControlを入れ子にしています。外側が芝生の列を表していて、実績データでいう1週間分に相当する部分です。横方向に連ねていくため、水平方向(Horizontal)に伸びるStackPanelを使います。内側は芝生の一つのマスを表していて、実績データでいう1日分に相当する部分です。縦方向に連ねていくため、垂直方向(Vertical)に伸びるStackPanelを使います。どちらのItemsControlも中身のカスタマイズにItemTemplateを利用しています。

実装:バインディングするデータ

バインディングしているデータWeekHeatmapsですが、 ViewModel内で以下のようなリストとして定義しています。

public class MainWindowViewModel : BindableBase
{
    ...

    public List<WeekHeatmap> WeekHeatmaps { get; }

    ...
}

リストの型として使っているWeekHeatmapクラスと関連クラスは以下の通りです。

public class WeekHeatmap
{
    public WeekHeatmap(List<HeatmapElement> heatmapElements)
    {
        if (heatmapElements.Count > 7) throw new ArgumentOutOfRangeException(nameof(heatmapElements), "ヒートマップの数が7を超えています。");
        HeatmapElements = heatmapElements;
    }

    public List<HeatmapElement> HeatmapElements { get; }
}
public class HeatmapElement
{
    public HeatmapElement(string color, string comment)
    {
        Color = color;
        Comment = comment;
    }

    public string Color { get; }
    public string Comment { get; }
}

ちょっと説明

芝生のマスの情報はHeatmapElementクラスに持たせています。芝生の色を表す文字列とマウスオーバーした時に表示されるTooltip用の文字列をプロパティとしています。芝生縦一列の一週間分の情報はWeekHeatmapクラスに持たせています。HeatmapElementのリストをプロパティとしています。機能には関係ないですが、一週間分という性質を補強するためリストの長さが7以上だった場合はエラーになるようにしています。

実行

サンプルのデータを代入し実行すると以下の画像ように芝生を描画できました。補足情報として赤文字でどの情報がどこを表しているか記しています。

実行画面。赤文字は後付け

参考

コード全体はGitHubにアップロードしています。リポジトリをcloneしてきたらVisualStudioでGitHubStyleHeatmap.slnを開いてF5実行すると上に記載した画像のウィンドウが表示されます。

まとめと感想

WPFでGitHubのContribution Graph(芝生)風のグラフを描画してみました。グラフを動的に生成するためにItemsControlを利用しました。StackPanelを入れ子にすることで芝生の行と列を表現しました。ItemsControlのItemTemplateを使ってマスの描画方法を指定しています。今回の取り組みでItemsControlの柔軟性を実感しました。特に入れ子にすることでリストの中身にもItemsControlが使えるのは表現の幅が広がっていいですね。また、バインディングするデータに自作クラスが使えるので工夫次第でいろんなリスト情報を見やすく描画するためにも使えると思います。発展として、WeekHeatmapクラスに「概要」プロパティを追加することで芝生の上部に情報を描画させることができるかもしれません。GitHubのように月を表示できそうです。

Unityで作ったゲームをWebGLビルドしてAzure Static Web Appsで公開する

Unityで作ったゲームを気軽に遊べるようにしたいと考えていたところ、Azureに静的Webサイトを簡単に無料でデプロイできるサービス(Static Web Apps)があったので使ってみました。

ちなみにサンプルのゲームにはUnity勉強でお世話になっている本「Unity2021 3D/2Dゲーム開発実践入門」のCHAPTER-3で作成するゲーム「Illumiball」を利用しています。

必要なもの

この記事の内容を試すためには以下の準備が必要です。

  • Unity HubでWebGL Build Supportモジュールをインストールしておく
  • Azureアカウント
  • GitHubアカウント

記事の構成

本記事は以下の2部構成で書いています。

  1. UnityでWebGLビルドする
  2. Azure Static Web Appsで公開する

1. UnityでWebGLビルドする

まずはUnityでの操作です。

圧縮を無効化する

デフォルトの設定では生成されるJavaScriptファイルが圧縮されているのですが、恐らくStatic Web Appsが対応していないことによりゲームが動作しないので、圧縮を無効化します。

  • [Edit] -> [Project Settings…] とクリックし、プロジェクト設定ダイアログを開く
  • [Player]メニューを選択し、WebGLのアイコンのタブを開く
  • [Publishing Settings] の [Compression Format] を [Disabled] にする

ビルドする

ビルドしてWebコンテンツを生成します。

  • [File] -> [Build Settings…] とクリックし、ビルド設定ダイアログを開く
  • [Platform] で [WebGL] を選択し、 [Switch Platform] をクリックする
  • プラットフォームの切り替えが完了したら、 [Build] をクリックする
  • フォルダ選択のダイアログが開くので、Webコンテンツを生成してほしいフォルダを指定して [フォルダーの選択] をクリックする

2. Azure Static Web Appsで公開する

先ほど生成したWebコンテンツを公開します。

GitHubにWebコンテンツをアップロードする

Static Web Apps は基本的にGitHubとの連携を前提としているのでまずはWebコンテンツをGitHubにアップロードします。

Static Web Appsを作成する

いよいよ最後の作業です。

  • Azureポータルから [Static Web Apps] を選択し、 [Create] をクリックする
  • 以下の画像を参考に情報を入力する。特筆する点は以下の通り
    • 個人の開発なら [Plan type] はFreeを選べる
    • [Azure Functions and staging details] は地理的に近そうな [East Asia] を選ぶ
    • [Deployment details] は [GitHub] にして、 [Sign in with GitHub] からGitHubアカウントを認証する
    • その後、先ほど作成したGitHubリポジトリを指定する
    • [Build Presets] は [Custom] にする
  • [Review + create] をクリックし、リソースを作成する
  • 少し待つとGitHub Actionsのワークフローが動き出し、WebコンテンツがSite Web Appsにデプロイされる

確認

Static Web Appsのリソースページから [Browse] をクリックし、ゲーム画面が表示されれば成功です。

ASP.NET Core APIでリストを一括更新したいときはAutoMapperが便利だった

APIで扱うデータの中にリストが含まれる場合に更新(PUT)でリストを一括で対応してほしいとき、EntityFrameworkの追跡機能とAutoMapperを使えば楽に実装できた、という話です。

例えば GET した時が以下のようなデータとします。

GET /api/TodoItems/1
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 1,
            "name": "Sub task1",
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 2,
            "name": "Sub task2",
            "isComplete": false,
            "todoItemId": 1
        },
    ]
}

このような場合PUT の時は TodoSubItems に追加、変更、削除があってもそのまま上書きしてもらいたいということです。

PUT /api/TodoItems/1
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 2,
            "name": "Sub task2 (updated)"
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 0,
            "name": "New task"
            "isComplete": false,
            "todoItemId": 1
        }
    ]
}

この例では TodoSubItem の1を削除、2を編集、一つを新たに追加しています。

補足: サブタスクでAPIのエンドポイント作ればいいんじゃない?

確かに TodoItems と TodoSubItems でエンドポイントを分ける設計もあります。ですが今回サブタスクはメインタスクに従属するもので、サブタスクの変更もメインタスクの更新時に行いたい、というユースケースを想定しています。例えば、伝票と伝票明細の関係、記事と記事タグなど、1対多の関係で1の属性の一つとして多(リスト)を持っている場合には実用的なパターンかと思っています。

準備

今回はデータベースにSQL Serverを利用します。今はDockerで簡単にSQL Serverを起動できます。

docker run -e "ACCEPT_EULA=Y" -e "SA_PASSWORD=<your_strong_password>" -p 1433:1433 --name todosubapi -d mcr.microsoft.com/mssql/server:2019-latest

たったこれだけ。いい時代になりました。

実装

ここから実装していきます。サンプルとして公式サイトのチュートリアルにあるToDoアプリを使います。このアプリにToDoタスクが複数のサブタスクを持てる機能を追加します。基本的にチュートリアル通りに進めて、サブタスク機能を実現するのに必要な最低限の実装を付け足す方針です。

Webプロジェクトの作成

公式チュートリアル通りです。詳細な作成方法は割愛します。プロジェクト名は「 TodoSubApi 」としました。

テスト

初期状態で正常に起動するか確認します。

  • Ctrl + F5 を押して実行する
  • Webブラウザが起動しSwaggerの画面が表示されることを確認する

モデルクラスの追加

基本公式チュートリアル通りですが、サブタスクを表す TodoSubItem を追加します。

  • ソリューションエクスプローラーを右クリックして [追加] -> [新しいフォルダー] を選択する
  • 「 Models 」という名前をつける
  • Models フォルダーを右クリックして [追加] -> [クラス] を選択する
  • クラスに「 TodoItem 」という名前をつける
  • 同様に Models 配下に「 TodoSubItem 」というクラスをつくる
  • TodoItem.cs と TodoSubItem.cs を以下のように作成する
namespace TodoSubApi.Models
{
    public class TodoItem
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public bool IsComplete { get; set; }
        public List<TodoSubItem> TodoSubItems { get; set; }
    }
}
namespace TodoSubApi.Models
{
    public class TodoSubItem
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public bool IsComplete { get; set; }
        public long TodoItemId { get; set; }
    }
}
ちょっと説明

TodoItem クラスの TodoSubItems プロパティはコレクションナビゲーションプロパティと呼ばれるものです。TodoSubItem クラスの TodoItemId は外部キーです。(参照

データベースコンテキストの追加

公式チュートリアルではインメモリデータベースを採用していますが、今回はSQL Serverで試すので、NuGetパッケージ「 Microsoft.EntityFrameworkCore.SqlServer 」を追加します。その後以下のようにデータベースコンテキストを追加します。

  • Models フォルダーを右クリックして [追加] -> [クラス] を選択する
  • クラスに「 TodoContext 」という名前をつける
  • TodoContext.cs を以下のように作成する
using Microsoft.EntityFrameworkCore;

namespace TodoSubApi.Models
{
    public class TodoContext : DbContext
    {
        public TodoContext(DbContextOptions<TodoContext> options)
            :base(options)
        {
        }

        public DbSet<TodoItem> TodoItems { get; set; }
        public DbSet<TodoSubItem> TodoSubItems { get; set; }
    }
}

データベースコンテキストの登録

公式チュートリアルとは異なり、SQL Serverを使う形に Startup.cs を書き換えます。

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<TodoContext>(opt =>
        opt.UseSqlServer("Server=tcp:127.0.0.1,1433;Initial Catalog=todoapi;User ID=sa;Password=<your_strong_password>;Persist Security Info=False;"));

    ...
}

マイグレーションとテーブル作成

この作業は公式チュートリアルにはありません。今回はSQL Serverを使うので事前にSQL Serverにテーブルを作成しておきます。

  • Visual Studioの [パッケージマネージャーコンソール] を開く
  • 以下のコマンドを実行し、マイグレーションを作成する
PM> Add-Migration InitialCreate
Build started...
Build succeeded.
To undo this action, use Remove-Migration.
  • プロジェクト配下に Migrations フォルダーがあることを確認する
  • 以下のコマンドを実行し、データベースを更新する
PM> Update-Database
Build started...
Build succeeded.
Done.

コントローラーのスキャフォールディング

公式チュートリアル通りです。詳細な方法は割愛しますがスクリーンショットだけ載せておきます。

PostTodoItem 作成メソッドの更新

公式チュートリアル通りです。

GetTodoItems メソッドとGetTodoItem メソッドの更新

これは公式チュートリアルにはありません。GET してきたときに TodoSubItems も取得できるように処理を追加します。

  • TodoItemsController.cs を以下のように編集する
[HttpGet]
public async Task<ActionResult<IEnumerable<TodoItem>>> GetTodoItems()
{
    return await _context.TodoItems.Include(x => x.TodoSubItems).ToListAsync();
}

...
[HttpGet("{id}")]
public async Task<ActionResult<TodoItem>> GetTodoItem(long id)
{
    var todoItem = await _context.TodoItems.Include(x => x.TodoSubItems).FirstOrDefaultAsync(x => x.Id == id);

    if (todoItem == null)
    {
        return NotFound();
    }

    return todoItem;
}
ちょっと説明

データコンテキストからデータを取得する処理に Include メソッドを追加して TodoSubItems を読み込んでいます。

PutTodoItem メソッドの更新

この記事のメインです。データ更新時に TodoSubItems の中身を自由に変えても適切に更新してもらうためAutoMapperを使う方法に変えます。

  • NuGetパッケージ「 AutoMapper 」を追加する
  • まず TodoItemsController.cs のコンストラクタにAutomapperの定義と設定を書く
private readonly IMapper _mapper;

...

public TodoItemsController(TodoContext context)
{
    _context = context;

    var config = new MapperConfiguration(cfg =>
    {
        cfg.CreateMap<TodoItem, TodoItem>();
        cfg.CreateMap<TodoSubItem, TodoSubItem>();
    });
    _mapper = config.CreateMapper();
}
  • PutTodoItem メソッドを書き換える
[HttpPut("{id}")]
public async Task<IActionResult> PutTodoItem(long id, TodoItem todoItem)
{
    if (id != todoItem.Id)
    {
        return BadRequest();
    }

    // コメントアウトする
    //_context.Entry(todoItem).State = EntityState.Modified;

    try
    {
        var found = _context.TodoItems.Include(t => t.TodoSubItems).FirstOrDefault(x => x.Id == id);

        _mapper.Map(todoItem, found);

        _context.TodoItems.Update(found);

        await _context.SaveChangesAsync();
    }
    catch (DbUpdateConcurrencyException)
    {
        if (!TodoItemExists(id))
        {
            return NotFound();
        }
        else
        {
            throw;
        }
    }

    return NoContent();
}
ちょっと説明

UPDATE処理のメインは try 句の中です。一度データベースから該当するIDのデータを持ってきて、更新したいデータをAutoMapperで上書きします。Entity Frameworkの追跡機能が変更箇所を判別してくれるのでいい感じにデータ更新してくれます。正直この辺の仕組みは完全には理解できていないですが、とりあえず想定していた動きをしてくれます。

Postman を使ってテスト

最後にAPIが想定通り動作するか Postmanを使って確かめます。

  • Ctrl + F5 で起動する
  • 作成 (POST)。Postmanで以下のクエリを送信する
    • ※:ポート番号は環境によって異なります。
POST: https://localhost:44366/api/TodoItems
{
    "id": 0,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 0,
            "name": "Sub task1",
            "isComplete": false,
            "todoItemId": 0
        },
        {
            "id": 0,
            "name": "Sub task2",
            "isComplete": false,
            "todoItemId": 0
        }
    ]
}
  • レスポンス例
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 1,
            "name": "Sub task1",
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 2,
            "name": "Sub task2",
            "isComplete": false,
            "todoItemId": 1
        }
    ]
}
  • 取得 (GET)。Postmanで以下のクエリを送信する
GET: https://localhost:44366/api/TodoItems/1
  • レスポンス例
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 1,
            "name": "Sub task1",
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 2,
            "name": "Sub task2",
            "isComplete": false,
            "todoItemId": 1
        }
    ]
}
  • 更新 (PUT)。Postmanで以下のクエリを送信する
    • ※:サブタスク1を削除、サブタスク2を変更、新しいサブタスクの一つ追加した、という想定です。
PUT: https://localhost:44366/api/TodoItems/1
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 2,
            "name": "Sub task2 (updated)",
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 0,
            "name": "New task",
            "isComplete": false,
            "todoItemId": 1
        }
    ]
}
  • 再取得 (GET)。Postmanで以下のクエリを送信する
GET: https://localhost:44366/api/TodoItems/1
  • レスポンス例
{
    "id": 1,
    "name": "Task1",
    "isComplete": false,
    "todoSubItems": [
        {
            "id": 2,
            "name": "Sub task2 (updated)",
            "isComplete": false,
            "todoItemId": 1
        },
        {
            "id": 3,
            "name": "New task",
            "isComplete": false,
            "todoItemId": 1
        }
    ]
}

サブタスクの削除、変更、追加が全て反映されているのが分かります。

まとめと感想

APIのデータの中にリスト(配列)を設けた場合の実装方法をまとめました。リスト自体をAPIのエンドポイントにして一つずつCRUDさせる設計もありますが、リストが完全に親の要素に従属している場合は今回のような設計のほうが簡単に利用できるので、ここで書いた内容が役立つ場面は意外とあると思います。この記事では書きませんでしたが、実際のAPIのモデルクラスとDBに記録するデータモデルクラスに同一のものを使う必要はありません。今回のサンプルだとサブタスクの外部キー TodoItemId はAPIに含める必要がない項目ですし、サブタスクのIDも本質的には不要です。そのため実際にはモデルクラスとデータモデルクラスを分けて定義します。その場合はクラス間のデータ変換方法が課題になりますが、やはりAutoMapperが便利なのでこの記事の方法と同じようなAutoMapperを使った実装で課題に対応できます。

ASP.NET Core SignalR でルーム付きチャットアプリを作ってみた

最近SignalRが気になったので公式チュートリアルやってみました。
分かりやすくていいんだけどリアルタイムアプリにありがちなチャットルーム毎に通信を分ける機能がなかったので自分で実装してみました。
余計な機能はつけずに、最低限ルーム内通信が成り立つ程度のシンプルな実装にしています。
尚、ルーム内通信を実現するのにSignalRのグループ機能を利用しています。
実装済みのコードはGitHubに上げてます。

バージョン

  •  Visual Studio 2019
  • .NET 5.0

プロジェクト作成

基本公式チュートリアル 通りです。

  • Visual Studioで新しいプロジェクトを作成する
  • 「ASP.NET Core Webアプリ」を選択する
    • ※同じVS2019でもインストール環境によっては名称が少し違うことがあります。
  • プロジェクト名は「SignalRGroupChat」とした

とりあえず実行

プロジェクトが作成出来たら、まずは初期状態で動くことを確認するため「F5」キーを押して実行します。

SignalRクライアントライブラリを追加する

公式チュートリアル 通りです。

  • ソリューションエクスプローラーで、プロジェクトを右クリックし、[追加] -> [クライアント側のライブラリ]を選択する
  • [クライアント側のライブラリを追加します]ダイアログで以下の通り選択する
    • プロバイダー: unpkg
    • ライブラリ:@microsoft/signalr@latest
    • [特定のファイルの選択]を選択する
    • dist/browser フォルダを展開し、 signalr.jssignalr.min.js を選択する
    • [ターゲットロケーション]に wwwroot/js/signalr/ と入力する
    • [インストール]をクリック

SignalRハブを作成する

基本は公式チュートリアル 通りだけど、ハブに実装するコードについてはグループ機能に関する処理に書き換えます。

  • プロジェクト直下に Hubs フォルダを作成する
  • Hubs フォルダ内に ChatHub.cs を作成し、以下のように編集する
using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;

namespace SignalRGroupChat.Hubs
{
    public class ChatHub : Hub
    {
        public async Task SendMessageToGroup(string group, string user, string message)
        {
            await Clients.Group(group).SendAsync("ReceiveMessage", user, message);
        }

        public async Task AddToGroup(string groupName)
        {
            await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
        }
    }
}
ちょっと説明
  • SendMessageToGroup 関数について
    • チュートリアルでは全クライアントに送信するが、今回は指定したグループに送信する(参考
  • AddToGroup 関数について
    • クライアントをグループに追加する処理(参考

SignalRを構成する

公式チュートリアル 通りです。

  • Startup.cs を以下のように変更する
using SignalRGroupChat.Hubs;
...
public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages();
    services.AddSignalR();
}
...
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    ...

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapRazorPages();
        endpoints.MapHub<ChatHub>("/chatHub");
    });
}

SignalRクライアントコードを追加する

ここは公式チュートリアルとは少し異なります。
まず、トップページはグループ名を入力してもらう画面にします。
そして、チャットルームページを新たに作成してチャット画面を実装します。

  • Pages\Index.cshtml を以下のように変更する
@page
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<div class="container">
    <div class="row">
        <div class="col-2">Room name</div>
        <div class="col-4"><input type="text" id="roomName" /></div>
    </div>
    <div class="row"> </div>
    <div class="row">
        <div class="col-6">
            <input type="button" id="moveToRoom" value="Move to room" />
        </div>
    </div>
</div>
<script src="~/js/index.js"></script>
  • wwwroot\js\index.js を作成し、以下のように編集する
document.getElementById("moveToRoom").addEventListener("click", function (event) {
    var roomName = document.getElementById("roomName").value;
    window.location.href = "Room/" + roomName;
});
  • Pages\Room.cshtml を作成し、以下のように編集する
    • VSを使っている場合はソリューションエクスプローラーで Pages フォルダを右クリックして[追加] -> [新しい項目] -> [Razorページ – 空]を選択すると良い(C#コードビハインドを自動生成してくれる)
@page "{roomname?}"
@model RoomModel
@{
    ViewData["Title"] = "Room";
}

<h1>@ViewData["Title"] : @Model.RoomName</h1>
<input type="hidden" id="roomName" value="@Model.RoomName" />
<div class="container">
    <div class="row"> </div>
    <div class="row">
        <div class="col-2">User</div>
        <div class="col-4"><input type="text" id="userInput" /></div>
    </div>
    <div class="row">
        <div class="col-2">Message</div>
        <div class="col-4"><input type="text" id="messageInput" /></div>
    </div>
    <div class="row"> </div>
    <div class="row">
        <div class="col-6">
            <input type="button" id="sendButton" value="Send Message" />
        </div>
    </div>
</div>
<div class="row">
    <div class="col-12">
        <hr />
    </div>
</div>
<div class="row">
    <div class="col-6">
        <ul id="messagesList"></ul>
    </div>
</div>
<script src="~/js/signalr/dist/browser/signalr.js"></script>
<script src="~/js/chat.js"></script>
  • Pages\Room.cshtml.cs を以下のように変更する
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace SignalRGroupChat.Pages
{
    public class RoomModel : PageModel
    {
        public string RoomName;

        public void OnGet(string roomName)
        {
            RoomName = roomName;
        }
    }
}
ちょっと説明

ここにはURLの末尾をルーム名として設定する処理を書いています。

  • wwwroot\js\chat.js を作成し、以下のように編集する
"use strict";

var connection = new signalR.HubConnectionBuilder().withUrl("/chatHub").build();

document.getElementById("sendButton").disabled = true;

connection.on("ReceiveMessage", function (user, message) {
    var li = document.createElement("li");
    document.getElementById("messagesList").appendChild(li);
    li.textContent = `${user} says ${message}`;
});

connection.start().then(function () {
    document.getElementById("sendButton").disabled = false;

    var roomName = document.getElementById("roomName").value;
    connection.invoke("AddToGroup", roomName).catch(function (err) {
        return console.error(err.toString());
    });

}).catch(function (err) {
    return console.error(err.toString());
});

document.getElementById("sendButton").addEventListener("click", function (event) {
    var roomName = document.getElementById("roomName").value;
    var user = document.getElementById("userInput").value;
    var message = document.getElementById("messageInput").value;
    connection.invoke("SendMessageToGroup", roomName, user, message).catch(function (err) {
        return console.error(err.toString());
    });
    event.preventDefault();
});
ちょっと説明
  • chat.js は公式チュートリアルのコードを参考にしています。
  • 公式チュートリアルに追加したのは以下の2点です。
    • SignalR接続開始時にグループに参加する処理を追加しています。
    • 送信ボタンを押したときにグループを指定してチャットメッセージ送信するようにしています。

アプリを実行する

  • 「F5」キーを押して実行します。
  • ブラウザが起動します。
  • トップページでルーム名を入力するとルーム専用のチャットページに遷移します。
  • 下の画像のようにルームに分かれてメッセージをリアルタイムで送受信できます。
    • 上2つのブラウザウィンドウはRoom1に、下2つはRoom2に参加している、という想定です。
チャットアプリ実行画面
チャットアプリ実行画面

まとめと感想

公式チュートリアルのSignalRサンプルアプリにルーム別メッセージ送受信機能を追加しました。そのためにSignalRのグループの仕組みを使って実装しました。
実装して感じたのはSignalRは高水準ライブラリだということ。クライアントとサーバで情報を送信しあうというより、お互いの関数を実行させる、というイメージでプログラミングできました。
ネットワーク上の流れを意識せずにプログラムの関数レベルでプログラミングできるのは処理内容に集中できるので使いやすいですね。

AutoMapperで詰め替えるDTOクラスにはinitアクセサーが使えそう(C#)

プログラム内のオブジェクトを外部に転送するためにDTO (Data Transfer Object) を使うことがありますが、C#であればAutoMapperという便利なライブラリがあります。

これまでAutoMapperでDTOに詰め替えする際、DTOクラスはプロパティは①getアクセサーのみでコンストラクタで代入するのが一般的でした。もしくは簡単に②setアクセサーを設定する方法もありました。

それぞれの方法の特徴は以下の通りです。

①getアクセサーのみでコンストラクタで代入

  • DTO生成後に意図せず値を書き換えられない(良いこと)
  • コンストラクタを作るのが面倒

②setアクセサーを設定する

  • DTO生成後に値を書き換えられてしまう
  • コンストラクタを作らなくていいので楽

それぞれ良いところと悪いところがありましたが、C#9.0 の新機能に initアクセサーが登場したことでこの2つのいいとこどりができるようになりました。特徴をまとめておきます。

③initアクセサーを使う

  • DTO生成後に値を書き換えられない(良いこと)
  • コンストラクタが不要

参考のため各方法のサンプルコードを載せておきます。

サンプルコード

①getアクセサーのみでコンストラクタで代入

using AutoMapper;
using System;

namespace AutoMapperTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var config = new MapperConfiguration(cfg => cfg.CreateMap<Item, ItemDto>());
            var mapper = config.CreateMapper();

            var item = new Item(1, "商品1", 100);
            ItemDto itemDto = mapper.Map<ItemDto>(item);

            Console.WriteLine("Item:");
            Console.WriteLine($"Id: {item.Id}, Name: {item.Name}, Price: {item.Price}");

            Console.WriteLine("ItemDto:");
            Console.WriteLine($"Id: {itemDto.Id}, Name: {itemDto.Name}, Price: {itemDto.Price}");

        }
    }

    class Item
    {
        public Item(int id, string name, int price)
        {
            Id = id;
            Name = name;
            Price = price;
        }

        public int Id { get; }
        public string Name { get; private set; }
        public int Price { get; private set; }
    }

    class ItemDto
    {
        public ItemDto(Item item) : this(item.Id, item.Name, item.Price)
        {
        }

        public ItemDto(int id, string name, int price)
        {
            Id = id;
            Name = name;
            Price = price;
        }

        public int Id { get; }
        public string Name { get; }
        public int Price { get; }
    }
}

②setアクセサーを設定する

using AutoMapper;
using System;

namespace AutoMapperTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var config = new MapperConfiguration(cfg => cfg.CreateMap<Item, ItemDto>());
            var mapper = config.CreateMapper();

            var item = new Item(1, "商品1", 100);
            ItemDto itemDto = mapper.Map<ItemDto>(item);

            Console.WriteLine("Item:");
            Console.WriteLine($"Id: {item.Id}, Name: {item.Name}, Price: {item.Price}");

            Console.WriteLine("ItemDto:");
            Console.WriteLine($"Id: {itemDto.Id}, Name: {itemDto.Name}, Price: {itemDto.Price}");

        }
    }

    class Item
    {
        public Item(int id, string name, int price)
        {
            Id = id;
            Name = name;
            Price = price;
        }

        public int Id { get; }
        public string Name { get; private set; }
        public int Price { get; private set; }
    }

    class ItemDto
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public int Price { get; set; }
    }
}

③initアクセサーを使う

using AutoMapper;
using System;

namespace AutoMapperTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var config = new MapperConfiguration(cfg => cfg.CreateMap<Item, ItemDto>());
            var mapper = config.CreateMapper();

            var item = new Item(1, "商品1", 100);
            ItemDto itemDto = mapper.Map<ItemDto>(item);

            Console.WriteLine("Item:");
            Console.WriteLine($"Id: {item.Id}, Name: {item.Name}, Price: {item.Price}");

            Console.WriteLine("ItemDto:");
            Console.WriteLine($"Id: {itemDto.Id}, Name: {itemDto.Name}, Price: {itemDto.Price}");

        }
    }

    class Item
    {
        public Item(int id, string name, int price)
        {
            Id = id;
            Name = name;
            Price = price;
        }

        public int Id { get; }
        public string Name { get; private set; }
        public int Price { get; private set; }
    }

    class ItemDto
    {
        public int Id { get; init; }
        public string Name { get; init; }
        public int Price { get; init; }
    }
}

CentOS8にGitLab Runnerをインストールする

自前のサーバで構築したGitLabでCI/CDを動かしたい場合はGitLab Runnerをインストールする必要がある。自分のGitLabサーバにGitLab Runnerをインストールする機会があったのでここに備忘録として残しておく。

前提

  • 自前のGitLabを既に構築しておりブラウザからrootアカウントでログインできる
  • GitLab RunnerサーバにSSHログインできてroot権限で作業できる
    • 今回はGitLabサーバとGitLab Runnerサーバを同一サーバに構築した
  • SELinuxを無効化している

バージョン

  • OS: CentOS8
  • GitLab: 13.2.3-ee
  • GitLab Runner: 13.2.2

準備

事前に以下の手順で設定情報をメモしておく。この情報はRunnerの設定時に利用する。

  1. GitLabにrootアカウントでログイン
  2. 「Admin Area」にアクセス
  3. Overview -> Runners ページにアクセス
  4. 「Set up a shared Runner manually」のURLとトークンをメモしておく

作業開始

ここからインストール、設定、起動の作業をする。GitLab RunnerサーバにSSHログインしrootアカウントになっておく。

Docker

今回はExecutor(CI/CDのjobを実行する実行環境)にDockerを採用するため、先にDockerをインストールし起動しておく。

インストール

curl -O https://download.docker.com/linux/centos/7/x86_64/stable/Packages/containerd.io-1.2.13-3.2.el7.x86_64.rpm
dnf install -y containerd.io-1.2.13-3.2.el7.x86_64.rpm
dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
dnf install -y docker-ce docker-ce-cli
systemctl start docker
systemctl enable docker

NAPTを設定

CentOS8のDockerではコンテナが名前解決のためホストと同じDNSサーバに問い合わせし、Firewallを起動している場合はIP到達性がなくなるためネットワークのエラーが発生してしまう。こちらの記事に載せられていた対策を実施する。

firewall-cmd --add-masquerade --permanent
firewall-cmd --reload

GitLab Runner

インストール

公式サイトを参考にした。

curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh | bash
dnf -y install gitlab-runner

設定

公式サイトを参考にした。以下のコマンドを実行して対話形式で情報を入力していく。

gitlab-runner register
Please enter the gitlab-ci coordinator URL (e.g. https://gitlab.com/):
https://xxxx.xxxx.xxxx/(ここに先ほどメモしたURLを貼り付ける)
Please enter the gitlab-ci token for this runner:
xxxxxxxxxxxxxx(ここに先ほどメモしたトークンを貼り付ける)
Please enter the gitlab-ci description for this runner:
[GitLab]: my-runner(この名前がRunner一覧ページに表示される)
Please enter the gitlab-ci tags for this runner (comma separated):
(指定しなくてもよい)
Please enter the executor: parallels, shell, ssh, kubernetes, custom, docker, docker-ssh, virtualbox, docker+machine, docker-ssh+machine:
docker(Executorはdockerにする)
Please enter the default Docker image (e.g. ruby:2.6):
alpine:latest(デフォルトにしたいDockerイメージを指定する)

確認

以下のコマンドでサービスが起動しているか確認できる。

systemctl status gitlab-runner

GitLabのWebページにrootアカウントでAdmin Areaにアクセスし、Runner一覧で今設定したのが登録されていることを確認する。

CI/CDを試してみる

簡単なPipelineを書いてRunnerを動かしてみる。

リポジトリを作成

GitLabのページからテスト用リポジトリを作成する。下の図では「runner-test」というリポジトリを作成している。

.gitlab-ci.ymlを作成

ブラウザでファイル作成、編集する便利機能があるのでそれを使ってみる。下の図の「New file」をクリックする。


「Select a template type」から「.gitlab-ci.yml」を選択


「Apply a template」から「Bash」を選択


「Commit changes」をクリックして、 .gitlab-ci.yml ファイルをリポジトリに追加する。

Pipelineの実行を確認

左メニューから「CI/CD」をクリックし、先ほどのcommitでパイプラインが実行されていることを確認する。(「Status」が「running」になっている)


しばらく待って「Status」が「passed」になればOK

AzureでCentOS8の仮想マシンを構築する

AzureでCentOS8の仮想マシンを構築する方法

仮想マシンを構築

まずはAzureのポータルにアクセスする。

トップページ左上の三本線をクリック

「Virtual Machines」をクリック

「+追加」をクリック

以下の情報を入力または選択し、「確認および作成」をクリック

  • 仮想マシン名:testVM(なんでもいい)
  • 地域:(Asia Pacific) 東日本
  • イメージ:CentOS-based 8.1
  • サイズ:Standard_B1ls(なんでもいいが検証なので一番安いやつ)
  • 認証の種類:パスワード
  • パスワード、パスワードの確認:任意のパスワード

確認ページが表示されるので問題なければ「作成」をクリック

デプロイが完了するまで待つ

デプロイが完了したら「リソースに移動」をクリック

仮想マシンにSSHログイン

「パブリックIPアドレス」をコピー

何かしらのSSHクライアントでログイン(下の図はPowerShell)

ssh AzureUser@(Public IP address)

ログインできた。

SSHできるIPアドレスを制限

このままだとどこからでもSSHログインできてしまうので、IPアドレスで制限する方法を載せておく。

仮想マシンのページから「ネットワーク」をクリックし、名前が「SSH」の行をクリック

以下の情報を入力し「保存」をクリック

  • ソース:IP Addresses
  • ソースIPアドレス/CIDR範囲:自分が使っているグローバルIPアドレス

リソースを削除

検証用途の場合、リソースを削除することを忘れずに。

仮想マシンを作ると他にもリソースが勝手に作られるのでリソースグループごと消すのが安心。

トップページ左上の三本線をクリックし、「リソースグループ」をクリック

今回作成したリソースグループ(今回は「testVM_group」)をクリック

「リソースグループの削除」をクリック

削除確認があるのでテキストボックスにリソースグループ名を入力して「削除」をクリック

これで今回作成した仮想マシンに関連するリソースがすべて削除される。

データベースにMySQLを使ってASP.NET CoreでWeb APIを作成する

公式サイトのチュートリアルをMySQLで実現してみたのでその記録

検証環境

  • OS: Windows 10
  • IDE: Visual Studio 2019

MySQLはDocker Composeで起動

docker-compose.yml を作成し、以下の内容を記載する。
(今回は検証なのでパスワードは一律で password にしておく)

version: '3'
services:
    db:
        image: mariadb:10
        command: mysqld --character-set-server=utf8 --collation-server=utf8_general_ci
        environment:
            MYSQL_DATABASE: todoapi
            MYSQL_USER: todoapi
            MYSQL_PASSWORD: password
            MYSQL_ROOT_PASSWORD: password
        ports:
            - "3306:3306"

以下のコマンドを実行し、MySQLサービスを起動しておく。

docker-compose up -d

以下のコマンドで起動しているか確認できる。

> docker-compose ps   
    Name                  Command               State           Ports
------------------------------------------------------------------------------
todoapi_db_1   docker-entrypoint.sh mysql ...   Up      0.0.0.0:3306->3306/tcp

Webプロジェクトの作成

ここは公式サイト通り

  • [ファイル] -> [新規作成] -> [プロジェクト]
  • テンプレートの中から [ASP.NET Core Webアプリケーション] を選択して [次へ] をクリック
  • プロジェクト名に TodoApi と入力して [作成] をクリック
  • [新しいASP.NET Core Webアプリケーションの作成] ダイアログで [.NET Core][ASP.NET Core 3.1] が選択されていることを確認
  • [API] テンプレートを選択して [作成] をクリック

モデルクラスの追加

ここも公式サイト通り

  • プロジェクト直下に Models フォルダを作成
  • Models フォルダ直下に TodoItem クラスを作成
  • TodoItem クラスに以下のコードを追加
public class TodoItem
{
    public long Id { get; set; }
    public string Name { get; set; }
    public bool IsComplete { get; set; }
}

データベースコンテキストの追加

公式サイトではSQLServerをインストールするが、今回はMySQLをインストールする。

  • [ツール] -> [NuGetパッケージマネージャー] -> [ソリューションのNuGetパッケージの管理]
  • [参照] タブを選択し、検索ボックスに Pomelo.EntityFrameworkCore.MySql と入力
  • 左側のウィンドウで [Pomelo.EntityFrameworkCore.MySql] を選択
  • 右側のウィンドウで [プロジェクト] チェックボックスをオンにして [インストール] をクリック

TodoContextデータベースコンテキストの追加

ここは公式サイト通り

  • Models フォルダ直下に TodoContext クラスを作成
  • TodoContext クラスに以下のコードを追加
using Microsoft.EntityFrameworkCore;

namespace TodoApi.Models
{
    public class TodoContext : DbContext
    {
        public TodoContext(DbContextOptions<TodoContext> options)
            : base(options)
        {
        }

        public DbSet<TodoItem> TodoItems { get; set; }
    }
}

データベースコンテキストの登録

データベース接続情報を記載するため、公式サイトとは異なる設定をする。

  • appsettings.json を編集して以下を追加
  "ConnectionStrings": {
    "MySQL": "Server=127.0.0.1;Database=todoapi;User=todoapi;Password=password;"
  }
  • Startup.cs を編集して以下を追加
using Microsoft.EntityFrameworkCore;
using TodoApi.Models;

(中略)

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<TodoContext>(opt =>
                opt.UseMySql(Configuration.GetConnectionString("MySQL")));
            services.AddControllers();
        }

コントローラーのスキャフォールディング

公式サイト通り

  • Controllers フォルダを右クリック
  • [追加] -> [新規スキャフォールディングアイテム]
  • [Entity Frameworkを使用したアクションがあるAPIコントローラー] を選択して [追加] をクリック
  • [Entity Frameworkを使用したアクションがあるAPIコントローラーの追加] ダイアログで以下を選択
    • モデルクラスで [TodoItem (TodoApi.Models)] を選択
    • データコンテキストクラスで [TodoContext (TodoApi.Models)] を選択
  • [追加] をクリック

PostTodoItem作成メソッドの確認

公式サイト通り

  • TodoItemsController.csPostTodoItem メソッドの return文を以下のように変更
return CreatedAtAction(nameof(GetTodoItem), new { id = todoItem.Id }, todoItem);

データベースのマイグレーション

ここは公式サイトにはない手順。
マイグレーションはコマンドラインから行う。
Visual Studioのプロジェクトを作成したフォルダで以下のコマンドを実行する。

dotnet ef migrations add DbInit
dotnet ef database update

(参考) dotnet-ef サブコマンドがインストールされていなければ以下のコマンドでインストールする

dotnet tool install --global dotnet-ef

POSTメソッドの確認

公式サイトと同じようにPostmanを使って確認する。

GETメソッドの確認

公式サイトと同じようにPostmanを使って確認する。

データベースを直接確認する

以下のコマンドでデータが作成されていることを確認する。

> docker exec -t todoapi_db_1 mysql -u todoapi -ppassword todoapi -e "SELECT * FROM TodoItems;"
+----+----------+------------+
| Id | Name     | IsComplete |
+----+----------+------------+
|  1 | walk dog |          1 |
+----+----------+------------+

今回はここまで

Amazon TranscribeをPython SDKで使ってみた

AWSの音声をテキストに変換(文字起こし)してくれるサービス Amazon Transcribeを使ってみたので備忘録に残しておく。TranscribeはS3にある音声ファイルを指定すると文字起こししてくれて、文字起こしの結果をS3にアップロードしてくれる。

処理の流れは以下のようにした。

  • STEP1: ローカルにある音声ファイルをS3にアップロード
  • STEP2: Transcribeに文字起こし処理を依頼
  • STEP3: 定期的に処理結果を確認
  • STEP4: 処理が完了したら処理結果のURLを取得
  • STEP5: 処理結果をS3からダウンロードし結果を表示 (Pyhon SDK)

基本的には前回のAzure Speech Servicesと同じ流れ。AWSはPython SDKが充実していてS3、Transcribeの処理どちらもSDKで実装できたのが良かった。

準備

S3バケットを作成

TranscribeではS3にアップロードされている音声ファイルを利用するためにS3バケットを作成する。まずはS3のページにアクセスし、右上の「バケットを作成」をクリック。

バケット名を入力する。今回は「speech.tetsis.com」にした。

そのまま「バケットを作成」をクリック。

バケット「speech.tetsis.com」が作成された。

IAMユーザーを作成

Python SDKでAWSのリソースを操作するためのIAMユーザーを作成する。まずはIAMユーザーページにアクセスし、「ユーザーを追加」をクリック。

ユーザー名は「speech-recognition」にした。「プログラムによるアクセス」をチェックし、「次のステップ:アクセス権限」をクリック。

「AmazonS3FullAccess」と「AmazonTranscribeFullAccess」をチェックして、「次のステップ:タグ」をクリック。

あとはそのまま進める。IAMユーザーが作成できたら「アクセスキーID」と「シークレットアクセスキー」が表示されるのでメモしておく。

awsコマンドをインストール

公式ページを参考にawsコマンドをインストールする。詳細な方法は割愛する。

awsコマンドで認証情報を設定

awsコマンドがインストールできたら認証情報を設定する。以下のコマンドを実行し、先ほどメモしておいた「アクセスキーID」と「シークレットアクセスキー」を入力する。

aws configure

Pythonコーディング

今回は以下の環境でPythonを実行する。

– OS: Windows 10
– Python 3.8.1
– pip 20.0.2

最初にPython SDKであるboto3をインストールしておく。

pip install boto3

今回は一つのファイル(aws_speech.py)で実装していく。汎用性の高い関数として実装した「関数コード」と、「main関数コード」の2パートに分けて紹介する。

まずはファイルの先頭にライブラリをインポートするimport文を記述する。

# -*- coding: utf-8 -*-
import os
import uuid
import argparse
import logging
import re
import time
import json

import boto3
from botocore.exceptions import ClientError

STEP1: ローカルにある音声ファイルをS3にアップロード

関数コード

この関数は公式ドキュメントを参考にした。

def upload_file(bucket_name, local_path, local_file_name):
    root_ext_pair = os.path.splitext(local_file_name)
    object_name = root_ext_pair[0] + str(uuid.uuid4()) + root_ext_pair[1] # S3バケット内で唯一のオブジェクト名になるようにランダム文字列(UUID)を挿入しておく
    upload_file_path = os.path.join(local_path, local_file_name)

    # Upload the file
    s3_client = boto3.client('s3')
    try:
        response = s3_client.upload_file(upload_file_path, bucket_name, object_name)
    except ClientError as e:
        print(e)
        return None
    
    print("\nUploading " + local_file_name +" to S3 as object:\n\t" + object_name)
    return object_name

main関数コード

S3バケット名は環境変数で指定するようにした。音声ファイルと言語はプログラム実行時に引数で指定するようにした。

if __name__ == "__main__":
    bucket_name = os.getenv("S3_BUCKET_NAME")

    parser = argparse.ArgumentParser()
    parser.add_argument("-f", "--file", type=argparse.FileType("r", encoding="UTF-8"), required=True)
    parser.add_argument("-l", "--locale", help="e.g. \"en-US\" or \"ja-JP\"", required=True)
    args = parser.parse_args()
    file_name = args.file.name
    locale = args.locale

    # STEP1: ローカルにある音声ファイルをS3にアップロード
    base_dir_pair = os.path.split(file_name)
    local_path = base_dir_pair[0]
    local_file_name = base_dir_pair[1]
    object_name = upload_file(bucket_name, local_path, local_file_name)

実行

実行前に環境変数にS3バケット名を設定する。テキスト変換させる音声は攻殻機動隊SAC26話の名セリフを使ってみる。Netflixで視聴できる。

> $env:S3_BUCKET_NAME = "speech.tetsis.com"
> python .\aws_speech.py -f .\草薙名セリフ.mp3 -l ja-JP

Uploading 草薙名セリフ.mp3 to S3 as object:
        草薙名セリフf6a85e99-8f22-4b95-82a0-77cfd3be80db.mp3

音声ファイルをS3にアップロードできた。実際にAWSコンソール画面を見ると音声ファイルがアップロードされているのが確認できる。

STEP2: Transcribeに文字起こし処理を依頼

関数コード

Transcribeで文字起こしを開始するためにはジョブ名を設定する必要がある。ジョブ名には文字数と種類の制約があるので、それに合わせるためS3にアップロードしたオブジェクト名を変換している。

また、OutputBucketNameには文字起こし結果を格納するS3バケットを指定できる。今回はSTEP1でアップロードしたバケットと同じバケットを使うこととにする。この関数は公式ドキュメントが参考になった。

def start_transcription_job(bucket_name, object_name, language_code):
    job_name = re.sub(r'[^a-zA-Z0-9._-]', '', object_name)[:199] # Transcribeのフォーマット制約に合わせる(最大200文字。利用可能文字は英大文字小文字、数字、ピリオド、アンダーバー、ハイフン)
    media_file_url = 's3://' + bucket_name + '/' + object_name

    client = boto3.client('transcribe')
    response = client.start_transcription_job(
        TranscriptionJobName=job_name,
        LanguageCode=language_code,
        Media={
        'MediaFileUri': media_file_url
        },
        OutputBucketName=bucket_name
    )

    print("\nTranscription start")
    return job_name

main関数コード

if __name__ == "__main__":
    # STEP1のコード(ここでは省略)

    # STEP2: Transcribeに文字起こし処理を依頼
    job_name = start_transcription_job(bucket_name, object_name, locale)

実行

> python .\aws_speech.py -f .\草薙名セリフ.mp3 -l ja-JP

Uploading 草薙名セリフ.mp3 to S3 as object:
        草薙名セリフ04bd9138-da2f-400c-9ea6-7650d524e34e.mp3

Transcription start

これで文字起こし処理を開始するところまでできた。AWSのコンソール画面からTranscribeページを見ると、処理が登録されていることが確認できる。Webブラウザから簡単に処理状態や文字起こし結果を確認できるのがAWSのいいところだと思う。

STEP3: 定期的に処理結果を確認

関数コード

get_transcription_jobのレスポンスのjsonフォーマットは公式ドキュメントを参考にした。

def get_transcription_status(job_name):
    client = boto3.client("transcribe")
    response = client.get_transcription_job(
        TranscriptionJobName=job_name
    )
    status = response["TranscriptionJob"]["TranscriptionJobStatus"]

    print("Transcription status:\t" + status)
    return status

main関数コード

if __name__ == "__main__":
    # STEP1のコード(ここでは省略)
    # STEP2のコード(ここでは省略)

    # STEP3: 定期的に処理結果を確認
    status = ""
    while status not in ["COMPLETED", "FAILED"]: # 処理が完了したら状態が"COMPLETED"、失敗したら"FAILED"になるからそれまでループ
        status = get_transcription_status(job_name) # 処理状態を取得
        time.sleep(3)

実行

> python .\aws_speech.py -f .\草薙名セリフ.mp3 -l ja-JP

Uploading 草薙名セリフ.mp3 to S3 as object:
        草薙名セリフ6d53dece-ea7c-4966-923a-e45687da42b0.mp3

Transcription start
Transcription status:   IN_PROGRESS
Transcription status:   IN_PROGRESS
Transcription status:   IN_PROGRESS
Transcription status:   IN_PROGRESS
Transcription status:   IN_PROGRESS
Transcription status:   IN_PROGRESS
Transcription status:   IN_PROGRESS
Transcription status:   IN_PROGRESS
Transcription status:   IN_PROGRESS
Transcription status:   IN_PROGRESS
Transcription status:   COMPLETED

処理が完了するのを待つところまで実行できた。

STEP4: 処理が完了したら処理結果のURLを取得

関数コード

処理が完了すると、文字起こし結果がS3にアップロードされる。get_transcription_job関数では文字起こし結果を格納しているS3オブジェクトのURLを返してくれる。

def get_transcription_file_url(job_name):
    client = boto3.client("transcribe")
    response = client.get_transcription_job(
        TranscriptionJobName=job_name
    )
    file_url = response["TranscriptionJob"]["Transcript"]["TranscriptFileUri"]

    print("\nFile URL:\n\t" + file_url)
    return file_url

main関数コード

if __name__ == "__main__":
    # STEP1のコード(ここでは省略)
    # STEP2のコード(ここでは省略)
    # STEP3のコード(ここでは省略)

    # STEP4: 処理が完了したら処理結果のURLを取得
    file_url = get_transcription_file_url(job_name)

実行

> python .\aws_speech.py -f .\草薙名セリフ.mp3 -l ja-JP

Uploading 草薙名セリフ.mp3 to S3 as object:
        草薙名セリフd3496aa1-11f8-4f90-aeb8-e4f705467a0d.mp3

Transcription start
Transcription status:   IN_PROGRESS
Transcription status:   IN_PROGRESS
...(中略)...
Transcription status:   IN_PROGRESS
Transcription status:   IN_PROGRESS
Transcription status:   COMPLETED

File URL:
        https://s3.ap-northeast-1.amazonaws.com/speech.tetsis.com/d3496aa1-11f8-4f90-aeb8-e4f705467a0d.mp3.json

最後に表示されているURLが文字起こし結果が格納されているS3オブジェクト。

STEP5: 処理結果をS3からダウンロードし結果を表示

関数コード

download_file関数は公式ドキュメントを参考にした。ダウンロードしたjsonファイルのデータフォーマットも公式ドキュメントを参考にした。

def download_file(bucket_name, object_name):
    s3 = boto3.client('s3')
    s3.download_file(bucket_name, object_name, object_name)
    print("\nDownloading S3 object:\n\t" + object_name)

def get_transcript_from_file(file_name):
    with open(file_name, "r", encoding="utf-8") as f:
        df = json.load(f)

    transcript = ""
    for result in df["results"]["transcripts"]:
            transcript += result["transcript"] + "\n"
    
    print("\nTranscription result:\n" + transcript)
    return transcript

main関数コード

if __name__ == "__main__":
    # STEP1のコード(ここでは省略)
    # STEP2のコード(ここでは省略)
    # STEP3のコード(ここでは省略)
    # STEP4のコード(ここでは省略)

    # STEP5: 処理結果をS3からダウンロードし結果を表示 (Pyhon SDK)
    result_object_name = file_url[file_url.find(bucket_name) + len(bucket_name) + 1:]
    download_file(bucket_name, result_object_name) # ここでダウンロード
    get_transcript_from_file(result_object_name)

実行

> python .\aws_speech.py -f .\草薙名セリフ.mp3 -l ja-JP

Uploading 草薙名セリフ.mp3 to S3 as object:
        草薙名セリフ6aa4385a-f760-4c76-9b03-2cf015593a59.mp3

Transcription start
Transcription status:   IN_PROGRESS
Transcription status:   IN_PROGRESS
...(中略)...
Transcription status:   IN_PROGRESS
Transcription status:   IN_PROGRESS
Transcription status:   COMPLETED

File URL:
        https://s3.ap-northeast-1.amazonaws.com/speech.tetsis.com/6aa4385a-f760-4c76-9b03-2cf015593a59.mp3.json  

Downloading S3 object:
        6aa4385a-f760-4c76-9b03-2cf015593a59.mp3.json

Transcription result:
だ けど 私 は 情報 の 並列 化 の 果て に 後 を 取り戻す ため の 一つ の 可能 性 を 見つけ た わ ちなみに その 答え は 好奇 心 多分 ね

どのセリフを文字起こししたのか、まあ分かる、よね。単語ごと(?)にスペース区切りされているから少し見づらいけどほとんど正確に文字に変換してくれてる(「後」は本当は「個」。セリフの重要な部分なだけに残念)。「ちなみに その 答え は」は別の人のセリフなんだけどそれでもちゃんと認識しててすごい。

全コード

最後に今回のコードの全体を載せておく。

# -*- coding: utf-8 -*-
import os
import uuid
import argparse
import logging
import re
import time
import json

import boto3
from botocore.exceptions import ClientError

def upload_file(bucket_name, local_path, local_file_name):
    root_ext_pair = os.path.splitext(local_file_name)
    object_name = root_ext_pair[0] + str(uuid.uuid4()) + root_ext_pair[1] # S3バケット内で唯一のオブジェクト名になるようにランダム文字列(UUID)を挿入しておく
    upload_file_path = os.path.join(local_path, local_file_name)

    # Upload the file
    s3_client = boto3.client('s3')
    try:
        response = s3_client.upload_file(upload_file_path, bucket_name, object_name)
    except ClientError as e:
        print(e)
        return None
    
    print("\nUploading " + local_file_name +" to S3 as object:\n\t" + object_name)
    return object_name

def download_file(bucket_name, object_name):
    s3 = boto3.client('s3')
    s3.download_file(bucket_name, object_name, object_name)
    print("\nDownloading S3 object:\n\t" + object_name)

def start_transcription_job(bucket_name, object_name, language_code):
    job_name = re.sub(r'[^a-zA-Z0-9._-]', '', object_name)[:199] # Transcribeのフォーマット制約に合わせる(最大200文字。利用可能文字は英大文字小文字、数字、ピリオド、アンダーバー、ハイフン)
    media_file_url = 's3://' + bucket_name + '/' + object_name

    client = boto3.client('transcribe')
    response = client.start_transcription_job(
        TranscriptionJobName=job_name,
        LanguageCode=language_code,
        Media={
        'MediaFileUri': media_file_url
        },
        OutputBucketName=bucket_name
    )

    print("\nTranscription start")
    return job_name

def get_transcription_status(job_name):
    client = boto3.client("transcribe")
    response = client.get_transcription_job(
        TranscriptionJobName=job_name
    )
    status = response["TranscriptionJob"]["TranscriptionJobStatus"]

    print("Transcription status:\t" + status)
    return status

def get_transcription_file_url(job_name):
    client = boto3.client("transcribe")
    response = client.get_transcription_job(
        TranscriptionJobName=job_name
    )
    file_url = response["TranscriptionJob"]["Transcript"]["TranscriptFileUri"]

    print("\nFile URL:\n\t" + file_url)
    return file_url

def get_transcript_from_file(file_name):
    with open(file_name, "r", encoding="utf-8") as f:
        df = json.load(f)

    transcript = ""
    for result in df["results"]["transcripts"]:
            transcript += result["transcript"] + "\n"
    
    print("\nTranscription result:\n" + transcript)
    return transcript

if __name__ == "__main__":
    bucket_name = os.getenv("S3_BUCKET_NAME")

    parser = argparse.ArgumentParser()
    parser.add_argument("-f", "--file", type=argparse.FileType("r", encoding="UTF-8"), required=True)
    parser.add_argument("-l", "--locale", help="e.g. \"en-US\" or \"ja-JP\"", required=True)
    args = parser.parse_args()
    file_name = args.file.name
    locale = args.locale

    # STEP1: ローカルにある音声ファイルをS3にアップロード
    base_dir_pair = os.path.split(file_name)
    local_path = base_dir_pair[0]
    local_file_name = base_dir_pair[1]
    object_name = upload_file(bucket_name, local_path, local_file_name)

    # STEP2: Transcribeに文字起こし処理を依頼
    job_name = start_transcription_job(bucket_name, object_name, locale)

    # STEP3: 定期的に処理結果を確認
    status = ""
    while status not in ["COMPLETED", "FAILED"]: # 処理が完了したら状態が"COMPLETED"、失敗したら"FAILED"になるからそれまでループ
        status = get_transcription_status(job_name) # 処理状態を取得
        time.sleep(3)

    # STEP4: 処理が完了したら処理結果のURLを取得
    file_url = get_transcription_file_url(job_name)

    # STEP5: 処理結果をS3からダウンロードし結果を表示 (Pyhon SDK)
    result_object_name = file_url[file_url.find(bucket_name) + len(bucket_name) + 1:]
    download_file(bucket_name, result_object_name) # ここでダウンロード
    get_transcript_from_file(result_object_name)