実践 React Emailを使ったHTMLメールの開発・運用

こんにちは、JX通信社でシニアエンジニアをしているSirosuzumeです。

JX通信社の「FASTALERT」には、ユーザーが事前に設定した地域で発生した災害情報を、HTMLメールで受信する機能があります。

HTMLメールには以下のような特徴があり、普段のフロントエンド開発の知識が役に立たないことが多いです。

  • Gmail、Outlook、その他のメーラーでは、使用できるタグやCSSに違いがある
  • CSS 自体が使えないクライアントも存在する

ベストプラクティス的なものを探しても、「tableタグを使ってレイアウトを頑張って調整しよう!」といった辛い現実が待ち受けています。

今回、HTMLメールのコーディングを担当することになり、もっと簡単な方法はないかと探していたところReact Emailというライブラリと巡り合いました。

React Emailは、名前の通りReactを使ってHTMLメールを作成するためのライブラリです。 用意されたコンポーネントを使うことで、Gmail、Apple Mail、Outlookといった主要なメールソフトに対応したHTMLメールを作成できます。

この記事ではReact Emailがどのようなものであるのか、どのようにプロダクトで運用したかを紹介します。

React Emailの機能

簡単にいうと、HTMLメール向けのReactコンポーネントと、そのNext.js製の開発サーバーを提供するライブラリです。

Getting Startedに従ってセットアップを行うと、以下の主要3パッケージを含む開発環境が作成されます。

  • @react-email/components
    • HTMLメール専用に作られたコンポーネント。例えばSection、Rowといったコンポーネントはレンダリングするとtableタグに変換される
  • @react-email/tailwind
    • Tailwindで指定されたクラスをHTMLメール用にインラインスタイルに変換するライブラリ
      • 例としてtext-basestyle=”font-size:1rem;line-height:1.5rem;”に変換される
      • remをpxに自動変換するといった機能はサポート対象外のため注意
        • remを使う場合は、pxに変換してから指定する必要がある
  • react-email
    • Next.jsで作られた開発サーバー
    • いくつかの企業製HTMLメールのexample等が最初から格納されている
    • resendを使用して、作成したメールを任意のアドレスに送る機能等がついている

Example

import { Body, Column, Container, Row, Text } from "@react-email/components";
import { MyTailwind } from "../tailwind";

export type ExampleForBlogEmailProps = {
  id: string;
  title: string;
  description: string;
};

export const ExampleForBlogEmail = ({
  title,
  description,
}: ExampleForBlogEmailProps) => {
  return (
    {/* MyTailwindはremをpxに上書きしたreact-email/componentのTailwindのコンポーネント */}
    <MyTailwind>
      <Body className="bg-slate-200 my-auto mx-auto font-sans px-2 pt-4 pb-16 text-black">
        <Container className="mx-auto py-8 px-4 max-w-[592px] bg-white rounded-lg">
          <Row>
            <Column>
              <Text className="text-base font-bold mb-0">{title}</Text>
              <Text className="text-sm mt-1">{description}</Text>
            </Column>
          </Row>
        </Container>
      </Body>
    </MyTailwind>
  );
};

これをHTMLで出力したものを見ると、tableタグとstyle属性変換されている様子を見ることができます。

<body style="background-color:rgb(226,232,240);margin-top:auto;margin-bottom:auto;margin-left:auto;margin-right:auto;font-family:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Roboto, &quot;Helvetica Neue&quot;, Arial, &quot;Noto Sans&quot;, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;padding-left:8px;padding-right:8px;padding-top:16px;padding-bottom:64px;color:rgb(0,0,0)">
  <table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:592px;margin-left:auto;margin-right:auto;padding-top:32px;padding-bottom:32px;padding-left:16px;padding-right:16px;background-color:rgb(255,255,255);border-radius:8px">
    <tbody>
      <tr style="width:100%">
        <td>
          <table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation">
            <tbody style="width:100%">
              <tr style="width:100%">
                <td data-id="__react-email-column">
                  <p style="font-size:16px;line-height:24px;margin:16px 0;font-weight:700;margin-bottom:0px">FASTALERT</p>
                  <p style="font-size:14px;line-height:20px;margin:16px 0;margin-top:4px">FASTALERTはSNS等のビッグデータをAIで即時解析し、防災DXや施設管理に活用することができます。リアルタイムな速報や、過去の災害のデータセットをAPIやウィジェットで提供しています。</p>
                </td>
              </tr>
            </tbody>
          </table>
        </td>
      </tr>
    </tbody>
  </table>
</body>

Node環境以外での運用を考える (Go html/templateの例)

FASTALERT開発チームでは、Go言語でHTMLメールのサーバーを開発し、html/templateのライブラリを使用してHTMLメールを生成しています。

そのため、React Emailで作成したコンポーネントのコードをそのまま使用することはできません。 作成したコンポーネントをhtml/templateに対応した形で出力する必要がありました。

またHTMLメールのデザインが複雑化すると、より細かい粒度のコンポーネントに分割して管理する必要があります。

その場合react-emailの提供する開発サーバーを用いるよりも、Storybookを使用してコンポーネントを管理する方が開発体験が良いのではないかと考えました。

運用の基本方針

html/templateでは埋め込みたい値を{{ value }} のように表現します。

まずは、React Emailで作成したコンポーネントをhtml/templateに変換する方法を考えます。

  • メールのデザイン時はpropsの値をそのまま出力する
  • コンポーネントはContainer/PresenterパターンのPresenterに相当するものを作成し、渡されたPropsをそのまま出力する形で設計する
    • 値の表示に計算が必要名箇所は、極力Go側で計算し、結果を文字列で渡す
  • export時はhtml/templateに対応する形に変換し出力する
  • html/templateのrangeやifに対応する

export時の出し分け方法の検討

html/templateに対応する形に変換するため、propsの値によって出し分けを行います。以下は、propsの値によって出し分けを行う関数の例です。

export function renderValue(
  /** デザイン時に出力する値 */
  value: string,
  /** export時に出力するPropsの名前 */
  valueName: string,
  /** exportかどうか */
  isExported: boolean,
): string {
  return isExported ? `{{ ${valueName} }}` : value;
}

// 使用例 isExportedをバケツリレーする
const html = render(<FastalertEmail isExported />)

ifや繰り返しへの対応

繰り返しやifの場合も、同様にpropsの値によって出し分けを行います。以下は繰り返しの場合の例です。 childrenには、繰り返しの中で出力するコンポーネントを渡します。

import React from "react";

export type ChildTemplateProps<T> = {
  value: T;
  arrayName: string;
  itemName: string;
  index: number;
  isExported: boolean;
};

export type GoRangeBlockTemplateProps<T> = {
  arrayName: string;
  itemName: string;
  array: T[];
  children: (props: ChildTemplateProps<T>) => React.ReactNode;
  isExported: boolean;
};

export const GoRangeBlockTemplate = <T,>({
  array,
  arrayName,
  itemName,
  children,
  isExported,
}: GoRangeBlockTemplateProps<T>) => {
  // export時は1つだけ出力する
  const arrayForRendering = isExported ? [array[0]] : array;
  return (
    <>
      {isExported && `{{ range $index, ${itemName} := .${arrayName} }}`}
      {arrayForRendering.map((value, index) => (
        <React.Fragment key={JSON.stringify(value)}>
          {/* childrenに空の配列などが入ることがあるため、typeofでfunctionの判定をいれる */}
          {typeof children === "function" &&
            children({ value, itemName, index, isExported, arrayName })}
        </React.Fragment>
      ))}
      {isExported && "{{ end }}"}
    </>
  );
};
// 使い方
<Section>
  <GoRangeBlockTemplate
    array={productions}
    arrayName="Productions"
    itemName="Production"
    isExported={isExported}
  >
    {({ itemName, value: { title, description } }) => (
      <Row>
        <Column>
          <Text className="text-md font-bold mb-0">
            {renderValue(title, `${itemName}.Title`, isExported)}
          </Text>
          <Text className="text-sm mt-1">
            {renderValue(
              description,
              `${itemName}.Description`,
              isExported,
            )}
          </Text>
        </Column>
      </Row>
    )}
  </GoRangeBlockTemplate>
</Section>

上記のようなコンポーネントを作成することで、html/templateに対応する形に変換することができます。

Storybookでの確認

Storybookを使用すれば、通常のフロントエンド開発と同様にコンポーネントのデザインを確認することができます。また、@react-email/componentsが提供するrender関数を使用することで、HTMLメールの出力結果を確認することもできます。以下はHTMLメールの出力結果を確認するためのStorybookの設定例です。

import type { Meta, StoryObj } from "@storybook/react";

import { render } from "@react-email/components";
import { ExampleForBlogEmail } from ".";
import { generateDefaultExampleForBlogProps } from "./index.mock";

export default {
  component: ExampleForBlogEmail,
} as Meta<typeof ExampleForBlogEmail>;

export const Primary: StoryObj<typeof ExampleForBlogEmail> = {
  render: (props) => <ExampleForBlogEmail {...props} />,
  args: {
    ...generateDefaultExampleForBlogProps(),
  },
};

export const Exported: StoryObj<typeof ExampleForBlogEmail> = {
  // pretty: trueを指定することで、インデントを整えて出力できる
  render: (props) => (
    <pre>
      {render(<ExampleForBlogEmail {...props} isExported />, {
        pretty: true,
      })}
    </pre>
  ),
  args: {
    ...generateDefaultExampleForBlogProps(),
  },
};

render関数はstringを返すため、jestやvitestと言ったテストフレームワークを使用して、出力結果をテストすることもできます。

exportの方法

前項に出てきたrender関数を使用してstringに変換したものを、ファイルに出力するスクリプトを組んで実行します。 以下は./dist/example.gohtmlに出力するスクリプトの例です。

import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { render } from "@react-email/components";
import { ExampleForBlogEmail } from "./_components/example-for-blog";
import { generateDefaultExampleForBlogProps } from "./_components/example-for-blog/index.mock";

if (!fs.existsSync("dist")) {
  fs.mkdirSync("dist");
}

const gohtml = render(
  <ExampleForBlogEmail {...generateDefaultExampleForBlogProps()} isExported />,
  {},
);

fs.writeFile(path.join("dist", "example.gohtml"), gohtml, (err) => {
  if (err) {
    console.error(err);
    process.exit(1);
  }
  console.log("File has been created");
});

実行時はtsxライブラリを使うと、簡単に実行できて便利でした。

npx tsx ./export.ts
# またはpackage.jsonにスクリプトを追加して実行する
pnpm run export

まとめ

React Emailを使用することで、HTMLメールのコーディングや管理が簡単になりました。Storybookの導入により、より小さな単位でコンポーネントを管理することができ、開発効率が向上しました。 今後、FASTALERTではメールによる各種情報の配信を増やしていく予定のため、React Emailの活用を進めていきます。

アプリケーションの動作を担保するテストをどう書くか

こんにちは。kimihiro_nです。
今回はアプリケーションの動作を保証するために不可欠なテストコードの書き方についてです。 特に外部依存要素のテストに焦点を当ててみていきたいと思います。

外部に依存するテストコード

皆さんはアプリケーションのテストコードを書いていますか? 内部的な状態を持たず、入力と出力が常に変化しない関数であれば、テストコードを書くのは比較的容易です。実際に関数を呼び出ししてその出力と期待値が一致しているかをみればテストすることができます。

しかし実際にアプリケーションを開発する場合、データベースへの接続だったり外部へのAPI呼び出しだったりといった外部の状態に依存した処理が含まれることが多いです。このような場合、素直にテストを書くのが難しいです。

多くの場合モックを利用して実際のデータベース呼び出しを置き換えたり、テスト用のリソースをdockerなどで構築してダミーデータを使って動作を確認する事になるでしょう。

これらの方法にはそれぞれ一長一短あります。

モックを利用する場合

モックを利用する利点としては、外部のリソースに頼らず純粋な単体テストとして実行出来ることです。しかし、実際のリソースに接続していないため、いざ繋いで動かしてみたら全く期待通りでなかったということも少なくありません。

テスト用リソースを利用する場合

テスト用のリソースを用意する場合、テストの段階でアプリケーションの挙動をある程度保証することができます。ただテスト用に動かす環境を作るのはコストが高く、リソースによってはローカルで再現できないといったこともよくあります。またローカルで動かせてもCI上で動かすのが大変だったり、テストの実行が遅くなってしまったりと万能ではないです。

アプリケーションの動作を担保するための方針

ではどのようにテストを書けば、アプリケーションの動作を担保することが出来るでしょうか。テストを書くための時間や動作環境によってベストな方法は異なりますが、最近私が採用している方針を共有したいと思います。

レポジトリの作成

まずはアプリケーションをテストしやすくするため、クリーンアーキテクチャでいうところのレポジトリを作成しています。データベースや外部APIなどのアクセスを抽象化するためのレイヤーです。

// UserRepository インターフェース
type UserRepository interface {
    // ユーザーを保存する
    Save(ctx context.Context, user *User) error
}

// EmailRepository インターフェース
type EmailRepository interface {
    // Eメールを送信する
    Send(ctx context.Context, email string, message string) error
}

Go言語での例になりますが、このような形で裏側のリソースに依存しないインターフェースをレポジトリとして定義します。ユーザーの保存先はMySQLかもしれませんし、NoSQLのDynamoDBになるかもしれませんが、インターフェースとしては受け取ったユーザーを保存するだけとなっています。メールについても同様でどんなAPI、SDKを利用するかはこの層では触れません。

レポジトリを使ったコードの実装

// UserService はユーザー管理に関わるサービスです
type UserService struct {
    userRepo   UserRepository  // ユーザーデータを操作するためのリポジトリ
    emailRepo  EmailRepository  // Eメール送信を扱うリポジトリ
}

func (s *UserService) RegisterUser(ctx context.Context, user *User, email string) error {
    // 入力のバリデーション
    if user.Name == "" || email == "" {
        return errors.New("invalid input")
    }

    // ユーザー情報の保存
    err := s.userRepo.Save(ctx, user)
    if err != nil {
        return err
    }

    // メール送信
    message := fmt.Sprintf("Hello %s, welcome!", user.Name)
    err = s.emailRepo.Send(ctx, email, message)
    if err != nil {
        return err
    }

    return nil
}

アプリケーションのロジックを組み立てるときは、このレポジトリを利用してコードを書いていきます。いわゆるユースケース層です。今回はユーザー登録の流れを例としてます。レポジトリで定義されたインターフェースを呼び出しているだけなので、この部分でも裏側のリソースが何なのかを気にすることなく実装が出来ます。

ユースケースの流れ

このコードを動かす時は、UserRepo、EmailRepoを実際の外部リソースを使って実装することになります。 図の例としてはUserRepoをMySQLで、EmailRepoをAmazon SESとしてみました。

この状態でどうテストを書くのがいいのかを考えてみます。

ユースケース層のテストコードを書く

レポジトリをモックしてユースケースをテスト
先ほども書いたとおり、ユースケース層ではレポジトリを経由して呼び出しているため、バックエンドがなにかを気にせずロジックを組み立てることが出来ます。バックエンドが何でもいいので当然モックでも問題ありません。

なので gomock などのモックライブラリを活用し、レポジトリをモックした上でユースケースのテストを書きます。

以下は、gomock を使用して UserRepository と EmailRepository をモックし、UserService の RegisterUser 関数をテストする例です。

func TestRegisterUser(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockUserRepo := NewMockUserRepository(ctrl)
    mockEmailRepo := NewMockEmailRepository(ctrl)

    ctx := context.Background()
    user := &User{Name: "Test User"}
    email := "test@example.com"
    message := fmt.Sprintf("Hello %s, welcome!", user.Name)

    // UserRepoのSaveが1度呼ばれることを確認
    mockUserRepo.EXPECT().Save(ctx, user).Return(nil).Times(1)
    // EmailRepoのSendが1度呼ばれることを確認
    mockEmailRepo.EXPECT().Send(ctx, email, message).Return(nil)Times(1)

    service := &UserService{
        userRepo:  mockUserRepo,
        emailRepo: mockEmailRepo,
    }
    // mockを利用してRegisterUserを呼び出し
    err := service.RegisterUser(ctx, user, email)
    assert.NoError(err)
}

モックライブラリにはメソッドがどんな引数で、何度呼ばれたかを確かめる機能があります。これを活用し、ユースケースがどのような動きをして欲しいのかをテストすることが可能です。 今回はシンプルな例なのでありがたみが少ないですが、例えば「バリデーションでエラーが出たらユーザーのSaveやメールのSendが呼ばれないこと」「Save時にエラーが出たらメールのSendが呼ばれないこと」は同様にモックを使ってテストすることが出来ます。実際のアプリケーションではもっと処理が複雑になると思いますが、そういった場合でもしっかりテスト出来ることが強みです。

一方でモックのセットアップでコード量が膨れやすいという欠点もあるので網羅的なテストにはあまり向いていないです。ユースケースの処理を変更する度それに応じてテストもまとめて変更する必要があるので、代表的なシナリオをピックアップしてテストを書くことが多いです。細かいロジックに関しては切り出してユニットテストにするのが確実です。

レポジトリ実装のテストをする

残りのレポジトリ実装の部分

ユースケースの部分はテストをすることが出来ました。 残るレポジトリ実装の部分についてもテストが出来れば、アプリケーション全体からみて一連の動作確認が行えたと言えそうです。 しかし、こちらはMySQLだったりAmazon SESだったりと背後のリソースに依存する部分が大きく、実際に動かしてみないと確認が難しそうです。

考えられる方針としては

  • ローカルで実際のリソースを動かす
  • ローカルでエミュレーターを動かす
  • 実際のリソースに接続してしまう
  • モックを使って入出力だけを確認する

あたりがありますが、どれを利用するかはリソースによっても異なってきます。

ローカルで実際のリソースを動かす

MySQLの場合、Dockerなどで簡単に動かすことが出来るので、docker composeなどで開発環境と一緒に立ち上げてしまうのがお手軽です。

# compose.yaml 

services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD=yes
      MYSQL_USER: testuser
    volumes:
      - db-data:/var/lib/mysql

volumes:
  db-data:

バージョンを揃えてあげることで本番環境との挙動のずれも最小限に抑えて動作確認ができます。 欠点としては動作を確認するためにデータベースを立ち上げたりデータをセットアップしたりと準備が多いことです。 また、CI上で動かすことを考えると、実行時間やCIサーバーのメモリの問題も出てきます。

ローカルでエミュレーターを動かす

DynamoDBのようにマネージドなサービスの場合、そのままローカルで動かすことは出来ません。 しかしDynamoDB localやLocalStackといった開発環境で代替する仕組みが公式・非公式で用意されていることも多く、そちらを活用することができます。 SESのようなサービスもLocalStackで模擬的に動かすことが可能です。(Community版はモックのみ)
Simple Email Service (SES) | Docs

AWSのSDKの場合バックエンドの Endpoint を差しかえられるようになっているため、これを docker compose で立ち上げたエミュレーターに向ければローカルで動作確認が可能です。

   var sesEndpoint *string
    // SES_ENDPOINT が定義されていたら上書きする
    if os.Getenv("SES_ENDPOINT") != "" {
        sesEndpoint = aws.String(os.Getenv("SES_ENDPOINT"))
    }
    sesClient := sesv2.NewFromConfig(cfg, func(o *sesv2.Options) {
        o.BaseEndpoint = sesEndpoint
    })

注意すべきところはやはりエミュレートしたものであることです。実環境とは異なる上限値が用いられていたりと完全な動作確認をすることは難しいです。

実際のリソースに接続してしまう

ローカルから直接実際のリソースに接続して確認するのも時には有効です。 動作を確かめる意味では確実ですし、流れているデータをそのまま利用できるなどの利点もあります。 反面、開発・テスト用のインフラを構築するコストや、リソースへセキュアに接続する方法をどうするかといった課題が出てきます。 テストコードから実際のリソースに接続するのも弊害が出やすいため、動作確認での利用に留めつつ、テストとしては別の手段を用意するのが賢明かもしれません。

モックを使って入出力だけを確認する

MySQLなどのデータベース接続ではsqlmock等を利用して、SQLの組み立てが期待通りかを確認する方法もあります。 クエリビルダーを活用しているときは有用な手段となります。

import (
    "context"
    "database/sql"
    "github.com/DATA-DOG/go-sqlmock"
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestRegisterUser(t *testing.T) {
    db, mock, err := sqlmock.New()
    assert.NoErr(err)
    defer db.Close()

    ctx := context.Background()
    user := &User{Name: "Test User", Email: "test@example.com"}

    mock.ExpectExec("^INSERT INTO users \\(name, email\\) VALUES \\(\\?, \\?\\)$").
        WithArgs(user.Name, user.Email).
        WillReturnResult(sqlmock.NewResult(1, 1))

    repo := NewUserRepo(db)
    err = repo.Save(ctx, user)
    assert.NoErr(err)

    if err := mock.ExpectationsWereMet(); err != nil {
        t.Errorf("there were unfulfilled expectations: %s", err)
    }
}

クエリビルダーを利用せず生のSQLを使って接続する場合などは、sqlmockを使用しても実際のSQLとテストのSQLが一致するかを確認するだけとなってしまい、意義のあるテストが行いづらいです。 モックを利用する場合なにをテストしたいのかが難しいところです。

まとめ

アプリケーション全体のテストを書くには、外部リソースに依存する部分をレポジトリとして抽象化することが大切です。 アプリケーションから外部依存をレポジトリとして分離することでロジック自体のテストが書きやすくなります。 完全なクリーンアーキテクチャを採用していなくても、外部依存要素を抽象化し、テストを簡素化するという考え方は有効です。 レポジトリの実装のテストは、実際のリソースを用いるかエミュレーターを利用するかなど、状況に応じて適切な方法を選択していきましょう。

「象・死んだ魚・嘔吐」をやってみた振り返り

こんにちは。スクラムマスターの@sakebookです。

今回は「象・死んだ魚・嘔吐」をチームでやってみたのでその振り返りをします。

「象・死んだ魚・嘔吐」とは

振り返り手法の一つです。Airbnb Story 大胆なアイデアを生み、困難を乗り越え、超人気サービスをつくる方法(原題: The Airbnb Story)の中で紹介されていたようです。

翻訳されてなかなかキャッチーなネーミングになっています。

それぞれ次のようなことを意味します。

  • 凄く大きい、見えているけど、みんな見ないふりをしている課題・問題。表層化しているけど大きすぎてみようとしていない。これが何かをみんなで話していく。

死んだ魚

  • 放っておくと腐っていく。そういう問題。放置しておくとまずいことになる問題ってなんだろう?ということを話し合う。

嘔吐

  • 自分の胸の中に隠していて、吐き出せなかったこと。これをこの場で嘔吐する。

厳密な定義は記事によって異なるかもしれませんが、大枠どんなことを指しているのかイメージがつくと思います。

どうして取り組もうと思ったか

チームのレトロスペクティブ(振り返り)にはKeep Problem Try(以降KPT)を採用していました。

チームメンバーと1on1を定期的に行ったり、各種ミーティングに参加しているなかで、スクラムの価値基準の「公開(Openness)、尊敬(Respect)、勇気(Courage)」あたりをもっと強化できないか?と考えトライしてみることにしました。

どのようにして取り組んだか

いきなり振り返り手法としていつものKPTの代わりに行うのではなく、内容の紹介・導入からと段階を分けて行いました。

その過程で、いつもの振り返りとは別枠でやってみることになりました。

初めの紹介・導入の際には、個人を責めることにならないように、参考にした記事にもあった、レトロスペクティブのプライムディレクティブを共通認識として持つようにしました。

それは、「私たちが何を見つけたとしても、その時点で分かっているスキルや能力、利用可能なリソース、手元の状況を用いて、誰もが自分のできる最高の仕事をすることを、私たちは理解し、心から信じています。」ということです。

その後実際にやってみる時にも見える位置に置き続けました。

FigJamにボードを用意して、各自に事前に記入してもらう形にしました。

背景の画像はこちらの記事のものを使ってます。

開催当日までに各自で非同期で付箋形式で書き込んでもらうようにしました。

なので実際は

  • 朝会で周知
  • 非同期で書き込み
  • 皆で集まり話し合い

と段階を踏んでいます。

集まってから各自付箋に目を通してもらい、話したいと思ったものにスタンプをつけてもらいました。スタンプが多いものから取り上げて話をしていきました。話す際にはNotionを併用してメモを取りました。

モザイクかけるの難しかったので小さくしてキャプチャしました。

やってみてどうだったか

「ミーティング開始の集まりが悪い」「障害発生時の動きについて」など、普段の振り返りでは出ない内容が出ました。一部マイクロサービスのメンテコストが高いなど、メンバー間で同じような課題を持っているものもありました。

一つ一つの課題が大きなものが多く、TRYに繋げることが難しかったです。

ファシリテーションに不安を感じてましたが、何について話すかを決めたらいつもの振り返りと同じように進めることはできました。

スタンプで投票する形で、どんなことに関心が高いかも可視化されたのは興味深かったです。

普段の振り返りでは出ない内容が出たこと自体とても良いことだと思っていて、抱えているものを吐き出す練習にもなるしチームの課題を見える形にできるのでやって良かったと思いました。

スクラムの価値基準の「公開(Openness)、尊敬(Respect)、勇気(Courage)」あたりをもっと強化できないか?

というところから始めたのですが、正直なところ一度の実施だと強化できたと言う感じはしていないです。ですが、継続して行うことで訓練になって強化されていくと感じてます。

次回以降どうする

レトロスペクティブの選択肢の一つにできればと考えていたのですが、毎週やっても変化がなさそうに感じられたので月一か隔月とかでできればと考えています。

進め方は、次回以降いくつか改善しようと思っている点があります。

付箋は書いた人に読み上げてもらう

付箋の内容だけみてスタンプを押す流れだと、書いた人の意図が正しく伝わらないのでは?という意見がありました。

集まって、皆に書いたものを読み上げてもらってからスタンプを押すようにしようと思っています。

似た内容をまとめる

タイムキーピングに不安があったので、進めることを優先して多いものから順に取り上げていったのですが、似た内容があったものはせっかくFigJamを使っているのでまとめられるといいと思いました。

一つのことに話しすぎない

こちらもよくある話ですが、普段話さない内容だったので集中しすぎました。FigJamだとタイマーもあるので次回は積極的に使っていきます。

無理に解決しない

上がった課題は大きいものは多いので、解決まで持っていこうと考えてしまうと無理矢理になってしまったり、話しきれなかったやるせなさが残ってしまいます。なので解決まで持っていくのを目標にせず、現状を知ることを目標にすることで気持ちを楽にさせたいと考えています。

最後に

次回もやってみてチームの定点観測に使っていければと思っています。

課題は勝手には無くならないので、見えたものに対してスクラムマスターとしてチームや組織に働きかけていきたいと思います。

これで本当にうまくいくかはわからないですが、やってみようと思ってるチームの参考になれば幸いです。

参考

zenn.dev

qiita.com

no-kill-switch.ghost.io

生成AIでニュースアプリの精度改善を競う社内コンペを開催しました

JX通信社CTOの小笠原(@yamitzky)です。4月19日に「AIコンペティション」を社内勉強会として開催したので、その取り組みを紹介します。

開催の目的

JX通信社では、AIで世界中のリスク情報を解析する「FASTALERT」という製品を開発しています。ただ、普段からエンジニアの全員がAI開発に取り組んでいるわけではなく、フロントエンドやバックエンドなど、AI以外のコンポーネントを開発しているエンジニアも多くいます。

数年前まではDeep Learningなどの高度なAIを活用した製品開発は敷居の高いものでしたが、 近年ではAutoMLやAmazon SageMakerのようなマネージドなAI開発の仕組みや、生成AIの台頭など、AIを使ったサービス開発の難易度は日に日に下がっています

そこで今回の勉強会では 「普段AI開発に関わってない人」をターゲットに、AIの分野や、AIを使ったプロダクト開発を身近に感じてもらうこと を目的として、AIのコンペティションを開催しました。

テーマ

なるべく実務に近いもの...ということで、弊社のニュースアプリ「NewsDigest」のタブ機能の改善をテーマに選定しました。NewsDigestには話題ごとのニュースを見れる機能(タブ機能)があります。しかし、「社会」「エンタメ」などをニュースをタブに分類するモデル自体は、やや古いものでした。

NewsDigestのスクリーンショット。各メディアから配信されている記事を、話題(タブ)ごとに閲覧できます

今回のコンペは「ニュース記事を解析し、タブを分類するモデルを作る」というテーマで開催し、タブ分類の正解率を競いました。

タイトルには「生成AI」と書きましたが、 手法自体は生成AI(プロンプト)でも、古典的な機械学習的手法でも、ノーコードでも、ルールベースでもエクセルでも何でもOK としています。今回の記事では「モデル」という表現に統一します。

事前準備

コンペは2時間の限られた時間で開催したため、勉強会の事前準備として、以下のものを用意しています。*1

  • 学習用データ・・・後述するサンプルコードでは利用していませんが、パラメータの学習を要するAI用に一応用意しました
  • 評価用データ・・・各自が作ったモデルの精度をポータルサイトで確認するためのサンプルデータ
  • ポータルサイト・・・NewsDigest風のUIで定性的な精度チェックをしたり、精度を定量的に確認するためのWebサイト
  • Pythonのサンプルコード

「実サービスでの運用」という点に近づけるため、次のような工夫しました。

  • あえて評価用データの正解ラベル等は開示しない。実際には自分で正解データを作らないといけないことも多いため
  • ポータルサイトでの定性的な確認をしやすいようにした。定量データが実際サービスで求められる品質や体験と乖離することもあるため

ポータルサイト

Gemini 1.5 Pro にお願いをして、ポータルサイトを作りました(Next.js/bun/Tailwind CSS/Cloud Runを利用)。解析結果のCSVをアップロードすると、タブに分類された記事を定性的に確認したり、精度チェックをしたりすることができます。このポータルサイトを見て、プロンプトやパラメータのチューニング結果のうまく行った/行ってないが確認できるようになっています。

ポータルサイトの実際のUI。右上にスコアが出ます。画像内のニュースは全て架空のものです。

サンプルコード

スムーズに体験してもらうために、サンプルコードを用意しました*2。あえてプロンプトを雑に作ったり、軽量な(精度が高くない)モデルを使ったりしています。実際、text-bisonのモデルは指示を無視して「ゲーム」や「生活」など、存在しないタブを誕生させ、スコアを下げてしまいます。プロンプトエンジニアリングの苦しみを味わってもらいました!

import vertexai
from vertexai.language_models import TextGenerationModel

# LLMのライブラリ初期化
vertexai.init()
parameters = {
    "candidate_count": 1,
    "max_output_tokens": 1024,
    "temperature": 0.9,
    "top_p": 1,
}
model = TextGenerationModel.from_pretrained("text-bison")

# 指示を自由にカスタマイズ
instruction = """あなたはニュース記事を判定し、カテゴリーを予測するAIです。記事のタイトルを受け取ったら、その記事のカテゴリーを推定してください。
カテゴリーは社会、政治、経済、国際、エンタメ、スポーツ、テクノロジーのどれかです。
記事のタイトルは、改行して複数渡されることがあります。その場合は、改行してそれぞれの判定結果を出力してください"""

# train.csvを元に、具体例をいくつか書いてあげる
examples = """input: 東北道でトレーラーとトラックの追突事故 2人の救出活動つづく 群馬・館林市
北海道 広尾町長選は新人の田中靖章氏が初当選

output: 社会
政治
"""


# 10記事ずつまとめて処理し、AIに予測させる
for i in range(0, len(titles), 10):
    input_text = "\n".join(titles[i:i+10])
    response = model.predict(
        prompt=f"""{instruction}

    {examples}

    input: {input_text}
    output: 
    """,
        **parameters,
    )
    print(response.text)

結果

当初のサンプルコードのモデルだと65.9%の正解率でしたが、優勝者は81.62%でした 🎉

優勝賞品のAmazonギフト券(AIによって生成された画像です)

優勝者コメントです。

タブ分類の工夫がスコアという形で可視化されて面白かったです!
時間があればプロンプト自体のチューニングもいろいろ試してみたかったです

【主に工夫した点】
生成AIの賢さによって精度も変わるだろうと思い、ChatGPT4-turboを使ってみることにしました。
予期しないカテゴリ(タブ)が返ってくる事を防ぐためFunction callingのレスポンスをEnum型で定義したことで安定して分類出来たように思います。

他にも「実際にAIを使う時の雰囲気掴めて良かった」といったコメントもあり、開催してよかったです。

今回は社内勉強会でのコンペ開催を通じて、AI開発の導入を体験していただきましたが、より良いプロダクト開発に役立てていけると良いなと思っています。

*1:コードやデータの公開は行っていませんが、もし同じような社内コンペを開催したい方が教えてください

*2:記載のものはCSVの読み書きなどを省略し、抜粋しています

リモートワークでもできる、気軽に始める勉強会のすすめ

スクラムマスターの@sakebookです。今回は「リモートワークでもできる、気軽に始める勉強会のすすめ」です。

「勉強会」というと、想像するものが人によって異なるので、事前にどんなものかを書いておくと

「直接の業務ではないが何かのテーマについて一緒に学んでいる会」

のことを勉強会と、この記事では定義します。

そんなのは勉強会じゃない!という人は適宜読み替えて、こういうことをやったよ気になったらやってみてねくらいの温度感で読んでください。

この日は動画再生が上手くいかなかった

リモートワークにより開催機会の減少

コロナ禍以前からリモートワークを取り入れていた弊社ですが、コロナ禍に伴い、地方へ移住した人や地方勤務にて採用したメンバーも増えています。そのため、なかなかオフサイトでのイベント開催が難しくなっていました。

勉強会も同様で、開催頻度が減少していました。

みんなで動画を見るみたいな体験をまたしたいと思った

今年の1月、会社の制度を利用してRegional Scrum Gathering Tokyo 2024(以降RSGT)というイベントにオフラインで参加しました。その際、改めて「オフラインで集まるっていいなー」と感じました。

そのような経緯もあり、「会議室でみんなで動画を見る」みたいな体験で、勉強会を再び開催したいと思っていました。

RSGTの参加者には発表の動画を早い段階で共有されました。社内共有の用途で利用可能だったので、これを題材にイベント動画視聴会という形で勉強会を開けないか?と考えました。

初めは参加の敷居を下げたいと思い、特定の言語や領域に依らないものでできればと考えていました。

社内にいくつかあるチームがスクラムを採用していたので、まさにRSGTの動画は題材にもぴったりだと思いました。

バーチャルオフィスで一緒に見る

弊社ではバーチャルオフィスとしてGatherというサービスを採用しています。

リモートワークが当たり前になった中でZoomの常時接続やTandemというサービスの検証などもしていたのですが、今はGatherを採用しています。

意識せずに働いていることを可視化できたり、ふらっと他の人やチームに絡んだりできる点などがメリットです。

Gatherでの勉強会開催では、参加者を増やしたり、集まってる感を出すために、次のような工夫をしました

なにやらイベントやってるぞという感を出した

開催していることを社内で可視化したいと思い、社内のイベントの告知用カレンダーに勝手に登録して存在を周知させました。

そして実際にGatherで開催するときには、目立つ位置で行うことで何かやってるぞという感じを出しました。

皆で一緒に見てる感じを出した

オフラインで勉強会を開催するときも、スクリーンに投影してみんなで見ると思いますが、同じような体験にするために同期再生で行うようにしました。

Gatherには、バーチャルオフィス内にテレビを設置し、動画を再生する機能があります。また、決められた時刻に自動再生を始める機能もあります。

埋め込みビデオの設定

事前に何をやるかのイメージを持ちやすくした

勉強会に興味を持ってくれた人に説明するために、次のような内容をテンプレとして周知するようにしました。

  • スライド(あれば)
  • 説明文
  • 再生時間
  • 関連リンクなど

RSGTの動画を題材にしたときには、ConfEngineのproposalを添えてます。

反響があった

動画の視聴が終わったら、感想戦で勝手にワイワイ......できるかと思ったらそうはならなかったです。なので2回目以降の開催からは、司会を用意して進めるようにしました。

イベント自体は参加者から好評で、第2回第3回と案内をしたときに社内で自主的に展開してくれる動きもありました。

自身のチームに展開してくれる様子

さらに何度かやっていくと、「この動画をイベント動画視聴会のテーマに共有できないか?」と参加者から提案をもらえるなど、一方通行ではないコラボレーションができていて良いと感じています。

提案される様子

まだ課題もある

継続はできそうですが、まだまだ課題もあります。

  • 司会を用意はしているが、なかなか話を回したりするのが難しい
    • この辺りは回数こなしたり他社の知見やプラクティスを知りたい
  • ワイワイしたいけど、動画再生中に喋ると動画の音声が聞こえにくくなる
    • オフラインの会話と違い、Gather上では人の声と動画の音声が混ざってしまう
  • GatherではYouTubeの時間指定再生が対応していない
    • やり方間違ってるだけ?解決方法知ってる人いたら教えてください
  • ふらっと参加しやすいようにしているが、人が増えすぎると感想戦というほど話せない

サステナブルな勉強会

運営というほどのことはやっておらず、題材を探してGatherでセッティングするだけなので準備もほぼ不要です。最悪人が集まらなければ自身が学ぶ回になるだけです。複数人で見た方が自分だけだと気づかない視点を得たり、補足の情報を追加で得たりできるので、テーマに興味があればお互いWin-Winになると考えています。

似たようなことやってみたいけど足踏みしてるとか、気になったという人はまずやってみるといいと思います。