フォトシンス エンジニアブログ

株式会社Photosynth のテックブログです

エンジニアのエゴを全面にだして会議室の在室確認モニターをつくってみた

ishturk - Qiita です。この記事はAdvent 14日目の記事です。

今回は久々にIoTっぽい話です。

会議室の在室モニターつくってみた

会議室に人がいるか・誰がいるっぽいのかわかるIoTデバイスをつくりました

なぜつくったのか

会議室に人がいるかどうか知りたい。オフィスで仕事していると週に数回感じます。プチストレス。

エンジニアだもの、ストレスを感じたら ついカッとなってつくっちゃう ものでしょう?

要件ぎめ

解決したいのはこんな困りごとです

会議室つかいたいけど、誰か中にいそう... 会議予約の時間だけど前の打ち合わせ延びてる?VIPだったら...

(そーっと様子を伺う)

いないんかーい!

弊社オフィスの会議室はとてもおしゃれなので、目線の高さがすりガラスになっていて中がみえないようになっています。 足下の高さは透けてるので、下から覗いて確認したりするんですけど、あまり気持ちの良い光景ではないですね...

room

会議室にはAkerunがついていて、常に施錠されています。

以下のようなことを実現しようと思います

  • 会議室に誰かいるのか、外からわかる → 人感センサが必要
  • 最後に会議室に入室したのが誰かわかる → AkerunのAPIで取得できる!

バイス選定・技術選定

人がいるかをセンシングするデバイス

まずはどうやって在室を検知するのか検討します。

  • お手軽導入(安価・調達しやすい・簡単に設置)
  • 検出精度そこそこ
  • プライバシーに配慮できる

という条件で調査してみます

赤外線センサー

  • 焦電センサーともよばれ、人感センサとして広く流通。人感センサ照明などで使われている
  • 人から発する赤外線(体温によるもの)を検知して、動きを判別する
  • 熱源の動きであれば、人以外でも反応してしまう

良さそうです。これを基準に他と比べて行こうと思います。

ミリ波レーダーセンサー

  • ミリ波(電波)を使って、物体の動きを検知する
  • 精度が高く、心拍などまで判別できる
  • 高コスト、設置環境にあわせた設計・工事が必要

お手軽ではないのでNG

超音波センサー

  • 超音波で物体との距離を測定できる。測距センサーや障害物検知デバイスとして広く流通。
  • 周囲の反響やノイズに影響されやすい

空間に人がいるか、を判断するには配置・判定ロジックが複雑になりそうなので、赤外線センサーに軍配

TOFセンサー

  • 赤外線やレーザーで物体との距離を測定できる。顔認証等で利用されているらしい
  • 高精度
  • 高価、インダストリアルユースで利用されるため入手性が良くない

カメラ

  • シンプル
  • ナレッジが豊富
  • 画像処理・演算でエッジ端末の性能が必要

人感検知用途ではオーバースペックなので、赤外線センサーに軍配

赤外線センサーに決まりました

構成

  • 室内にセンサーを配置
  • 在室状態は会議室外で確認したいので、会議室入口のモニターに表示
  • 会議室の入口は複数かもしれないので、モニターは複数箇所に設置できるように

ということを考えました。 以下のようになりました。 モニターは作る手間を惜しんでSBC+ディスプレイにしましたが、マイコン+電子ペーパーとかのほうが安価になるかもしれません。

outline

無線通信

採用する無線についても、なるべくシンプルにしたかったのと、知見があるBluetooth Low Energy(BLE)を採用しました。

BLE のあれこれ

ロール

BLEでは、振る舞いによってロールという定義があります。いわゆるマスター/スレーブのような関係性です。

  • Connection型

CentralとPeripheralの2つがあり、相互に接続したうえで通信します。 Centralが接続要求→Peripheralが受諾することで接続が確立されます。 接続後はGATTという規則に則ってデータのやり取りされます。

  • Broadcaster型

Connection型と違い、接続することなく、Broadcasterが一方的にデータを発信します。Observer側からデータを送ることはできません。

どちらの型も、advertise/scan の仕組みは共通で、peripheral から特定フォーマットのパケットを送信します。このデータは接続を要せず、どんなデバイスでも受信・読み込むことができます。

Connection型のほうが、セキュアに大容量のデータのやり取りが可能です。 一方でBroadcaster型は同時に多数のデバイスに情報を伝えることができ、接続処理の実装が不要です。

在室モニターでは

  • 情報量は小さいがリアルタイムに知りたい
  • 在室状況は複数のモニターで同時にアクセスしたい
  • セキュアな情報ではない

ということから、Broadcaster型 を採用します。

データ構造

advertise パケットのデータ構造です。 AD Type で規定されたデータを含めることができます。 AD TypeはBluetoothSIGで決められています。

図は Nordicのサイト から拝借

自由にデータを設定できるTypeとして Manufacture Specific Data があります(iBeaconもこのフィールドを使ってます)。 今回はこのTypeを使ってAdvertiseにデータを埋め込んでいきたいと思います。

AdvData は全体で31Byteまでの制約があるので、埋め込むデータもなるべく小さいサイズにします。bit arrayで以下のように決めました。

  • 直近1分以内に検知したら 0b00000001
  • 直近5分以内になら 0b00000010
  • 直近10分以内になら 0b00000100
  • 直近20分以内になら 0b00001000
  • 直近30分以内になら 0b00010000
  • 直近40分以内になら 0b00100000
  • 直近50分以内になら 0b01000000
  • 直近60分以内になら 0b10000000

これで、最後に検知した時間がおおよそわかります

実装

センサーの配線図です。とてもシンプル。 赤外線センサーは パナソニック製のPaPIRsを採用しました。

センサーデバイス制御のソースコードです。 Raspberry Pi PICO W を使用したので MicroPythonになりました。 ChatGPTに書かせました。

はじめはaiobleを使用したコードがサジェストされたのですが、どうもadvertiseを動的に変更することが考慮されてないようだったので、low-level Bluetoothを直接参照した実装に変えました。

import tkinter as tk
import asyncio
from datetime import datetime, timedelta
from ble_scanner import BLEScanner
import threading
from akerun_log import get_latest_user_history, get_user_access_details

# 組織IDとデバイスIDを指定
ORGANIZATION_ID = "O-xxxxxx-xxxxxx"  # 必要に応じて変更してください
AKERUN_DEVICE_ID = "Axxxxxxxxxx"  # 必要に応じて変更してください

class RoomStatusApp:
    def __init__(self, root):
        self.root = root
        self.root.title("部屋の状況モニター")
        self.root.geometry("1600x1200")
        self.root.configure(bg="#2c3e50")  # ダークグレーの背景

        # UIの初期化
        self.title_label = tk.Label(
            root, text="部屋の状況", font=("Arial", 40, "bold"), bg="#2c3e50", fg="#ecf0f1"
        )
        self.title_label.pack(pady=10)

        self.status_label = tk.Label(
            root, text="部屋にいる: 不明", font=("Arial", 32), bg="#2c3e50", fg="#bdc3c7"
        )
        self.status_label.pack(pady=10)

        self.time_label = tk.Label(
            root, text="最終検知: -- 分前", font=("Arial", 28), bg="#2c3e50", fg="#bdc3c7"
        )
        self.time_label.pack(pady=10)

        self.entry_label = tk.Label(
            root,
            text="RSSI: -- (時刻: --)",
            font=("Arial", 28),
            bg="#2c3e50",
            fg="#bdc3c7",
        )
        self.entry_label.pack(pady=10)

        # 最終入退室者のラベルを追加
        self.last_user_label = tk.Label(
            root,
            text="最終入退室者: --",
            font=("Arial", 28),
            bg="#2c3e50",
            fg="#ecf0f1"
        )
        self.last_user_label.pack(pady=10)

        # BLEスキャナーの初期化
        self.ble_scanner = BLEScanner(target_name="detector-00")
        self.running = True

        # BLEスキャンを非同期で開始
        self.thread = threading.Thread(target=self.run_async_loop, daemon=True)
        self.thread.start()

        # 定期的にUIを更新
        self.update_status()

    def run_async_loop(self):
        """バックグラウンドスレッドでBLEスキャンを実行"""
        asyncio.run(self.start_ble_scan())

    async def start_ble_scan(self):
        """BLEスキャナーを開始"""
        await self.ble_scanner.start_scan()
        while self.running:
            await asyncio.sleep(10)  # 10秒間隔でスキャン

    def update_status(self):
        """スキャン結果に基づいてUIを更新"""
        scan_result = self.ble_scanner.get_detection_time_and_rssi()
        if scan_result:
            detection_time = scan_result["detection_time"]
            rssi = scan_result["rssi"]

            print(scan_result)
            if detection_time > 0 and detection_time < 5:
                self.status_label.config(text="部屋にいる: はい", fg="#2ecc71")  # 緑色
            else:
                self.status_label.config(text="部屋にいる: いいえ", fg="#e74c3c")  # 赤色
            last_detected = datetime.now() - timedelta(minutes=detection_time)
            timestamp = last_detected.strftime("%H:%M")
            self.time_label.config(text=f"最終検知: {detection_time} 分前")
            self.entry_label.config(text=f"RSSI: {rssi} (時刻: {timestamp})")
        else:
            self.status_label.config(text="部屋にいる: 不明", fg="#bdc3c7")
            self.time_label.config(text="最終検知: -- 分前")
            self.entry_label.config(text="RSSI: -- (時刻: --)")

        # 最終入退室者の更新
        self.update_last_user_info()

        # 5秒後に再度更新
        self.root.after(5000, self.update_status)

    def update_last_user_info(self):
        """最終入退室者を更新"""
        try:
            # Akerun APIから最新のユーザー履歴を取得
            latest_history = get_latest_user_history(ORGANIZATION_ID, AKERUN_DEVICE_ID)
            user_access_details = get_user_access_details(latest_history)

            # ユーザー名とアクセス日時を表示
            self.last_user_label.config(
                text=f"最終入退室者: {user_access_details['user_name']} (時刻: {user_access_details['accessed_at']})"
            )
        except Exception as e:
            self.last_user_label.config(text="最終入退室者: エラー発生")
            print("Error getting user access details:", str(e))

    def stop_ble_scan(self):
        """スキャンの停止"""
        self.running = False
        self.ble_scanner.stop_scan()

# アプリケーション起動
if __name__ == "__main__":
    root = tk.Tk()
    app = RoomStatusApp(root)
    try:
        root.mainloop()
    finally:
        app.stop_ble_scan()

ble_scanner.py

import asyncio
from bluepy.btle import Scanner, DefaultDelegate
import time
import struct

class ScanDelegate(DefaultDelegate):
    def __init__(self, target_name="detect-00"):
        super().__init__()
        self.target_name = target_name
        self.scan_results = []

    def handleDiscovery(self, dev, isNewDev, isNewData):
        if isNewDev or isNewData:
            name = dev.getValueText(9)  # Complete Local Name

            if name == self.target_name:
                # Get the manufacturer specific data (advertisement data)
                manuf_data = dev.getValueText(255)
                if manuf_data is not None:
                    # Convert the string data to bytes
                    manuf_data_bytes = bytes.fromhex(manuf_data.replace(" ", ""))
                    # Skip the first two bytes (0th and 1st byte)
                    sensor_data = manuf_data_bytes[2:]
                    # Parse the sensor detection time (next bytes)
                    if len(sensor_data) > 0:  # Ensure there's enough data for detection time
                        detection_raw = int.from_bytes(sensor_data, byteorder='little')

                        if detection_raw == 0:
                            detection_time = 0
                        elif detection_raw == 0b1:
                            detection_time = 1
                        elif detection_raw <= 0b10:
                            detection_time = 5
                        elif detection_raw <= 0b100:
                            detection_time = 10
                        elif detection_raw <= 0b1000:
                            detection_time = 20
                        elif detection_raw <= 0b10000:
                            detection_time = 30
                        elif detection_raw <= 0b100000:
                            detection_time = 40
                        elif detection_raw <= 0b1000000:
                            detection_time = 50
                        elif detection_raw <= 0b10000000:
                            detection_time = 60
                        else:
                            detection_time = 0

                        # Append results with sensor detection status and raw data
                        self.scan_results.append({
                            'address': dev.addr,
                            'name': name,
                            'rssi': dev.rssi,
                            'sensor_status': detection_raw,
                            'detection_time': detection_time,
                            'raw_data': manuf_data_bytes
                        })


class BLEScanner:
    def __init__(self, target_name="detector-00"):
        self.target_name = target_name
        self.scanner = Scanner().withDelegate(ScanDelegate(self.target_name))
        self.delegate = ScanDelegate(self.target_name)
        self.scanner.delegate = self.delegate
        self.scanning = False
        self.cache = None  # キャッシュ用の変数

    async def _scan_background(self):
        """バックグラウンドでスキャンを5秒ごとに実行し、結果をキャッシュします。"""
        while self.scanning:
            self.delegate.scan_results.clear()
            # Perform scan asynchronously for 5 seconds
            await asyncio.to_thread(self.scanner.scan, 5.0)
            # キャッシュの更新
            self.cache = self.delegate.scan_results[-1] if self.delegate.scan_results else None
            print("Scan complete, cache updated.")
            await asyncio.sleep(5)

    async def start_scan(self):
        """バックグラウンドでスキャンを開始します。"""
        if not self.scanning:
            print("Starting BLE scan...")
            self.scanning = True
            # スキャンをバックグラウンドタスクとして実行
            asyncio.create_task(self._scan_background())

    def stop_scan(self):
        """スキャンを停止します。"""
        self.scanning = False
        print("Stopping scanner.")

    def get_detection_time_and_rssi(self):
        """キャッシュされた結果から、最新の検出時間とRSSIを返します。"""
        if self.cache:
            return {
                "detection_time": self.cache['detection_time'],
                "rssi": self.cache['rssi']
            }
        else:
            return None

akerun_log.py

import os
import requests
from dotenv import load_dotenv

# .envファイルを読み込む
load_dotenv()

# アクセストークンを取得
ACCESS_TOKEN = os.getenv("ACCESS_TOKEN")
if not ACCESS_TOKEN:
    raise ValueError("ACCESS_TOKEN is not set in the .env file")

# Akerun APIのエンドポイント
API_BASE_URL = "https://api.akerun.com/v3"

def get_latest_user_history(organization_id, akerun_device_id):
    """
    特定のAkerunデバイスの最後のユーザーアクセス履歴を取得
    :param organization_id: 組織ID
    :param akerun_device_id: AkerunデバイスID
    :return: 最後のユーザーアクセス履歴 (userがNULLのものは無視)
    """
    url = f"{API_BASE_URL}/organizations/{organization_id}/accesses"
    headers = {
        "Authorization": f"Bearer {ACCESS_TOKEN}",
    }
    params = {
        "akerun_ids[]": [akerun_device_id],
        "limit": 100  # 必要に応じて取得件数を調整
    }

    response = requests.get(url, headers=headers, params=params)

    if response.status_code != 200:
        raise Exception(f"Failed to fetch data: {response.status_code}, {response.text}")

    data = response.json()
    # アクセス履歴データを取得
    accesses = data.get("accesses", [])

    # userがNULLではないアクセスをフィルタリング
    valid_accesses = [access for access in accesses if access.get("user") is not None]

    # 最後のアクセスを取得
    return valid_accesses[0] if valid_accesses else None

def get_user_access_details(access_data):
    """
    ユーザーアクセス履歴の詳細(ユーザー名とアクセス日時)を取得
    :param access_data: アクセス履歴データ
    :return: ユーザー名とアクセス日時
    """
    if access_data:
        user_name = access_data['user'].get('name', 'Unknown User')
        accessed_at = access_data.get('accessed_at', 'Unknown Time')
        return {"user_name": user_name, "accessed_at": accessed_at}
    return {"user_name": "No valid access", "accessed_at": "No valid time"}

モニター側のソースコードです。こちらもChatGPTに書かせました。RaspberryPi上で簡易なGUIを実現で指定したところ、tkinterがサジェストされました。

AkerunのAPI仕様は(developersサイト)https://developers.akerun.com/#introductionで公開されています。今回は履歴一覧のAPIを利用しています。

import tkinter as tk
import asyncio
from datetime import datetime, timedelta
from ble_scanner import BLEScanner
import threading
from akerun_log import get_latest_user_history, get_user_access_details

# 組織IDとデバイスIDを指定
ORGANIZATION_ID = "O-111111-111111"  # 必要に応じて変更してください
AKERUN_DEVICE_ID = "A12345678"  # 必要に応じて変更してください

class RoomStatusApp:
    def __init__(self, root):
        self.root = root
        self.root.title("部屋の状況モニター")
        self.root.geometry("1600x1200")
        self.root.configure(bg="#2c3e50")  # ダークグレーの背景

        # UIの初期化
        self.title_label = tk.Label(
            root, text="部屋の状況", font=("Arial", 40, "bold"), bg="#2c3e50", fg="#ecf0f1"
        )
        self.title_label.pack(pady=10)

        self.status_label = tk.Label(
            root, text="部屋にいる: 不明", font=("Arial", 32), bg="#2c3e50", fg="#bdc3c7"
        )
        self.status_label.pack(pady=10)

        self.time_label = tk.Label(
            root, text="最終検知: -- 分前", font=("Arial", 28), bg="#2c3e50", fg="#bdc3c7"
        )
        self.time_label.pack(pady=10)

        self.entry_label = tk.Label(
            root,
            text="RSSI: -- (時刻: --)",
            font=("Arial", 28),
            bg="#2c3e50",
            fg="#bdc3c7",
        )
        self.entry_label.pack(pady=10)

        # 最終入退室者のラベルを追加
        self.last_user_label = tk.Label(
            root,
            text="最終入退室者: --",
            font=("Arial", 28),
            bg="#2c3e50",
            fg="#ecf0f1"
        )
        self.last_user_label.pack(pady=10)

        # BLEスキャナーの初期化
        self.ble_scanner = BLEScanner(target_name="detector-00")
        self.running = True

        # BLEスキャンを非同期で開始
        self.thread = threading.Thread(target=self.run_async_loop, daemon=True)
        self.thread.start()

        # 定期的にUIを更新
        self.update_status()

    def run_async_loop(self):
        """バックグラウンドスレッドでBLEスキャンを実行"""
        asyncio.run(self.start_ble_scan())

    async def start_ble_scan(self):
        """BLEスキャナーを開始"""
        await self.ble_scanner.start_scan()
        while self.running:
            await asyncio.sleep(10)  # 10秒間隔でスキャン

    def update_status(self):
        """スキャン結果に基づいてUIを更新"""
        scan_result = self.ble_scanner.get_detection_time_and_rssi()
        if scan_result:
            detection_time = scan_result["detection_time"]
            rssi = scan_result["rssi"]

            print(scan_result)
            if detection_time > 0 and detection_time < 5:
                self.status_label.config(text="部屋にいる: はい", fg="#2ecc71")  # 緑色
            else:
                self.status_label.config(text="部屋にいる: いいえ", fg="#e74c3c")  # 赤色
            last_detected = datetime.now() - timedelta(minutes=detection_time)
            timestamp = last_detected.strftime("%H:%M")
            self.time_label.config(text=f"最終検知: {detection_time} 分前")
            self.entry_label.config(text=f"RSSI: {rssi} (時刻: {timestamp})")
        else:
            self.status_label.config(text="部屋にいる: 不明", fg="#bdc3c7")
            self.time_label.config(text="最終検知: -- 分前")
            self.entry_label.config(text="RSSI: -- (時刻: --)")

        # 最終入退室者の更新
        self.update_last_user_info()

        # 5秒後に再度更新
        self.root.after(5000, self.update_status)

    def update_last_user_info(self):
        """最終入退室者を更新"""
        try:
            # Akerun APIから最新のユーザー履歴を取得
            latest_history = get_latest_user_history(ORGANIZATION_ID, AKERUN_DEVICE_ID)
            user_access_details = get_user_access_details(latest_history)

            # ユーザー名とアクセス日時を表示
            self.last_user_label.config(
                text=f"最終入退室者: {user_access_details['user_name']} (時刻: {user_access_details['accessed_at']})"
            )
        except Exception as e:
            self.last_user_label.config(text="最終入退室者: エラー発生")
            print("Error getting user access details:", str(e))

    def stop_ble_scan(self):
        """スキャンの停止"""
        self.running = False
        self.ble_scanner.stop_scan()

# アプリケーション起動
if __name__ == "__main__":
    root = tk.Tk()
    app = RoomStatusApp(root)
    try:
        root.mainloop()
    finally:
        app.stop_ble_scan()

トライアル

会議室のモニターに設置してみました。

こいつ...動くぞ...!

実際に導入するなら...

トライアルの結果、期待通りの振る舞いをすることがわかりました。 今回はついカッとなってサクッとつくりましたが、実際に導入するとなると、課題があります

  • ブレッドボードではなく、基板実装(PCBA)にして筐体に格納しないと、物理接触・衝撃・静電気等で停止・故障する
  • モニターに公開APIの認証情報・入退室履歴が残るので、セキュアな管理・設置ができるように
  • 長期安定動作するか検証

また、そもそも会議室が時間通り空くように、予約時間終了前に利用者に知らせるようなソリューションもほしいよね? という声もありました。それはそう!

おわり

今回のプロジェクトは、以下の裏の目的がありました。

  • RasperryPi PICO WBluetoothを試したかった
  • 自分で実装しないでChatGPTに書かせて、ハードウェアを動かすコードを書けるのか試したかった

製品開発ではこんなことできないので、趣味プロトタイピングとして実施しました。 API・ライブラリ仕様が公開されているものは、そのリファレンスをChatGPTに渡すと内容を解釈して実装してくれました。便利。

エンジニアはサクッと動くものをつくるのは得意です。ですが、好きなようにつくると好み・スキル・経験に基づいて作ってしまうことも多々。 ソリューションとして開発するなら、要求の洗い出し・要件定義・技術選定を丁寧にすすめることがとても大事。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 photosynth.co.jp Akerunにご興味のある方はこちらから akerun.com

Amazon Cognitoで独自認証フローを構築する方法

この記事は Akerun - Qiita Advent Calendar 2024 - Qiita の23日目の記事です。

こんにちは。@ps-shimizuです。バックエンドシステムの開発やプロジェクトマネージャーを担当しています。

先日業務でAmazon Cognitoのカスタム認証チャレンジに触れる機会があったのですが、カスタム認証チャレンジに関する情報が少なく開発に手こずることがありました。その経験を踏まえ、本記事ではカスタム認証チャレンジの実装手順から、実際にカスタム認証チャレンジでOTP(ワンタイムパスワード)を発行・検証する流れについてお話しします。

注意事項

カスタム認証チャレンジとは

カスタム認証チャレンジは、Amazon Cognito が提供する認証フローを拡張し、独自の認証ステップを組み込む仕組みです。以下のようなユースケースに適しています

  • SMSやメールを利用したOTP認証 (本記事ではこちらのユースケースを利用します)
  • セキュリティ質問を用いた認証
  • 外部サービスと連携した認証

カスタム認証を設定するには、以下の2つのコンポーネントを使用します。

  1. カスタムLambdaトリガー: Cognitoが特定のイベントで実行するLambda関数。
  2. カスタム認証フロー: Cognitoで設定する認証モード。

カスタム認証フロー

以下がカスタム認証チャレンジのフローです。 各Lambda関数の役割に関しては後述のLambda関数の作成項目で説明します。

カスタム認証フロー

※出典: カスタム認証チャレンジの Lambda トリガー

ユーザープールの作成手順

では早速 Amazon Cognitoのユーザープールの作成から進めていきます。

カスタム認証チャレンジを実装するためには、まずCognitoユーザープールを作成する必要があります。以下は基本的な設定手順です。

  1. ユーザープールの新規作成:

    • AWS Management Consoleにアクセスし、「Cognito」を選択します。
    • 「ユーザープール」から「ユーザープールを作成」をクリックします。
  2. サインインオプションの設定:

    • アプリケーションタイプは「従来のウェブアプリケーション」を選択します。
    • サインイン方法として、「メールアドレス」を選択します。
    • オプション項目については入力せず、アプリケーションを作成します。
  3. ユーザープールの保存:

    • 保存に成功すると以下の画面が表示されます。
  4. カスタム認証フローの有効化:

    • 続いて作成したユーザープールの「アプリケーションクライアント」の編集を行います。
    • 「認証フロー」セクションで「Lambda トリガー(ALLOW_CUSTOM_AUTH)」を有効に更新します。
      • 任意で「ユーザ名とパスワード (ALLOW_USER_PASSWORD_AUTH)」も有効にします。
  5. Lambdaトリガーの設定:

    • ユーザープール設定の「認証 > 拡張機能」を選択し、Lambdaトリガーの追加を行います。
    • Lambdaトリガーの「カスタム認証」を選択し、以下のLambda関数を割り当てます。
      • 認証チャレンジを定義: DefineAuthChallenge
      • 認証チャレンジを作成: CreateAuthChallenge
      • 認証チャレンジレスポンスを確認: VerifyAuthChallengeResponse
    • Lambda関数作成の流れは以下です。
      • 割り当てるLambda関数を作成していない場合は「Lambda関数の作成」からLambdaの作成を行います。
      • LambdaのランタイムはRuby3.3を選択してください。

この手順を完了するとLambdaトリガーが添付画像のようになっていればLambdaの紐付けは完了です。

実装ステップ

1. Lambda関数の作成

カスタム認証に利用するLambda関数を作成します。以下に、各トリガーの役割とコード例を示します。

DefineAuthChallenge

認証に成功した際、CognitoはこのLambdaトリガーを呼び出してカスタム認証フローを開始します。

require 'json'

class DefineAuthChallenge
  def self.handler(event:, context:)
    new.handler(event: event, context: context)
  end

  def handler(event:, context:)
    if event['request']['session'].nil? || event['request']['session'].empty?
      event['response']['challengeName'] = 'CUSTOM_CHALLENGE'
    else
      if event['request']['session'].last['challengeResult']
        event['response']['issueTokens'] = true
        event['response']['failAuthentication'] = false
      else
        event['response']['issueTokens'] = false
        event['response']['failAuthentication'] = true
      end
    end

    event
  end
end

def lambda_handler(event:, context:)
  DefineAuthChallenge.handler(event: event, context: context)
end

事前に作成したLambdaにはまだ何も実装されていないので、 上記コードをLambdaのコードソースエリアに貼り付け「Deploy」ボタンを押下します。

CreateAuthChallenge

チャレンジを生成する関数です。こちらの関数でOTPを発行し、ユーザへOTPを記載したメール送信を行います。

require 'json'
require 'securerandom'
require 'aws-sdk-ses'

class CreateAuthChallenge
  def self.handler(event:, context:)
    new.handler(event: event, context: context)
  end

  def handler(event:, context:)
    if event['request']['challengeName'] == 'CUSTOM_CHALLENGE'
      otp = SecureRandom.random_number(1000000).to_s.rjust(6, '0')

      # Amazon SESを利用してOTPをメール送信。
      send_otp_via_ses(event['request']['userAttributes']['email'], otp)

      event['response']['publicChallengeParameters'] = { 'email' => event['request']['userAttributes']['email'] }
      event['response']['privateChallengeParameters'] = { 'otp' => otp }
      event['response']['challengeMetadata'] = 'CUSTOM_CHALLENGE'
    end
    event
  end

  private

  def send_otp_via_ses(email, otp)
    ses = Aws::SES::Client.new(region: 'ap-northeast-1')
    ses.send_email(
      destination: { to_addresses: [email] },
      message: {
        body: {
          text: { charset: 'UTF-8', data: "Your OTP is #{otp}" }
        },
        subject: { charset: 'UTF-8', data: 'Your Authentication Code' }
      },
      source: '[email protected]'
    )
  end
end

def lambda_handler(event:, context:)
  CreateAuthChallenge.handler(event: event, context: context)
end

同じ要領で上記ソースコードをLambdaのコードソースエリアに貼り付け「Deploy」ボタンを押下します。

VerifyAuthChallengeResponse

クライアントから受け取った回答と、CreateAuthChallenge で発行したOTPが一致しているか検証する関数です。

require 'json'

class VerifyAuthChallengeResponse
  def self.handler(event:, context:)
    new.handler(event: event, context: context)
  end

  def handler(event:, context:)
    expected_otp = event['request']['privateChallengeParameters']['otp']
    user_provided_otp = event['request']['challengeAnswer']

    event['response']['answerCorrect'] = (user_provided_otp == expected_otp)
    event
  end
end

def lambda_handler(event:, context:)
  VerifyAuthChallengeResponse.handler(event: event, context: context)
end

こちらも同じ要領でLambdaのコードソースエリアに貼り付け「Deploy」ボタンを押下します。

2. バックエンドでの連携

RubyでCognitoを利用した認証フローを実装します。以下 app.rb(CognitoClient)のコード全文と.env に設定する環境変数です。

# ファイル名: app.rb

require 'aws-sdk-cognitoidentityprovider'

class CognitoClient
  def initialize
    @client = Aws::CognitoIdentityProvider::Client.new(
      region: ENV['AWS_REGION']
    )
    @user_pool_id = ENV['AWS_COGNITO_USER_POOL_ID']
    @client_id = ENV['AWS_COGNITO_APPLICATION_CLIENT_ID']
  end

  # カスタム認証を使用しないパスワードサインイン
  def sign_in(username, password)
    resp = @client.initiate_auth(
      client_id: @client_id,
      auth_flow: 'USER_PASSWORD_AUTH',
      auth_parameters: {
        'USERNAME' => username,
        'PASSWORD' => password,
        'SECRET_HASH' => calculate_secret_hash(username)
      }
    )
    puts "Authentication successful: #{resp}"
    resp
  rescue Aws::CognitoIdentityProvider::Errors::ServiceError => e
    puts "Error signing in: #{e.message}"
  end

  # カスタム認証チャレンジを利用した認証開始
  def custom_auth(username, password)
    resp = @client.initiate_auth(
      client_id: @client_id,
      auth_flow: 'CUSTOM_AUTH',
      auth_parameters: {
        "USERNAME" => username,
        "PASSWORD" => password,
        'SECRET_HASH' => calculate_secret_hash(username)
      }
    )
    puts "Custom Authentication successful: #{resp}"
    resp
  rescue Aws::CognitoIdentityProvider::Errors::ServiceError => e
    puts "Error signing in: #{e.message}"
  end

  # カスタム認証チャレンジ検証
  def custom_challenge(username, session, code)
    resp = @client.respond_to_auth_challenge(
      client_id: @client_id,
      challenge_name: 'CUSTOM_CHALLENGE',
      session: session, # custom_authで取得したresp.sessionを設定する
      challenge_responses: {
        "USERNAME" => username,
        "ANSWER" => code,
        'SECRET_HASH' => calculate_secret_hash(username)
      }
    )
    puts "Custom Challenge successful: #{resp}"
    resp
  rescue Aws::CognitoIdentityProvider::Errors::ServiceError => e
    puts "Error signing in: #{e.message}"
  end

  private

  def calculate_secret_hash(username)
    data = "#{username}#{@client_id}"
    digest = OpenSSL::Digest.new('SHA256')
    hmac = OpenSSL::HMAC.digest(digest, ENV.fetch('AWS_COGNITO_APPLICATION_CLIENT_SECRET'), data)
    Base64.encode64(hmac).strip
  end
end

.envの設定です。

AWS_REGION=ap-northeast-1
AWS_PROFILE='your-aws-profile'
AWS_SDK_LOAD_CONFIG=true
AWS_COGNITO_APPLICATION_CLIENT_ID='your-cognito-application-client-id'
AWS_COGNITO_APPLICATION_CLIENT_SECRET='your-cognito-application-client-secret'
AWS_COGNITO_USER_POOL_ID='your-cognito-user-pool-id'

カスタム認証を実践

1. カスタム認証 (CUSTOM_AUTH)

app.rb, .envの準備が整ったらようやくカスタム認証チャレンジの開始です。

ここではカスタム認証を実行しメールアドレス・パスワードのペアの検証と、OTP発行処理を行います。カスタム認証フロー図でいうところの赤枠部分が処理内容です。

require_relative 'app'
client = CognitoClient.new
custom_auth_response = client.custom_auth(
  'your-user-name',
  'your-password'
)

# challenge_parametersの値を取得できていれば認証フロー開始処理に成功している。
puts custom_auth_response.challenge_parameters

カスタム認証が成功していれば、以下のようなメールを受信しています。

2. チャレンジ応答の送信 (CUSTOM_CHALLENGE)

カスタム認証が成功した際に送信されたメールに記載されている、OTPを利用してチャレンジ応答を行います。カスタム認証フロー図でいうところの赤枠部分が処理内容です。

custom_challenge_response = client.custom_challenge(
  'your-user-name',
  custom_auth_response.session,
  'your-otp'
)

# access_tokenの値を取得できていればカスタム認証チャレンジの認証成功。
puts custom_challenge_response.authentication_result.access_token

チャレンジの検証に成功していれば、response.authentication_result に各種トークンが格納されています。

これでカスタム認証は完了です。

まとめ

Amazon Cognito のユーザープール作成からカスタム認証チャレンジを利用して独自の認証フローを実践する方法について紹介しました。

今回はシンプルにOTP作成からOTPの検証処理までの構築としましたが、OTPに有効期限を設定することや一度認証に利用したOTPを無効化するなどの柔軟な対応なども可能です。 要件にマッチする機能がなければカスタム認証チャレンジの採用も視野に入るかと思います。

また、私がカスタム認証チャレンジに触れていた際にはリリースされていませんでしたが、Amazon Cognitoの多要素認証にメールが追加されましたので前提条件が揃っていればそちらの利用も視野に入るかと思います。

参考情報


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 photosynth.co.jp Akerunにご興味のある方はこちらから akerun.com

エンジニアとしての育休体験:ワークライフバランスの新しい挑戦

はじめに

こんにちは。ny-yoです。Photosynthにソフトウェアエンジニアとして入社して2年目になりました。製造関連のソフトウェア開発・保守を中心にweb、インフラなど、薄く広く携わっています。
そんな私ですが、今年、第一子が誕生したことをきっかけに育児休業を取得しました。
取得して感じたこと、これからの働き方について考えたことを紹介します。

なぜ取ったか

最初は「取らなくてもいいかな」と思っていました。「休業扱い」なので当然収入はゼロ。育児休業給付金もあるが、6割支給で減収。
が、いろいろと情報を集めたりいろんな人の話を聞いているうちに、
「子供はまるで別の生き物」
「とにかくやってみないとわからないことが多い」
「なれないうちは育児と仕事の両立は不可能」
と感じ、思い切って3ヶ月の育休を取得することにしました。

休みに入る前

幸いにも休み前に関わっていたプロジェクトは概ね順調に遂行してました。
QA評価完了、インフラ構築も終わり、APIサーバーへの本番環境デプロイへの準備とリリースに向けての各種調整という状態でした。
私が担当している領域もほぼ完了していたので、大きな引き継ぎはなかったです。

とはいえ、何かあった時の調査・証跡や各種手順などは引き継ぎと3ヶ月後の復帰した自分への「遺言」も兼ねて作成&レビューしました。

あと、これはIoTサービスを扱っているPhotosynthならではだと思いますが、使っていた検証デバイスなど休み期間中の紛失リスクが懸念あったので、保管場所・台数を記録、関連メンバーに周知するなど徹底しました。デバイスあってこそのIoTサービスなのでその辺りのケアは慎重に扱うようにしてました。

この「記憶より記録すること」「周知すること」のアプローチはとても大事だと常に感じてます。

育休期間の過ごし方

休み期間中は、業務で使っていた会社貸与PCは回収(情シスで預かっていただいた)、さらに、社用slackアカウント、メールアカウントも全て期間中は無効化されました。
情報セキュリティの観点からも当然の対応だと思います。が、やはり、アクセスできる状態だと「つい見てしまう、通知に反応してしまう」状態なので、いい意味で完全に業務から切り離された状態になったと思います。
そもそも「休業扱い」なので業務すること自体がNG。

休み中はとにかく子供の世話に翻弄していた気がします。とにかく昼夜関係なく泣く、すぐ熱が出る、あやす、などと色々大変で、それは今も変わらないです。最近はやっと慣れてはきましたが、当時は休み取らないととてもじゃないけど仕事との両立なんかできなかったと感じてます。

休み明けの業務復帰

復帰後はキャッチアップが必要だったため、出社とリモートワークを半分ずつ取り入れてバランスよく柔軟な働き方を心がけていました。このとき、休み前に色々残しておいたおかげで復帰後のキャッチアップ・思い出しは概ねスムーズにできたかなと感じています。

復帰して2ヶ月ぐらい経った頃、休み前に携わっていたプロジェクトの保守案件がありました。その際、アプリケーションのデプロイ方法を完全に失念していて、「どうやるんだっけ」と社内リソースを漁っていたらまさにぴったりの記事が見つかり、誰が書いたんだろうと思ってみたら半年前の自分でした。

やはり「記憶より記録」、「遺言」が大事ですね。

ワークスタイルの変化

子供中心の生活にがらっと変わって、まだまだ手がかかるので、家のタスクをこなさないと生きていけないなと感じる日々です。なので、緊急案件や調査・対応がないときは長い時間働かないように心がけるようにしています。早めに切り上げたり「明日でもよいもの」は無理せず持ち越したりなど。

Photosynthのエンジニアは裁量労働制のため、働き方・時間をある程度自分で柔軟にコントロールできるのもありがたいなと感じてます。朝ごはんを食べさせたり、夕方にお風呂に入れたり寝かしつけしたり、そのための時間も確保できるようになってます。

その分プライベート時間はなくなり、子供関連と家事関連の時間になりました。なので、自分の勉強時間や興味ある技術のちょっとしたリサーチなどは思い切って諦めました。

本当に必要な業務に直結するものだけ業務時間に調べて、あとは追い追い身につくでしょうぐらいの楽観的な感じで。

あとは、技術書代は順調にミルク代に消えていき、毎日子供を抱っこするせいか体重が6キロ落ちました。

育休を取って感じたこと・よかったこと

Photosynthは周りでも育休をとっている社員がいたり、上長に申し出た時も前向きな反応をいただけたりと、私の観測している限り、このあたりの理解がかなり得やすい環境なのではないかと思います。おかげで気兼ねなく休みに入れましたし、休みの期間メンバーにさまざまな形でフォローいただいたりなど、とても感謝しています。

「育児」=「育自」。子供の成長を通して、自分も人間的に成長する、のようです。大変なことも多いですが、子供の成長を間近で見れるのは嬉しいですね。エンジニアとしても日々精進して、より良いIoTサービスの開発に貢献していければと思います。

以上です。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 photosynth.co.jp Akerunにご興味のある方はこちらから akerun.com

OpenAIのAPIを使って施解錠してみた

この記事は Akerun Advent Calendar 2024 - Qiita20日目の記事です。

こんにちは。Esperna - Qiita  です。ファームウェアの開発をしています。 本記事はOpen AIのAPIやAkerun APIを使って遊んでみたいと思ってる方に読んでもらえると嬉しいです。

何故OpenAIのAPIを使って施解錠してみようと思ったか

Function CallingというOpen AIが提供している仕組みが面白そうと思ったからです。 ChatGPTはAIに対して自然言語の入力を渡すと自然言語で応答を返してくれますが、 Function Callingという仕組みは、Open AIのAPIに対して自然言語の入力及び自身が定義した関数の情報を渡すと JSON形式で関数と引数を返してくれるというものです。 Function Callingを使うことで、自然言語の入力に対して自分が呼び出したい関数の呼び出しを行うことができます。

何をしたか

百聞は一見に如かず。以下はCUIからの標準入力に対して、Akerun APIを実行するというデモです。

ご覧の通り、「解錠して」、「施錠して」、「開けごま」、「鍵かけといて」、「鍵開けて」、「鍵閉めて」、「アバカム」など自然言語の入力にばらつきがあっても、 モーター錠の施錠及び解錠という一貫した出力を得ることができます。 「月が綺麗ですね」という文脈を無視した発言に対しては「何を言ってるのか分かりません」という応答を返しています。

youtu.be

今回はシンプルさとデモ作成のスピードを優先するため、Akerun APIとしては内容がシンプルで分かりやすい遠隔操作の施錠及び解錠操作のリクエストを選び 自然言語の入力も音声入力ではなくCUIからの標準入力としました。 オートロックまでの秒数やオートロック開始・終了時刻を指定できる設定変更リクエスAPIなどを使うように、自作の関数及びプロンプトを指定してあげれば もっと複雑なこともできます。

工夫した点

今回のデモの応答パターンは 施錠動作、解錠動作、「何を言ってるのか分かりません」応答 の3つなのですが、冪等性を担保するため(何回やっても同じ応答にする)ために、次のことを行いました。

OpenAIのChat Completions APIを呼び出す際にはユーザからのmessageを渡す必要があります。 デモではアシスタントとユーザー(あなた)のやり取りは延々と繰り返してますが、 Chat Completions APIには全てのmessageを渡すのではなく、system、assistant、userの3つのメッセージのみを渡すことで 応答が安定するようにしました。

今回書いたコードのスニペットは以下です。

function direct(msg) {
    rl.question(msg, async (answer) => {
        messages.push({ role: "user", content: answer });

        const response = await openai.chat.completions.create({
            model: "gpt-4o",
            messages,
            tools,
        });
        const toolCall = response.choices[0].message.tool_calls[0];
        const args = JSON.parse(toolCall.function.arguments);
        const request = args.request;
        const command = getCommand(request);
        executeCommand(command);
        messages.pop();
        direct("アシスタント:次は何をしますか?\nあなた: ");
    });
}
direct("アシスタント: " + messages[1].content + "\nあなた: ");
function getCommand(request) {
    switch (request) {
        case "lock":
            return Commands.LOCK;
        case "unlock":
            return Commands.UNLOCK;
        default:
            return Commands.UNKNOWN;
    }
}

function executeCommand(command) {
    switch (command) {
        case Commands.LOCK:
        case Commands.UNLOCK:
            cp.execSync(commandsMap.get(command));
            break;
        case Commands.UNKNOWN:
        default:
            console.log("何を言ってるのかわかりません");
            break;
    }
}

const tools = [
    {
        type: "function",
        function: {
            name: "getCommand",
            description:
                "Get the command string. Call this whenever you need to know command, for example when a customer asks 'open the door'",
            parameters: {
                type: "object",
                properties: {
                    request: {
                        type: "string",
                        description:
                            "The customer's request regrading smart lock. Possible request is single word request. Possible request shall be 'lock' or 'unlock' or 'unknown'",
                    },
                },
                required: ["request"],
                additionalProperties: false,
            },
        },
    },
];

const messages = [
    {
        role: "system",
        content:
            "You are a helpful customer support assistant for smart lock. You need to get command by choosing 'lock' or 'unlock' or 'unknown' as single word request from customer message. customer message shall be translated into  English and shall be interpreted as lock or unlock",
    },
    {
        role: "assistant",
        content:
            "私はスマートロックのためのサポートアシスタントです。施解錠・錠設定にまつわる指示を承ります",
    },
];

今後の予定

既に 【動画あり】Google AssistantでスマートロックAkerunを音声操作する - フォトシンス エンジニアブログで似たようなことやってますが、 音声入力できるようにしてみたいなと思います。 自然言語の入力をCUIからの標準入力ではなく音声入力にすれば、一気に使う人の敷居が下がります。 施解錠や錠設定の処理自体を自然言語で処理できるようにすることが効率化に寄与するとは思いませんが、 自然言語のインターフェースによって実行できる処理が増えていけば、 省人化・人手レスな世の中に繋がっていくと思っています。

参考文献

platform.openai.com developers.akerun.com


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 photosynth.co.jp Akerunにご興味のある方はこちらから akerun.com

効率的な開発を実現するためのタスクとPRの切り分け方

Akerun - Qiita Advent Calendar 2024 - Qiita の19日目の記事です。

はじめに

こんにちは、MIWA Akerun Technologiesが運営する賃貸住宅管理システムの開発チームでエンジニアをしている井上です。

今回の記事は、自分自身の成長過程で気づいたタスクの分割とPR(Pull Request)の切り分け方について共有し、特に若手エンジニアの皆さんが仕事を進めやすくなるきっかけになればと思い書きました。

エンジニアとして業務を進める中で、タスクの整理が不十分な状態で開発を進めてしまい、手戻りやレビューの負担を増やしてしまうことがありました。その経験を踏まえて、どのように改善していったのかを具体的にお話しします。


タスクの分割とPRの切り分け方について

自分の仕事のやり方の課題

私が直面していた課題は、タスクを適切に分割せず、1つの大きなPRを作ってしまうことでした。結果として、以下の問題が発生していました。

  1. コードレビューがしにくい
    • 変更範囲が大きくなり、レビュアーが全体像を把握するのに時間がかかってしまう。
  2. 手戻りが発生しやすい
    • 複数の機能を一度に実装すると、バグや仕様変更が発生した際に修正範囲も広がる。
  3. 進捗が見えづらい
    • 小さな単位で進めないため、タスクの完了までに時間がかかっているように見える。

このような状況が続くと、チームの開発速度や品質にも悪影響が出ると感じるようになりました。

課題への向き合い方と改善方法

この課題を解決するために、私は以下の改善方法を実践しました。

1. アーキテクチャの各層に分割する

タスクをアーキテクチャの各層(レイヤー)ごとに分割することで、役割ごとの明確な境界線を意識しました。 これにより、変更範囲が局所化され、PRもそれぞれのレイヤー単位で分けやすくなりました。

ちなみに、私が所属する開発チームのプロダクトの一部は以下の様なアーキテクチャで開発を進めています。

階層名 説明
handler 外部からの入力をusecaseが求めるインタフェースに変換する責務を負う。 HTTP Request内のパラメータを取り出してusecaseに渡す。
usecase usecaseにはアプリケーション固有のビジネスルールが含まれている。handlerから入力を受け取り、ビジネスロジックに従ってrepositoryを呼び出す。
repository データの集約・永続化の責務を負う。 usecaseが実際のテーブル構造などを把握しなくてもentityの永続化を行える責務を負う。
entity usecaseによって扱われるドメインモデルとドメインロジック

例として、「新しいAPIのエンドポイントを追加する」というタスクがあった場合

  • データベースのスキーマ,entity追加
  • handler層の追加
  • usecase層の追加
  • repository層の追加(curl等で動作確認が取れるまで)

上記の様にタスクを分割することで、進捗が追いやすくなり開発効率も改善されました。

2. テストファーストで実装を進める

アーキテクチャの層ではPRの規模が大きく、レビュー対応に時間がかかる時は各テストケースでPRを分割することで、さらに粒度を細かくすることができます。

  • テストケースを作成する
    • 例: APIエンドポイントの正常系や異常系の挙動を定義
  • テストケースを満たす実装を行う
    • 実装が完了したらPRを作成し、レビュアーに共有
  • 次のテストケースに進む
    • 小さなサイクルで繰り返し、機能を段階的に完成させました。

3. 実装内容をTODO化して共有する

タスクの切り分けが難しい場合、実装内容をまずTODOリストとして整理しました。 その上で、テックリードや同僚と実装方針を事前に確認し、フィードバックを受けることで不明確な内容を減らしました。 このアプローチにより、タスクのスコープが明確になり、後から大きな手戻りが発生するリスクを抑えられました。

4. 小さなPRを意識する

各タスクを小さく区切ることで、PRの変更内容を最小限に抑えました。 「小さいPR = レビューしやすい」というメリットがあり、フィードバックが早く返ってくるようになりました。

5. 優先度を整理して進める

タスクを分割したら、その中で優先度をつけて着手する順番を決めるようにしました。 例えば、先にデータベースのスキーマを作成し、その後モデルやAPI実装を進めることで、機能ごとの依存関係も整理されました。

結果の振り返り

この改善を進めた結果、以下の変化がありました。

うまくいったこと

  • コードレビューがスムーズに進むようになった
    • PRが小さくなったことで、レビュアーからのフィードバックが早くなり、修正の手戻りが減りました。
  • 開発の進捗が見えやすくなった
    • 小さなタスクを順番にクリアしていくことで、自分自身やチームも進捗を確認しやすくなりました。
  • チームのコミュニケーションが改善された
    • 細かい単位でのレビューや進捗報告が増え、認識のズレが減りました。
  • 見積もりの精度が向上した
    • タスク分割時に時間や作業量を意識したことで、作業の見通しが立てやすくなりました。
    • タスクの粒度を細かくすることで、見積もり精度が向上しました

うまくいかなかったこと

  • 最初はタスク分割の粒度に悩んだ
    • 分割しすぎてしまうと逆に手間になる場合もあり、適切なサイズ感を掴むまでに時間がかかりました。
  • 小さなPRを意識しすぎるあまり、一度に複数のブランチを作成してしまい、管理が煩雑になったこともありました。

これらの課題は、チームメンバーと相談しながら「PRの適切な粒度」を見つけることで少しずつ改善していこうと思います。


最後に

この記事では、タスクの分割とPRの切り分け方について、私の経験とその改善方法を紹介しました。私自身、最初はうまくできなかったことも多かったですが、少しずつ改善を重ねることで確実に業務の質が向上してきました。

特に若手エンジニアの皆さんにとって、日々の業務でタスクの分割やPRの切り分けは意識しにくいかもしれません。しかし、少しずつ工夫することでチームの生産性やコード品質にも繋がっていくと思います。

この記事が、皆さんの仕事の参考になれば幸いです。最後までお読みいただき、ありがとうございました!


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 photosynth.co.jp Akerunにご興味のある方はこちらから akerun.com

設計の決定理由を GitHub Discussions に残すようにした話

この記事は Akerun - Qiita Advent Calendar 2024 - Qiita の18日目の記事です。

こんにちは、Miwa Akerun Technologiesが運営する賃貸住宅管理システムの開発チームでエンジニアをしている ps-yu1129 - Qiita です。

私の所属するチームでは、設計や開発に関するドキュメントのうち、フロードキュメントを GitHub Discussions、ストックドキュメントを GitHubWiki にまとめています。この運用をチームで約1年間続けてきたため、振り返りたいと思います。

当初のドキュメントについての課題感

ドキュメントについての取り決めがない時は、設計資料や相談事項を Confluence に書いていましたが、下記のような課題がありました。

  • フロードキュメントなのか、ストックドキュメントがどれなのかの区別がつかない
  • 知りたい情報がまとまっておらず、情報にたどり着くのに時間がかかる
  • どのような議論があり、どういう意思決定があって今の設計になったのかが不明

そこで、フロードキュメントを GitHub Discussions に記載し、ストックドキュメントを GitHubWiki に記載することにしました。

GitHub Discussions および Wiki に集約することにした理由

GitHub のサービス上にドキュメントを集約することにした一つ目の理由は、ドキュメント管理サービスが変わっても使い続けられるという点です。Photosynth の開発部では、過去に別のドキュメント管理サービスを使用していましたが、現在は Confluence を使用しています。今後ドキュメント管理サービスの移行があるかはわかりませんが、少なくとも、GitHub から別のコード管理サービスに移行する可能性の方が低いと思います。

二つ目の理由は、開発者向けの情報を一つのサービスに集約できるためです。ドキュメントもソースコードGitHub 上で管理できるため、サービスの行き来をしなくて済みます。

GitHub Discussions の運用について

GitHub Discussions はリポジトリの設定から有効化できます。(詳しくは リポジトリの GitHub Discussions を有効化または無効化する - GitHub Docsを参照)有効化すると、いくつかデフォルトでカテゴリが用意されていますが、これに加えて設計用に ADR(Architecture Decision Record)というカテゴリを追加しています。

GitHub Discussions カテゴリ一覧

カテゴリを追加した理由は、スレッド作成者によって記載内容にバラツキがあることや、最終的に何が決定された内容なのかがわかりづらいという問題があったためです。カテゴリの作成は、リポジトリ/.github/DISCUSSION_TEMPLATE/ 配下に yaml ファイルを追加することでできます。(詳細は ディスカッション カテゴリ フォームの作成 - GitHub Docs を参照)

実際に使用しているテンプレートは下記の通りです。

title: "[ADR] "
body:
  - type: markdown
    attributes:
      value: |
        1. 下書き
            - コメント募集をする場合には、「代替案」を記載する
            - 提案をする場合には「決定」を記載する
        2. コメント募集(オプション)
        3. 提案
        4. 承認 or 却下 or 代替
        5. 決定
  - type: dropdown
    id: status
    attributes:
      label: ステータス
      options:
        - draft
        - rfc
        - proposed
        - accepted
        - rejected
        - deprecated
        - superseded
    validations:
      required: true
  - type: textarea
    id: context
    attributes:
      label: コンテキスト
      description: どのような状況でこの決定を迫られているのか?
      placeholder: |
        例えば、この決定をする前にどのような問題があったのか?
        また、この決定をする前にどのような選択肢があったのか?
    validations:
      required: true
  - type: textarea
    id: decision
    attributes:
      label: 決定
      description: 決定した内容と決定がなされた理由
      placeholder: |
        Bad: サービス間に用いるのは非同期メッセージが最善の選択肢だと思う
        Good: サービス間に非同期メッセージを使用する。なぜなら...
  - type: textarea
    id: alternative
    attributes:
      label: 代替案
      description: 決定したい内容について意見を募集したい場合に記載する
      placeholder: |
        - 案1)
        - 案2)
        - 案3)
  - type: textarea
    id: consequences
    attributes:
      label: 影響
      description: 「決定」の影響について記載する。
      placeholder: |
        - メリット1)
        - メリット2)
        - デメリット1)
        - デメリット2)
  - type: textarea
    id: compliance
    attributes:
      label: 順守
      description: 「決定」の評価・統制方法について
  - type: textarea
    id: notes
    attributes:
      label: 備考
      description: |
        - オリジナルの著者
        - 承認日
        - 承認者
        - 変更点
  - type: textarea
    id: review-points
    attributes:
      label: 設計レビュー観点
      description: |
        設計時のレビュー観点。
        レビュアーに参照してもらうため、中身は変更しないこと。
      value: |
        - リソースのライフサイクルが明確になっているか
        - etc...

「設計レビュー観点」の項目については、設計段階からバグを排除するための施策として追加したものになります。レビュアーだけでなく、設計者もレビュー観点に記載の項目が設計から漏れていないかのチェックができるため、重宝しています。

上記のテンプレートを用いて、例えば以下について議論しています。

  • 要件を実現するためにどこにどのような変更を加える必要があるかの検討
  • テーブル設計
  • ライブラリの選定

なお、Discussions がクローズした後に変更が必要になった場合であっても、クローズした Discussions の修正は行いません。修正が必要になった場合は、別途 Discussions を立てて議論を行います。

GitHub Wiki の運用について

GitHub Discussions で議論して決まったことや、設計や開発に関してストックドキュメントとして残したいものについては Wiki に記載しています。例えば、下記のようなものです。

  • 実装方針の説明
  • テーブル設計の規約
  • OpenAPI で管理している API ドキュメントの書き方

また、内容に変更があった場合には、その都度更新するようにしています。

運用してみての振り返り

約1年ほど運用してきましたが、GitHub Discussions や Wiki に情報が集約されているため、体感ですがドキュメントを探す時間が少なくなったように思います。

また、仕様や技術選定について、なぜその意思決定を行なったのかの理由が議論を含めてわかるため、変更を加える際に設計意図を確認でき、調査時間の短縮になっています。とはいえ、当初設計したものから変更を加える機会はまだ少ないため、ありがたみを感じる場面は今後増えてくるのかなと思います。

ドキュメントの管理に悩んでいる方は、GitHub Discussions や Wiki を検討してみてはいかがでしょうか。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 photosynth.co.jp Akerunにご興味のある方はこちらから akerun.com

組み込みの知見をAIレビュワーに継承させてみた

組み込みの知見をAIレビュワーに継承させてみた

〜ベテランエンジニアの暗黙知ADR化してAIレビューに活かす〜

この記事は Akerun Advent Calendar 2024 - Qiita の17日目の記事です。

こんにちは。 いとう です。ファームウェアやってます。今年は実際に取り掛かった仕事が機密伴う仕事ばっかりだったので記事化は諦め、今回はアドベントカレンダーのために一念発起して組み込み開発全体に役に立つことがないか考えて記事を書きました。

はじめに

組み込みファームウェア開発では、ハードウェアの制約や開発環境の特性など、さまざまな暗黙知が存在します。これらの知見は多くの場合、ベテランエンジニアの経験として属人化しており、チーム全体での共有が課題となっています。今回は、これらの暗黙知ADR(Architecture Decision Record)として文書化し、AIによるコードレビューに活用した取り組みについて考えてみました。

暗黙知の例と影響

メモリ制約による問題

組み込み開発では、一見問題ない修正が思わぬ事態を引き起こすことがあります。例えば、ある開発者が計算精度向上のために整数演算を浮動小数点演算に変更したところ、大きな問題が発生しました。

// Before: 整数演算による重力加速度の計算
// センサー出力を物理値(m/s^2)に変換
// 9.80665...を10倍した98で近似
int32_t acceleration = (raw_value * 98) >> 10;  // 98/1024で近似

// After: 浮動小数点演算に変更
float acceleration = raw_value * 9.80665f / 1024.0f;

このシンプルな変更により、ARM7TDMI(FPU非搭載)プロセッサ上でソフトウェア浮動小数点ライブラリが自動的にリンクされ、わずか数行の修正でROMサイズが4KB増加。リリース直前のタイミングでFlashROMの容量制限に引っかかるという冷や汗モノの事態となりました。

組込み開発の現場では、FPUが搭載されていないプロセッサ(Cortex-M0/M0+など)が今でも現役。浮動小数点演算をコードに入れただけで、バイナリサイズが数KB増える事態に。原因は「ソフトウェア浮動小数点ライブラリ」が自動的にリンクされるため。

さらに驚くべきことに、Cortex-M0には除算器すら搭載されていません。つまり、単純な割り算もソフトウェアで実装することになり、想像以上に重い処理になってしまうのです。そのため、上記のコードは以下のように書き換えるのが定石です:

// 最適化バージョン:シフト演算を活用
// 9.80665を1024倍した10042で近似
int32_t acceleration = (raw_value * 10042) >> 10;

「きれいなコード」を目指して浮動小数点演算を使用した若手エンジニアが、リリース直前に容量制限にひっかかり青ざめる—— そんな光景は、組み込み開発ではよくある話です。

これを防ぐには、コードレビューで「これってFPU載ってましたっけ?」の一言を投げかけることが重要かもしれません。では、このような知見をどのようにチームで共有し、AIによるコードレビューに活かしていけばよいのでしょうか?

割り込みコンテキスト

ある日、新人エンジニアが相談に来ました。「デバッグプリントを増やしたら、たまにシステムが落ちるんです...」

void interrupt_handler(void)
{
    process_sensor_data();
    // 問題のコード
    LOG_INF("Interrupt: processed sensor data. value=%d", sensor_value);
}

たまにしか発生しない問題のため長い時間がかかった調査の末、判明した恐ろしい真相。RTOSの標準ログ機能のバックエンドとして使用していたSegger RTTが、バッファが一定量溜まると内部でmutexを取得しようとしていたのです。

つまり...

  1. 割り込みハンドラからのログ出力
  2. バッファが溜まると内部でmutex取得
  3. 割り込みコンテキストからmutexは取得できない!
  4. assert発生 → リブート

しかも、デバッガを繋いでいると発生しない(ログ出力が間に合う)という厄介な代物でした。

割り込みハンドラは神聖な領域。そこでは以下が絶対に禁止です:

  • メモリの動的確保
  • 同期処理(mutex, semaphoreなど)
  • 時間のかかる処理全般

これ以来、私たちのチームには「割り込みハンドラでのログ出力を見たら質問する」という暗黙の文化が根付きました。でも、こんな暗黙知、どうやって継承していけばいいのでしょうか?

ADRによる知見の文書化

組み込み開発では、ベテランエンジニアの「あの時の不具合」という武勇伝のような経験が、実は重要な技術的知見だったりします。今回はそんな暗黙知ADR(Architecture Decision Record)として文書化してみることにしました。

ADRのフォーマット

フォーマットは https://github.com/moomoo-ya/LADR-template を使わせていただきました。

以下に主なADRを示します。ベテランエンジニアの方々なら、タイトルを見ただけで「あぁ、あるある」と思い当たる節があるのではないでしょうか。各ADRは折りたたんでありますので、気になる項目をクリックして詳細をご確認ください。

なお、これらのADRはこの記事のために作成した例示用のものです。実際の現場ではより詳細な内容や、プロジェクト固有の制約が加わることでしょう。

浮動小数点の使用禁止

adr-01-浮動小数点の使用禁止.md

# Architecture Decision Records: 浮動小数点の使用禁止

## Date - 日付

2024-12-14

## Context - 文脈

プロジェクトで使用している Cortex M0 プロセッサは浮動小数点演算ユニット(FPU)を搭載していない。このため、float や double などの浮動小数点型を使用すると、ソフトウェアエミュレーションによって処理が行われ、以下の問題が発生する:

- ROM サイズの増大
- 実行速度の低下

## Status - 採用状況

Accepted

## Decision - 決定事項

すべてのコードにおいて以下の型の使用を禁止する:

- float
- double
- long double

代わりに以下の手法を使用する:

1. 整数型による演算 (int, long など)
2. ビットシフトを用いたスケーリング
3. 参照テーブルによる近似計算
4. 整数による比例計算

## Consequences - 結果

### メリット

- ROM サイズの削減
- 処理速度の向上
- 予測可能な実行時間

### デメリット

- 固定小数点演算の実装が必要
- 計算精度の制限

除算演算のガイドライン

adr-02-除算演算のガイドライン.md

# Architecture Decision Records: 除算演算のガイドライン

## Date - 日付

2024-12-14

## Context - 文脈

Cortex-M0 プロセッサは除算命令を持たず、コンパイラにより複数の命令に展開される。これは以下の課題を引き起こす:

- 実行時間が長い
- 命令数が多くコードサイズが増加
- 実行時間が一定でない

一方で、除算が必要な計算は一定数存在する。

## Status - 採用状況

Accepted

## Decision - 決定事項

除算を使用する前に、以下の代替手段を検討する:

1. シフト演算での代替
   - 2のべき乗による除算はシフト演算で実装

2. 定数による乗算への変換
   - コンパイル時に計算可能な除数は逆数を乗算に変換

3. テーブル参照
   - 頻繁に使用される値は Look Up Table で代替

上記で対応できない場合のみ除算を使用可能とする。
除算を使用する場合は、使用理由をコメントとして記載する。

コメント例:
    ```c
    // 任意の値での除算が必要なため、
    // シフトや定数乗算での代替が不可能
    result = value / divisor;
    ```

## Consequences - 結果

### メリット

- 処理性能の向上
- コードサイズの削減
- 実行時間の予測性向上

### デメリット

- 実装の複雑化
- コードレビューでの確認項目増加

固定幅整数型の使用義務化

adr-03-固定幅整数型の使用義務化.md

# Architecture Decision Records: 固定幅整数型の使用義務化

## Date - 日付

2024-12-14

## Context - 文脈

プロジェクトでは PC 上でのシミュレータと組み込み機器の両方で動作するコードを扱う。プラットフォーム間で以下の問題が存在する:

- int や long のサイズがプラットフォームにより異なる
- plain charの符号の有無が処理系定義である
- シミュレータと実機で異なる動作をする可能性がある
- デバッグが困難になる

## Status - 採用状況

Accepted

## Decision - 決定事項

### 1. 数値型として使用する場合

以下の型の使用を禁止し、代わりに固定幅整数型を使用する:

禁止する型:
- int
- long
- short
- unsigned int
- unsigned long
- unsigned short
- plain char (数値として使用する場合)

使用する型:
- int8_t, uint8_t
- int16_t, uint16_t
- int32_t, uint32_t
- int64_t, uint64_t

### 2. 文字列処理の場合

文字列処理においては以下のルールに従う:

    ```c
    // OK: 文字列リテラルと文字列処理
    const char* str = "hello";
    char buffer[16];

    // NG: 数値処理としてのplain char
    char value = 65;  // 符号の解釈が環境依存

    // OK: 数値処理には固定幅整数型を使用
    uint8_t value = 65;
    ```

## Consequences - 結果

### メリット
- プラットフォーム間での一貫した動作
- コードの意図が明確
- デバッグの容易さ
- 文字列処理の標準ライブラリとの互換性維持

### デメリット
- 既存コードの修正が必要
- タイピング量の増加
- 用途による型の使い分けが必要

割り込みコンテキストでのメモリアロケーション禁止

adr-04-割り込みコンテキストでのメモリアロケーション禁止.md

# Architecture Decision Records: 割り込みコンテキストでのメモリアロケーション禁止

## Date - 日付

2024-12-14

## Context - 文脈

割り込みハンドラは予測不可能なタイミングで実行され、リアルタイム性と信頼性が要求される。以下の問題が懸念される:

- 動的メモリ確保はヒープロックによりデッドロックの可能性がある
- メモリ断片化によりアロケーション失敗の可能性がある
- スタック使用量の予測が困難になる
- システム全体の応答性が低下する

## Status - 採用状況

Accepted

## Decision - 決定事項

割り込みコンテキストにおいて以下の操作を禁止する:

1. 禁止される関数:
   - malloc()
   - free()
   - new
   - delete
   - その他動的メモリ確保を行う関数

2. 代替手段:
   - 静的メモリ確保
   - プール管理
   - メッセージキュー

## Consequences - 結果

### メリット
- デッドロックの防止
- メモリ使用量の予測性向上
- システムの信頼性向上
- 割り込み応答時間の安定化

### デメリット
- 実装の制約
- 静的メモリ管理の必要性

0除算の実行時チェック義務化

adr-05-0除算の実行時チェック義務化.md

# Architecture Decision Records: 0除算の実行時チェック義務化

## Date - 日付

2024-12-14

## Context - 文脈

組み込みシステムにおいて、0除算は以下の重大な問題を引き起こす可能性がある:

- 未定義動作によるシステムの予期せぬ振る舞い
- ハードウェア例外の発生
- システムのクラッシュやリセット
- デバッグの困難さ

特に製品の出荷後は、このような問題の修正が極めて困難となる。

## Status - 採用状況

Accepted

## Decision - 決定事項

すべての除算処理において、以下のルールを適用する:

1. 除算前の0チェックを必須とする:
    ```c
    // 必須の実装パターン
    if (denominator != 0) {
        result = numerator / denominator;
    } else {
        // エラー処理(状況によって異なる)
    }
    ```

2. 禁止されるパターン:
    ```c
    // 禁止:チェックなしの除算
    result = numerator / denominator;
    ```

3. コンパイル時に確定する定数による除算は除く
    ```c
    // OK: コンパイル時に確定する除算
    const int32_t FACTOR = 100;
    result = value / FACTOR;
    ```

## Consequences - 結果

### メリット
- 実行時の予期せぬ動作の防止
- システムの信頼性向上
- デバッグの容易さ
- 問題の早期発見

### デメリット
- コードサイズの増加
- 実行パフォーマンスへの影響
- コーディング量の増加

ISR命名規則と制約

adr-06-ISR命名規則と制約.md

# Architecture Decision Records: ISR命名規則と制約

## Date - 日付

2024-12-14

## Context - 文脈

割り込みハンドラ(ISR)は以下の特性を持つ:
- 非同期で実行される
- 高い優先度を持つ
- 他の処理を遮断する
- リアルタイム性が要求される

特にログ出力に関して、以下の重大な問題が判明している:
- Zephyr RTTログバックエンドにおいて、特定条件下でミューテックスによるブロッキング待ちが発生
- これによりISRコンテキストで例外が発生するリスクがある
- システム全体の信頼性に関わる問題となる

## Status - 採用状況

Accepted

## Decision - 決定事項

1. 命名規則:
    ```c
    // 必須:_isr サフィックス
    void timer0_handler_isr(void)
    void uart_rx_isr(void)
    ```

2. ISR内での禁止事項:

- ログ出力の禁止(RTTを含むすべてのログバックエンド)

    ```c
    // 禁止:ISR内でのログ出力
    void uart_rx_isr(void) {
        LOG_ERR("Error detected");  // NG: ミューテックスによるブロッキングのリスク
    }
    ```

- デバッグプリントの禁止
- 長時間の処理
- 動的メモリ確保
- ブロッキング処理

3. 推奨される実装パターン:
    ```c
    void timer0_handler_isr(void) {
        // 最小限の処理のみ
        flag_set(EVENT_TIMER);
        // タスクへの通知
        notify_task(TASK_ID_TIMER);
        // ログが必要な場合はタスクコンテキストで実行
    }
    ```

## Consequences - 結果

### メリット
- ISRの明確な識別
- リアルタイム性の確保
- コードレビューの容易さ
- 実行時間の予測性向上
- ミューテックスによる例外の防止
- システムの信頼性向上

### デメリット
- タスクとの連携実装が必要
- コーディングの制約増加
- デバッグ情報の取得に工夫が必要

再帰呼び出しの制限

adr-07-再帰呼び出しの制限.md

# Architecture Decision Records: 再帰呼び出しの制限

## Date - 日付

2024-12-14

## Context - 文脈

組み込みシステムにおいて、スタックには以下の制約がある:
- 物理メモリが限られている
- スタックサイズが少なめに設定されている
- スタックオーバーフローは致命的な問題を引き起こす
- 実行時のスタック使用量の予測が困難

再帰呼び出しは特に以下の問題を引き起こす可能性がある:
- 呼び出し深度によってスタック使用量が変動
- 最悪ケースの予測が困難
- スタックオーバーフローのリスク

## Status - 採用状況

Accepted

## Decision - 決定事項

1. 基本ルール:
- 再帰呼び出しの使用を原則として禁止
- 反復(ループ)による実装を推奨

2. 例外的な許可条件:
- スタック使用量の解析が完了している
- 最大呼び出し深度が明確
- 解析内容を詳細にコメントで記録

3. 許可される実装例:
    ```c
    /*
    * スタック使用量解析結果
    * アーキテクチャ: Cortex-M0+
    * コンパイラ: GCC 10.2.1
    * コンパイラオプション: -Os -g
    * 解析ツール: ARM Compiler 6.16
    * 最大呼び出し深度: 4
    * 1回あたりのスタック使用量: 16バイト
    * 最大スタック使用量: 64バイト
    * 解析日: 2024-12-14
    */
    int32_t factorial(int32_t n) {
        if (n <= 1) return 1;
        return n * factorial(n - 1);
    }
    ```

4. 禁止される実装例:
    ```c
    // NG: スタック解析なしの再帰
    void process_tree(node_t* node) {
        if (node == NULL) return;
        process_tree(node->left);
        process_tree(node->right);
    }
    ```

## Consequences - 結果

### メリット
- スタック使用量の予測性向上
- システムの信頼性向上
- 実行時の安全性確保
- メモリ使用量の明確化

### デメリット
- 一部のアルゴリズム実装が複雑化
- スタック解析の工数が必要
- コード可読性が低下する可能性

### 運用上の注意点
- スタック解析は環境が変わるたびに再実施
- コンパイラのバージョンアップ時は要再解析
- 最適化オプションの変更時は要再解析

ADRについて

ここまでいくつかのADRを例示しましたが、読者の皆さんの現場でも「あるある」な事例が思い浮かんだのではないでしょうか? マイコンの機種固有の問題や、開発環境の制約、プロジェクト特有のノウハウなど、組み込み開発には多くの暗黙知が存在します。

このようなADRをチームで作成・運用していく過程自体が技術継承となります。また、後からチームに参加したメンバーにとっても、「なぜそうしているのか」を理解する貴重な情報源となるでしょう。

さて、せっかく形式知化したこれらのナレッジ。人力でコードレビューに活用するのも良いのですが、これをAIに学習させることで、より効率的なコードレビューができないでしょうか?そう考えた私は、あるツールを試してみることにしました...

AIレビュワーの活用

今回は Anthropic が発表し、話題になっている MCP を使用してリポジトリ内に格納したADR文書をもとにAIにレビューしてもらえないか検討してみました。

プロジェクトの構成

project/
├── adr/                      # ADRを格納するディレクトリ
│   ├── adr-01-浮動小数点の使用禁止.md
│   ├── adr-02-除算演算のガイドライン.md
│   └── adr-03-固定幅整数型の使用義務化.md
│
├── main.c
└── README.md                # プロジェクトの説明

MCPを使ったレビュー

Model Context Protocol (MCP) は、AIモデルに対してコンテキストを提供するための標準化されたプロトコルです。今回はこのMCPを利用して、ADRの内容をAIに理解させ、コードレビューを自動化してみました。

MCPの実装は https://github.com/modelcontextprotocol/servers から入手可能です。今回は以下の2種類のサーバーを使用します:

  • Local File Server: ローカルファイルシステム上のADRファイルを読み込むために使用
  • Git Server: リポジトリの任意の時点での差分を取得するために使用(git_diffがリリース版には提供されていないため、最新ソースをクローンして使用しています)

設定ファイル

claude_desktop_config.json の設定例:

{
    "mcpServers": {
        "git": {
            "command": "/Users/yuito/.local/bin/uv",
            "args": [
                "--directory",
                "/Users/yuito/src/github.com/modelcontextprotocol/servers/src/git",
                "run",
                "mcp-server-git"
            ]
        },
        "filesystem": {
            "command": "/Users/yuito/.volta/bin/npx",
            "args": [
                "-y",
                "@modelcontextprotocol/server-filesystem",
                "/Users/yuito/"
            ]
        },
    }
}

(コマンドが絶対パスで指定しているのは volta が悪さをしているのか PATH が通っているのに環境でコマンドが見つからないことがあったためです。原因の追及はしていませんが、ひとまず絶対パスを指定することで動作するようになりました。)

プロンプト例

コードレビューをしてもらいます
/path/to/git/dir/adr ディレクトリに複数のADR (Architecture Decision Record) があります。それぞれのADRについて、内容を確認してください。
/path/to/git/dir/ ディレクトリで git_diff を使って targetは origin/main..origin/source_branch で差分を取得してください。
この変更点に対して ADR に違反していることがないかチェックしてください。明確にADRに記載されている禁止事項があったときだけ指摘してください。

レビュー例

今回はZephyrの基本的なLチカサンプルに対しての変更をレビューしてもらうこととしました。

main.c(クリックで展開)

/*
 * Copyright (c) 2016 Intel Corporation
 *
 * SPDX-License-Identifier: Apache-2.0
 */

#include <stdio.h>
#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>

/* 1000 msec = 1 sec */
#define SLEEP_TIME_MS   1000

/* The devicetree node identifier for the "led0" alias. */
#define LED0_NODE DT_ALIAS(led0)

/*
 * A build error on this line means your board is unsupported.
 * See the sample documentation for information on how to fix this.
 */
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);

int main(void)
{
    int ret;
    bool led_state = true;

    if (!gpio_is_ready_dt(&led)) {
        return 0;
    }

    ret = gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);
    if (ret < 0) {
        return 0;
    }

    while (1) {
        ret = gpio_pin_toggle_dt(&led);
        if (ret < 0) {
            return 0;
        }

        led_state = !led_state;
        printf("LED state: %s\n", led_state ? "ON" : "OFF");
        k_msleep(SLEEP_TIME_MS);
    }
    return 0;
}

試しに次のような変更点を加えます。Lチカ周期に使用している整数リテラル浮動小数点にしてみました。ちゃんと指摘できるでしょうか?やってみます。

diff --git a/main.c b/main.c
index 4cab496..f4c5e4b 100644
--- a/main.c
+++ b/main.c
@@ -9,7 +9,7 @@
 #include <zephyr/drivers/gpio.h>
 
 /* 1000 msec = 1 sec */
-#define SLEEP_TIME_MS   1000
+#define SLEEP_TIME_MS   1000.0f
 
 /* The devicetree node identifier for the "led0" alias. */
 #define LED0_NODE DT_ALIAS(led0)

結果

実際にやってみると何度かtoolの使用確認が出てきて・・・

最終的にこのような結果が得られました。

はい、承知しました。以下の手順で進めていきます:

1. まずADRの内容を確認します
2. 次にgit diffで差分を確認します
3. 最後に差分がADRに違反していないかチェックします

まず、ADRの内容を確認しましょう:

それぞれのADRの内容を確認していきます:

次に、git diffで差分を確認します:

ADRの内容と差分を照らし合わせて、違反がないかチェックします。

この変更内容を確認したところ、以下のADRに対する明確な違反が見つかりました:

1. ADR-01「浮動小数点の使用禁止」への違反
   - 変更点で `SLEEP_TIME_MS` の値を `1000.0f` と定義していますが、これは明確に浮動小数点数(float型)の使用であり、ADR-01で禁止されている float 型の使用に該当します。
   - ADR-01では、float, double, long double の使用が明確に禁止されています。
   - 代わりに整数値の `1000` を使用するべきです。

つまり、この変更は ADR-01 に違反しており、以下のように修正する必要があります:

    ```c
    #define SLEEP_TIME_MS   1000    // 整数値を使用
    ```

その他のADRについては、この変更による違反は見られません。

ちゃんと指摘できていますね!

GitLab対応MCP Serverを使ったレビューの自動化

上記のClaudeを使ったレビュー結果を手動でGitLabにコピペするのも一つの手ですが、せっかくならレビューコメントの投稿まで自動化したいところです。

GitLabのサーバー自体はMCP純正で提供されていますが、実際のレビューに必要なToolがありませんでした。そこで、GitLab APIを利用したMCP Serverを自作することにしました。

必要なAPIは以下の3種類です: - マージリクエストの詳細取得 - マージリクエストの最新バージョン取得 - ディスカッション(レビューコメント)の投稿

実装したサーバーのコードは以下のリポジトリで公開しています:

github.com

プロンプト例

GitLabプロジェクト **project_id** の マージリクエスト **merge_request_iid** 番のコードレビューをしてもらいます。 
まず **project_dir**/adr ディレクトリに複数のADR (Architecture Decision Record) があります。それぞれのADRについて、内容を確認してください。
マージリクエストの内容を取得してください
マージリクエストのプロジェクトのgitディレクトリは **project_dir** と同期しています。一旦git fetchしてください
マージリクエストの情報から git_diff を使って targetは origin/target_branch..origin/source_branch で差分を取得してください。
この変更点に対して ADR に違反していることがないかチェックしてください。明確にADRに記載されている禁止事項があったときだけ指摘してください。指摘にあたる処理が見当たらなければここで終了です
指摘ごとに以下の手順を繰り返してください
gitの差分から指摘の行番号(複数ある場合は最後の行)を割り出してください。
そして、gitlab discussion を作成してください。
  - base_sha/head_sha/start_sha は get_merge_request_latest_version より取得できます
  - new_path, old_path は指摘したいファイルの名称です
  - new_lineは先ほど割り出した行番号です
  - old_lineは常にnullでお願いします
ADRに違反する項目の指摘事項をまとめbodyに入れてください。

結果

承知しました。順を追って作業を進めていきます。

1. まずADRの内容を確認します:

各ADRの内容を確認します:

2. マージリクエストの内容を取得します:

3. gitのfetchを実行します:

4. 差分を確認します:

5. バージョン情報を取得します:

ADRの内容とマージリクエストの差分を確認したところ、以下のADR違反が見つかりました:

1. ADR-01「浮動小数点の使用禁止」に違反しています。

コードでは `1000.0f` という浮動小数点数を使用していますが、ADR-01では明確に float の使用を禁止しています。

この違反について、GitLab discussionを作成します:

これにて、すべてのADR違反の確認と指摘が完了しました。

ちゃんと投稿できました。

完全自動レビュー対応

ここからは少し遊び心のある実装をご紹介します。実用的かどうかは読者の皆さんの判断にお任せしますが...。

現状のMCPを使ったレビューでは、以下のような手間が残っています:

  • プロジェクトIDとマージリクエストIDの指定
  • 連動するGitリポジトリディレクトリパスの指定
  • 新規チャット時の "Allow for This Chat" ボタンの連打

これらを自動化できないか考えました。そこで、MacAppleScriptを活用して完全自動化に挑戦してみました:

  1. GitLab CIのRunner環境としてMacを設定
  2. AppleScriptで:
    • Claudeの起動
    • プロンプトの入力自動化
    • "Allow for This Chat" ボタンの自動クリック

GitLab CIの設定例です。

workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' # MRイベントの場合のみ実行

auto_review:
  stage: build
  script:
    - osascript ./script/auto-reviewer.scpt ${CI_PROJECT_ID} ${CI_MERGE_REQUEST_IID} ${CI_PROJECT_DIR} & PID=$!;
    - osascript ./script/monitor-process-and-directory.scpt $PID ${CI_PROJECT_DIR}
  tags:
    - advent-calendar
  timeout: 300s

さらに、無機質になりがちなレビューコメントに遊び心を加えてみました。プロンプトに含む構文ファイルさえ用意すれば色々な構文でレビューしてくれます。

実装上の注意点

実装したスクリプトを使用する際には、以下の点に注意が必要です:

  1. Electronアプリへのアクセシビリティのため:

  2. 初期設定時の注意:

  3. パフォーマンスの課題:

    • プロンプト実行完了時にUI要素の監視が重くなる現象が発生
    • 対策として別スクリプトを並列実行
    • プロンプト完了は .done ファイルの出力で検知

実装したAppleScriptは以下のリポジトリで公開しています:

github.com

これによって誰かがMRを作成すると私のmacの画面がぴょこぴょこ動いてしまい全く実用的ではありませんが余っているMacがあれば、個性的なAIレビュワーとして活躍させてみてはいかがでしょうか?

記事の終わりに変更差分とAIレビュアーが行った指摘を並べて終わりとさせていただきます。

0除算

差分

ユーザー入力の数値で割り算を実行する

diff --git a/main.c b/main.c
index 4cab496..f92c2a2 100644
--- a/main.c
+++ b/main.c
@@ -34,6 +34,12 @@ int main(void)
                return 0;
        }
 
+       int32_t denominator;
+       printf("Enter the interval denominator: ");
+       scanf("%d", &denominator);
+
+       interval = SLEEP_TIME_MS / denominator;
+
        while (1) {
                ret = gpio_pin_toggle_dt(&led);
                if (ret < 0) {
@@ -42,7 +48,7 @@ int main(void)
 
                led_state = !led_state;
                printf("LED state: %s\n", led_state ? "ON" : "OFF");
-               k_msleep(SLEEP_TIME_MS);
+               k_msleep(interval);
        }
        return 0;
 }

指摘

ちいかわ構文

割り込みコンテキスト

差分

タイマーによるLチカをしちゃった例

diff --git a/main.c b/main.c
index 4cab496..db7cd67 100644
--- a/main.c
+++ b/main.c
@@ -19,11 +19,31 @@
  * See the sample documentation for information on how to fix this.
  */
 static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);
+struct k_timer my_timer;
+
+void timer_expiry_function(struct k_timer *dummy)
+{
+       static bool *led_state = NULL;
+
+       if (led_state == NULL) {
+               led_state = (bool *)malloc(sizeof(bool));
+               *led_state = false;
+       }
+
+       int ret = gpio_pin_toggle_dt(&led);
+       if (ret < 0) {
+               return;
+       }
+
+       led_state = !led_state;
+       printf("LED state: %s\n", led_state ? "ON" : "OFF");
+}
+
+K_TIMER_DEFINE(my_timer, timer_expiry_function, NULL);
 
 int main(void)
 {
        int ret;
-       bool led_state = true;
 
        if (!gpio_is_ready_dt(&led)) {
                return 0;
@@ -34,15 +54,10 @@ int main(void)
                return 0;
        }
 
+       k_timer_start(&my_timer, K_MSEC(SLEEP_TIME_MS), K_MSEC(SLEEP_TIME_MS));
+
        while (1) {
-               ret = gpio_pin_toggle_dt(&led);
-               if (ret < 0) {
-                       return 0;
-               }
-
-               led_state = !led_state;
-               printf("LED state: %s\n", led_state ? "ON" : "OFF");
-               k_msleep(SLEEP_TIME_MS);
+               k_sleep(K_FOREVER);
        }
        return 0;
 }

指摘

printfも使っているので複数の指摘

ちいかわ構文

mallocの指摘

おじさん構文

再帰呼び出し

差分

Lチカを再帰呼び出しで実現した意欲作

index 4cab496..46e1637 100644
--- a/main.c
+++ b/main.c
@@ -20,10 +20,26 @@
  */
 static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);
 
+void on_led(void);
+void off_led(void);
+
+void on_led(void){
+               gpio_pin_set_dt(&led, 1);
+               printf("LED state: ON");
+               k_msleep(SLEEP_TIME_MS);
+               off_led();
+}
+
+void off_led(void){
+               gpio_pin_set_dt(&led, 0);
+               printf("LED state: OFF");
+               k_msleep(SLEEP_TIME_MS);
+               on_led();
+}
+
 int main(void)
 {
        int ret;
-       bool led_state = true;
 
        if (!gpio_is_ready_dt(&led)) {
                return 0;
@@ -34,15 +50,8 @@ int main(void)
                return 0;
        }
 
-       while (1) {
-               ret = gpio_pin_toggle_dt(&led);
-               if (ret < 0) {
-                       return 0;
-               }
 
-               led_state = !led_state;
-               printf("LED state: %s\n", led_state ? "ON" : "OFF");
-               k_msleep(SLEEP_TIME_MS);
-       }
+       on_led();
+
        return 0;
 }

指摘

ツンデレ構文


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 photosynth.co.jp Akerunにご興味のある方はこちらから akerun.com