TECH PLAY

株式会社エブリー

株式会社エブリー の技術ブログ

385

はじめに こんにちは、エブリーでサーバーサイドをメインに担当している清水です。 私のチームではPHP, Laravelを使用して小売店向けのSaaS側Webサービスの開発を行っています。 過去の記事 でご紹介した通り、 私たちはモノレポの構成を採用しており、リポジトリの中身は大きく3つに分けることができます。 モバイルアプリ向け(mobile-api) 管理画面向けAPI (dashboard-api) 両APIで共通の部分(共通パッケージやGitHub Actionsの設定ファイルなど) 過去に 本ブログで紹介 されたDev Containersを私たちのチームでも導入検討を行いましたので、本記事でご紹介いたします。 開発するときに感じていた課題 開発環境は Docker を使って整備していたものの、実際の現場では次のような課題を感じていました。 新メンバーの環境構築に時間がかかる ローカルのIDEで使用するPHPのインストール、gitの設定など必要な手順が複数あり、ドキュメントを読んでも初回セットアップで詰まるケースがありました。 ※アプリ自体は Docker コンテナ上で動作していたものの、ローカルの IDE(VS Code)で補完や静的解析を有効にするため、ローカルにも PHP をインストールする必要がありました。 ローカルを汚したくない PHP等のプロジェクト固有の開発に必要なものをローカルに直接インストールしたくない、という意見もありました。 PCを変更するときにいろいろインストールし直さなければならなくなる手間が増えることといった問題も起こりえます。 VS Code の拡張機能や設定など、開発環境がメンバーによって違う 特に「どの拡張機能を入れるべきか」が明文化されておらず、新規メンバーが最初に迷いやすい状態でした。 「そんな便利な拡張機能あったの!?」みたいなことが起きることもあります。 Dev Containers導入で期待できること 「誰が開いても同じ環境になる」再現性 各メンバーが VS Code で “Reopen in Container” するだけで PHP やその他ツールのバージョン、拡張機能が完全に一致します。 環境構築を自動化できる postCreateCommand / postAttachCommand .env 作成、composer install、DB マイグレーション、GitHub 認証など、手作業になりがちな初期セットアップを自動化できます。 私たちはこれらの作業をmakeコマンドを利用することで部分的に自動化できていましたが、さらに楽できそうです。 チーム全体で同じ VS Code 拡張をプリセット Intelephense, Xdebug, Namespace Resolver など、Laravel 開発に必要な拡張を共通化し、環境差異を解消しました。 postCreateCommand / postAttachCommand とは postCreateCommand: コンテナが 作成直後またはリビルド直後 に実行されるコマンドを定義する機能 postAttachCommand: 既存のコンテナに VS Code/Dev Containers が接続(アタッチ)されたときに実行されるコマンドを定義する機能 参考: https://containers.dev/implementors/json_reference/#lifecycle-scripts devcontainer.jsonに以下のように設定すると起動時の操作を自動化できる // 初回セットアップ " postCreateCommand ": " bash mobile-api/.devcontainer/scripts/postCreateCommand.sh ", // 再接続ごとの軽い同期 " postAttachCommand ": " bash mobile-api/.devcontainer/scripts/postAttachCommand.sh ", 導入にあたって悩んだこと・着地点 1. モノレポ全体を開発するためのコンテナを新しく作るか、既存のコンテナにDev Containersを入れるか 最初に悩んだのは、「Dev Containers専用のコンテナを新規に構築するか、それとも既存のコンテナを流用するか」でした。 リポジトリはもともと Docker Compose ベースで構築されており、すでにアプリケーションやDBなどの実行環境が整っていました。 そのため、「開発用に新しくコンテナを作り直すのは時間がかかるうえに、既に動いている環境を追加で作成するのも冗長ではないか」という懸念がありました。 まずはお試しとして、 既存の mobile-api コンテナに .devcontainer を追加する形 でDev Containers を導入してみることにしました。 2. モノレポ構成ゆえに、コンテナ内から外側を編集できずに困った 既存の mobile-api に .devcontainer を追加して起動してみたところ、思わぬ落とし穴がありました。 mobile-api はモノレポの一部のディレクトリであり、その外側には共通パッケージ ( packages/common ) や GitHub Actions の設定が配置されています。 しかし、Dev Container のマウント対象が mobile-api 直下だけだったため、コンテナ内からリポジトリ外側のファイルを編集できない状況でした。 たとえば、API 側で共通パッケージの修正をしたいときに、コンテナを立ち上げたままでは packages/common にアクセスできず、いったんホスト側で開き直す必要がありました。 モノレポで開発しているからこそ、API側と一緒に共通パッケージを編集したいケースも多いのでかなり悩みました。 最終的には、Dev Container のワークスペースマウントをリポジトリ全体( /workspaces )に変更することで解決しました。 これにより、 mobile-api ・ dashboard-api ・ packages のすべてをコンテナ内から一貫して操作できるようになりました。 devcontainer.jsonに以下のような内容を追加します " workspaceFolder ": " /workspaces ", " mounts ": [ " source=${localWorkspaceFolder}/..,target=/workspaces,type=bind,consistency=cached " , ] , 3. VS CodeのターミナルからGitHubに接続できない コンテナ内はローカルとは異なる環境なのでGit関連の設定が必要です。 Git操作だけローカルでやればいいのではないか?とも思いましたが、VS Code上の操作ができないことはかなり不便に感じたので対応が必要でした。 devcontainer.json内に git / GitHub CLI のインストール設定を追加 " features ": { " ghcr.io/devcontainers/features/git:1 ": {} , " ghcr.io/devcontainers/features/github-cli:1 ": {} } コンテナ内でgh auth loginを叩いてGitHub認証を行う形を採用 postAttachCommandで以下のコマンドを自動的に実行する形にしました gh auth login --hostname github.com --git-protocol https --web && gh auth setup-git 開き直すたびに認証を求められないように、GitHub CLIの認証情報は名前付きボリュームに保存する形を採用しました " mounts ": [ " source=${localWorkspaceFolder}/..,target=/workspaces,type=bind,consistency=cached ", " source =gh_config, target =/ root /. config / gh , type = volume " //GitHub用のボリューム ] , どうしても原因がわからないのですが、自動実行の場合のみ以下のようなエラー表示になるので、手動でブラウザでURLを開いて認証する形で妥協しました Running the postAttachCommand from devcontainer.json... [devcontainer-postattach] start [devcontainer-postattach] vendor present -> skip install [devcontainer-postattach] gh not logged in -> launching web login ! First copy your one-time code: XXXX-XXXX Press Enter to open https://github.com/login/device in your browser... /usr/bin/xdg-open: 1032: www-browser: not found /usr/bin/xdg-open: 1032: links2: not found /usr/bin/xdg-open: 1032: elinks: not found /usr/bin/xdg-open: 1032: links: not found /usr/bin/xdg-open: 1032: lynx: not found /usr/bin/xdg-open: 1032: w3m: not found xdg-open: no method available for opening 'https://github.com/login/device' ! Failed opening a web browser at https://github.com/login/device exit status 3 Please try entering the URL in your browser manually 最終的なdevcontainer.jsonの内容 { " name ": " mobile-api devcontainer ", // ルートの compose.yml を使用 " dockerComposeFile ": " ../compose.yml ", " service ": " mobile-api ", // モノレポ全体を作業対象に " workspaceFolder ": " /workspaces ", // 一緒に起動するサービス " runServices ": [ " mobile-api ", " database " ] , // VS Code 設定 " settings ": { " terminal.integrated.defaultProfile.linux ": " bash ", " git.openRepositoryInParentFolders ": " always " } , // 推奨拡張機能 " extensions ": [ " bmewburn.vscode-intelephense-client ", " xdebug.php-pack ", " mehedidracula.php-namespace-resolver ", " ms-azuretools.vscode-docker " ] , // コンテナ機能(git と GitHub CLI をインストール) " features ": { " ghcr.io/devcontainers/features/git:1 ": {} , " ghcr.io/devcontainers/features/github-cli:1 ": {} } , // モノレポ全体を /workspaces にマウント // gh CLI の認証情報は volume で永続化 " mounts ": [ " source=${localWorkspaceFolder}/..,target=/workspaces,type=bind,consistency=cached ", " source=gh_config,target=/root/.config/gh,type=volume " ] , // 初回セットアップ " postCreateCommand ": " bash mobile-api/.devcontainer/scripts/postCreateCommand.sh ", // 再接続ごとの軽い同期 " postAttachCommand ": " bash mobile-api/.devcontainer/scripts/postAttachCommand.sh ", // コンテナユーザー " remoteUser ": " root " } ※部分的にブログ用の内容に書き換えてあります おわりに Dev Containers を導入したことで、VS Code を開いて GitHub の認証を行うだけで、すぐに開発できる環境が整うようになりました。 これまで初期セットアップに時間を取られていた部分が一気に自動化され、特に新メンバーのオンボーディングが大幅に楽になったのではないかと感じています。 まだ本格的な運用段階には入っておらず、今後チーム全体で利用を進めていく中で、思わぬ問題が出てくる可能性もあります。 運用していくうちに「やはり Dev Containers 専用のコンテナを新規に構築した方がよかったかもしれない」と感じる場面が出てくるかもしれない気がしています。 新たに気付くことがあればまた本ブログで紹介したいと思います。 最後までお読みいただきましてありがとうございました。
アバター
はじめに デリッシュキッチンのiOSアプリを開発している成田です。 2025年10月24日にSwift SDK for Androidのプレビュー版がリリースされました( Announcing the Swift SDK for Android )。 📣Announcing the first preview releases of Swift for Android, enabling you to build Android business logic with the same Swift that you use for Apple platforms. https://t.co/UAR6LO3prQ #Android pic.twitter.com/QNKY2bCrFi — Swift Language (@SwiftLang) 2025年10月24日 Swift SDK for Androidは、Swiftで書いたコードをAndroid向けにビルド・実行できるようにするためのSDKです。 今回は、そんなSwift SDK for Androidを使ったサンプルアプリの1つを実際に動かしてみながら、その応用としてiOSアプリ内の汎用的な文字列バリデーションロジックを抽出し、Androidアプリで使ってみようと思います。 サンプルプロジェクトを起動してみる サンプルアプリを動かす前に、Swift SDK for Androidのセットアップが必要です。 公式ガイド を参考に、Host Toolchain、Swift SDK、Android NDKのセットアップを行いました。詳細な手順は他の記事でも紹介されているため、ここでは省略します。 今回はサンプルアプリとして、 swift-android-examples リポジトリの hello-swift-raw-jni を動かしてみました。 Hello from Swift ❤️ という文字列が画面の中央に表示される非常にシンプルなアプリになっています。 調べてみると、確かに helloswift.swift というSwiftファイルがあり、Swiftで書かれたコードが呼ばれているのが分かります。 import Android @_cdecl("Java_org_example_helloswift_MainActivity_stringFromSwift") public func MainActivity_stringFromSwift(env: UnsafeMutablePointer<JNIEnv?>, clazz: jclass) -> jstring { let hello = ["Hello", "from", "Swift", "❤️"].joined(separator: " ") return hello.withCString { ptr in env.pointee!.pointee.NewStringUTF(env, ptr)! } } このコードでは、以下の処理を行っています。 @ cdecl属性: JNIの命名規則に合わせた関数名を指定します。Java {パッケージ名} {クラス名} {メソッド名}という形式で、Kotlin側のMainActivity.stringFromSwift()メソッドに対応します。 JNI環境パラメータ: envはJNIのAPIを呼び出すために必要な環境へのポインタ、clazzは呼び出し元のJavaクラスです。 文字列の生成と変換: Swiftの文字列を配列から生成し、withCStringでC文字列に変換した後、NewStringUTFを使ってJavaのjstring型に変換して返します。 このように、生のJNIを使う場合は、Swift側でJNIのAPIを直接使ってJava/Kotlin側の型と相互変換する必要があります。 JNIについて JNI(Java Native Interface)は、Java/KotlinからC/C++を呼び出すための標準的なインターフェースです。 上記の例では、KotlinからC/C++をJNI経由で呼び出し、さらにそのC/C++からSwift関数を呼び出しています。 バリデーションロジックをAndroidで使えるようにする手順 概要 今回は、以下のような汎用的なバリデーションロジックを移行することを考えます。例えば、iOS側では以下のようにメールアドレスとパスワードの、正規表現を使ったバリデーションロジックを実装していたとします: iOS側での使用例(Swift): import Foundation extension String { func isValidEmail () -> Bool { let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" return NSPredicate(format : "SELF MATCHES %@" , emailRegex).evaluate(with : self ) } func isValidPassword () -> Bool { let passRegax = "^[a-zA-Z0-9^$*.\\[\\]{}()?\\-\"!@#%&\\/\\,><':;|_~`=+]{8,256}$" return NSPredicate(format : "SELF MATCHES %@" , passRegax).evaluate(with : self ) } } この汎用的なバリデーションロジックをAndroidでも使えるようにするため、Swift SDK for Androidを使用して移行していきます。 実装手順 実装は以下の7つのステップで進めます: Swiftライブラリの作成: Swift Packageとして StringValidator を実装します swift-java.config の設定: Javaラッパー生成のための設定ファイルを作成します build.gradle の設定: SwiftビルドとJavaラッパー生成の設定を追加します swiftkit-core の公開: 必要なJavaパッケージをローカルMavenリポジトリに公開します Androidアプリの作成: validation-app を作成し、 validation-lib への依存関係を追加します KotlinからSwift関数を呼び出す: 生成されたJavaラッパーを使ってKotlinから呼び出します UI実装: バリデーションをテストするためのメールアドレスとパスワードのフォームを作成します それでは、各ステップの詳細を見ていきましょう。 1. Swiftライブラリの作成 サンプルプロジェクトには既に hello-swift-java ディレクトリ配下に hashing-lib / hashing-app という例が含まれています。これらをテンプレートとして、同じディレクトリ配下に validation-lib ディレクトリを作成し、Swift Packageとして StringValidator を実装してみます。 Package.swiftの設定 // swift-tools-version: 6.1 import CompilerPluginSupport import PackageDescription let package = Package( name : "StringValidator" , platforms : [ .macOS ( .v15 )] , products : [ .library ( name: "StringValidator" , type: .dynamic , targets: [ "StringValidator" ]) ] , dependencies : [ .package ( url: "https://github.com/swiftlang/swift-java" , branch: "main" ) , ] , targets : [ .target ( name: "StringValidator" , dependencies: [ .product ( name: "SwiftJava" , package: "swift-java" ) , .product ( name: "CSwiftJavaJNI" , package: "swift-java" ) , ] , plugins: [ .plugin ( name: "JExtractSwiftPlugin" , package: "swift-java" ) ] ) , ] ) StringValidator.swiftの実装 元のiOSコードでは extension String として実装されていましたが、 swift-java プラグインが自動的にJavaラッパーを生成するため、トップレベルの public func として実装します: import Foundation import SwiftJava public func isValidEmail (_ email : String ) -> Bool { let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" return matches(pattern : emailRegex , string : email ) } public func isValidPassword (_ password : String ) -> Bool { guard password.count >= 8 && password.count <= 256 else { return false } let passRegex = "^[a-zA-Z0-9^$*.\\[\\]{}()?\\-\"!@#%&\\/\\,><':;|_~`=+]+$" return matches(pattern : passRegex , string : password ) } private func matches (pattern : String , string : String ) -> Bool { guard let regex = try ? NSRegularExpression(pattern : pattern , options : [] ) else { return false } let range = NSRange(location : 0 , length : string.utf16.count ) return regex.firstMatch( in : string , options : [] , range : range ) != nil } iOSコードとの違い iOS側: extension String で isValidEmail() や isValidPassword() として実装( "test@example.com".isValidEmail() のように呼び出し) Android側: トップレベルの public func として実装( isValidEmail("test@example.com") のように呼び出し) swift-java プラグインはトップレベルの public func に対してJavaラッパーを生成するため、この形式で実装しています。機能的には同じバリデーションロジックを提供します。 2. swift-java.configの設定 swift-java プラグインの設定ファイルを作成します。このファイルは Sources/StringValidator/ ディレクトリに配置します: { " javaPackage ": " com.example.stringvalidator ", " mode ": " jni " } javaPackage : 生成されるJavaラッパーのパッケージ名を指定 mode : jni モードを指定(JNI経由でSwift関数を呼び出す) 3. build.gradleの設定 hashing-lib の build.gradle を参考に、SwiftビルドとJavaラッパー生成の設定を追加します。 主な設定内容 Swift SDKのパス設定: getSwiftlyPath() と getSwiftSDKPath() 関数でSwift SDKとSwiftlyのパスを自動検出します。 全ABI向けのビルドタスク: arm64-v8a、armeabi-v7a、x86_64の3つのABI向けにSwiftコードをビルドします。各ABIごとに buildSwift${abi} タスクが作成されます。 Swiftランタイムライブラリのコピー: Swiftランタイムライブラリ( swiftCore 、 Foundation など)を自動的にコピーします。 生成されたJavaファイルのソースディレクトリへの追加: swift-java プラグインが生成したJavaラッパーファイルを、Androidライブラリのソースセットに追加します。 build.gradle の主要な設定は以下の通りです: plugins { alias(libs.plugins.android.library) } android { namespace "com.example.validationlib" compileSdkVersion 34 defaultConfig { minSdkVersion 28 } } dependencies { implementation( 'org.swift.swiftkit:swiftkit-core:1.0-SNAPSHOT' ) } // Swift SDKのパスを取得する関数 def getSwiftlyPath() { // 環境変数またはgradle.propertiesから取得 // または一般的なパスを検索 } def getSwiftSDKPath() { // Swift SDKのパスを取得 } // ABI定義 def abis = [ "arm64-v8a" : [triple: "aarch64-unknown-linux-android28" , .. .], "armeabi-v7a" : [triple: "armv7-unknown-linux-android28" , .. .], "x86_64" : [triple: "x86_64-unknown-linux-android28" , .. .] ] // 全ABI向けにSwiftビルドタスクを作成 def buildSwiftAll = tasks. register ( "buildSwiftAll" ) { inputs.file( new File(projectDir, "Package.swift" )) inputs.dir( new File(projectDir, "Sources/StringValidator" )) // ... } abis. each { abi, info -> def task = tasks. register ( "buildSwift ${abi.capitalize()} " , Exec) { workingDir = layout.projectDirectory executable(getSwiftlyPath()) args( "run" , "swift" , "build" , "+ ${swiftVersion} " , "--swift-sdk" , info.triple) } buildSwiftAll.configure { dependsOn(task) } } // 生成されたJavaファイルとJNIライブラリをソースセットに追加 android { sourceSets { main { java { srcDir(buildSwiftAll) } jniLibs { srcDir(generatedJniLibsDir) } } } } preBuild.dependsOn(copyJniLibs) 4. swiftkit-coreの公開 swift-java プロジェクトは、SwiftからJava/Kotlinへのラッパー生成に必要なJavaパッケージ( swiftkit-core など)をまだ公式のMavenリポジトリに公開していません。そのため、Androidプロジェクトで利用するには、ローカルMavenリポジトリに公開して参照可能にする必要があります。 SwiftやiOS開発での例に置き換えると、CocoaPodsやSwift Package ManagerでまだGitHubや公式リポジトリに公開されていないライブラリをローカルパスから利用するのと同じイメージです。Mavenリポジトリは、Java/Kotlinのライブラリを配布する仕組みで、SPMやCocoaPodsリポジトリのようなものです。 以下のコマンドをターミナルで実行します: $ cd hello-swift-java/validation-lib $ swift package resolve $ ./.build/checkouts/swift-java/gradlew --project-dir .build/checkouts/swift-java :SwiftKitCore:publishToMavenLocal 5. Androidアプリの作成 validation-lib と同じディレクトリ配下( hello-swift-java ディレクトリ配下)に validation-app ディレクトリを作成し、 validation-lib への依存関係を追加します。 Androidアプリのビルドと依存関係の管理には、Gradleというビルドツールを使用します。iOS開発でSwift Package Manager (SPM)の Package.swift やCocoaPodsの Podfile で依存関係を定義するのと同様に、Androidでは build.gradle.kts ファイルで依存関係を定義します。また、Xcodeプロジェクトの .xcodeproj で設定を管理するのと同様に、Gradleでは build.gradle.kts でアプリの設定と依存関係を一括で管理します。 build.gradle.ktsの設定 plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) } android { namespace = "com.example.validationapp" compileSdk = 36 defaultConfig { applicationId = "com.example.validationapp" minSdk = 28 targetSdk = 36 } buildFeatures { compose = true } } dependencies { implementation(project( ":hello-swift-java-validation-lib" )) // Jetpack Compose関連の依存関係 implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) implementation(libs.androidx.material3) // ... } 6. KotlinからSwift関数を呼び出す 生成されたJavaラッパーを使って、KotlinからSwift関数を呼び出します。 swift-java プラグインが自動的に生成した StringValidator クラスを使用します: import com.example.stringvalidator.StringValidator val isValid = if (email.isNotEmpty()) { StringValidator.isValidEmail(email) } else null このコードでは、 swift-java プラグインが自動生成した StringValidator クラスの静的メソッド isValidEmail() を呼び出しています。 生成されたJavaラッパーは、Swiftのトップレベル関数をJava/Kotlinの静的メソッドとして提供するため、通常のクラスメソッドを呼び出すのと同じ感覚で、Swiftで書いた関数を利用できます。 7. UI実装 Jetpack Composeでバリデーションをテストするための入力フォームを実装します。 この辺はCursorによしなに作ってもらいました。偉大です。 MainActivity.ktの実装例(主要部分のみ) import com.example.stringvalidator.StringValidator import androidx.compose.runtime. * import androidx.compose.material3. * @Composable fun ValidationScreen() { var email by remember { mutableStateOf( "" ) } var password by remember { mutableStateOf( "" ) } Column { // Emailバリデーション val isValidEmail = if (email.isNotEmpty()) { StringValidator.isValidEmail(email) } else null TextField( value = email, onValueChange = { email = it }, isError = isValidEmail == false ) // Passwordバリデーション val isValidPassword = if (password.isNotEmpty()) { StringValidator.isValidPassword(password) } else null TextField( value = password, onValueChange = { password = it }, isError = isValidPassword == false ) } } このコードでは、 StringValidator.isValidEmail() と StringValidator.isValidPassword() を直接呼び出すことで、Swiftで書いたバリデーションロジックを使用しています。 remember { mutableStateOf(...) } で状態を管理し、 TextField の onValueChange で値が変更されるたびにバリデーション関数が自動的に再実行されます。その結果が isError プロパティに反映されるため、ユーザーが入力している間、リアルタイムでバリデーション結果が表示されます。 動作確認・デモ エミュレータでの動作確認 Android Studioで validation-app を実行し、エミュレータで動作確認をします。 アプリを起動すると、メールアドレスとパスワードの入力フォームが表示されます。各フィールドに入力すると、リアルタイムでバリデーションが実行され、以下のように動作します。 Emailフィールド : 有効なメールアドレス形式で入力すると、エラー表示が消えます。無効な形式(例: test@ や test など)では、エラー状態が表示されます Passwordフィールド : 8文字以上256文字以下の要件を満たす有効な文字列を入力すると、エラー表示が消えます。要件を満たさない場合は、エラー状態が表示されます まとめ 今回は、Swift SDK for Androidを使ってiOSアプリで使っていた汎用的な文字列バリデーションロジックをAndroidアプリで再利用する手順を紹介しました。ポイントは以下の通りです。 Swift SDK for Androidを使うことで、既存のiOSのSwiftコードをAndroidでも活かせる swift-java プラグインを使えば、Swiftのトップレベル関数をJava/Kotlinから簡単に呼び出せる まだプレビュー版で制約もありますが、今後はより多くのiOSアプリでのコードをAndroidで再利用できるようになると思われます。 おまけ 今日はハロウィンらしいので、おまけとしてSwiftUIだけで作った可愛いアニメーションを載せておきます(笑)。
アバター
はじめに はじめまして。2025年の8月から1ヶ月間、株式会社エブリーのインターンシップに参加していた山本陽右と申します。配属は、国内最大級のレシピ動画メディア「デリッシュキッチン」の知見を活かし、リテールメディアのプラットフォーマーを目指す「リテールハブ」事業部の「小売アプリ」開発です。今回、小売アプリの機能改善に取り組みました。 経緯 学部、大学院と建築学専攻である私がプログラミングに興味を持ったきっかけは、卒業論文でカビの成長モデルをJuliaで実装したことでした。それがきっかけで、建設業界だけでなくソフトウェア業界にも興味を持って就職活動していました。そこで縁あって未経験ながらエブリーのインターンに参加し、学業と両立しながら事前学習を進めました。 背景・課題 リテールハブ事業部では、小売店の収益拡大やファン創出のために、 デジタルサイネージを用いた「 ストアDX 」 ネットスーパー 小売アプリ の3つの事業を展開しています。私は今回、小売アプリの開発に携わりました。 小売アプリにはお客様に送信するチラシやPOPを編集する「お知らせ」機能があります。これがお客様の端末でどう見えるかプレビューする機能があるのですが、これの使い勝手があまりよくありませんでした。 具体的には、 編集中にプレビューボタンを押しても、編集中の内容が反映されない プレビュー画面(/preview)から戻ると、編集内容が保持されない これらの課題を解決し、ユーザー体験を向上させることに注力しました。 改善計画 改善案 まず、要件の整理からやらせていただきました。手探りでしたが、アドバイスもいただき、最終的には上記のように2つの課題に切り分けて別々に対処することにしました。 当初は、pinia(Vueで、ローカルの情報をグローバル変数として保持できる機能)を使って、ローカルで編集中の内容を保持・反映することで対応しようと思い、提案書を作りましたが、「プレビューは本番と同じ形式 = 一度データベースに保存し、それを取得・表示するからこそ意味がある 」とレビューをいただき、バックエンドの改修も作業に含めることにしました。 技術スタック 上記のような理由から、データベースから改修する必要がありました。 小売アプリではフロントエンドは Vue 、バックエンドは Laravel を使用しています。そのため、Laravelのマイグレーションを活用しました。現状のテーブルに要素を付け足すのではなく、(変更履歴やバージョン管理など)今後の拡張性も考慮して公開されている記事用のデータベース"articles"とは別に、下書き保存用のデータベース"article_drafts"を作成することにしました。また、2つ目の課題にはpiniaを使用し、手早く実装しようと考えました。 実装方針 新規APIの作成(下書きの保存) webルートの拡張(既存ロジックの拡張) 既存のプレビュー機能は埋め込みhtmlで実装しており、それを活用しながらデータの取得先を下書きDBに繋ぎかえた piniaによる編集内容の復元 書いたコード バックエンド 既存のコードに則って、コントローラー、サービス、リポジトリの3層構造+リクエスト(認証、バリデーション)で実装しました。まず、この設計思想を理解するのに時間がかかりました。理解を深めるためにphpの歴史を調べながら、実際にコードに触れて学んでいきました。 <?php // === コントローラーレイヤー === // HTTPリクエストの受け取り、認証・権限チェック、レスポンスの返却を担当 public function storeDraft ( DraftRequest $ draftRequest ) : JsonResponse { // リクエストデータのバリデーション済みデータを取得 $ validated = $ draftRequest -> validated () ; // (権限周りの処理) // サービス層に処理を委譲し、下書きデータを保存 $ draft = $ this -> articleService -> storeDraft ( $ validated , $ staff ) ; // JSONレスポンスとして保存された下書きデータを返す return response () -> json ([ 'article' => $ draft ]) ; } // === サービスレイヤー === // ビジネスロジックの実装、トランザクション管理、DTOの作成を担当 public function storeDraft ( array $ validated , Staff $ staff ) : array { // データベーストランザクションを開始(データの整合性を保証) DB :: beginTransaction () ; try { // バリデーション済みデータをDTO(Data Transfer Object)に変換 // DTOはレイヤー間でのデータ受け渡しを構造化・安全に行うためのオブジェクト $ createDraftDto = new CreateDraftDto ( // (省略) ) ; // リポジトリ層に処理を委譲し、下書きデータを保存 $ articleDraft = $ this -> articleRepository -> storeDraft ( $ createDraftDto ) ; // トランザクションをコミット(変更を確定) DB :: commit () ; // 保存されたデータを配列形式で返す return $ articleDraft -> toArray () ; } catch ( \ Exception $ e ) { // エラー発生時はロールバック(変更を破棄) DB :: rollBack () ; throw $ e ; } } // === リポジトリレイヤー === // データベース操作を担当し、モデルのCRUD操作を抽象化 public function storeDraft ( CreateDraftDto $ createDraftDto ) : ArticleDraft { // article_idとstaff_idの組み合わせでレコードを検索 // 見つかれば既存レコードを更新、なければ新規作成(Upsert操作) // これにより、同じ記事の同じスタッフによる下書きは常に1件のみ保持される $ articleDraft = ArticleDraft :: updateOrCreate ( // (省略) ) ; // 保存されたArticleDraftモデルインスタンスを返す return $ articleDraft ; } 困ったこと バックエンドの実装を終え、当初の方針でフロントエンドとの繋ぎ込みを進め、8割ほど実装が完了した段階で、設計時に考慮しきれていなかった新たな課題が複数見えてきました。 具体的には、 直リンク問題: プレビュー用のURLに直接アクセスされた場合の挙動が保証できない。 ブラウザバック時の挙動がブラウザによって異なる可能性:piniaで保持していた編集内容が消えてしまうケースがある。 これらは技術的には解決可能な問題ではありますが、コードが膨れ上がってしまいます。 インターン期間中にも終わらなくなってしまいます 。そこで一度立ち止まってアプローチそのものを見直すことにしました。そして、これらの問題の根本原因は「プレビューのためにページを遷移している」ことにあると考え、プレビュー処理を ページ遷移からモーダル表示に変更する という解決策にたどり着きました。仕様の変更なのでデザインの修正は必要になりますが、モーダルだとそれ専用のページがあるわけではない = 画面遷移しないので、直リンクの対策も、ブラウザバックの挙動も気にする必要がなくなります。 フロントエンド 以上から、仕様を変更してモーダルで実装することにしました。 <iframe :src= "createApiUrl(`/web/articles-drafts/${articleId}`)" class = "preview-iframe" frameborder= "0" ></iframe> ページ遷移をモーダルに変更しましたが、内部処理は同じで、埋め込みhtmlのiframeを使用しています。基本的なロジックは改善前から完成していたので、モーダルへの移行は比較的簡単でした。 const handlePreview = async () => { isLoading.value = true //下書き保存APIの呼び出し await storeDraft(articleId, currentValuesForPreview()) isLoading.value = false //モーダルを開く showPreviewModal.value = true } これはプレビューボタンを押した時に呼び出される関数なのですが、フロントではこの実装が一番気に入っています。リーダブルコードの原則に則り、見通しよく書けたと思っています。最終的にはシンプルになりましたが、仮実装でハードコードしてしまったり、苦労して書き上げたものが無意味になったりと、かなり回り道しました。ですが、汚くても一通りコードを書いて自分で理解することで、削って整えることができるのだとも思いました。このバランス感覚は今後の課題にしたいと思います。 // 下書き保存用のAPIを呼び出す関数 const storeDraft = async ( id : number , values : ArticleFormValues ) => { // ガード節 if (id === INVALID_ARTICLE_ID || !values) { return false } // CSRFトークンを取得(セキュリティ対策) const csrfToken = await getCsrfToken() // APIエンドポイントURLを生成 const url = ` ${ createApiUrl( '/articles-drafts' ) } / ${ id } ` // useFetchを使ってAPIリクエストを実行 await useFetch(url, createCsrfFetchOptions(csrfToken), { // リクエスト前に実行 beforeFetch ( { options } ) { errors.value = undefined options. body = JSON . stringify (convertToAPIValuesForDraft(id, values)) return { options } } , // エラー発生時の処理 onFetchError : ( ctx ) => handleAPIError(ctx, errors), // レスポンス受信後の処理 afterFetch ( ctx ) { const { data : { article : apiArticle } , } = ctx.data article.value = convertToClientArticle(apiArticle) return ctx } , } ) .post() // POSTリクエストを送信 .json() // JSONレスポンスとして処理 // エラーがなければtrue、発生していればfalseを返す return !errors.value } 既存のコードを利用しつつ、storeDraft(下書きの保存・更新)APIは新規で作りました。 1つ目のコードブロックではstoreDraftをカプセル化していますが、内部でも適宜、下位問題を切り出して見通しをよくする努力をしました。 言われてみれば当たり前なのですが、変数や関数の名前を「それが何をしているか、何を表しているか」をわかりやすいものにするだけで、可読性がかなり高まることは、とても学びになりました。 改善結果 改善前後の比較 改善前 変更前タイトル 改善前のプレビュー機能 変更後のタイトル プレビューに編集内容が反映されておらず、編集画面から戻ると編集内容も消えてしまう(画像はタイトルのみですが、本文も同様) 改善後 改善後のプレビュー機能 変更後本文 編集内容が保持・反映されており、要件が満たされていることがわかリます。 実装コスト削減とUX向上 ページ遷移をモーダルに変更した結果、 piniaによる編集内容の保持が不要になった アクセス方法が制限されるため、直リンクやブラウザバックへの対策が不要になった モーダル枠外の任意の場所をクリックしても編集に戻れるようになった これまでは「編集に戻る」ボタンと、ブラウザバックでしかプレビューを終了できなかった というメリットが生まれ、要件を満たしながら、実装コストを下げつつUXも向上させるという、 一石二鳥の実装 ができました! 学んだことと振り返り モダンな技術スタック :要件の整理からVue、Laravel、データベースまで、幅広く勉強させていただきました。もう少し事前学習ができていればインフラまで踏み込めたのが少々心残りです。 初めてのチーム開発経験 :個人開発と違って、全員が同じ方向性を向いて、同じ目的意識を持って開発を進めるためには意見のすり合わせが不可欠だと感じました。また、会議の初めにゴールを明確にするなど、会議のための会議にならない工夫を実践することもできました。 仕事の姿勢 :納期があり、制約があり、その中で優先順位をつけて良いものを届けるという、就業型インターンならではの経験ができました。慣れていないから余計に目先の実装に追われがちで、システム全体やそれを使うクライアントにまで意識を向けることが難しかったですが、今後も大事にしていきたい経験です。 ソフトウェアエンジニアに求められる思考 :期間中に読んだ「リーダブルコード」だけでなく、社員さんの話し方、考え方から少しずつ吸収しようと心がけていました。インプットだけでなく学んだ知識の実践までできたので、自らの血肉になった実感があります。 おわりに 学業や学会の準備をしながらのインターンで、非常に忙しかったですが、充実した1ヶ月間でした。要件を満たして実装完了までできて、とても嬉しいです。 最後になりましたが、小売アプリの皆さんには大変お世話になりました。とてもよくしていただきました。この場で御礼申し上げます。
アバター
はじめに 開発本部でデリッシュキッチンのアプリウェブグロース向けの開発を担当している hond です! 9月末にMCPの最新仕様が2025/11/25にリリースされることが発表されました。この記事では 2024-11-05 から 2025-03-26 、 2025-06-18 、そして次期 2025-11-25 でどのような変更が行われてきたか主要な機能と私が特に興味を持った点について説明しようと思います。 先日Go公式のMCP SDKについて発表したスライドもあるので、興味のある方はぜひチェックしてください。 speakerdeck.com MCPとは MCP(Model Context Protocol)は、LLM に外部コンテキストを安全かつ一貫した方法で渡すためのオープンプロトコルです。プロトコルは JSON‑RPC 2.0 で、基本的には stateful な接続を前提としています。主要なcomponentは Host 、 Client 、 Server の3つで、IDE やチャットなどの実行環境( Host )と、実際に Server とやり取りを行う Client が Server ごとに生成されます MCP Architecture 主要機能 Client には作業の起点を示す Roots と、LLMへの生成依頼を行う Sampling があり、 Server には実行可能な機能一覧を返す Tools 、ファイルやデータベーススキーマなどLLMがコンテキストとして利用可能なソースを公開する Resources 、再利用可能な Prompts が用意されています。 バージョニングの原則 MCPの仕様はリリースされた日付( YYYY-MM-DD )で管理されています。初期化時には Client / Server 間で利用可能なバージョンの合意を行います。 以降の章ではバージョンごとの主要な変更点についてまとめていきます。 2024-11-05 概要 最初の安定版にあたる 2024-11-05 では、通信方式・ライフサイクル・機能群の土台が定義されました。ざっくりの全体像は次のとおりです。 Transport: HTTP+SSE、stdio ベース: JSON-RPC 2.0 / Base lifecycle / Capabilities Client: Roots / Sampling Server: Tools / Resources / Prompts ユーティリティ: Pagination / Logging / Completion Client Roots Client が「どこを起点に動くのか」ファイルシステムのルートを明示するのが Roots です。初期化フェーズで Server が Roots を確認し、その後の操作はこの範囲(ワークスペースやルートディレクトリなど)の中で行われます。 Sampling Server が Client を介してLLMに生成や補完を依頼する仕組みです。モデルのヒントや優先度、トークン数などを指定して、対話生成を柔軟にコントロールできます。 Sampling を使うと生成・補完はLLMに任せることができ、 Server は特定のドメイン知識を抱える必要がなくなります。 例 Sampling - Model Context Protocol 下記が公式から引用した例になります。 フランスの首都に関する情報をLLMに補完させることで Server が、それらの情報を持つ必要をなくしています。 Request { " jsonrpc ": " 2.0 ", " id ": 1 , " method ": " sampling/createMessage ", " params ": { " messages ": [ { " role ": " user ", " content ": { " type ": " text ", " text ": " What is the capital of France? " } } ] , " modelPreferences ": { " hints ": [{ " name ": " claude-3-sonnet " }] , " intelligencePriority ": 0.8 , " speedPriority ": 0.5 } , " systemPrompt ": " You are a helpful assistant. ", " maxTokens ": 100 } } Response { " jsonrpc ": " 2.0 ", " id ": 1 , " result ": { " role ": " assistant ", " content ": { " type ": " text ", " text ": " The capital of France is Paris. " } , " model ": " claude-3-sonnet-20240307 ", " stopReason ": " endTurn " } } Server Tools ファイル操作や外部 API 呼び出しなどのServerが行えるアクションを、 Client に公開する機能です。 Client はToolsから実行したい機能を選択し、必要な引数を渡して実行します。 Resources Server が Client にファイルやデータベーススキーマなどの Server が有する情報を公開する機能です。各リソースはURIによって公開され、 Client はこれらの情報をLLMにコンテキストとして利用することが可能です。 例 Resources - Model Context Protocol 下記はResourceの一覧取得に関するRequestとResponseになります。 Request { " jsonrpc ": " 2.0 ", " id ": 1 , " method ": " resources/list ", " params ": { " cursor ": " optional-cursor-value " } } Response { " jsonrpc ": " 2.0 ", " id ": 1 , " result ": { " resources ": [ { " uri ": " file:///project/src/main.rs ", " name ": " main.rs ", " description ": " Primary application entry point ", " mimeType ": " text/x-rust " } ] , " nextCursor ": " next-page-cursor " } } Prompts Server が利用可能なプロンプトの雛形を公開する機能です。 Client は prompts/list で候補を見つけ、 prompts/get でテンプレート本文と変数を取得します。 2025-03-26 2025-03-26 では認証方式の追加や通信方式の改善、スキーマの改善が行われました。( Key Changes )以降はその中でもMajor changesに当たるOAuth,Streamable HTTP,JSON-RPC Batching,Tool Annotationsについて説明します。 Authorization Framework HTTPベースのTransportではOAuth 2.0,OAuth 2.1の仕様に基づいたAuthorizationの実装が推奨されるようになりました。stdioではAuthorizationは非推奨となっており代わりに環境変数を用いた方式が推奨されています。 この仕様追加により各言語SDKが認証を公式提供する様になったので、企業など不特定多数にMCPを提供する際に特定のユーザーのみに制限する実装が容易になりました。 Streamable HTTP Transportの推奨がstdio,HTTP+SSEからstdio,Sreamable HTTPに変更されました。これにより Client としては単一のエンドポイントで双方向の通信が可能になるので以前より実装が容易になります。 双方向の通信を実現するためになぜWebSocketを採用しなかったのかなどSreamable HTTPに移行するにあたる詳細な判断理由はこちらの PR に記述されています。 JSON-RPC Batching 複数のリクエストをまとめて送る仕組みであるJSON-RPC Batchingが導入されました。 JSON-RPC 2.0 の仕様にはBatchingが定義されているものの、MCPの仕様では明確化されておらずJSON-RPCの仕様から外れた状態になっていたこともありこのタイミングで仕様書含め明記される様になりました。 例 [ { " jsonrpc ": " 2.0 ", " id ": 1 , " method ": " tools/list ", " params ": {}} , { " jsonrpc ": " 2.0 ", " id ": 2 , " method ": " resources/list ", " params ": {}} ] Tool Annotations inputSchema / description だけでは伝わりづらい動作特性(read‑only / destructive / sensitive など)を明示するためにTool Annotationsが追加されました。 これによりLLMがTool利用する際の判断基準がこれまでより明確になり、より場面にあったTool選択を適切に行える様になりました。 2025-06-18 2025-06-18 では、セキュリティと相互運用性の強化、実装の簡素化を中心に改善が行われました。( Key Changes )以降はJSON-RPC Batchingの削除、Structured Tool Output、Elicitationの内容を抜粋し説明します。 JSON-RPC Batchingの削除 前バージョンである 2025-03-26 で「JSON‑RPC準拠」として導入されたバッチングですが、実運用面で採用が伸びず言語SDK・ストリーミングとの両立で実装の複雑さが増したため削除されました。 Structured Tool Output Toolの結果を構造化データとして返せる機能が追加されました。これにより Client はレスポンスを検証可能になるためより型安全な運用が可能になります。 既存の仕様ではスキーマが固定されていなかったため柔軟なJSONパースが求められていましたが、型が厳密になるためレスポンスに応じた Client の動作を行うことが可能になります。 例 Tools - Model Context Protocol Tool { " name ": " get_weather_data ", " title ": " Weather Data Retriever ", " description ": " Get current weather data for a location ", " inputSchema ": { " type ": " object ", " properties ": { " location ": { " type ": " string ", " description ": " City name or zip code " } } , " required ": [ " location " ] } , " outputSchema ": { " type ": " object ", " properties ": { " temperature ": { " type ": " number ", " description ": " Temperature in celsius " } , " conditions ": { " type ": " string ", " description ": " Weather conditions description " } , " humidity ": { " type ": " number ", " description ": " Humidity percentage " } } , " required ": [ " temperature ", " conditions ", " humidity " ] } } Response { " jsonrpc ": " 2.0 ", " id ": 5 , " result ": { " content ": [ { " type ": " text ", " text ": " { \" temperature \" : 22.5, \" conditions \" : \" Partly cloudy \" , \" humidity \" : 65} " } ] , " structuredContent ": { " temperature ": 22.5 , " conditions ": " Partly cloudy ", " humidity ": 65 } } } Elicitation Server がTool実行に必要な不足情報を、 Client 経由でユーザーに尋ねる機能です。Elicitationの内容としては単純に同意を求めるものから実際にemailなど文字列の入力を求めるものが可能です。 またStructured Tool Outputと組み合わせることでより柔軟な値を型安全に扱うことが可能です。 スキーマは下記の4つの型がサポートされています。 String Number Boolean Enum 例 Elicitation - Model Context Protocol 下記の例は Server がユーザーに対して「名前」「メールアドレス」「年齢」の入力を求めるものになります。 Request { " jsonrpc ": " 2.0 ", " id ": 2 , " method ": " elicitation/create ", " params ": { " message ": " Please provide your contact information ", " requestedSchema ": { " type ": " object ", " properties ": { " name ": { " type ": " string ", " description ": " Your full name " } , " email ": { " type ": " string ", " format ": " email ", " description ": " Your email address " } , " age ": { " type ": " number ", " minimum ": 18 , " description ": " Your age " } } , " required ": [ " name ", " email " ] } } } Response { " jsonrpc ": " 2.0 ", " id ": 2 , " result ": { " action ": " accept ", " content ": { " name ": " Monalisa Octocat ", " email ": " octocat@github.com ", " age ": 30 } } } 2025-11-25 最後にリリース予定の 2025-11-25 について実装にあたり特に意識する必要がありそうな点をまとめます。このバージョンについては現状まだ仕様がリリースされていないので mcp blog を元に説明します。 非同期処理 現状の実装ではMCPは処理がすべて同期的に処理が行われます。しかし、すべてのToolの処理がすぐ終わるわけではなく大量のファイルの入出力などは膨大な時間を要します。これにより移行の処理がブロックされてしまう問題がありました。これらの問題やさらに長い時間を要する操作も可能にするために非同期処理の追加が提案されています。詳細についてはこちらの issue から確認することが可能です。 非同期処理の具体的な実行フローは下記が提示されています。 Client が tools/list を用いてToolの発見行う tools/call を用いて非同期Toolの実行をリクエストする。この際に Server はバックグラウンドでの実行開始し、非同期処理を追跡するためのTokenを Client に返す Client はTokenを用いて tools/async/status を呼び出し非同期処理の実行ステータスを取得する 実行完了ステータスを取得後 Client は tools/async/result を呼び出し結果を取得する Server はクリーンアップ処理を行う 公式拡張 MCPの成長に伴い特定の分野において、実装パターンが固まりつつあるようです。それらの内容を仕様に記述するのではなく今後は公式拡張という側面でプロトコル拡張機能として文書化していく方針が提示されています。これによって特定の分野におけるMCPの実装をより加速することが見込まれているようです。 まとめ 今回はMCPの正式リリースされたバージョン 2024-11-05 から次期リリースの 2025-11-25 の主要な変更点についてまとめました。JSON-RPC Batchの追加・削除のように利用しやすいよう柔軟に仕様を変更していく姿勢が利用者としては好感を持てるなと思いました。私自身Goの公式MCP SDKに関心があるので 2025-11-25 の仕様が確定次第改めて仕様の詳細確認して追っていこうと思いました。 また、 blog末尾 にTypescriptやSwiftのメンテナーが不足している旨が記述されていたので機会があったらチャレンジしてみたいです。 ここまで読んでいただきありがとうございます。MCPの仕様を追っている方の参考になったら幸いです。 参考資料 仕様 2024-11-05: spec.modelcontextprotocol.io/specification/2024-11-05 仕様 2025-03-26: spec.modelcontextprotocol.io/specification/2025-03-26 仕様 2025-06-18: spec.modelcontextprotocol.io/specification/2025-06-18 仕様 Draft: spec.modelcontextprotocol.io/specification/draft MCP Blog: blog.modelcontextprotocol.io SEP-1391(非同期の提案): github.com/modelcontextprotocol/modelcontextprotocol/issues/1391
アバター
目次 はじめに 運営チームの設立 ブース内容の選定 作成物のラフ作成 アンケートボード フォトブースパネル デザイン依頼からの発注発送 デザイナーとの調整 発注発送周り 当日運営メンバーの募集と運営マニュアル 前日準備から当日 まとめ はじめに こんにちは、開発本部開発 1 部トモニテグループのエンジニアの rymiyamoto です。 先月は Go Conference 2025 が開催され、エブリーではスポンサーブースを出展させていただきました。 今回はそのブースの準備の話を綴っていきます gocon.jp 運営チームの設立 まず社内で運営に手伝ってくれるメンバーを募集し、私含め 4 名で進めていくこととなりました。 スケジュールを精査し、準備すべきことをまとめていきました。 今回だと以下のようになりました。 日時 やること 08/11~15 予算確定 スケジュール確定 ラフ案確定 08/18~22 デザイン変更不要なものの発注 デザイン依頼 08/25~29 当日運営メンバー募集 09/08~12 制作物の発注、当日説明会 09/15~19 この週までに物品揃える 09/22~25 荷物発送、当日シフト最終確定 09/26 前日設営 09/27~28 当日 また運営チームでは週 1 回程度のミーティングを行い進捗や相談事を確認していきました。 ブース内容の選定 every をカンファレンス参加者の方々に多く知ってもらい、かつ楽しめるブースを軸にしました。 もともと過去のカンファレンスで行っていたものを踏襲し、費用面を抑えつつ新たな取組を検討しました。 去年の初めてカンファレンス協賛をするときから同じなのですが、全体的なカラーはコーポレートカラーではなく認知が広いサービスであるデリッシュキッチンをベースに黄色を用いるのは変えず、ブース内容で色々工夫していきました。 これまでのカンファレンスを通して普段やってきたものである X フォローのプレゼントキャンペーン X アカウントのフォローによってデリッシュキッチンのキッチングッズが当たるくじ引き ハズレでもステッカーとコーヒーバッグをプレゼント デリッシュキッチンのサイネージ 連携している小売様のところで設置させていただいているサイネージのデモ アンケートボード 参加者との会話や認識を広める質問を設定したボード を続投しつつ、新たな取組として「SNS で拡散してもらいやすいよう会社やサービスの認知拡大を目的としたフォトブース」を行うこととしました。 他には Gopher くんのキャラ弁当をデリッシュキッチンのレシピとして紹介する案もありましたが、準備期間の都合上断念しました... 作成物のラフ作成 ボードとして設置するものである「アンケートボード」と「フォトブースパネル」である構成案を出していきました。 アンケートボード 今回年齢層の広い方に回答してもらえるよう横軸を「Go 歴」、縦列を「気になるトピック」を質問として設定しました。 Go 歴は Go 言語を触り始めた年として、1.0 がリリースされた 2012 年 3 月 28 日から考慮し、15 年を超えるものを含めるようにしました。また気になるトピックは Go 言語自体の話題と Go 言語を用いた開発体験のグループで考えていき、最終的には以下のような質問を設定しました。 Go1.25 Go と AI コーディング Go のパッケージ Go と DB 他社の Go の知見 その他 フォトブースパネル 大きすぎると会場の妨げや予算的にも難しいため、デザインを工夫して 2 種類のパネルで構成することとしました。 会社やサービスロゴを用いたパネル Go のキャラクターでもある Gopher くんを用いたパネル 特に後者の Gopher くんを用いたパネルを、実際に会社のサービスであるデリッシュキッチンと絡めるに当たって、料理をしている Gopher くんのラフを作成しました デザイン依頼からの発注発送 デザイナーとの調整 ラフ案が固まった後に社内のデザイナーさんにデザインを依頼していきます。このときプロダクト開発の施策もある中でのデザイン依頼になるので、発注物の到着の時期を考慮しつつ長めにバッファを持ってデザイン依頼をかけれるように心がけています。 ラフと文字だけでは伝わりにくいことがあるので、デザイン依頼時には他社の実例や過去の制作物で近いものも追加の資料として添付することで、コミュニケーションを円滑に行えるようにしました。 発注発送周り 会場への搬送を運送会社に依頼する都合上、会社には荷物発送の前週までに揃えるように発注タイミングを設定していきます。 デザイン制作が必要なものに関しては作成完了次第逐次発注で、またこれまでのカンファレンス運営でも用いていたステッカーやコーヒーバッグはラフが必要ないのものは早めに発注をかけていきました。 注意としてコーヒーバッグのような賞味期限があるものに関してはあまり早めに発注をかけると無駄になるので、カンファレンス当日から逆算して発注をかけています。 発注したものが社内に届き次第、実物の確認を行い荷物を箱詰めしていきました。このとき、納品時の段ボールそのままだと箱の数が多く、会場での返送用の段ボールを多く抱えることになるため、極力荷物はまとめるようにして早めに発送をかけました。 また箱詰めの際は何が入っているかを発送伝票だけではなく箱にも記載することで設営や撤収の際に迷わないようにしています。 当日運営メンバーの募集と運営マニュアル 社内のエンジニアで募集をかけて当日ブース運営スタッフを募集をするのですが、今回スポンサー協賛特典でいただける招待枠のチケットと一般参加のチケットで予算的に負担可能な枚数を考慮して人員数を決めています。 今年の Go Conference 2025 は 2 日間開催となるため、両日参加できるメンバーを優先して募集をかけ、人員確保を行いました。 また前日準備や当日運営の手順をまとめたマニュアルを作成していきます。運営マニュアルでは今回のカンファレンスでの運営について以下の内容をまとめています。 会場マップとブースの配置 ブース運営の目的と全体感 各施策の説明 フォトブース デリッシュキッチンのサイネージ アンケートボード X フォローのプレゼントキャンペーン(くじの割合も記載) 服装 聞きたいセッション&ワークショップのチェックシート 前日準備出のやることリスト 当日の入館方法 当日シフト(キックオフ時では未完成だが、チェックシートをもと作成する) ブース運営以外でのやること共有(SNS 広報、参加レポート執筆など) 実際に作成したもの運営メンバーとキックオフミーティングをしてすり合わせを行いました。このとき当日シフトを作成するに当たって運営メンバーの聞きたいセッション&ワークショップのチェックシートの記載の依頼や、ブース運営以外でのやることの分担を相談していきます。 またシフトに関しては、今回は新卒メンバーの参加が多くブース運営の経験者が少ない状況であるため、常に経験豊富なメンバーを 1 人つけるようにし、当日の混雑が予想されるお昼の時間帯などでは人員を多めに設定しました。 前日準備から当日 前日は会場に向かいブースの設営を進めていきました。 このとき極力当日の朝に慌てずにスタートできるよう、くじ引きなど数がはっきりしているものは当日分で分けておくようにしました。 そのおかげで当日はリラックスして運営を行うことができました。 詳しい当日の実際の様子は参加レポートからご覧いただけます。 tech.every.tv まとめ 今回の Go Conference 2025 スポンサーブース運営についてのざっくりとした流れをご紹介しました。 まだ会社としてのカンファレンスでのスポンサー協賛の経験が浅くはありますが、数をこなせばこなすほど反省点や改善ポイントが出てくるので、次回以降はより良いものにしていくことができます。 またイベントを通してその言語やフレームワークの愛され方を感じることができるので、参加者とのコミュニケーションをより良いものにしていくことができると思います。 最後に、今回の Go Conference 2025 の開催にあたり、運営の皆様には大変お世話になりました。スポンサーとして参加させていただく機会をいただき、心より感謝申し上げます。 また本記事が初めてカンファレンス運営のスポンサーブース運営をする方々の参考になれば幸いです。
アバター
はじめに 1ヶ月間株式会社エブリーでデータサイエンティストとしてインターンをしている中村です。 私が配属された「デリッシュリサーチ」チームでは、デリッシュキッチンの膨大な検索ログデータを抽出・加工して、メーカー・小売の意思決定を支援しています。 本インターンでは、アプリ内の検索データから未来の「食トレンドワード」の予測に挑戦しました。 開発背景 食品業界では新商品の企画から販売までに時間がかかるため、企画の段階で「販売時期のトレンド」を正確に予測することがビジネスの成否を大きく左右します。 一般的に、トレンドが本格化するまでには、感度の高い層の検索行動などに「先行指標」が現れます。 そこで私たちは、「デリッシュキッチン」の膨大な検索ログデータにこのトレンドの”予兆”が現れるのではないか、という仮説を立てました。 今回のインターンでは、この仮説に基づきデータドリブンに未来のトレンドを予測するという課題に挑みました。 トレンド予測のパイプライン 今回作成したコードは月に1度実行され、前月までの検索データの推移をもとにトレンド予測を行います。 大きな流れとして、「1. SQLによる候補の抽出」と「2. LLMによる絞り込み」という2つのステップで構成しました。 1. SQLによる絞り込み まず、SQLクエリを用いて、検索データ全体からトレンドの兆候を示す可能性のあるワードを絞り込みます。 ここでの目的は、再現率(Recall)を重視し、ポテンシャルのある単語を可能な限り拾い上げることです。 当初、仮説ベースでクエリを設計しましたが期待したような出力は得られませんでした。 そこで、過去のトレンドワードのデータを分析し、ブーム発生前の共通パターンを特定する帰納的アプローチに切り替えました。 過去のトレンドワードの流行のきっかけと推移を調査し、トレンド候補として取得したい時期を設定し各ワードがその時期に結果に含まれるようクエリを設計しました。 分析の結果、これらのワードには以下のような2つの特徴が共通することが判明しました。 検索数が少ない:流行前は世間的に認知が低いため検索数が一般的な料理ワードと比較して少ない傾向にありました。 検索頻度スコアの最大を更新:流行の兆しが見られているタイミングでアプリ内でも検索頻度スコアが過去最高を更新していたことが判明しました。 (注) 検索頻度スコア:全検索ワード1000回あたりの特定のワードの検索回数 過去トレンド例:せいろ 過去トレンドの例として、せいろのトレンド推移を紹介します。 せいろは2024年9月にレシピ本が出版されたことをきっかけにブームとなり、デリッシュキッチン内でも急上昇を見せています。 しかし、トレンド化する予兆が全くなかったわけではありません。 2023年6月以前はほとんど検索されていなかったものの、インフルエンサーの投稿などから注目が集まり2023年7月~2024年1月の多くの月で検索頻度が過去最高を更新しています。 このように多くの過去トレンドワードでは大流行する前に先述した2つの特徴を持つトレンド化の予兆を示す時期があることが判明し、十分クエリで絞り込み可能と考えました。 先述した2つの条件をクエリに落とし込み候補を約1500件まで絞り込みました。 次に、この結果を分析したところ「バレンタイン」や「秋刀魚」といった季節性要因で検索が増加したワードが多数含まれていました。 これらはトレンドと異なるため周期的なパターンを検出するロジックを作成し、これらを除外する処理を追加しました。 この処理によってデータは約900件にまで絞り込めました。 後述するLLMでの絞り込みではデータ数に比例したコストがかかるため、絞り込んだ全てのワードを使うことはできません。 そこで昨年からの検索数の増加量を基準に並び替えを行い上位100件を"トレンドワード候補"として使用しました。 2. LLMによる絞り込み クエリによる絞り込みでは正解の単語を確実に取得することを重視しているため、中にはデリッシュキッチンのSNS経由など他の要因で検索が増加した単語が含まれています。 そこで、各候補ワードの定性的な評価を行うため、LLMを用いた分類ステップを導入しました。 LLMには、Web検索機能を用いて各単語の背景(定義、メディアでの扱われ方、SNSでの話題性など)を調査させた上で、以下の5つのトレンドタイプに分類するタスクを実行させます。 この中でfuture(high)に分類されたワードを、最終的に使用します。 past : 過去に流行したもの ongoing : 現在流行しているもの future(low) : 今後流行する可能性があるが、現時点では限定的 future(high) : 流行の兆しがあり、今後大きなインパクトが期待されるもの stable : 一過性の流行ではなく、社会に定着しているもの 最後に出力用にデータの整形を行います。 データや分析を提供する目的は、企業の意思決定支援です。 そのためには、単に単語リストを提供するだけでは不十分であり、そのワードの定義や分類の根拠を説明する必要があります。 先ほどの分類ステップでLLMには分類結果と同時に、その判断に至った具体的な理由や背景情報をテキストで生成させています。 その説明を入力にLLMに要約を作成させ、表示用の説明文としました。 ここでは具体例を掲載することはできませんが、韓国ブームや健康志向といったマクロな社会潮流と一致する単語を複数抽出できており、本手法の有効性を確認できました。 技術的な工夫 非同期処理の活用 LLMによる分類ステップでは、100件の候補ワードを処理する必要がありました。 当初、APIリクエストを同期的に逐一実行していたため、1ワードあたり約3分、全体で約5時間を要し開発イテレーションの大きなボトルネックとなっていました。 これでは、プロンプトチューニングを行う上でも実際の実行でも問題となります。そこでPythonの非同期処理を用いて並列でリクエストを送信しました。 ただし、OpenAI APIにはレート制限が存在します。 短時間にリクエストが集中するとエラーが返されるため、リトライ処理の実装が不可欠です。 今回はtenacityライブラリを活用し、リクエスト失敗時に最大6回まで再試行するロジックを組み込み、処理の安定性を確保しました。 これらの対応により、全体の処理時間を大幅に短縮でき、プロンプトチューニングや本番実行を短時間で行えるようになりました。 @ retry (wait=wait_random_exponential( min = 1 , max = 60 ), stop=stop_after_attempt( 6 )) async def call_gpt (search_word: str , prompt_template: str , schema: dict ,date_formatted: str ,recipe_master_attention: str , model_name: str = "gpt-5-mini-2025-08-07" ) -> tuple : try : prompt = prompt_template.format(research_word=search_word,date_formatted=date_formatted,recipe_master_attention=recipe_master_attention) response = await client.responses.create( model=model_name, tools = [{ "type" : "web_search" , "user_location" :{ "type" : "approximate" , "country" : "JP" , "city" : "Tokyo" , "region" : "Tokyo" }, }], input =[ { "role" : "system" , "content" : "あなたは、食のトレンドを専門とするリサーチャーです。" }, { "role" : "user" , "content" : prompt}, ], text = schema ) res_dict = json.loads(response.output_text) res_dict[ "search_word" ] = search_word return res_dict, response.usage except Exception as e: print (f "❌ Failed to analyze word: {search_word}, Error: {e}" ) raise e 分類根拠の説明 OpenAI APIは構造化出力をサポートしており、指定したスキーマでレスポンスを受け取ることができます。 これを利用して、トレンドタイプに加えてその分類の根拠もテキスト形式で出力させています。 分類根拠を出力させることでLLMの推論の過程を理解することができ、プロンプトチューニングが効率化されるだけでなく、その内容を要約してクライアント向けの説明文を生成することも可能になりました。 今後の課題 今回の分析である程度期待した精度の出力を得ることに成功しましたが、予測精度と提供価値をさらに高めるために、2つの改善点が考えられます。 LLMは検索データを考慮していない 現状、LLMによる分類のステップでは、プロンプトにアプリ内の検索数の推移を含めていません。 LLMに検索増という事実だけでなく生のデータを与えることで、検索増の要因の考察の精度が上昇することが期待されます。 過去の予測を考慮した出力 クライアントである企業にとっての価値は、「まだ見ぬトレンドの種」をいち早く知ることです。 その点で、過去に提示した単語が数ヶ月後に再び表示されると、「新しい発見がない」という印象を与えかねません。 現在のクエリでは、一度候補に入ると3ヶ月間は必ず"トレンド候補"となります。この期間が適切であるかは考慮する必要があります。 対策として、一度予測として提示した単語をフィルタリングするといった出力制御ロジックを組み込むことで、常に新鮮で多様な「未来のヒント」を提供できるようになると考えています。 まとめ アプリ内の検索データをもとに、SQLを用いた定量的な候補抽出とLLMを用いた定性的な評価によって食トレンドを予測する機能を実装しました。 今回の実装は、LLMのweb検索機能を使用しているため、過去データでの性能検証ができません。 現在の予測が正しいかは数ヶ月後になってみないとわかりません。 予測には海外で流行している料理などもあり、今後日本で話題となることを期待しています。 今回のインターンでは、丁寧なコードレビューや毎日のフィードバックを元に、開発を改善しひとつの機能を実装することができました。 膨大なデータから価値を創造した体験を経て、データサイエンティストとして働く上での解像度が劇的に高まりました。 特に整備されたデータ基盤のもと試行錯誤を繰り返したことで、技術的に成長し、普段の勉強や研究では得られないような業務上の知識を多く得られました。
アバター
はじめに こんにちは。デリッシュキッチン開発部でiOSエンジニアをしている谷口恭一です。 デリッシュキッチンでは新規画面のUI実装は主にSwiftUIを使用していて、@State、@Publishedなどを使って状態管理の仕組みを学びながら日々実践しています。 SwiftUIの状態管理に関連する言語機能は便利な機能ですが、使い方を誤ってコンパイルエラーになったときに全く意味がわからないというような状況になることがあります。 そこで、これらの言語仕様を調査してみようと考えました。特に@Bindingがどのように動作しているのかを調査したので、それを解説します。 目次 Bindingとは PropertyWrapper DynamicMemberLookup + KeyPath Bindingの詳細な動作 まとめ Bindingとは まずSwiftUIでよく見るBindingについて簡単に説明します。Appleのドキュメントには以下のような例があります。 struct PlayButton : View { @Binding var isPlaying : Bool var body : some View { Button(isPlaying ? "Pause" : "Play" ) { isPlaying.toggle() } } } struct PlayerView : View { var episode : Episode @State private var isPlaying : Bool = false var body : some View { VStack { Text(episode.title) .foregroundStyle(isPlaying ? .primary : .secondary) PlayButton(isPlaying : $isPlaying ) // Pass a binding. } } } /// 以下structとPreviewは自分で定義 struct Episode { let title : String } #Preview {     PlayerView(episode : . init (title : "エピソード1" )) } https://developer.apple.com/documentation/swiftui/binding このように親Viewである PlayerView では@Stateで isPlaying を定義して、この値の状態の変更によって画面を再描画できるようにしています。 子Viewである PlayButton では isPlaying を@Bindingで定義することにより、子Viewでの値の変更でも親Viewが更新されるようにしています。 isPlaying = false isPlaying = true 一見すると、@Stateと同様、@Bindingを付与した変数も変更時にUI更新が行われるような気がします。つまり、@Bindingとは、@StateのようなSwiftUIのView再描画用機能の1つであると思われます。 しかし、@Bindingはそのような機能を提供していません。最終的に、その理由を理解することをゴールとして解説していきます。 AppleのドキュメントによるとBindingは以下のように定義されています。 @frozen @propertyWrapper @dynamicMemberLookup struct Binding < Value > ここから読み取れることとして、 Binding型は、ある型をラップするための型であるということ frozen: 構造体が将来変更されないということ propertyWrapper、dynamicMemberLookupという機能が付与されているということ 実際に実装を確認すると以下のようになっています。 @frozen @propertyWrapper @dynamicMemberLookup public struct Binding < Value > { public var wrappedValue : Value { get nonmutating set } public var projectedValue : Binding < Value > { get } public init (projectedValue : Binding < Value > ) public subscript< Subject > (dynamicMember keyPath : WritableKeyPath < Value , Subject > ) -> Binding < Subject > { get } } まずはこれらにどのような意味があり、背景の言語仕様がどのようなものであるかについて1つ1つ解説していきます。 PropertyWrapper まず、Binding型に付与されているものとして@propertyWrapperがあります。 swift.orgのドキュメントによるとPropertyWrapperとは、ある型のラッパーを作ったときに、そのプロパティ自体の定義と、値の 保存方法の管理 を分離する機能だと示されています。 A property wrapper adds a layer of separation between code that manages how a property is stored and the code that defines a property. https://docs.swift.org/swift-book/documentation/the-swift-programming-language/properties/ つまり、以下の例の場合、Bool型というのがプロパティ自体の定義ですが、これに加えて値の保存方法を指定された方法で管理できますよ、という仕組みのことです。 @Binding var isPlaying : Bool PropertyWrapperはwrappedValueという計算プロパティを実装する必要があります。この計算プロパティ内で実装者は値の保存方法、更新時の処理などを追加することができます。 また、任意でprojectedValueという計算プロパティを実装すると、この値には $ という記号で簡易アクセスする機能が付与されます。 以下の例では、 @Wrapper var a: Int = 1 と定義すると、 $a と書くと a.projectedValue と同等の意味になり、 projectedValue: 1 というStringを返すというような動作になります。 @propertyWrapper struct Wrapper < Value > {     private var value : Value           init (wrappedValue : Value ) {         self .value = wrappedValue     }          var wrappedValue : Value {         get { value }         set { value = newValue }     }          var projectedValue : String {         return "projectedValue: \( value ) "     } } $ の簡易アクセサはSwiftUIでよく見る、 PlayButton(isPlaying : $isPlaying ) このような書き方の正体です。 また、PropertyWrapperには、アンダースコア( _ )プレフィックスを使うことでWrapper自体にアクセスできる機能もあります。 例えば、 @Wrapper var a: Int = 1 と定義した場合: a → wrappedValue ( Int 型の値そのもの)にアクセス $a → projectedValue (この例では String )にアクセス _a → Wrapper自体( Wrapper<Int> )にアクセス SwiftUIでは通常、structで実装されたViewはイニシャライザを省略できるため、 _ を使う機会は少ないですが、カスタムイニシャライザを実装する際などに使用します。 では、@Bindingというプロパティラッパーが提供する「 保存方法 」とは何でしょうか? Appleのドキュメントによると、 A property wrapper type that can read and write a value owned by a source of truth. つまり、「 A Source of Truthな値を読み書きできる 」という保存方法です。 「A Source of Truth」はエンジニアならお馴染みの「 Single Source of Truth 」と同じ概念です。 「Single Source of Truth(SSoT)」とは、 単一の信頼できる情報源 という意味です。 最初の例で言うと、isPlayingという情報は2種類あります PlayerViewのisPlaying PlayButtonのisPlaying このとき、信頼できる情報源はもちろん親ViewであるPlayerViewのisPlayingです。(親ViewのisPlayingがどのように 単一の信頼できる情報源 を提供しているかについては後述します。) PlayButtonで管理しているisPlayingは常に必ず親ViewのisPlayingと同じでなければなりません。そうでないと、同じ画面に2種類の状態が混在して、どちらが正しく再生状態を表しているかわからなくなってしまいます。 そこで、親ViewのPlayerViewの情報源を 単一の信頼できる情報源 として、それを参照し、いつでも子ViewのisPlayingが親Viewのものと同じ状態であり続ける機能が欲しくなると思います。 しかし、SwiftUIのViewはstructであり、その中で定義されたプロパティは値型です。 よって、子Viewに渡されるisPlayingの実態は、初期化時に作成された親ViewのisPlayingのコピーであり、親Viewのプロパティとは別のメモリ領域に格納されます。 したがって、通常の方法では親Viewのプロパティを直接参照したり値を更新することはできません。 そこでBindingという機能を付与することによって、このような値を読み書きできるようにしています。 Bniding型が読み書きしている親ViewのisPlayingは @Stateを付けることによって、「 単一の信頼できる情報源 」を提供しています。 @StateのPropertyWrapperの「 保存方法の管理 」とは何かをAppleのドキュメントから確認すると、 A property wrapper type that can read and write a value managed by SwiftUI. Use state as the single source of truth for a given value type that you store in a view hierarchy. SwiftUI manages the property’s storage. When the value changes, SwiftUI updates the parts of the view hierarchy that depend on the value. https://developer.apple.com/documentation/swiftui/state つまり、 View階層内でSwiftUIが管理している、Single Source of Truthな値を読み書きできる 値が変更されるとSwiftUIはその値に依存するView階層の箇所を更新する という保存方法であるということがわかります。 上図のように、View内で@Stateで宣言された値は、単にスタックメモリに保存されるわけではなく、SwiftUIが提供する特別な保存領域で管理されるというわけです。@Stateは読み書きできることに加えて、値の変更時に依存するViewを再計算するという機能も備わっています。 そして、そのような値を子Viewから読み書きするためにBindingを提供する必要があったという背景です。 以下にState型の実装を示しました。ここから、State型のpropertyWrapperのprojectedValueは Binding<Value> であることがわかります。 @frozen @propertyWrapper public struct State < Value > : DynamicProperty { public var wrappedValue : Value { get nonmutating set } public var projectedValue : Binding < Value > { get } } よって、 @State private var isPlaying : Bool = false ... PlayButton(isPlaying : $isPlaying ) というように@Stateが付与された値( State<Bool> )に $ をつけてアクセスしたときは、 Binding<Bool> が返されるという挙動になり、PlayButtonの @Binding var isPlaying: Bool で定義された Binding<Bool> 型に値を渡せることに納得がいくかと思います。 このように、State型、Binding型におけるPropertyWrapperは、SwiftUIの階層的にViewを構築していくという設計思想を実現するための最重要機能であることがわかると思います。 DynamicMemberLookup + KeyPath 次に、Bindingの定義に付与されていた@dynamicMemberLookupについて解説します。swift.orgのドキュメントによると、 Apply this attribute to a class, structure, enumeration, or protocol to enable members to be looked up by name at runtime. The type must implement a  subscript(dynamicMember:)  subscript. つまり、DynamicMemberLookupとは実行時にメンバーを名前で検索できるようにする機能です。メンバーとは、その型に紐づくプロパティやメソッドなどです。 https://docs.swift.org/swift-book/documentation/the-swift-programming-language/attributes/ @dynamicMemberLookup struct DynamicStruct { subscript (dynamicMember member : String ) -> String { return " \( member ) was accessed" } } let obj = DynamicStruct() print(obj.someProperty) // "someProperty was accessed" print(obj.anyName) // "anyName was accessed" 上の例では、objは全くメンバーを持っていませんが、 someProperty や anyName にアクセスすることができます。アクセス時の動作は subscript で実装することができます。アクセスできるメンバーは実行時に動的に決定されるので、型安全性は失われます。 検索には任意の型を用いることができ、Bindingではこの型としてKeyPathという型を使用しています。KeyPathとはプロパティ自体を変数として使用できる機能です。以下の例のように、 \.title というように書くと、メンバー自体を変数にできます。 struct Recipe { var title : String } var recipe1 = Recipe(title : "レシピ1" ) // 読み取り let keyPath : KeyPath < Recipe , String > = \.title // 書き込み let writableKeyPath : WritableKeyPath < Recipe , String > = \.title recipe1[keyPath : writableKeyPath ] = "ハンバーグ" print(recipe1.title) // ハンバーグ KeyPathをダイナミックメンバーの検索時の型として用いる例は以下のようになります @dynamicMemberLookup struct Wrapper < T > { let value : T subscript< U > (dynamicMember keyPath : KeyPath < T , U > ) -> U { return value[keyPath : keyPath ] } } let wrapper = Wrapper(value : Recipe (title : "レシピ1" )) print(wrapper.title) // "レシピ1" Wrapperという型はvalueというプロパティしか持っていませんが、titleというプロパティに直接アクセスすることができています。また、KeyPathはアクセスするときに存在するプロパティかどうかをコンパイル時にチェックするので型安全にdynamicMemberLookupを使用することができます。 つまり、最初の例のようにDynamicMemberとしてStringを受け取っていたときと違って、somePropertyなどの存在しないメンバーにアクセスしようとするとコンパイルエラーになります。 ここで、Bindingの定義を再度見てみます。 @frozen @propertyWrapper @dynamicMemberLookup public struct Binding < Value > { public var wrappedValue : Value { get nonmutating set } public var projectedValue : Binding < Value > { get } public init (projectedValue : Binding < Value > ) public subscript< Subject > (dynamicMember keyPath : WritableKeyPath < Value , Subject > ) -> Binding < Subject > { get } } ここの subscript() を見ると、Bindingは保持するValue型の任意のメンバー(Subject型)に直接アクセスすることができて、アクセスした結果、アクセスしたメンバーのBinding( Binding<Subject> )が取得できるということを表しています。 Bindingの詳細な動作 以上の説明によって、Bindingがどんな言語仕様を用いているかがわかりました。ここで、最初のPlayerの例に戻りたいと思います。 以下のコード例では、PlayerViewにおいて、最初の例とデータの持ち方を少し変更しました。 具体的には、isPlayingを直接定義するのではなく、PlayStateという構造体を使って再生状態を管理するようにしました。 struct PlayButton : View { @Binding var isPlaying : Bool // ここでは、前述のPropertyWrapperの`_`プレフィックスを使って、Wrapper自体(`Binding<Bool>`)を直接代入しています。 //通常、SwiftUIが自動的にinitを生成するため、このコードは省略可能です。 init (isPlaying : Binding < Bool > ) { self ._isPlaying = isPlaying } var body : some View { Button(isPlaying ? "Pause" : "Play" ) { isPlaying.toggle() } } } struct PlayerView : View { var episode : Episode @State private var playState : PlayState = . init (isPlaying : false ) var body : some View { VStack { Text(episode.title) .foregroundStyle(playState.isPlaying ? .primary : .secondary) PlayButton(isPlaying : $playState .isPlaying) // Pass a binding. } } } struct Episode { let title : String } struct PlayState { var isPlaying : Bool } #Preview { PlayerView(episode : . init (title : "エピソード1" )) } ここで PlayButton(isPlaying : $playState .isPlaying) というようにアクセスしている部分は PlayButton(isPlaying : playState.projectedValue.isPlaying ) と解釈できます。 playState.projectedValue は Binding<PlayState> 型です。Binding型には当然isPlayingというメンバーは存在しません。しかし、 Binding<Value> のdynamicMemberLookupのsubscriptが返却する型は Binding<Subject> であることから、 Binding<PlayState>.isPlaying は Binding<Bool> になります。 よって、子ViewのPlayButtonのイニシャライザに渡すことができます。 次にPlayButtonの Button(isPlaying ? "Pause" : "Play" ) { isPlaying.toggle() } この部分で、isPlayingの値の変更は、Bindingが参照する 単一の信頼できる情報源 である親ViewのisPlayingを変更します。この変数は@Stateで定義されているため、SwiftUIが管理する保存領域が変更され、この値に依存しているUIが更新されるという仕組みになっています。 今回の例では、@Binding自体はこのような特殊な値の保存管理方法をする@Stateな変数を読み書きできるという能力を持っていることがわかります。 逆に@Binding自体はSwiftUIのViewに作用してViewを更新したりする能力は持っていないことに注意する必要があります。@Stateと違って、@BindingはSwiftUIとは一切関係ない機能であると捉えることもできると思います。 まとめ SwiftUIのBinding型はPropertyWrapper、DynamicMemberLookup + KeyPathという言語機能が使われている。 PropertyWrapperは値の定義と値の「 保存方法の管理 」を分離する機能である。 DynamicMemberLookupは動的にメンバーにアクセスできる仕組みで、KeyPathをダイナミックメンバーに用いると、型安全にメンバーにアクセスできる。 Binding型は「 Single Source of Truthな値を読み書きできる 」機能が付与されていて、SwiftUIの@Stateなどで定義された値を読み書きするために主に使われている。しかしBinding自体は、SwiftUIのViewに作用する機能ではない。 SwiftUIは非常に記述量が少なく、簡単に階層的な状態管理を実装することができますが、裏側の仕組みとしてものすごく複雑で面白い言語仕様が使われていることに気がつきました。Swiftのコミュニティと言語仕様に感謝ですね。
アバター
はじめに こんにちは、開発1部でソフトウェアエンジニアをしている新谷です。 今回は、AIエージェントで仕様駆動開発を実現する国産ツール「cc-sdd」を実務で約1ヶ月使用してみたので紹介します。 cc-sddとは cc-sddは、仕様駆動開発をAIエージェントで実現する国産ツールです。 github.com 2025年10月10日時点では、以下のAIツールに対応しています。 Claude Code Cursor IDE Gemini CLI Qwen Code 本記事の事例では、Claude Codeを使用しています。 cc-sddは、3つのファイルを順次承認制で作成していく仕組みになっています。 requirements.md: 要件定義フェーズで使用するファイル。受け入れ基準がEARS記法で書かれます design.md: 技術設計書として使用するファイル。実装の詳細な設計を記述します task.md: 実装可能な単位にタスクを分解したファイルです 各ファイルの具体的な内容については、後述の実務事例で紹介します。 仕様駆動開発を実務で導入したいモチベーション 端的に言うと、設計書を書けばシステムが完成するという開発フローが、チームでの開発生産性を大きく向上させると考えているからです。 見込める開発効率 具体的には、以下のような開発効率の向上が見込めると考えています。 コードを書くときにはAIとやり取りしなくていいので、その間別タスクが可能 design.mdを作るときにチームで設計が問題ないか認識を合わせられる 事前に設計などを共有できているため、コードレビューの負担が軽減される 課題 一方で、以下のような課題もあると考えています。 結局実装しないと分からない部分もあるのではないか design.mdをどの粒度まで作り込むべきか判断が難しい 実務で取り組んだ事例 実際に2つのAPI開発でcc-sddを使用してみました。 前提条件 チーム全員が使っているわけではなく、私だけが使用 バックエンドのAPI開発での適用 まだ1ヶ月程度の使用期間 既存サービスに新しいAPIを追加する実装 最低限のCLAUDE.mdは作成済み 事前にsteeringファイルは作成済み 事例1: アプリ内のプッシュ通知設定API 要件の概要 アプリにプッシュ通知のon/offボタンを作成 現在の状態を取得するAPIと、状態を変更するAPIを実装 作成されたファイルの例 requirements.md(一部) ### Requirement 1: 通知設定の永続化 **User Story:** アプリのユーザーとして、プッシュ通知の受信設定を保存し、その設定が永続的に維持されることを期待する #### Acceptance Criteria 1. WHEN ユーザーが初めてアプリを利用開始する THEN システムはプッシュ通知設定をデフォルトで「有効」として初期化 SHALL 2. IF データベースにユーザーの通知設定が存在しない THEN システムはデフォルト値として「有効」を返す SHALL 3. WHEN ユーザーが通知設定を変更する THEN システムはその設定をデータベースに即座に永続化 SHALL 4. IF データベース保存に失敗した THEN システムはエラーレスポンスを返し、設定は変更されない SHALL 5. WHERE 同一ユーザーが複数デバイスを使用している THE SYSTEM SHALL 全デバイスで統一された通知設定を適用 design.md(一部) ## コンポーネントと インターフェース ### バックエンドサービス & メソッドシグネチャ #### NotificationSettingService type NotificationSettingService struct { repo domainRepository.UserNotificationSettingRepository } // GetUserNotificationSetting ユーザーの通知設定を取得 func (s *NotificationSettingService) GetUserNotificationSetting(ctx context.Context, userID int64) (*domainModel.UserNotificationSetting, error) // UpdateUserNotificationSetting ユーザーの通知設定を更新 func (s *NotificationSettingService) UpdateUserNotificationSetting(ctx context.Context, userID int64, enabled bool) (*domainModel.UserNotificationSetting, error) task.md(一部) ## パート1: 通知設定API機能 ### フェーズ1: データモデルとマイグレーション - [x] 1. データベーススキーマとマイグレーションの作成 - db/migrations/配下に新しいマイグレーションファイルを作成 - user _ notification _ settingsテーブルのCREATE文を実装 - インデックス設計(user _ id, enabled)を含める - ロールバック用のDROP文も実装 - _要件: REQ-1, REQ-6_ cc-sddの適用結果 design.mdは、チームへの共有も含めて3日ほどかけて作成・修正しました task.mdは少し修正した程度で済みました 実装後にコードの大きな修正は不要でした 事例2: ミッション達成計算API 要件の概要 ゲームでよくあるアクションによって実績が解除される機能 事前に決めたミッションをユーザーが達成しているかどうかを判定 ミッションは複数あり、入れ替わりや制限期間はなし 作成されたファイルの例 requirements.md(一部) ### Requirement 1: ミッション管理機能 **Objective:** 管理者として、ミッションの定義と管理を柔軟に行いたい、将来的な拡張が容易にできるようにするため #### Acceptance Criteria 1. WHEN システムが起動される THEN ミッションサービス SHALL サーバー設定から全てのミッション定義を読み込む 2. IF 新しいミッション定義がサーバー設定に追加される THEN ミッションサービス SHALL アプリケーション再起動なしに新ミッションを有効化する 3. WHERE ミッション定義が存在する THE ミッションサービス SHALL 以下の情報を管理する:ミッションID、名称、達成条件、表示順序 4. WHEN ミッション定義が不正な形式で設定される THEN ミッションサービス SHALL エラーログを出力し、該当ミッションを無効化する design.md(一部) ### Domain Layer #### Mission **Responsibility & Boundaries** - **Primary Responsibility**: ミッション定義とその達成条件を管理 - **Domain Boundary**: ミッションドメイン - **Data Ownership**: ミッションのメタデータと達成条件 - **Transaction Boundary**: 読み取り専用(設定ファイルから) **Dependencies** - **Inbound**: MissionService - **Outbound**: なし - **External**: なし **Contract Definition** type Mission struct { ID string `json:"id"` Title string `json:"title"` RequiredCount int `json:"required_count"` } task.md(一部) ## ミッション機能Phase1 実装タスク - [x] 1. データベースとドメインモデルの基盤構築 - [x] 1.1 ユーザーミッション達成記録テーブルの作成 - user _ mission _ completionsテーブルのマイグレーションファイル作成 - user _ id, mission _ id を複合主キーとして定義 - created _ atインデックスの追加 - ロールバック用のダウンマイグレーション作成 - _Requirements: 1.3, 7.1, 8.1_ cc-sddの適用結果 design.mdは5日ほどかけて作成・修正しました task.mdは少し修正した程度でした 実装に関しては、コードの責務や書き方などが不適切で大きく修正が必要でした うまくいかなかった原因 ロジックが複雑だったにもかかわらず、design.mdに詳細を記載できていなかった CLAUDE.mdの記載が不足していた ロジックをどこに配置すべきか、責務の定義が不明確 テストの書き方の指針が不足 task.mdのレビューが不十分だった design.md作成について 今回初めてdesign.mdを作成しましたが、思った以上に時間がかかりました。 原因としては、以下のようなものがあると考えています。 自分の設計力不足 どの粒度で記載すべきかの判断に迷った 別タスクとの並列作業によるスイッチングコスト チームへの設計共有時に発生するレビュー時間 まとめ cc-sddを約1ヶ月実務で使用してみて、以下のことがわかりました。 簡単な新規のAPI追加実装であれば、CLAUDE.mdを適切に書いておくことで修正不要で実装できる可能性がある 複雑なロジックを持つAPIや既存APIの改修については、まだチューニングが必要 design.mdにどこまでの粒度で記載すべきか、まだ明確な基準が定まっていない design.mdの作成には3〜5日かかっており、設計スキルやツールへの習熟が必要 仕様駆動開発は、設計とレビューだけで実装が完了する世界を作れる可能性があると考えています。 今後も試行錯誤を重ねながら、開発速度を爆速にできるよう取り組んでいきたいと思います。
アバター
はじめに こんにちは、トモニテで開発を担当している吉田です。 デジタル広告の運用において、広告パフォーマンスの分析とレポート作成は重要な業務の一つです。しかし、弊社では手動でレポートを作成しており、営業活動に集中する時間を削ってしまう課題がありました。 本記事では、Google Ad Manager(GAM)の REST API と BigQuery を連携させ、レポート作成を自動化するシステムの構築事例について、紹介します。 背景:セールスレポート作成の課題 ビジネス課題 セールスチームが Google Ad Manager の広告レポートを手動で作成する際、以下の課題に直面していました。 レポート作成工数の嵩み : 現状 30 分〜1 時間程度の工数が発生 データ抽出の複雑さ : GAM から直接データを取得する手間 営業活動時間の減少 : レポート作成に時間を取られ、営業活動に集中できない 期待される成果 レポート作成の自動化により、以下の成果を期待しました。 工数削減 : レポート作成時間の短縮 営業活動の強化 : レポート作成時間を営業活動に充て、売上貢献の向上 データ活用の効率化 : BigQuery での SQL による柔軟なデータ抽出 技術選定:REST API の採用 既存システムの課題 社内の別サービスでは、Google Ad Manager の SOAP API を使用していました。しかし、以下の理由から REST API(現在 Beta 版)で実装することを決定しました。 項目 SOAP API REST API 実装の複雑さ XML ベースで複雑 JSON ベースでシンプル エラーハンドリング 複雑な XML パースが必要 標準的な HTTP ステータスコード デバッグの容易さ XML ログの可読性が低い JSON ログで直感的 メンテナンス性 古い技術スタック モダンな技術スタック ドキュメント 限定的 豊富で分かりやすい REST API の選択理由 開発効率の向上 : JSON ベースのシンプルな実装 保守性の向上 : モダンな技術スタックによる将来性 エラー処理の簡素化 : 標準的な HTTP レスポンスの活用 チーム開発の効率化 : より直感的な API 設計 注意 : Google Ad Manager REST API は現在 Beta 版のため、本番環境での使用には注意が必要です。API の仕様変更や制限事項について、公式ドキュメントを定期的に確認することをお勧めします。 システムアーキテクチャ 全体構成 Cloud Run : メイン処理コンテナ GAM REST API を呼び出してレポートデータを取得 取得したデータを BigQuery に格納 GAM REST API : 広告データの提供 BigQuery : データの保存と分析 データフロー 実行開始 : Cloud Run が HTTP リクエストまたはイベントで実行 日付抽出 : リクエストから対象日付を取得 レポート生成 : GAM API を使用してレポートデータを取得 BigQuery 挿入 : 取得したデータを BigQuery に保存 GAM REST API の実装詳細 API クライアントの初期化 from google.ads import admanager_v1 # GAM クライアントの初期化 client = admanager_v1.ReportServiceClient() レポート定義の作成 GAM REST API では、レポートの構造を詳細に定義する必要があります。 def create_report_definition (target_date: date, dimensions: list , metrics: list ) -> admanager_v1.Report: """GAMレポートの定義を作成""" report = admanager_v1.Report() # ディメンションとメトリクスの設定 report.report_definition.dimensions = dimensions report.report_definition.metrics = metrics report.report_definition.report_type = admanager_v1.types.Report.ReportType.HISTORICAL # フィルター条件の設定(特定のプレフィックスから始まるアドユニットのみを対象にする) report.report_definition.filters = [ admanager_v1.types.Report.Filter( field_filter=admanager_v1.types.Report.Filter.FieldFilter( field=admanager_v1.types.Report.Field( dimension=admanager_v1.types.Report.Dimension.AD_UNIT_NAME ), operation=admanager_v1.types.Report.Filter.Operation.MATCHES, values=[ admanager_v1.types.Report.Value(string_value= "PREFIX_.*" ) ] ) ) ] # 日付範囲の設定 report.report_definition.date_range.fixed = admanager_v1.types.Report.DateRange.FixedDateRange( start_date=date_pb2.Date( year=target_date.year, month=target_date.month, day=target_date.day ), end_date=date_pb2.Date( year=target_date.year, month=target_date.month, day=target_date.day ) ) return report レポートの実行とデータ取得 def create_and_run_report (client: admanager_v1.ReportServiceClient, report: admanager_v1.Report) -> str : """GAMレポートを作成して実行""" # レポート作成 request = admanager_v1.CreateReportRequest( parent=f "networks/{NETWORK_ID}" , report=report, ) create_response = client.create_report(request=request) report_id = create_response.report_id # レポート実行 run_request = admanager_v1.RunReportRequest( name=f "networks/{NETWORK_ID}/reports/{report_id}" ) operation = client.run_report(request=run_request) run_result = operation.result() return run_result.report_result データの抽出と変換 GAM API から取得したデータを Pandas DataFrame に変換する処理です。 def extract_dimension_value (dim_value) -> any : """ディメンション値を抽出""" if dim_value.string_value: return dim_value.string_value elif dim_value.int_value: return dim_value.int_value elif dim_value.double_value: return dim_value.double_value # その他の型も同様に処理 else : return None def extract_metric_value (primary_value) -> any : """メトリクス値を抽出""" if primary_value.int_value: return int (primary_value.int_value) elif primary_value.double_value: return primary_value.double_value else : return None def fetch_report_data (client: admanager_v1.ReportServiceClient, report_result_name: str , column_names: list [ str ]) -> pd.DataFrame: """レポートデータを取得してDataFrameに変換""" fetch_request = admanager_v1.FetchReportResultRowsRequest( name=report_result_name ) rows_response = client.fetch_report_result_rows(request=fetch_request) rows_list = [] for row in rows_response: row_data = [] # ディメンション値を取得 for dim_value in row.dimension_values: row_data.append(extract_dimension_value(dim_value)) # メトリクス値を取得 for metric_group in row.metric_value_groups: for primary_value in metric_group.primary_values: row_data.append(extract_metric_value(primary_value)) rows_list.append(row_data) df = pd.DataFrame(rows_list, columns=column_names) return df BigQuery との連携設計 スキーマ設計の考え方 BigQuery へのデータ保存では、以下の設計思想を採用しました。 日付別テーブル分割 : パフォーマンスとコスト最適化 型安全性の確保 : 適切なデータ型の設定 効率的なクエリ : 分析に適したスキーマ設計 スキーマ定義 以下のスキーマ定義は一例です。実際のプロジェクトでは、ビジネス要件や分析ニーズに応じて適切なカラム名とデータ型を設定してください。 # ディメンションのスキーマ DIMENSION_SCHEMA = [ bigquery.SchemaField( "date" , "INTEGER" ), bigquery.SchemaField( "advertiser_name" , "STRING" ), bigquery.SchemaField( "advertiser_id" , "INTEGER" ), bigquery.SchemaField( "order_name" , "STRING" ), bigquery.SchemaField( "order_id" , "INTEGER" ), bigquery.SchemaField( "line_item_type" , "STRING" ), bigquery.SchemaField( "line_item_name" , "STRING" ), bigquery.SchemaField( "line_item_id" , "INTEGER" ), bigquery.SchemaField( "ad_unit" , "STRING" ), bigquery.SchemaField( "ad_unit_id" , "INTEGER" ), bigquery.SchemaField( "demand_channel_name" , "STRING" ), bigquery.SchemaField( "creative_name" , "STRING" ), bigquery.SchemaField( "creative_id" , "INTEGER" ), ] # メトリクスのスキーマ METRICS_SCHEMA = [ bigquery.SchemaField( "total_impressions" , "INTEGER" ), bigquery.SchemaField( "total_clicks" , "INTEGER" ), bigquery.SchemaField( "total_ctr" , "FLOAT" ), ] BigQuery へのデータ挿入 def insert_df_to_bigquery (df: pd.DataFrame, target_date: date, bigquery_schema: list [bigquery.SchemaField], table_name: str ): """Pandas DataFrameをBigQueryに挿入""" client = bigquery.Client() job_config = bigquery.LoadJobConfig( schema=bigquery_schema, write_disposition=bigquery.WriteDisposition.WRITE_TRUNCATE, ) date_str = target_date.strftime( '%Y%m%d' ) table_id = f "{PROJECT_ID}.{DATASET}.{table_name}_{date_str}" job = client.load_table_from_dataframe(df, table_id, job_config=job_config) job.result() Cloud Run の実装 メイン処理の実装 @ cloud_event def main (cloud_event: CloudEvent) -> None : """Cloud Run のエントリーポイント""" try : # 対象日付を抽出 target_date = extract_target_date(cloud_event) # GAM レポートデータを取得 df = get_gam_report_data(dimensions, metrics, column_names, target_date) # BigQuery に挿入 insert_df_to_bigquery(df, target_date, schema, table_name) print ( "レポート処理が完了しました" ) except Exception as e: print (f "処理でエラーが発生しました: {e}" ) raise e 運用面での工夫 Cloud Run のデプロイと実行 Cloud Run のデプロイは gcloud コマンドで行い、以下の設定で実行されます。 Region : asia-northeast1 Runtime : Python 3.13 Memory : 512MB Trigger : HTTP 手動実行のためのコマンド 運用効率を向上させるため、以下のような手動実行用のコマンドを作成しました。 単独日付指定 : 特定の日付のレポートを生成 範囲指定 : 開始日から終了日までの期間でレポートを一括生成 これらのコマンドにより、スケジュール実行以外にも必要に応じて柔軟にレポートを生成できるようになっています。 システムの実行方式 システムは Cloud Run として実装されており、様々な実行パターンに対応できます。例えば、以下のような方法があります。 手動実行 : HTTP トリガーによる直接実行 スケジュール実行 : Cloud Scheduler による定期実行 イベント駆動 : Pub/Sub や Eventarc を経由した実行 ブログ内で言及はしていませんが、弊社では Cloud Scheduler から Pub/Sub トピックを起動し、サブスクリプションを通じて Cloud Run を定期実行する仕組みを構築しています。この仕組みにより、毎日決まった時間にレポートデータが自動的に更新され、手動作業を大幅に削減できています。 実装で得られた知見 1. GAM REST API の特徴 メリット : JSON ベースで直感的な実装 豊富なドキュメントとサンプルコード 標準的な HTTP エラーハンドリング 注意点 : レポート実行は非同期処理のため、完了待ちが必要 大量データの場合はページネーションが必要 レート制限に注意が必要 2. BigQuery との連携 最適化のポイント : 日付別テーブル分割によるクエリ性能向上 適切なスキーマ設計によるストレージコスト削減 WRITE_TRUNCATE モードによる冪等性の確保 成果と今後の展望 期待される成果 工数削減 : レポート作成時間の短縮(現状 30 分〜1 時間) 営業活動の強化 : レポート作成時間を営業活動に充て、売上貢献の向上 データ活用の効率化 : BigQuery での SQL による柔軟なデータ抽出 まとめ Google Ad Manager REST API と BigQuery の連携により、セールスレポート作成の自動化を実現しました。 このシステムにより、セールスチームが営業活動により多くの時間を割けるようになり、結果として売上の向上に貢献することが期待されます。 同様の課題を抱えている組織の参考になれば幸いです。 参考 developers.google.com googleapis.dev googleapis.dev cloud.google.com
アバター
1. はじめに こんにちは、everyで1ヶ月間のインターンシップに参加させていただいた宮田です。本記事では、デリッシュキッチンの新機能開発に携わった経験と、そこで得られた学びを紹介します。 現在、デリッシュキッチンの既存仕様に対して、ユーザー体験を向上させるための新しい機能開発を進めています。今回のタスクでは、ユーザーをグループ化する新機能のバックエンドAPI実装を担当しました。 2. プロジェクト全体像と技術スタック デリッシュキッチンサーバーの概要は下の図のようになっています。ダッシュボード側ではユーザー情報の管理・監視を行い、モバイルアプリ側ではデータベースからリモートキャッシュにセットした情報をユーザー管理画面に表示します。詳細は DELISH KITCHENのシステムアーキテクチャ で説明しています。今回は、ユーザーをグループ化するAPIとそのグループに招待するコード作成・取得機能を中心としたAPIを実装しました。 技術スタック バックエンド : Go (Echo) データベース : MySQL リモートキャッシュ : Redis 3. デリッシュキッチンサーバー・バックエンド実装 デリッシュキッチンのバックエンドはクリーンアーキテクチャで構成されています。クリーンアーキテクチャとは、ビジネスロジックを外部のフレームワークやツールから切り離すことで、保守性・拡張性を高める設計手法です。主にrepository、infrastructure、service、handler、routerの5つの階層を用いています。最近では、多くの企業で標準的に採用されているようですが、私は今回が初めての経験だったため、概念の理解やコード分割に苦戦しました。 infrastructure・repository infrastructureは、外部システムとの接続やデータの永続化を担当する層です。repositoryは、データアクセスロジックを抽象化し、ビジネスロジックからデータベースの実装詳細を隠蔽する役割を持ちます。この2つによって、データベース操作の詳細をビジネスロジックから分離し、テスタビリティと保守性を向上させています。 今回のグループ機能実装では、グループの作成・招待コード生成・招待コード取得のためのリポジトリインターフェースを定義し、MySQL用の実装を作成しました。 // 招待コード作成のリポジトリ実装例 func (r *InvitationCodeRepository) CreateTx(ctx context.Context, tx dbr.SessionRunner, m *model.InvitationCode) (*model.InvitationCode, error ) { result, err := tx.InsertInto(r.getTable()). Columns( "group_id" , "invitation_code" , "expires_at" , "is_active" ). Record(m). Exec() if err != nil { return nil , e.Wrap(err, "couldn't create invitation code" ) } id, err := result.LastInsertId() if err != nil { return nil , e.Wrap(err, "couldn't get last insert id" ) } m.ID = id return m, nil } データベースへのINSERT操作をトランザクション内で実行しています。招待コード作成ではグループとの関連( group_id )と状態管理( is_active 、 expires_at )を含めたレコードを作成しています。 LastInsertId() で生成されたIDを取得してモデルに設定し、エラー処理は pkg/errors パッケージでラップして詳細な情報を保持しています。 service serviceは、ビジネスロジックを実装する層で、repositoryを通じて取得したデータに対して業務要件を満たす処理を行います。複数のrepositoryを組み合わせて複雑な処理を実現し、トランザクション管理も担当します。 今回の実装では、グループへの招待コード自動生成や、招待コード取得時のアクセス権限チェックなどのビジネスルールを実装しました。特に招待コードは、セキュリティを考慮してランダム文字列生成と有効期限設定を行っています。 // 招待コード生成のサービス実装例 func (s *InvitationCodeServiceImpl) CreateInvitationCode(ctx context.Context) (*model.InvitationCode, error ) { // トランザクション開始 session := db.GetSession( "t3" ) tx, err := session.Begin() if err != nil { return nil , e.Wrap(err, "failed to begin transaction" ) } defer tx.RollbackUnlessCommitted() // 既存の招待コードを無効化 _, err = s.invitationCodeRepo.DeactivateByGroupIDTx(ctx, tx, group.ID) if err != nil { return nil , e.Wrap(err, "failed to deactivate existing invitation codes" ) } // セキュアなランダム文字列生成 code, err := random.GenerateInvitationCode() if err != nil { return nil , e.Wrap(err, "failed to generate invitation code" ) } // 24時間の有効期限設定 expiresAt := time.Now().Add( 24 * time.Hour) newInvitationCode := model.NewInvitationCode(group.ID, code, expiresAt) createdInvitationCode, err := s.invitationCodeRepo.CreateTx(ctx, tx, newInvitationCode) if err != nil { return nil , e.Wrap(err, "failed to create invitation code" ) } if err := tx.Commit(); err != nil { return nil , e.Wrap(err, "failed to commit transaction" ) } return createdInvitationCode, nil } serviceレイヤーでは、複数のリポジトリを組み合わせたビジネスロジックを実装しています。招待コード生成では、トランザクション管理下で既存コードの無効化と新規コード生成を一貫して行い、ACID特性を保証しています。また、セキュリティ面では24時間の有効期限設定やランダム文字列生成を行い、システムの安全性を確保しています。 handler・router handlerは、HTTPリクエストを受け取り、リクエストデータの検証、serviceの呼び出し、レスポンスの組み立てを行う層です。routerは、URLパスとHTTPメソッドに基づいて適切なhandlerにリクエストを振り分ける役割を担います。この2つで、外部からのAPIリクエストを適切に処理し、JSONレスポンスを返すWebAPIを実現しています。 今回は、グループ作成・招待コード生成・招待コード取得の3つのエンドポイントを実装しました。各エンドポイントでは、リクエストパラメータのバリデーション、認証チェック、エラーハンドリングを適切に行っています。 // 招待コード作成のハンドラー実装例 func (h *InvitationCodeHandlerImpl) CreateInvitationCode(c echo.Context) error { user := h.userAuth.GetUser(c) if user == nil { return types.ErrNotAuthorized } invitationCodeModel, err := h.invitationCodeService.CreateInvitationCode(dctx.NewUserContext(c)) if err != nil { return err } invitationCodeResponse := response.NewInvitationCode(invitationCodeModel) return JSONHTTPSuccessHandlerAsMap( "invitation_code" , invitationCodeResponse, c) } handlerレイヤーでは、HTTPリクエストを受け取ってserviceレイヤーに処理を委譲し、適切なJSONレスポンスを返しています。全てのエンドポイントで共通して認証チェック( userAuth.GetUser() )を実行し、未認証の場合は ErrNotAuthorized エラーを返しています。また、 dctx.NewUserContext() でユーザー情報をコンテキストに埋め込み、service層でユーザー固有の処理ができるようにしています。レスポンス生成では、統一的なフォーマット( JSONHTTPSuccessHandlerAsMap )を使用してクライアントに一貫した形式でデータを返すよう設計されています。 4. インターンシップを通じて学んだこと GoとTypeScriptの比較 今回初めてGoを使用して開発を行ったため、書き方や仕様を把握するのが大変でした。普段はTypeScriptを使用することが多いのですが、Goを触ったことで以下のような気づきを得ました。 型の違い TypeScriptでは柔軟で表現力が高いのに対し、Goはシンプルで設計の曖昧さを許さないという違いがあります。ポインタやスライス設計を意識せざるを得ない点は新鮮でした。 非同期処理とcontext TypeScriptはPromise/async-awaitが主流ですが、Goはcontext.Contextで処理のライフサイクルを統一的に管理できます。これは信頼性を高める強力な仕組みだと実感しました。 テスト文化 Jestでの振る舞いテストが中心のTypeScriptに比べて、Goは層ごとの責務を意識してモックを徹底的に利用します。特に、データベースに直接触らずにテストするという設計方針は強く印象に残りました。 実装について アーキテクチャ設計とパフォーマンス 今回の実装では、プロジェクトのコーディング規約に従った型設計の重要性を学びました。例えば、スライス型の設計では []Type ではなく []*Type を使用することで、パフォーマンス向上とコードベース全体の一貫性を保つことができます。また、クリーンアーキテクチャにおける依存関係の管理では、定義されていない方法での依存が発生しないよう、各層の責務を明確に分離することが重要でした。 トランザクション管理とエラーハンドリング データベース操作では、単体の関数とトランザクション版の関数を分離し、前者は後者を呼び出すだけにしてメインロジックは後者に集約する設計パターンを学びました。エラー処理では、 == ではなく errors.Is() を使用した適切な比較や、 types パッケージで定義された標準エラーの活用により、一貫性のあるエラーハンドリングを実現できました。 コード効率性とパフォーマンス最適化 実装時には、早期returnの活用やfor-rangeでの要素検索における標準パッケージ slices の使用など、効率的なロジック設計を心がけました。また、無駄なDBアクセスを避けるためのロジック設計や、ORMの LoadOne メソッドを適切に使用することで、パフォーマンスの向上を図りました。 エラー処理の考え方 TypeScriptのtry-catchに比べ、Goは戻り値で明示的にerrorを返すため、どこで失敗する可能性があるかが明確に見えます。特に、infrastructure層でwrapしたエラーをservice層で再度wrapするかどうかの判断や、エラーの発生源を意識したスタックトレース設計の重要性を学びました。 コーディング規約と命名規則 変数名とコメントの適切性 実装時には、変数名がデータベースのカラム名や既存のプロジェクト慣習に則っているかを常に確認することの重要性を学びました。また、コードを読めば分かる内容についてはコメントを書かず、本当に必要な説明のみをコメントとして残すことで、コードの可読性を向上させることができました。 関数の命名と設計 新しい機能を作成する際には「Add」ではなく「Create」を使用するなど、既存のコードベースの命名規則に従うことの重要性を実感しました。また、使用されていないinterfaceや関数定義は削除し、コードベースをクリーンに保つことも大切だと学びました。 テスト実装 モックの活用とテスト設計 単体テストでは実データベースを使用せず、下位のservice/repositoryにはモックを使用することで、テストの独立性と実行速度を確保できました。テストケース作成時には「このテストで何が検証できているのか」を常に意識し、冗長なテストを避けることの重要性を学びました。 並列テストとテストケース設計 DBアクセスを行わないテストでは t.Parallel() を使用した並列化を必ず行い、テスト実行時間を短縮しました。また、全てのエラーパターンを網羅的にテストケースに含め、特に gomock.Any() ではなく具体的な型での検証を行うことで、より堅牢なテストを実現できました。 プルリクエストとレビュー文化 プルリクエスト作成時の配慮 PR作成時には、将来のタスクで使用予定の実装でも、今回のPRに関係ない部分はレビューの邪魔になるため除外することの重要性を学びました。また、テストが落ちている状態でPRを作成しないよう、事前にテストを実行して通った状態にしておくことも基本的なマナーだと感じました。 レビュー可能なPRの作成 プロジェクトに関わっていないレビュアーでもレビューできるよう、PRのdescriptionには初見では分からない情報や背景を丁寧に記載することの大切さを実感しました。これにより、チーム全体での知識共有とコードの品質向上に貢献できます。 レビュー文化 レビューの返ってくるスピードの早さに驚きました。レビューをしないと他の人の作業を止めてしまう、また、人のコードを客観的に見ることで自分も勉強になるから優先的にレビューを行うという考え方が非常に良いと思い、ぜひ自分も真似していきたいと感じました。モックの生成コマンドをMakefileに追加するなど、チーム開発での協調性を意識した細かい配慮も重要だと学びました。 5. まとめ 1ヶ月間のインターンシップを通して、デリッシュキッチンのグループ機能という大事な新機能実装を任せていただいて非常に貴重な体験となりました。普段行っているWeb開発では体験できないテストやCI/CDの自動化ツールであったり、リリース作業などを体験させていただけました。これまで概念として知っていたデータベースのインデックスやトランザクションなど、実際に自分の知識を初めてコードに反映することができてよかったです。また、細かくレビューしていただいたことで、商用としてのより良い実装だけでなく、社内の実装ルールやPR作成時に気をつけなければいけないことなど、自分の中に今までなかった様々なことを学ばせていただきました。今回のインターンシップ参加を通して、従業員として業務をこなしたことによる新しい発見や成長を得ることができ、自分がこれから勉強するべきことなども見つけることができました。また、どれだけ既存コードが理解できていなくてわからない状態でも、実装や開発は非常に楽しいなと常に思っていたので、改めて自分が開発が好きだということを再確認できてよかったです。これからは、今回の実装で学んだことやレビューいただいた内容を元に、どんどん成長して、より良いエンジニアになっていきたいです。
アバター
はじめに こんにちは。デリッシュキッチン開発部でバックエンドエンジニアをしている鈴木です。 Go言語の組み込み関数 len() は、一見シンプルに配列やスライスなどの「長さ」を返す関数ですが、その実装はコンパイラやランタイムレベルで特別な扱いを受けています。本記事では、 len の言語仕様からコンパイラ内部の処理フロー、SSA最適化、最終的なアセンブリコード、さらにはruntime内部構造体に至るまでを網羅的に順を追って詳しく説明していきます。 len の仕様と定数評価 まず、Go言語仕様において len(x) がどのように定義されているかを確認しましょう。 len は組み込み関数であり、以下のような様々な型に適用できます。 文字列 ( string ) : バイト数(文字列の長さ)を返す 配列 ( [n]T またはポインタ *[n]T ) : 配列の要素数を返す(固定長n。ポインタ経由でも配列長は型で決まる) スライス ( []T ) : スライスの現在の長さ(要素数)を返す マップ ( map[K]T ) : マップに定義されているキーの数を返す チャネル ( chan T ) : チャネルのバッファに蓄積されている要素数を返す いずれの場合も len(x) の返り値の型は int であり、その値は必ず int 型に収まります。また、 nil のスライス・マップ・チャネルに対する長さは常に0 になることが明示されています。 さらに len は場合によって コンパイル時定数 として評価されます。具体的には、 引数が 文字列リテラル の場合、 len の結果はコンパイル時定数になります(文字列のバイト数をそのまま定数として扱う)。 引数の型が 配列型 (または配列へのポインタ型)で、その引数の式にチャネル受信や非定数関数呼び出しを含まない場合、 len と cap の結果は定数とみなされます。この場合、その配列式自体は実行時に評価されません。言い換えれば、配列長がコンパイル時に判明していて副作用もないとき、コンパイラは len を単なる定数として処理します。 例 以下のように、長さが決まっている配列リテラルに対する len はコンパイル時定数となり得ます(Go仕様より) const c1 = 1.0 const c2 = len ([ 10 ] float64 { 2 }) // [10]float64{2}には関数呼び出しがなく定数とみなせる const c3 = len ([ 10 ] float64 {c1}) // c1自体は定数なのでlen(...)は定数 Fig. 1. コンパイル時定数となる len の例 以上の仕様から、 len は他の言語における通常の関数というより 演算子的な性質 を持つ設計になっていることが分かります。その場で値を計算するというよりも、「この値(または型)の長さ」というビルトインのプロパティを返すものとして扱われます。 コンパイラ内部での len 処理フロー len は組み込み関数として コンパイラに特別扱い されます。Goコンパイラは構文解析・型チェック・SSA変換・最適化・コード生成といった複数のフェーズを経てソースコードを機械語に変換します。ここでは len がソースからどのようにコンパイルされていくか、主要な段階ごとに追ってみましょう。 Universeブロックへの組み込み関数登録 Goでは Universeブロック と呼ばれる特別な領域に、組み込みの定数・型・関数があらかじめ定義されています。 len もこの中で定義されており、コンパイラ起動時に下記のように登録されます( len は内部的な演算コード OLEN に対応付けられます)。 { "append" , ir.OAPPEND}, { "cap" , ir.OCAP}, { "clear" , ir.OCLEAR}, { "close" , ir.OCLOSE}, { "complex" ,ir.OCOMPLEX}, { "copy" , ir.OCOPY}, { "delete" , ir.ODELETE}, { "imag" , ir.OIMAG}, { "len" , ir.OLEN}, { "make" , ir.OMAKE}, ... Fig. 2. 組み込み関数と内部コードの対応( len は ir.OLEN として登録) 上記はコンパイラ内部 ( cmd/compile/internal/typecheck/universe.go ) での builtinFuncs 配列の一部です。コンパイラはこれを使って、ソース中で len という識別子を見つけた際に通常の関数ではなく 組み込み関数として処理 します。実際、構文解析の段階で len(x) という構文を読み込むと、 len は単なる関数呼び出しではなく「組み込み関数 len の適用」という特別なノードとしてAST(抽象構文木)に格納されます。 型チェックとAST変換 構文解析後、コンパイラはAST上で各ノードの型チェックを行い、不正な操作を検出したり必要な変換を施したりします。 len については 関数呼び出しではなく単項演算子的な扱い になるため、型チェック段階でASTノードが変換されます。具体的には、 len(x) に対応するノードは ir.UnaryExpr (単項式)に置き換えられ、その操作種別として ir.OLEN が設定されます。 また型チェック中に、 len の引数の型が正しいかどうかを検証します。Goコンパイラ内部では先述の通り len が適用可能な型を予めフラグテーブル okforlen で定義しており、例えば配列・チャネル・マップ・スライス・文字列に対して len が使えるよう真に設定されています( src/cmd/compile/internal/typecheck/universe.go )。 okforlen[types.TARRAY] = true okforlen[types.TCHAN] = true okforlen[types.TMAP] = true okforlen[types.TSLICE] = true okforlen[types.TSTRING] = true Fig. 3. 組み込み関数 len が適用可能な型の定義(コンパイラ内部テーブル) 型チェック関数 typecheck1 内では、ノードの種類が OLEN (または OCAP )の場合に専用の処理に分岐し( src/cmd/compile/internal/typecheck/typecheck.go )、関数 tcLenCap で詳細なチェックと型設定を行います( src/cmd/compile/internal/typecheck/expr.go )。その実装コードの概略をFig. 4に示します。 switch n.Op() { ... case ir.OCAP, ir.OLEN: n := n.(*ir.UnaryExpr) return tcLenCap(n) } // tcLenCap typechecks an OLEN or OCAP node. func tcLenCap(n *ir.UnaryExpr) ir.Node { n.X = Expr(n.X) n.X = DefaultLit(n.X, nil ) n.X = implicitstar(n.X) ... var ok bool if n.Op() == ir.OLEN { ok = okforlen[t.Kind()] } else { ok = okforcap[t.Kind()] } if !ok { base.Errorf( "invalid argument %L for %v" , l, n.Op()) n.SetType( nil ) return n } n.SetType(types.Types[types.TINT]) return n } Fig. 4. len / cap ノードの型チェック処理(不正な型ならエラーし、戻り値型を int に設定) 上記のように、まず len の引数 n.X を再帰的に式として型チェックし( Expr(n.X) 等)、デフォルトのリテラル型適用やポインタ間接の暗黙的挿入( implicitstar )を行った後、 okforlen テーブルを参照して引数型が許容されるか検査しています。もし許可されない型であればエラーを報告し(invalid argument for len )、ノードの型を nil にして終了します。問題なければ、 len ノード自体の型( n.Type )を int 型に設定します。これにより、この時点でコンパイラは「 len(x) の結果は int である」ことをAST上で確定させるわけです。 型チェック段階までで特に重要なのは、 len が 実際の関数呼び出しではなくコンパイラ内部で特別扱いされる 点です。Goの組み込み builtin.go には func len(v Type) int と宣言されていますが実体はなく、IDEなどで定義を見ても空っぽな関数が出てくるだけです。これはコンパイラがビルトインを直接処理するためで、 len はユーザが実装を見るような通常の関数ではないのです。 SSA形式への変換(中間表現) すべての型チェックが終わると、次は SSA形式 への変換(静的単一代入形式の中間表現)に入ります。Goコンパイラでは各関数ごとにASTからSSAを構築し、最適化を行った後、機械語の生成へと進みます。 len についてはSSA生成時にさらに各型ごとに扱いが分岐します。その処理を示したのが以下のコードです( src/cmd/compile/internal/ssagen/ssa.go ) // expr converts the expression n to ssa, adds it to s and returns the ssa result. func (s *state) expr(n ir.Node) *ssa.Value { ... switch n.Op() { case ir.OLEN, ir.OCAP: n := n.(*ir.UnaryExpr) // Note: all constant cases are handled by the frontend. If len or cap // makes it here, we want the side effects of the argument. See issue 72844. a := s.expr(n.X) t := n.X.Type() switch { case t.IsSlice(): op := ssa.OpSliceLen if n.Op() == ir.OCAP { op = ssa.OpSliceCap } return s.newValue1(op, types.Types[types.TINT], a) case t.IsString(): // string; not reachable for OCAP return s.newValue1(ssa.OpStringLen, types.Types[types.TINT], a) case t.IsMap(), t.IsChan(): return s.referenceTypeBuiltin(n, a) case t.IsArray(): return s.constInt(types.Types[types.TINT], t.NumElem()) } Fig. 5. len / cap ノードのSSA変換処理(引数の型に応じて異なるSSA命令や定数に展開) 上記のように、SSA生成フェーズでは len (および cap )に対し以下のような分岐処理が行われます。 引数が 配列型 の場合(デフォルトケース) — n.X.Type().NumElem() で配列要素数を取得し、単にその値を定数(SSA上の定数値)として返します。すなわち、コンパイル時点で配列長が分かる場合、SSA上では既にリテラルな定数となります。例えば [5]int 型の変数であれば、その len は5という定数になります。この実装ではコンパイル時に型オブジェクトから NumElem() メソッドで配列長(内部的には型情報中の Bound フィールド)を取得しています(Fig. 5中の t.NumElem() 部分)。 引数が スライス型 の場合 — ssa.OpSliceLen というSSA命令を生成します( cap の場合は ssa.OpSliceCap )。これはスライスの長さ情報を取り出す専用のSSA命令です。 引数が 文字列型 の場合 — ssa.OpStringLen というSSA命令を生成します。文字列については cap は存在しないので len の場合だけです。 引数が マップ型またはチャネル型 の場合 — s.referenceTypeBuiltin という専用のヘルパー関数を呼び出します。マップとチャネルは内部実装が参照型であるため、これらについては汎用的な処理が取られています(この部分は次節で詳説)。 このSSA段階の分岐により、 len の動作は 引数の型ごとに最適化 されます。配列長は定数畳み込みされ、スライス・文字列長はSSA上で専用命令(後述のとおり最終的には単なるメモリアクセスに変わる)となり、マップ・チャネル長は多少複雑な処理( nil チェックを含むコード)に展開されます。 コード生成と最適化 SSAフォームへの変換後、コンパイラはアーキテクチャ固有の最適化・コード生成を行います。 len に関しても、この段階でSSA命令が具体的な機械語に置き換わります。各型における主な変換は以下のとおりです。 配列 : SSA上既に整数定数になっているため、そのまま即値リテラルとしてコード中に埋め込まれます。実行時の計算は発生しません。 スライス・文字列 : OpSliceLen や OpStringLen といったSSA命令は、実行時には 構造体の該当フィールドを読み取る単純な命令 に変換されます。Goにおけるスライスは実体として内部にポインタ・長さ・容量のフィールドを持つ構造体で表現されますし、文字列もデータへのポインタと長さを持つ構造体です。したがって、例えばスライスの長さ取得はメモリ上で「ポインタの直後にある int 値」を読み出す操作になります。GoコンパイラはSSA最適化の Late Expansion 段階でこれらを展開し、ポインタ演算で適切なオフセットから長さを取り出すコードにします(典型的にはポインタサイズ分オフセットした位置がlenフィールドです)。実際、x86-64アーキテクチャではスライス長の取得は1命令で完了します。例えば「レジスタに入っているスライス構造体の長さフィールドを別のレジスタに移す」といった具合です。 (具体例は後述) マップ・チャネル : これらも内部的にはポインタで表現された参照型で、runtimeパッケージ内の構造体( hmap や hchan )として実装されています。 len を求める場合、構造体の先頭に格納されたフィールド(マップなら要素数 count 、チャネルならキュー中の要素数 qcount )を読み出せば良いのですが、 注意点はポインタが nil の可能性 です。 len(nil) が0を返すという仕様を守るため、コンパイラは nil チェックをコード中に組み込みます。SSAで生成された referenceTypeBuiltin 関数内の処理はまさにそれを実現しています。 Fig. 6は referenceTypeBuiltin 関数内の該当部分を抜粋したものです( src/cmd/compile/internal/ssagen/ssa.go )。このコードは「マップ/チャネルの len / cap 用のSSAコード」を生成します。 lenType := n.Type() nilValue := s.constNil(types.Types[types.TUINTPTR]) cmp := s.newValue2(ssa.OpEqPtr, types.Types[types.TBOOL], x, nilValue) b := s.endBlock() b.Kind = ssa.BlockIf b.SetControl(cmp) b.Likely = ssa.BranchUnlikely bThen := s.f.NewBlock(ssa.BlockPlain) bElse := s.f.NewBlock(ssa.BlockPlain) bAfter := s.f.NewBlock(ssa.BlockPlain) ... switch n.Op() { case ir.OLEN: // length is stored in the first word for map/chan s.vars[n] = s.load(lenType, x) ... } return s.variable(n, lenType) Fig. 6. マップ/チャネルに対する len / cap のSSA展開( nil チェックと長さフィールド読み取りの生成) この生成ルーチンでは、まず引数ポインタ x が nil と等しいか比較するSSA値を作り( OpEqPtr )、if文ブロックを構築しています。 cmp が真(つまりポインタが nil )の場合は「 len =0」を返す経路、偽の場合は実際に長さを読み取る経路に分岐する形です。実際の長さ読み取りは、 s.load(lenType, x) によって行われます。これは与えられたポインタ x (マップまたはチャネル)から lenType ( int 型)の値を読み取る、つまり構造体の先頭の int フィールドを読み出すことを意味します。上述のようにマップでは先頭に count 、チャネルでは先頭に qcount があるため、ちょうどそれが len の返すべき値になっています。 最後に return s.variable(n, lenType) とすることで、この計算結果をSSA上の変数(仮想レジスタ)として len ノードに対応付けています。こうしてSSA上では、 nil 分岐と読み取りコードが表現され、最終的にバックエンドでこれが具体的な分岐命令とメモリアクセス命令に変換されます。 型ごとの len の挙動と実装詳細 以上、コンパイラ内部での変換処理を見てきました。ここで改めて、各データ型について len がどのように動作し、最終的にどんなコードになるのかをまとめます。 配列に対する len 配列型( [n]T )に対する len は コンパイル時に決まる定数 です。配列の長さ n はその型の一部であり、Goではコンパイル時に配列サイズが確定しています。したがって、配列変数や配列リテラルに対する len は、コンパイラがその場で n という定数値に置き換えます。 var a [ 5 ] int fmt.Println( len (a)) // コンパイル時にlen(a)は5に置き換えられる Fig. 7. 配列に対する len の使用例(コンパイル時に定数5に置き換わる) 上記 len(a) は実行時の計算を必要とせず、生成されるコード上では定数5として扱われます。仮にポインタ型 *[5]int であっても、指している配列長は5と決まっているため、同様に len(p) は5となります。ただし、ポインタが nil であっても配列長自体は型から分かるため、 (*[5]int)(nil) に対する len も5を返します(もっとも、そのようなコードを書くことは稀ですが、仕様上そうなっています)。 コンパイラ実装的には、配列長は型オブジェクト内のフィールド( types.Array.Bound src/cmd/compile/internal/types/type.go )に保持されており、 NumElem() メソッド( src/cmd/compile/internal/types/type.go )で取得可能です。Fig. 8にGoコンパイラ内部の配列型定義の抜粋を示します。 // 配列型の定義(コンパイラ内部表現) type Array struct { Elem *Type // 要素の型 Bound int64 // 要素数(未確定の場合<0) } func (t *Type) NumElem() int64 { t.wantEtype(TARRAY) return t.Extra.(*Array).Bound } Fig. 8. 配列型 Array の定義と長さを返す NumElem メソッド このようにしてコンパイラは配列長を取得します。結果として、 配列の len 呼び出しは単なる定数参照 となり、ランタイムコストはゼロです。 スライスに対する len スライス( []T )は可変長のシーケンスを表す構造体で、内部的にはポインタ(配列データへの参照)、長さ Len 、容量 Cap の3つのフィールドから構成されています。Goの標準パッケージ reflect では以下のように定義されています( src/reflect/value.go )。 type SliceHeader struct { Data uintptr Len int Cap int } Fig. 9. スライスの内部構造体 SliceHeader スライスに対する len(s) は、この構造体の Len フィールドの値を返します。コンパイル後の機械語では、スライスの長さ取得は 対応するフィールドを読み出すだけ の操作になります。例えばx86-64の場合、スライスがレジスタやスタック上に載っていれば、その Len 部分をMOV命令で読み込むだけで済みます。 コンパイラはSSA命令 OpSliceLen でスライス長取得を表し、最終的なコード生成時にそれを適切なオフセットの読み出し命令に置き換えます。すでに述べた通り、 Len フィールドは構造体先頭のポインタの直後に位置するため、ポインタのサイズ分オフセットしたメモリアドレスから int 値を読み取れば len が得られます。コンパイラはこの offset 計算と読み出しを自動的に行います。 nil スライス の場合でも、内部表現上は Data=nil, Len=0, Cap=0 という構造体値になっています。したがって len(nilSlice) もメモリ上0を読み取るだけで、特別な分岐なしに0が得られます。スライスに関しては、 nil であっても長さフィールドは常に0にセットされているため、 余分な nil チェックは不要 という点がマップ/チャネルとは異なります。このため、例えば関数の引数でスライスを受け取る場合、コンパイラは nil かどうかに関わらず単一の命令で長さを取得するコードを生成します。 文字列に対する len 文字列( string )は不変のバイト列を表す型で、内部的にはデータへのポインタと長さを持つ点でスライスに似ています(容量がないぶんスライスよりフィールドが一つ少ない構造体です)。 len(str) は 文字列のバイト数 (メモリ上の長さ)を返します。こちらも実行時には文字列構造体の長さフィールドを読み取るだけで、スライス同様に1命令で取得可能です。 文字列は nil という値は存在しません(空文字列""は Len=0 ですが Data フィールドはスライスとは異なりゼロではない可能性があります)。しかし言語仕様上、文字列はゼロ値では Data フィールドが特定の nil ではなく 別の特殊な場所 を指している実装になっていますが、長さは0となっています。そのため len("") は0を返し、その他の場合も格納されたバイト数を返します。 マップに対する len マップ( map[K]V )は参照型で、内部的にはハッシュマップ構造体( runtime.hmap 型)のポインタとして実装されています。 len(m) はマップの要素数(エントリ数)を返します。ランタイムの hmap 構造体定義を見ると、先頭に count というフィールドがあり、そこに現在の要素数が保持されています。以下にその一部を示します( src/runtime/map_noswiss.go )。 type hmap struct { count int // # live cells == size of map. Must be first (used by len() builtin) flags uint8 B uint8 noverflow uint16 hash0 uint32 buckets unsafe.Pointer ... } Fig. 10. マップの内部構造体 hmap (先頭の count に要素数を保持) この count こそが len の返す値です。コンパイル後のコードでは、マップのポインタ( *hmap )がレジスタなりメモリなりにあるとして、そのアドレスに対して 先頭の int 値を読み出す 処理になります。例えばx86-64では、マップポインタがレジスタRDIに入っている場合、 MOVQ (RDI), AX のような命令で先頭8バイト(64ビット)の count をAXレジスタに読み込み、それを返り値とする、といったコードになります。 しかしマップの場合、スライスと違い ポインタが nil である可能性 があります。 nil マップは要素数0と定義されているため、 nil を扱う際は0を返さねばなりません。 nil ポインタのまま先頭を読みに行けばメモリアクセス違反になるため、コンパイラは事前に nil かどうかチェックするコードを生成します(Fig. 6参照)。実際のアセンブリでは、 CMPQ RDI, $0 (マップポインタが0か比較)といった命令で nil 判定し、ゼロなら長さ0をセットして終了、それ以外なら MOVQ (RDI), AX で count を読み込む、といった分岐になります。 チャネルに対する len チャネル( chan T )も参照型で、内部的には双方向キュー構造 runtime.hchan のポインタで表現されています。 len(ch) はチャネルバッファに蓄積している要素の個数を返します。ランタイムの hchan 定義では先頭に qcount という uint 値があり、これが現在のバッファ内データ数を保持しています( src/runtime/chan.go )。 type hchan struct { qcount uint // total data in the queue dataqsiz uint buf unsafe.Pointer elemsize uint16 closed uint32 elemtype *_type ... } Fig. 11. チャネルの内部構造体 hchan (先頭の qcount にキュー内要素数を保持) この qcount が len(chan) の返り値になります。実装上はマップと同様、チャネルポインタの先頭ワードを読み出すだけです。ただしチャネルも nil ポインタの可能性があるため、やはり nil チェックを含むコード になります。 nil チャネルは長さ0と定義されていますので、 nil であれば0を返すよう分岐します。非 nil なら qcount を読み取ります。 SSAから最終アセンブリへの変換例 最後に、実際のアセンブリコード上で len がどのようになるか、簡単な例を示します。以下にスライスとマップの len を返す関数を想定し、x86-64アセンブリ出力を例示します。 // func lenSlice(s []int) int lenSlice : MOVQ RSI , AX // s.Len(RSIに入っている長さ)をAXレジスタに移動 RET // そのまま返り値として返す // func lenMap(m map[int]int) int lenMap : CMPQ RDI , $ 0 // mポインタ(RDI)がnilか比較 JEQ .Lnil // ==0(nil)の場合.Lnilラベルへジャンプ MOVQ ( RDI ), AX // *m.count(マップ先頭のcountフィールド)をAXにロード RET .Lnil : XORL AX , AX // AXを0クリア(len=0) RET Fig. 12. len のアセンブリ出力例(スライスの場合は1命令、マップの場合は nil チェック+読み取り) 上記のように、スライス長取得は Len フィールドが保持されているレジスタ(ここでは関数呼出規約上RSIに格納)からそのままAXへコピーするだけで完了しています。一方、マップ長取得ではRDIレジスタにマップのポインタが渡されており、まずそれがゼロかどうか比較した後、ゼロでなければメモリアドレスRDIが指す先の値( count )を読み取っています。 nil の場合はジャンプしてAXレジスタをゼロクリアすることで0を設定し、リターンしています。 このように、実行時コードにおいて len は非常に低コストな操作です。実際、 スライスや文字列の len 取得はオーバーヘッドのない単なるメモリ参照 となり、 マップやチャネルでも nil 判定+メモリ参照程度 に展開されます。この最適化された生成により、例えばループの終了条件に len(slice) を毎回書いても問題ない(自明なインライン展開なので)のはこのためです。 コンパイラは場合によってはさらなる最適化も行います。例えばループ内で長さが変わらないスライスに対して毎回 len を呼んでいると、最適化でループ前に一度だけ len を計算しレジスタに保持する、といったことも行われます。また、コンパイラは range ループのコード生成時にも内部で len を使いますが、これも一定の場合で定数とみなして評価を省略する挙動があります。 まとめ: なぜ len は演算子的に設計されているのか 以上を踏まえ、最後に len が「関数」ではなく言語組み込みの演算子のように設計されている理由についてまとめます。 1. 多様な型に対応するため len は配列、スライス、文字列、マップ、チャネルといった複数の組み込み型に対して使えます。もし通常の関数として定義しようとすると、これらすべての型について関数やメソッドを用意する必要があり煩雑です。しかし組み込み関数としてコンパイラが特別扱いすることで、統一した名前 len で様々な型の「長さ」を取得できるようになっています。ジェネリクスが導入された現在でも、 len はビルトインのままです(型パラメータPに対して len(x) が使えるのは、その型集合内のすべての具体型について len が定義されている必要がある、という形で言語仕様に組み込まれています)。 2. 効率のため 上述のとおり、 len の実装は非常に効率的に最適化されます。コンパイル時に分かる長さは定数化し、実行時に必要な場合も単なるフィールドアクセスや軽微な分岐で済みます。これはコンパイラレベルで len を演算子的に扱っているからこそ可能となる最適化です。通常の関数呼び出しであればインライン展開や最適化の制約が生じえますが、 len は言語レベルで特別扱いされるためそのようなオーバーヘッドがありません。 3. コードの簡潔さと安全性 len を組み込みとする設計は、言語利用者にとっても扱いやすさと安全性につながっています。例えば len は定義上panicを起こし得ません(どんな引数でも0以上の整数を返す)し、 nil も安全に処理されます。仮に len が通常の関数であった場合、 nil 参照のチェックや異常系処理をユーザが意識する必要があったかもしれませんが、現在の設計ではそうした心配は不要です。また、ビルトインであるため ユーザは len をオーバーライドしたり別の意味に使ったりできません 。これにより、常に len という表記は言語仕様どおりの意味を持ち、コードの可読性・一貫性が保たれます。 4. 内部実装のカプセル化 len を組み込み関数としたことで、各データ構造の内部実装(例えばマップの構造体やチャネルの構造体)を直接公開せずに「要素数」という情報だけを提供できます。ユーザはこれら構造の詳細を気にせず len を使えますし、仮に将来内部実装が変わっても len の振る舞いは保証されます。実際、Goのランタイム実装は自由に変更可能ですが、 len の結果だけは常に正しくなるようコンパイラとランタイム側で約束しています。 以上の理由から、Goの len は言語レベルで特殊扱いされる演算子的な組み込み関数として設計されています。そのおかげで、私たちは len をまるで配列やスライスなどに対する演算子のように どんな場面でも安心して 使うことができます。実装上も無駄なコストがなく、 「長さを求める」という非常に基本的な操作を高速かつ安全に行う ことをGoは保証しているのです。 おわりに 本記事では、Goにおける基本的な組み込み関数 len の内部挙動についてまとめてみました。普段何気なく使っている関数ですが、調べていくと知らないことばかりで驚きの連続でした。Goには len の他にもいくつか同様の組み込み関数( cap , new , make など)があります。これらも len と同じくコンパイラの Universe ブロックに定義され、内部で特別に処理されます。Go言語の公式ドキュメントや実装コードを読むことで、ビルトインがどのように扱われているかさらに理解が深まるでしょう。本記事が、 len という身近な関数の背後にある言語仕様とコンパイラ技術について理解を深める一助になれば幸いです。 参考文献: The Go Programming Language Specification How does Go calculate len()..? - tpaschalis Go Documentation Server - universe.go Go Packages - reflect Stack Overflow - How to see Go func len() Implementation
アバター
はじめに こんにちは、開発部でデータサイエンティストをしている蜜澤です。 現在Amazon QuickSightを使用して、データ分析ツールを作成しています。 Highcharts Visualを有効に活用できていませんでしたが、従来の折れ線グラフでは実現できなかった、数値によって小数点表示を変更することや、凡例をクリックすることでグラフの表示/非表示を切り替えることが可能になったので、分析ツールの利便性向上のために検証を行いました。 その際に少し困ったことがあったので、紹介させていただければと思います。 本記事の内容はかなりニッチな内容になっているため、QuickSightを日頃から使用している方向けの内容となっております。 使用するデータ 以下のような、レシピ動画サイトでのユーザーの検索ログを集計したという想定の模擬データを使用します。 それぞれのカラムの定義は以下の通りです。 event_date:日付(2025-09-01~2025-09-07) search_word:検索されたワード(キャベツ、豚肉) count:検索回数 QuickSightの準備 QuickSightのデフォルトのビジュアルを使用した折れ線グラフと、Highcharts Visualを使用した折れ線グラフを作成します。 コントロールに入力したワードの検索回数が見れるようにフィルターとコントロールの設定もします。 作成したビジュアルが以下になります。 ほとんど同じ見た目になるように作成しました。 チャートコードは以下になります。 { " tooltip ": { " headerFormat ": " {point.x:%b %Y-%m-%d}<br> ", " crosshairs ": [ { " width ": 1 , " color ": " gray " } ] , " borderWidth ": 1 , " borderColor ": " #C0C0C0 ", " shadow ": false } , " legend ": { " enabled ": false } , " xAxis ": { " categories ": [ " getColumn ", 0 ] , " labels ": { " style ": { " fontSize ": " 10px " } , " rotation ": -45 } , " tickmarkPlacement ": " on ", " tickLength ": 5 , " tickWidth ": 1 } , " yAxis ": { " title ": { " text ": "" } , " labels ": { " style ": { " fontSize ": " 10px " } } , " min ": 0 } , " series ": [ { " type ": " line ", " data ": [ " getColumn ", 1 ] , " name ": " 検索回数 ", " lineWidth ": 2 , " marker ": { " enabled ": true , " symbol ": " circle ", " radius ": 2 , " states ": { " hover ": { " enabled ": false } } } , " states ": { " hover ": { " lineWidth ": 2 } } } ] , " colors ": [ " getColorTheme " ] } 困ったこと 先ほどの例ではコントロールで「キャベツ」を指定していましたが、「豚肉」に変更すると、以下のようにビジュアルも変更されます。 しかし、データに存在しないワードの場合は以下のようになります。 これがまさに困った点で、デフォルトの折れ線グラフの場合は「データなし」と表示されるので、「にんじんのデータは無いんだな」と利用者が気づけますが、Highcharts Visualを使用すると、何も表示されないため、「データがないのか」「読み込み中なのか」「他に問題があるのか」どういう状況なのかわからなくなってしまいます。 試したこと Highcharts Visualでデータがない場合に「データなし」と表示する方法がないか調べた結果、チャートコードにnodataというオプションがあるということがわかったので、それを設定してみました。 以下のコードを先ほどのチャートコードに追加しました。 " lang ": { " noData ": " データなし " } , " noData ": { " style ": { " fontWeight ": " bold ", " fontSize ": " 12px ", " color ": " #606060 " } } しかし、「データなし」とは表示されず、何も表示されないままでした。 それでは、このオプションはなんのためにあるのだろうと疑問に思ったので、色々と検証してみた結果、以下のようにチャートコードのdataで参照する値がnullになっている場合に表示されるものだとわかりました。 " series ": [ { " type ": " line ", " data ": [] , ... チャートコードのdataの部分を上記のようにして実行すると、以下のように「データなし」と表示されました。 まとめ QuickSightの折れ線グラフでデータに存在しない値をフィルターで指定した時に、デフォルトの折れ線グラフを使用した場合は「データなし」と表示されるが、Highcharts Visualを使用している場合は現状では残念ながら「データなし」と表示する方法がないことがわかりました。 nodataというオプションはありますが、これはチャートコードで表示するデータを指定できていない場合に「データなし」と表示できるだけであり、上記の問題を解決できるわけではありませんでした。 現在Highcharts Visualはベータ版なので、今後のアップデートに期待したいです!
アバター
はじめに こんにちは。開発部でiOSエンジニアをしている野口です。 以前にCursor✖️iOS開発の記事を書きまして、現在もCursorでiOS開発を続けているのですが、その記事では「ビルドエラーはXcodeで解消している」と記載していました。 実際に運用してみて、ビルドエラーが発生するたびにXcodeに切り替えて確認するのが少し手間に感じるようになってきました。そこで今回は、XcodeのビルドエラーをCursorに自動的に取り込んで、より快適にCursorで開発に集中できる環境を作ってみました。 この記事は上記の記事を前提にしている箇所がありますので、まだお読みでない方は先にそちらをご覧いただけるとわかりやすいかと思います。 今回やったこと 今回は、Xcodeのビルドエラーを自動的にCursorに取り込むスクリプト( xcode_build_watch.sh )を作成しました。 このスクリプトは以下の処理を自動化します。 Xcodeでビルドを実行 :AppleScriptでCmd+Rを送信 ビルドログを監視 : fswatch で新しいログファイルの作成を検知 エラーを解析 :ログファイルからエラー行を抽出 エラーを出力 :エラーを出力(オプションでCursorのチャットに自動入力) また、前回の記事でご紹介した tasks.json の設定を利用して、Cursorで Cmd+R でスクリプトを実行できるようにします。 { " version ": " 2.0.0 ", " tasks ": [ { " label ": " xcode.run ", " type ": " shell ", " command ": " ${workspaceFolder}/xcode_build_watch.sh ", " problemMatcher ": [] } ] } それでは、実際のスクリプトの詳細を見ていきましょう。 まず、スクリプト全体を掲載し、その後で各部分の動作を詳しく解説していきます。 xcode_build_watch.sh #!/bin/bash # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 設定 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ビルドログディレクトリ BUILD_LOG_DIR = " /Users/<ユーザー名>/Library/Developer/Xcode/DerivedData/<プロジェクト名>-<ハッシュ>/Logs/Build " SCRIPT_DIR = " $( cd " $( dirname " $0 " ) " && pwd ) " OUTPUT_FILE = " $SCRIPT_DIR /latest_build_log.txt " # 最新のビルドログを保存するファイル ERROR_TEMP_FILE = " $SCRIPT_DIR /latest_errors.txt " # 最新のエラーを保存するファイル # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 関数定義 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # Xcodeでビルドを実行 run_xcode_build() { echo " 🚀 Xcodeビルド&監視を開始します " echo "" # Xcodeが起動しているか確認 if ! pgrep -x " Xcode " > /dev/null ; then echo " ⚠️ 警告: Xcodeが起動していません " echo " Xcodeを起動してから再実行してください " return 1 fi # XcodeでCmd+Rを実行 if osascript \ -e ' tell application "Xcode" to activate ' \ -e ' tell application "System Events" to keystroke "r" using {command down} ' \ -e ' delay 0.5 ' \ -e ' tell application "Cursor" to activate ' 2 > /dev/null ; then echo " ✅ Xcodeでビルドを開始しました " echo "" return 0 else echo " ❌ エラー: Xcodeの操作に失敗しました " return 1 fi } # ビルドログを監視 watch_build_logs() { echo " 🔍 ビルドログ監視を開始しました " echo " ⚡ 次のビルドが完了したら自動的にエラー解析を実行します " echo "" # ディレクトリの存在確認 if [ ! -d " $BUILD_LOG_DIR " ]; then echo " ❌ エラー: ビルドログディレクトリが見つかりません: $BUILD_LOG_DIR " return 1 fi # fswatchのインストール確認 if ! command -v fswatch &> /dev/null ; then echo " ❌ エラー: fswatchがインストールされていません " echo " インストール: brew install fswatch " return 1 fi # fswatchでビルドログディレクトリを監視(1回だけ) local event event = $( fswatch -1 -e " .* " -i " \\.xcactivitylog$ " " $BUILD_LOG_DIR " 2 >&1 ) local exit_code = $? # fswatchのエラーチェック if [ $exit_code -ne 0 ]; then echo " ❌ エラー: fswatch実行エラー (終了コード: $exit_code ) " echo " $event " return 1 fi # eventが空でないことを確認 if [ -z " $event " ]; then echo " ❌ エラー: ビルドログの検出に失敗しました " return 1 fi # ファイルが存在することを確認 if [ ! -f " $event " ]; then echo " ❌ エラー: 検出されたファイルが存在しません: $event " return 1 fi echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ " echo " 🔔 新しいビルドログを検出しました " echo " 📄 ファイル: $( basename " $event " ) " echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ " echo "" # エラー解析を実行 analyze_build_errors " $event " } # ビルドエラーを解析 analyze_build_errors() { local log_file =" $1 " echo " === Xcodeビルドエラー解析 === " echo "" # ログファイルが指定されていない場合は最新を検索 if [ -z " $log_file " ]; then if [ ! -d " $BUILD_LOG_DIR " ]; then echo " ❌ エラー: ビルドログディレクトリが見つかりません " echo " パス: $BUILD_LOG_DIR " return 1 fi log_file = $( ls -t " $BUILD_LOG_DIR " /*.xcactivitylog 2 > /dev/null | head -1 ) if [ -z " $log_file " ]; then echo " ❌ エラー: ビルドログが見つかりません " return 1 fi else # ファイルの存在確認 if [ ! -f " $log_file " ]; then echo " ❌ エラー: 指定されたログファイルが存在しません " echo " パス: $log_file " return 1 fi fi echo " 解析中: $( basename " $log_file " ) " echo "" # 必要なコマンドの確認 if ! command -v gunzip &> /dev/null || ! command -v strings &> /dev/null ; then echo " ❌ エラー: 必要なコマンド(gunzip/strings)が見つかりません " return 1 fi # ログを解凍 if ! gunzip -c " $log_file " > " $OUTPUT_FILE " 2 > /dev/null ; then echo " ❌ エラー: ログファイルの解凍に失敗しました " return 1 fi # エラーを抽出 local errors errors = $( strings " $OUTPUT_FILE " 2 > /dev/null | \ grep -oE ' /Users[^"]+\.swift:[0-9]+:[0-9]+: error:[^"]* ' ) if [ -z " $errors " ]; then echo " ✅ エラーは見つかりませんでした " return 0 fi echo " ❌ ビルドエラーが見つかりました: " echo "" echo " $errors " echo "" # エラーを一時ファイルに保存 echo " $errors " > " $ERROR_TEMP_FILE " # インタラクティブプロンプト(オプション) echo -n " Cursorチャットに送信しますか? [Y/n]: " read -r response case " $response " in [ yY ] | "") send_errors_to_cursor ;; [ nN ] ) echo " スキップしました " ;; * ) echo " 無効な入力です。スキップします。 " ;; esac return 0 # エラーは見つかったが、処理は正常に完了 } # Cursorチャットにエラーを送信(オプション) send_errors_to_cursor() { if [ ! -f " $ERROR_TEMP_FILE " ]; then echo " ❌ エラー: エラーファイルが見つかりません " echo " パス: $ERROR_TEMP_FILE " return 1 fi local errors errors = $( cat " $ERROR_TEMP_FILE " ) if [ -z " $errors " ]; then echo " ❌ エラー: エラー内容が空です " return 1 fi # エラー数をカウント local error_count error_count = $( echo " $errors " | wc -l | tr -d ' ' ) echo " 📊 検出されたエラー数: $error_count " # エラーメッセージを整形してクリップボードにコピー local error_text =" 以下のエラーを修正してください: $errors " if ! echo " $error_text " | pbcopy ; then echo " ❌ エラー: クリップボードへのコピーに失敗しました " return 1 fi echo " 📋 エラーをクリップボードにコピーしました " echo " 💬 Cursorチャットを開いています... " # Cursorのチャットを開く if osascript \ -e ' tell application "Cursor" to activate ' \ -e ' delay 0.3 ' \ -e ' tell application "System Events" to keystroke "l" using {command down} ' \ -e ' delay 0.3 ' \ -e ' tell application "System Events" to keystroke "v" using {command down} ' 2 > /dev/null ; then # 通知を表示 osascript -e ' display notification "Cursorチャットにエラーをペーストしました" with title "ビルドエラー" ' 2 > /dev/null echo " ✅ 完了しました " return 0 else echo " ⚠️ 警告: Cursorの操作に失敗しました(エラーはクリップボードにコピー済み) " return 1 fi } # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # メイン処理 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ main() { # Xcodeでビルド実行+監視 run_xcode_build || exit 1 watch_build_logs local exit_code = $? echo "" if [ $exit_code -eq 0 ]; then echo " ✅ 処理が完了しました " else echo " ⚠️ スクリプトがエラーで終了しました (終了コード: $exit_code ) " fi return $exit_code } # スクリプト実行 main 解説 スクリプトの内容について、順を追って解説していきます。 事前準備 まず、このスクリプトを動作させるために必要な準備について説明します。 1. fswatchのインストール ビルドログの監視に fswatch というツールを使用します。Homebrewでインストールできますので、以下のコマンドを実行してください。 brew install fswatch fswatch はファイルシステムの変更を監視するツールで、今回は新しい .xcactivitylog ファイルが作成されたタイミングを検知するために使用します。 2. ビルドログディレクトリのパスを設定 スクリプト内の BUILD_LOG_DIR のプレースホルダー( <ユーザー名> 、 <プロジェクト名> 、 <ハッシュ> )を、ご自身の環境に合わせて実際の値に置き換えてください。 BUILD_LOG_DIR = " /Users/<ユーザー名>/Library/Developer/Xcode/DerivedData/<プロジェクト名>-<ハッシュ>/Logs/Build " また、DerivedDataの場所は設定によって変わるので確認してください。 3. 実行権限を付与 スクリプトファイルに実行権限を付与します。 chmod +x xcode_build_watch.sh これで準備は完了です!それでは、スクリプトがどのように動作するのか見ていきましょう。 全体の流れ スクリプトのエントリーポイントは main 関数です。この関数が各ステップを順番に呼び出していきます。 main() { # Xcodeでビルド実行+監視 run_xcode_build || exit 1 watch_build_logs local exit_code = $? echo "" if [ $exit_code -eq 0 ]; then echo " ✅ 処理が完了しました " else echo " ⚠️ スクリプトがエラーで終了しました (終了コード: $exit_code ) " fi return $exit_code } main 関数では、まず run_xcode_build でXcodeのビルドを開始します。もしここで失敗したら exit 1 でスクリプト全体を終了します。成功したら次の watch_build_logs に進み、新しいビルドログの作成を待ち受けます。 このスクリプトは 4つのステップ で動作します。各関数が次の関数を順番に呼び出していく構造になっています。 1. Xcodeでビルド開始 (run_xcode_build) ↓ 2. ビルドログを監視 (watch_build_logs) ↓ 新しいログを検出したら 3. エラーを解析 (analyze_build_errors) ↓ 4. エラーを出力 ↓ オプション: ユーザーが承認したら、Cursorに入力 (send_errors_to_cursor) それぞれのステップを詳しく見ていきましょう。 ステップ1: Xcodeでビルド開始 最初のステップでは、 run_xcode_build関数 でXcodeをアクティブにし、Cmd+Rでビルドを開始し、Cursorに戻ります。 なお、Cmd+Rのキーストロークが確実に実行されるよう、0.5秒の待機時間を設けてからCursorに戻るようにしています。 osascript \ -e ' tell application "Xcode" to activate ' \ -e ' tell application "System Events" to keystroke "r" using {command down} ' \ -e ' delay 0.5 ' \ -e ' tell application "Cursor" to activate ' 各コマンドの説明: tell application "Xcode" to activate :Xcodeをアクティブにする keystroke "r" using {command down} :Cmd+R(ビルド実行)を送信 delay 0.5 :0.5秒待機 tell application "Cursor" to activate :Cursorに戻る この処理により、Xcodeに切り替えることなくビルドが開始され、すぐにCursorでの作業に戻れます。 ステップ2: ビルドログを監視 次に、 watch_build_logs関数 が fswatch を使って新しいビルドログの作成を待ち受けます。 fswatch -1 -e " .* " -i " \\.xcactivitylog$ " " $BUILD_LOG_DIR " 各オプションの説明: -1 :1回だけイベントを検知したら終了 -e ".*" :いったんすべてのファイルを除外(デフォルトではすべての変更を検知してしまうため) -i "\\.xcactivitylog$" :その上で、 .xcactivitylog で終わるファイルだけを監視対象に含める $BUILD_LOG_DIR :ビルドログが保存されるディレクトリ Buildディレクトリ内で .xcactivitylog ファイルだけを監視するための設定をしています。 Xcodeでビルドが完了すると、以下の場所に新しい .xcactivitylog ファイルが作成されます。 /Users/<ユーザー名>/Library/Developer/Xcode/DerivedData/<プロジェクト名>-<ハッシュ>/Logs/Build/*.xcactivitylog このファイルの作成を検知して、次のステップに進みます。 ステップ3: エラーを解析 3つ目のステップでは、 analyze_build_errors関数 がビルドログからエラーを抽出します。 3-1. ログを解凍 .xcactivitylog ファイルはgzip形式で圧縮されているため、まず解凍します。 gunzip -c " $log_file " > " $OUTPUT_FILE " 3-2. エラーを抽出 解凍したログから、エラー行だけを抽出する処理を行います。 解凍したログは以下のようなイメージになっています。(加工して、抜粋しています) from project 'Feature')f06c6d4c1b47c741^705ce8521b47c741^1(2@2#32"com.apple.dt.IDE.BuildLogSection38"Compile 376 Swift source files (arm64)70"CompileSwift normal arm64 (in target 'Feature' from project 'Feature')0e86764c1b47c741^158fe3521b47c741^-42213"/Users/username/Projects/MyApp/Sources/MyFile.swift:96:27: error: expected expression in list of expressions type: , /Users/username/Projects/MyApp/Sources/MyFile.swift:61:17: error: expected expression after 'await' strings "$OUTPUT_FILE" | \ grep -oE '/Users[^"]+\.swift:[0-9]+:[0-9]+: error:[^"]*' 各コマンドの役割: strings :バイナリから読み取り可能な文字列を抽出 grep -oE '/Users[^"]+\.swift:[0-9]+:[0-9]+: error:[^"]*' :拡張正規表現を使ってエラーメッセージを抽出 -o :マッチした部分だけを出力 -E :拡張正規表現を使用 /Users[^"]+ : /Users から始まり、 " 以外のすべての文字が1文字以上続く(ファイルパスを抽出) \.swift:[0-9]+:[0-9]+ : .swift ファイル、行番号、列番号 : error:[^"]* : : error: に続く、 " 以外のすべての文字(エラーメッセージ本文を抽出) この処理により、以下のような形式でエラーが抽出されます。 /Users/username/Projects/MyApp/Sources/MyFile.swift:96:27: error: expected expression in list of expressions type: , /Users/username/Projects/MyApp/Sources/MyFile.swift:61:17: error: expected expression after 'await' ステップ4: エラーを出力 4つ目のステップでは、 analyze_build_errors関数 が抽出したエラーをターミナルに出力します。 echo "❌ ビルドエラーが見つかりました:" echo "" echo "$errors" 基本的な機能はここまでです。エラーがターミナルに表示されるので、それを確認してコードを修正できます。 オプション: Cursorのチャットに自動入力 さらに便利にするために、エラーをCursorのチャットに自動入力する機能も用意しています。この機能は オプション なので、スクリプトから削除しても問題ありません。 まず、ユーザーに確認プロンプトを表示します。 echo -n "Cursorチャットに送信しますか? [Y/n]: " read -r response Yまたはエンターキーを押すと、次の処理が実行されます。 まず、エラーメッセージを整形してクリップボードにコピーします。 echo "以下のエラーを修正してください: $errors" | pbcopy 次に、Cursorのチャットを開いてエラーを貼り付けます。 osascript \ -e 'tell application "Cursor" to activate' \ -e 'delay 0.3' \ -e 'tell application "System Events" to keystroke "l" using {command down}' \ -e 'delay 0.3' \ -e 'tell application "System Events" to keystroke "v" using {command down}' 各コマンドの説明: tell application "Cursor" to activate :Cursorをアクティブにする keystroke "l" using {command down} :Cmd+Lでチャットを開く keystroke "v" using {command down} :Cmd+Vでコピーしたエラーを貼り付け これで、エラーが自動的にCursorのチャットに入力され、すぐにAIに質問できる状態になります! まとめ 今回は、XcodeのビルドエラーをCursorに自動的に取り込むスクリプトをご紹介しました。 前回の記事と組み合わせることで、CursorでのiOS開発環境がさらに充実したものになったと感じています。 少し設定は必要ですが、一度設定してしまえば快適に開発できるようになりますので、ぜひお試しください!
アバター
目次 はじめに SQLBoilerのコード生成フローをおさらい 調査のきっかけになったAIレビュー columnsWithoutDefault が示すもの 実際の挙動を確かめる まとめ — AIレビューとの付き合い方 はじめに こんにちは。開発本部開発1部デリッシュキッチンMS2に所属している惟高です。 私が現在関わっているプロジェクトでは、SQLBoiler という ORM を使って既存の MySQL スキーマから Go のモデルコードを自動生成しています。 普段は SQLBoiler が生成してくれるモデルを中身を覗かず「ブラックボックス」として使っていますが、ある日の AI コードレビューからのコメントをきっかけに、その挙動を丁寧に追うことにしました。 この記事では、SQLBoiler がテーブル定義をどのように読み取りモデルを生成するのかをたどりながら、普段は意識していなかった内部構造を改めて整理していきます。 Note: SQLBoiler は現在メンテナンスモードのため、将来的に別 ORM への切り替えを検討する可能性があります。この記事は現行運用の振り返りとしてご覧ください。 SQLBoilerのコード生成フローをおさらい SQLBoiler の CLI は、Cobra ベースのコマンドからドライバーとテンプレートを組み合わせてモデルを出力します。 実装 では boilingcore.New が呼ばれ、設定を元にスキーマを読み取りテンプレートを準備します。 大まかな流れは以下のとおりです。 スキーマ情報の取得 : DB ドライバーが drivers.Table と drivers.Column 構造体にメタデータを詰めます。 ここで列ごとの Default や Nullable の情報が集約されます。 列のカテゴリー分け : 取得したカラムは FilterColumnsByDefault や FilterColumnsByAuto などの関数で種類ごとに分類されます。 たとえば Default 文字列が空なら「デフォルトなし」、 AutoGenerated が true なら「DB が自動で値を生成してくれる列」といった具合にルール化されます。 テンプレート生成 : 上記の結果がテンプレートに渡され、 models パッケージのコードとして出力されます。 テンプレートコード の先頭で ColumnsWithoutDefault や ColumnsWithDefault の配列が生成されるのがその一例です。 調査のきっかけになったAIレビュー あるテーブルに nullable な group_id カラムを追加したところ、自動生成コードにおける columnsWithoutDefault にもその列が追加され、レビューボットから「ここに載っているなら必須では?」という指摘を受けました。 今回のマイグレーション ALTER TABLE schema_version_cv_invalid_conditions ADD COLUMN group_id INT NULL DEFAULT NULL ; AIレビューでの指摘 SQLBoiler が出力した差分は次のようなものでした。 var schemaVersionCVInvalidConditionColumnsWithoutDefault = [] string { "schema_version_id" , "target_column" , "condition_type" , "group_id" // 今回追加された箇所 } 「NULL を許す設計だったはずなのに必須扱い?」と違和感を覚え、SQLBoiler が生成したモデルコードを確認してみました。 columnsWithoutDefault が示すもの 今回は AI レビューで指摘があった columnsWithoutDefault について調査した結果をまとめていきます。 結論から言うと、 ColumnsWithoutDefault は「DB が自動補完しない列の一覧」であり、必須かどうかを判定する仕組みではありませんでした。 テンプレート を辿ると、 FilterColumnsByDefault(false, columns) の結果がそのまま ColumnsWithoutDefault として出力されていることが分かります。 実装 は Column.Default の文字列が空かどうかを確認しているだけです。 Note: SQLBoiler の MySQL ドライバーでは column_default が SQL の NULL のとき、 *string にスキャンした値が nil になるため Column.Default は空文字のままです。 DEFAULT NULL を宣言した列もこのパターンに当たるので、 ColumnsWithoutDefault 側に分類されます。( 該当コード ) columnsWithoutDefault の使用用途は以下があります。 INSERT クエリの生成 wl, _ := columns.InsertColumnSet( {{$alias.DownSingular}}AllColumns, {{$alias.DownSingular}}ColumnsWithDefault, {{$alias.DownSingular}}ColumnsWithoutDefault, nzDefaults, ) ここでは ColumnsWithoutDefault をベースに、モデル側で値が設定された ColumnsWithDefault を足し合わせたうえで INSERT に載せる列( wl )を決めています。 テストでのダミーデータ投入 randomize.Struct(seed, &a, {{$ltable.DownSingular}}DBTypes, false, strmangle.SetComplement({{$ltable.DownSingular}}PrimaryKeyColumns, {{$ltable.DownSingular}}ColumnsWithoutDefault)...) テストコードでも ColumnsWithoutDefault を補助配列として利用できます。 例えば「親レコードに子レコードをひも付けるテスト」( SetChild などの関連付け処理)では、DB が補ってくれない列だけを取り出してランダムなダミーデータで埋め、その状態で関連付けメソッドが意図どおり機能するかを確認する用途に使っています。 実際の挙動を確かめる 生成されたモデル構造体では、該当する列は null.Int のような nullable 型で表現されます。 type SchemaVersionCVInvalidCondition struct { // 今回追加された部分のみを表示 GroupID null.Int `boil:"group_id" json:"group_id,omitempty" toml:"group_id" yaml:"group_id,omitempty"` } null.Int{Valid:false} のまま Insert() するとクエリには列が含まれず、MySQL 側では NULL が保存されます。 まとめ — AIレビューとの付き合い方 今回、AI コードレビューの「必須では?」という指摘をきっかけに、SQLBoiler の内部構造について学ぶことができ、特にcolumnsWithoutDefault がどのように生成・利用されているかを改めて確認できました。 あわせて、columnsWithoutDefault のような自動生成コードは仕様を知らないと誤検知を招きやすいので、AI レビューではレビュー対象外にするなど運用でノイズを抑える工夫も必要だと感じました。 これから AI レビューを使う機会は増えていくはずです。 だからこそ、指摘を鵜呑みにせず該当コードや生成ロジックを辿って本当に正しいかどうか確かめる姿勢を持っていたいと思います。 少しでも参考になれば幸いです。最後まで読んでいただき、ありがとうございました。
アバター
目次 はじめに セッション・ワークショップ紹介 今日から始めるpprof(ymotongpooさん) Goを使ってTDDを体験しよう!!(chihiroさん) Goで体感するMultipath TCP ― Go 1.24 時代の MPTCP Listener を理解する(Takeru Hayasakaさん) 0→1製品の毎週リリースを支えるGoパッケージ戦略——AI時代のPackage by Feature実践(OPTiM 上原さん) エブリーエンジニアのセッション エブリースポンサーブース ノベルティ アンケート フォトブース 各社スポンサーブース Resilireさん オプティムさん ナレッジワークさん まとめ 非公式アフターイベント Go Bash vol.2 のお知らせ 最後に はじめに 去年に引き続き、2025 年 9 月 27 日(土)、28 日(日)に開催された「Go Conference 2025」に参加させていただきました。 今回も参加レポートとして、会場の様子やセッションの感想についてお届けします! gocon.jp セッション・ワークショップ紹介 今日から始めるpprof(ymotongpooさん) こんにちは、開発1部の岩﨑です。 私は1日目のワークショップ「今日から始めるpprof」に参加しました。 gocon.jp ymotongpooさんをスピーカーとして、Goのプロファイリングツールであるpprofの基本的な使い方から実際にソースコードを計測して改善するまでをハンズオン形式で実施いただきました。 実施内容は下記の通りです。 プロファイルとは pprof の基本機能の説明 pprof をプログラムに組み込む pprof でプロファイルを取得する pprof でプロファイルを可視化する pprof の読み方を理解する pprof の結果に基づいて改善する 改善結果を pprof で確認する (ボーナス)実際の開発に活かせる継続的プロファイルの解説 Goにおけるプロファイルとは、パフォーマンスプロファイルのことを指します。 統計的にアプリケーションのパフォーマンスに関する情報を収集し、プログラムのボトルネックを特定するために使用されます。 pprofはGoの標準ライブラリに含まれており、プロファイルデータの取得から可視化までを簡単に行うことができます。 ワークショップ内では以下のリポジトリのソースコードを使用しました。 github.com 題材はcutコマンドを実装したCLIツールだったので、 runtime/pprof パッケージを使用してpprofを計装しました。 実装はシンプルで、計測したいコードに下記の実装を追加するだけです。 import ( "os" "runtime/pprof" ) func main() { report, _ := os.Create( "cpu.prof" ) defer report.Close() _ = pprof.StartCPUProfile(report) defer pprof.StopCPUProfile() // アプリケーションのメインロジック // ... } ビルドしたバイナリを実行すると、指定したファイル名(今回は cpu.prof )でプロファイルデータが生成されます。 次に、生成されたプロファイルデータをpprofツールで解析します。 go tool pprof -http :9999 cpu.prof -http オプションを指定することで、Webインターフェースでプロファイルデータを可視化でき、指定しない場合はインタラクティブなCLIでプロファイルデータを解析できます。 Webインターフェースでは、以下のような画面が表示されます。 画像にあるグラフはFlame GraphというViewの概念で、関数の呼び出し階層と各関数のCPU使用率を視覚的に表現したものです。 このFlame Graph が長い場合はリソースを多く消費していることを示しており、ボトルネックとなっている可能性が高いです。 また、Sourceではソースコードのどこで時間が消費されているかを行単位で確認することができます。 画像では _, err := f.Read(buf[:]) でCum が510ms消費されていることがわかります。 ここでFlatはその関数が消費した時間を指し、Cumはその関数とその関数から呼び出される関数で消費した時間を表しています。 つまりFlatが遅い場合はアルゴリズム自体に問題がある可能性が高く、Cumが遅い場合は呼び出している関数に問題がある可能性が高いです。 ただ標準パッケージや著名なパッケージは最適化されていることが多いので、基本的には自分の実装を疑うのが良いとのことでした。 よってこの行でループごとにReadを呼び出していることが原因である可能性が高いということがわかります。 ワークショップ内でこのボトルネックを解消する改善を試み、改善後のProfileデータの変化を以下のコマンドで確認することで、計測によってパフォーマンスが改善されることを体感できました。 $ go tool pprof -http :9999 -base cpu.prof cpu_optimized.prof 推測するな計測せよ、のポリシーに従ってパフォーマンス改善を行うことを学ぶことができ、とても有意義なワークショップになりました。 Goを使ってTDDを体験しよう!!(chihiroさん) デリッシュキッチンでバックエンドエンジニアをしている秋山です。 私はこれまでTDDについて少し学んだことはあったものの、実際に取り組んだことはなかったのでこちらのワークショップに参加しました。 gocon.jp ワークショップは主に下記の流れで進みました。 自己紹介 TDD(テスト駆動開発)の概要 FizzBuzzを例としたTDDのデモ お題に基づいてTDDを実践 全体振り返り ここでは特にデモについて紹介できればと思います。 FizzBuzzを題材として、下記のような流れでデモを行なっていただきました。 ① TODOリスト(テストリスト)を作成する まずは実装前に、TODOリストを箇条書きで整理しました。 例えば、 3の倍数の場合はFizzを返す 5の倍数の場合はBuzzを返す 3と5の倍数の場合はFizzBuzzを返す のように具体的なTODOを書いていきます。 ② TODOリストに基づくテストの作成 作成したTODOリストすべてに対して一気にテストを書くのではなく、まずは1つの項目に絞ってテストを作成しました。 最初は「3の倍数の場合はFizzを返す」のテストを作成しました。 func TestFizzBuzz(t *testing.T) { got := FizzBuzz( 3 ) if got != "Fizz" { t.Errorf( "FizzBuzz(%d) = %v, want %v" , 3 , got, "Fizz" ) } } この段階ではまだFizzBuzzの実装をしていないため、テストは失敗します。 ③ テストを満たすように実装 次に、②で作成したテストを通すための最低限の実装を行います。 例えば、 func FizzBuzz(n int ) string { return "Fizz" } というように、ロジックは深く考えずに、まずはシンプルにテストを通すことを優先しました。 実装後、テストを実行すると無事テストが通ります。 これでテストが通ったので今度は TODOリストにある次の項目のテストコードを作成 テストを満たすように実装 という具合で繰り返すように細かく実装していきました。 実装途中で足りていなかったTODOに気づくこともあるので、その都度TODOを追加して進めていきました。 デモの後は、与えられたお題に対してペアプロでTDDを実践していきました。 個人的には最初のTODOリストを作る作業が難しかったです。 chihiroさんからのご説明の中で「集中」というワードが出てたのですが、 デモや実践を体験する中で「TDDを行うことで一つのことに集中することができ、余計なことを考えなくて済む」ということを実感できました! 全体を通して、TDDの流れを一定理解できたかなと思うので、今後はTDDの理解を深めながら業務でも実践していきたいと思いました! Goで体感するMultipath TCP ― Go 1.24 時代の MPTCP Listener を理解する(Takeru Hayasakaさん) こんにちは、開発1部の黒髙です。 私からは、2日目のセッション「Goで体感するMultipath TCP ― Go 1.24 時代の MPTCP Listener を理解する」について紹介します。 gocon.jp こちらでは、Go 1.24 からデフォルトで有効化された Multipath TCP(MPTCP)の仕組みと、その Go における活用方法が解説されました。資料は以下で公開されています。 speakerdeck.com MPTCP は、既存の TCP を拡張して 1 つの通信セッションの下で複数の TCP コネクション(subflow)を同時に扱えるようにしたプロトコル です。これにより、例えばスマートフォンの Wi-Fi と 4G 回線を束ねて 1 本のセッションとして利用し、 帯域幅をまとめて高速化したり、片方の回線が切れても通信を継続したりできる のが大きな特徴です。アプリケーションから見ると通常の TCP と同じインターフェースで使えるため、下層のネットワークが複数回線を意識して処理を行います。 発表ではまず、Go 1.21 で MPTCP サポートが入り、1.24 からは net.Listen が自動的に MPTCP を有効化するようになった経緯が紹介されました。既存のコードを大きく書き換えることなく MPTCP の恩恵を受けられる一方で、 ListenConfig.SetMultipathTCP(false) で明示的に無効化することも可能であり、SocketOption の互換性に問題がある場合の回避策として説明されていました。 今回のアップデートにより、クライアントが MPTCP を使って接続してきた場合でも特別な対応をしなくても自動的に利用でき、問題があれば従来通り TCP にフォールバックするため、既存のアプリケーションにも導入しやすいことが分かりました。 Go1.24の仕様についてはもちろんですが、初めて触れたMPTCPプロトコルの概念をしっかり理解することができ、大変勉強になりました。 0→1製品の毎週リリースを支えるGoパッケージ戦略——AI時代のPackage by Feature実践(OPTiM 上原さん) 開発1部の きょー です! 僕の方からはOPTiM 上原さんの 「0→1製品の毎週リリースを支えるGoパッケージ戦略——AI時代のPackage by Feature実践」のセッションについて共有しようと思います。 gocon.jp このセッションでは以下のような流れで進んでいきました。 Package by Layer 構成で開発した際に感じた課題の紹介 1で感じた課題の解決策となる、機能ごとにレイヤーを分離するという Package by Feature 構成についての紹介 Package by Feature 構成で得られた効果 パッケージ戦略の比較 Package by Layer 構成(レイヤードアーキテクチャのようなものとして認識しています。)で開発した際に感じた課題の紹介では、開発効率と保守性の低下が取り上げられていました。開発人数が多くなるにつれ発生するコンフリクト頻度の増加や、プロジェクト拡大に伴う機能間の結合度の増大。また増大した結果コードを変更しようとした際の影響範囲の増加に悩まされていたようでした。 これらの課題を解決したく取り入れたのが以下のような Package by Feature という構成です。 Package by Feature 構成を取り入れた結果以下のような効果を得られたようでした。 【開発効率】機能別の並行開発によるコンフリクト発生率を 1/3 に削減 【開発効率】機能協会の明確化によりAIコーディングとの親和性が向上 【保守性】機能単位でアーキテクチャ変更・分割が容易 【保守性】ディレクトリ構造と依存関係の可視化により設計理解が促進 【保守性】機能別テスト環境の構築・管理が効率化 speakerdeck.com 簡単なまとめになりますが、今後チームが成長し開発規模が拡大した際に取り入れてみたいと思いました! エブリーエンジニアのセッション エブリーからは、開発1部 デリッシュキッチンAWG の本丸から、「10年もののAPIサーバーにおけるCI/CDの改善の奮闘」というタイトルでスポンサーセッションをさせていただきました。 speakerdeck.com gocon.jp エブリーのメインサービス「デリッシュキッチン」のAPIサーバーは、Go・echoで構成されており、約10年の歴史の中で肥大化していくつも技術的な負債を抱えています。今回のセッションではCI/CDに焦点を当て、Goのコードを中心としたCI/CD高速化の取り組みについて解説しました。 聞いていただいた皆様、ありがとうございました! エブリースポンサーブース エブリーは昨年に引き続きスポンサーブースを出させていただきました。 弊社サービスであるデリッシュキッチンをイメージした黄色基調のブースとなっています。 足を運んでいただいた皆様、本当にありがとうございました! ノベルティ 今回は以下のようなノベルティを用意させていただきました。 ドリップバッグコーヒー 会社・サービスのステッカー キッチングッズ キッチングッズは鍋、計量スプーン、まな板、しゃもじ、お箸を用意し、Xフォローで引けるくじでプレゼントさせていただきました。 たくさんのご参加、ありがとうございました! アンケート 参加者の方々がコミュニケーションを取れるようなきっかけを作りたく、アンケートボードを用意させていただきました。お題は、「Go歴」と「気になるトピック」です。 回答いただいた多くの皆様、ありがとうございました!最終結果はこちらです…! 0~5年から、15年以上の大ベテランまで、とても幅広い歴のGopherにご回答いただけています! かなりバラけましたが、8月にリリースされたGo 1.25、GoとAIコーディング、他社のGoの知見は特に気になる方が多かったようです! フォトブース 皆さんにGo Conferenceをもっと楽しんでいただけるよう、今回新たな取り組みとして、フォトブースを設置させていただきました! パネルは、デリッシュキッチンエプロンを着たGopher、デリッシュキッチンアイコン、エブリーロゴをご用意しました。 ブースにいらっしゃった多くのGopherの皆様に、"Gopher"と一緒に写真を撮っていただきました! 楽しんでいただけていたら幸いです! 各社スポンサーブース 他社さんのスポンサーブースにもたくさん訪問させていただきました! 各社趣向を凝らしたブースや様々な企画が展開され、会場全体がとても賑わっていました。 いくつかご紹介させていただきます。 Resilireさん Resilireさんは、自社サービスで提供しているサプライチェーンのリスク監視について、クイズを交えながらわかりやすく解説していただきました!! エンジニアのイベントだと、リスクを「単一障害点」に例えるとエンジニアに理解してもらえる、というお話が印象的でした。サプライチェーンとシステム設計には、共通する部分があることを理解できました…! オプティムさん オプティムさんのブースでは、コードの一部を見てどのGoのどのパッケージかを当てる、「Go Package Guessr」にチャレンジしました! 最初わからなくても、10秒後からヒントが出るのでなんとか答えることができました。普段使っていないパッケージを知る良い機会になりました!無事ノベルティもゲットしました! ナレッジワークさん ナレッジワークさんでは、Goのコードをレビューする、「ENABLE THIS CODE by your REVIEW」に参加しました! 2日間で全4問あり、自分は2日目の朝イチ、第3問をレビューさせていただきました。ちゃんと想定していた部分は突っ込めたみたいでよかったです!ノベルティは K のキーキャップをいただきました! まとめ Go Conference 2025は、Goに関する最新の情報や活用事例、アイディアなどを学べ、Go コミュニティの盛り上がりを感じられる、とても素敵なイベントでした。 今後もGoのコミュニティ、Go Conferenceがより一層発展していくことを期待しています。 今回の参加レポートが、Goを学びたい、活用したい方の参考になれば幸いです。 運営の皆さん、カンファレンスを開催していただきありがとうございました!! 非公式アフターイベント Go Bash vol.2 のお知らせ Go Conference 2025 にスポンサー参加した LayerX、ANDPAD、OPTiM、Resilire、エブリー の Gopher たちが Go Conference 2025 に刺激を受け、トークや感想戦を繰り広げ、 Beer ではなく Go で盛り上がるイベントを開催します! https://layerx.connpass.com/event/367057/ layerx.connpass.com Go Conferenceに参加された方も、されなかった方も、ぜひご参加ください! 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv 最後までお読みいただき、ありがとうございました!
アバター
はじめに こんにちは。開発 2 部でリテールハブの小売アプリを担当している池です。 2024 年 11 月、AWS Cognito の認証機能にパスワードレス認証機能(Email/SMS OTP やパスキー)が標準機能として追加されました。 これまでは Cognito でパスワードレス認証を実現するにはカスタム Lambda トリガー(CUSTOM_AUTH 認証フロー)を利用して OTP 認証を実装する必要がありましたが、標準機能として追加されたことによって Lambda を使わずに Cognito のみで OTP を実現することができるようになりました。 小売アプリの開発においてパスワードレス認証を検討する機会があったため本記事では Cognito パスワードレス認証を試して、設定方法や認証フローおよび必要な API を確認します。 Cognito パスワードレス認証の 3 つの方式 Cognito の標準パスワードレス認証には以下の 3 つの方式があります。 Email OTP - メールで送信されるワンタイムパスワードで認証 SMS OTP - SMS で送信されるワンタイムパスワードで認証 WebAuthn/パスキー - 生体認証やハードウェアトークンで認証 今回は、この中から Email OTP 認証 を AWS CLI から試し、サインアップからトークン取得までの一連の流れを確認してみます。 事前準備 AWS コンソールでの設定(パスワードレス認証のアクティベート設定) 1. User Pool の作成・設定 Cognito User Pool を作成した後、コンソールで以下を設定します。 サインイン設定 「認証 > サインイン > 選択ベースのサインインのオプション」から編集に遷移 「使用できる選択肢」で「メールメッセージのワンタイムパスワード」を選択して保存 この設定をすることにより「パスワードなしのステータス」がアクティブになります。 パスワードレス認証(USER_AUTH 認証フロー)の有効化 USER_AUTH は 2024 年 11 月に追加された Cognito のパスワードレス認証フローです。従来の CUSTOM_AUTH のようなカスタム Lambda が不要で、標準機能として Email/SMS OTP やパスキー認証に対応しています。認証フローの USER_AUTH を有効にする設定を行います。 「アプリケーションクライアント > アプリケーションクライアントに関する情報」から編集に遷移 「認証フロー」の「選択ベースのサインイン: ALLOW_USER_AUTH」にチェックをつけて保存 オプション:OTP の有効期限をカスタマイズ(デフォルト 3 分) このフローを有効化することで、サインアップから認証まで一貫してパスワードレスで実装できます。 必要な情報の確認 設定完了後、以下の情報を控えておきます。 User Pool ID - 例: ap-northeast-1_XXXXXXXXX App Client ID - 例: 1a2b3c4d5e6f7g8h9i0j フロー概要 今回は Email OTP 認証における以下の 2 つのフローを確認したいと思います。 A. サインアップ後の自動サインインフロー(初回登録時) サインアップ(ユーザー登録) → OTP 送信 OTP 入力 → Session 返却 Session を使って自動サインイン → トークン取得 B. 通常のサインインフロー(2 回目以降) サインイン開始(OTP 発行要求)→ OTP 送信 OTP 入力 → トークン取得 シーケンス図 A. サインアップ後の自動サインインフロー 初回登録時は、サインアップと同時に OTP が送信され、ユーザーが認証コードを入力すると Session が返却されます。この Session を使うことで、追加の認証処理を挟まずにトークンを取得し、自動的にサインインまで完了できます。つまり「ユーザー登録 → メール確認 → そのままサインイン」が一気通貫で実現される流れです。 B. 通常のサインインフロー(2 回目以降) 2 回目以降のサインインでは、まず initiate-auth を実行すると OTP がメールで送信されると同時に Session が返却されます。ユーザーは受け取った OTP を入力し、その Session と組み合わせて respond-to-auth-challenge を呼び出すことで認証が完了し、トークンが返却されます。 まとめると、通常サインインは「OTP 発行(Session 付与)→ OTP 入力 & Session 提示 → トークン取得」という流れになります。 API/CLI コマンドまとめ Email OTP 認証で使用する API/CLI コマンドの一覧 フェーズ API/CLI コマンド 用途 レスポンス A. サインアップ後の自動サインインフロー サインアップ sign-up ユーザー登録 UserSub、認証コード送信先 認証コード確認 confirm-sign-up メールアドレス確認 Session (自動サインイン用) 自動サインイン initiate-auth (SESSION) Session でサインイン IdToken、AccessToken、RefreshToken B. 通常のサインインフロー サインイン開始 initiate-auth (USER_AUTH) OTP 送信 Session、EMAIL_OTP チャレンジ 認証コード確認 respond-to-auth-challenge OTP 入力・トークン取得 IdToken、AccessToken、RefreshToken 共通操作 OTP 再送 (サインアップ時) resend-confirmation-code 新しい OTP 送信 送信先情報 OTP 再送 (サインイン時) initiate-auth (USER_AUTH) 新しい OTP 送信 新しい Session CLI で実行してみる (1) サインアップ 事前準備のパスワードレス認証の設定を行っておくことで、パスワード不要で sign-up を実行することができます。 aws cognito-idp sign-up --client-id <APP_CLIENT_ID> --username <email@example.com> --user-attributes Name=email,Value=<email@example.com> パラメータ解説 --client-id : User Pool に紐づくアプリクライアントの ID --username : サインイン時に使用するユーザー名(ここではメールアドレスを指定) --user-attributes : ユーザー属性。Email OTP を使う場合は Name=email を必須で登録 レスポンス例 { " UserConfirmed ": false , " CodeDeliveryDetails ": { " Destination ": " y***@e*** ", " DeliveryMedium ": " EMAIL ", " AttributeName ": " email " } , " UserSub ": " d7f40a38... ", " Session ": " AYABeLIBKr5g... " } CLI の実行に成功すると認証コードが書かれた Email が届きます。 (2) 認証コードの確認(サインアップ時) 認証コードの確認は confirm-sign-up で行います。 aws cognito-idp confirm-sign-up --client-id <APP_CLIENT_ID> --username <email@example.com> --confirmation-code 123456 レスポンス例 { " Session ": " AYABeLIBKr5g... " } パスワードレス認証が有効な場合、 confirm-sign-up のレスポンスに Session が含まれます。この Session を使用して次のステップでサインアップ時のシームレスな自動サインインが可能になります。 (2-補足) 認証コード再送 認証コードを再送する際には resend-confirmation-code を利用します。 aws cognito-idp resend-confirmation-code --client-id <APP_CLIENT_ID> --username <email@example.com> レスポンス例 { " CodeDeliveryDetails ": { " Destination ": " y***@e*** ", " DeliveryMedium ": " EMAIL ", " AttributeName ": " email " } } (3) サインアップ後の自動サインイン confirm-sign-up で Session が返された場合、その Session を使用して initiate-auth を行うことで自動サインインが可能です。 aws cognito-idp initiate-auth --auth-flow USER_AUTH --client-id <APP_CLIENT_ID> --auth-parameters USERNAME=<email@example.com>,SESSION=<confirm-sign-up-session> パラメータ解説 --auth-flow : パスワードレス認証フローである USER_AUTH を指定 --auth-parameters : USERNAME にメールアドレスを指定。ここではパスワード不要。SESSION に confirm-sign-up のレスポンスで返ってきた Session をそのまま利用。 成功時レスポンス { " ChallengeParameters ": {} , " AuthenticationResult ": { " AccessToken ": " eyJraWQiO... ", " ExpiresIn ": 3600 , " TokenType ": " Bearer ", " RefreshToken ": " eyJjdHkiO... ", " IdToken ": " eyJraWQiO... " } } (4) 通常の OTP 認証開始(2 回目以降のサインイン) 2 回目以降のサインインでは、 USER_AUTH フローを使用して OTP 認証を開始します。 aws cognito-idp initiate-auth --auth-flow USER_AUTH --client-id <APP_CLIENT_ID> --auth-parameters USERNAME=<email@example.com> パラメータ解説 --auth-flow : パスワードレス認証フローである USER_AUTH を指定 --auth-parameters : USERNAME にメールアドレスを指定。ここではパスワード不要 レスポンス例 { " ChallengeName ": " EMAIL_OTP ", " Session ": " AYABeLnHu7... ", " ChallengeParameters ": { " CODE_DELIVERY_DELIVERY_MEDIUM ": " EMAIL ", " CODE_DELIVERY_DESTINATION ": " y***@e*** " } , " AvailableChallenges ": [ " EMAIL_OTP " ] } (5) 認証コードの確認(サインイン時) 認証コードの確認は respond-to-auth-challenge を利用します。 aws cognito-idp respond-to-auth-challenge --client-id <APP_CLIENT_ID> --challenge-name EMAIL_OTP --session "AYABe9eY..." --challenge-responses USERNAME=<email@example.com>,EMAIL_OTP_CODE=123456 パラメータ解説 --challenge-name : 今回は EMAIL_OTP を指定 --session : initiate-auth のレスポンスで返ってきた Session をそのまま利用 --challenge-responses : ユーザー名と OTP をセットで渡す レスポンス例 { " ChallengeParameters ": {} , " AuthenticationResult ": { " AccessToken ": " eyJraWQiOi... ", " ExpiresIn ": 3600 , " TokenType ": " Bearer ", " RefreshToken ": " eyJjdHkiOi... ", " IdToken ": " eyJraWQiOi... " } } (5-補足) OTP の再送 OTP が届かない/期限切れの場合は、再度 initiate-auth を実行すると新しい OTP がメールに送信されます。 制限事項 MFA との併用制限 パスワードレス認証には以下の MFA 関連の制限があります。 ユーザーは MFA またはパスワードレス認証のいずれか一方のみ使用可能(両方の併用は不可) MFA を「必須」に設定している場合 パスワードレス認証を設定できない MFA を「オプション」に設定している場合 MFA を設定済みのユーザー:パスワードレス認証を使用できない MFA 未設定のユーザー:パスワードレス認証を使用できる つまり、パスワードレス認証を利用する場合は MFA を「オプション」または「なし」に設定する必要があります。この制限は AWS Cognito の仕様によるもので、詳細は 公式ドキュメント を参照してください。 機能プラン 2025 年 9 月時点で Cognito には Lite, Essential, Pro という 3 つのプランがあります。 それぞれのプランにおいて利用できる機能と料金体系が異なり、Lite < Essential < Pro の順番で機能が豊富になり、その分料金は高くなります。 Cognito パスワードレス認証を利用するには Essential 以上のプランが必要になります。 ※ 機能プランおよび料金体系は変更されるのでご注意ください。 まとめ 本記事では 2024 年 11 月から Cognito 標準機能として利用可能になったパスワードレス認証(Email OTP)で AWS CLI を使ってサインアップからサインインまでのフローを確認しました。 これにより、従来はカスタム Lambda が必須だった OTP 認証を、Cognito 標準機能のみでシンプルに実現できることが分かりました。 実運用では MFA との併用制限 や プラン要件 に注意する必要がありますが、ユーザー体験を向上させたいケース等で有効な選択肢となります。 今回は Email OTP のみを確認しましたが、パスキー(WebAuthn)といった他の方式も検証してみたいと思います。 最後まで読んでいただきありがとうございました。少しでも参考になれば幸いです。 参考リンク AWS 公式ドキュメント - Passwordless authentication AWS CLI リファレンス - cognito-idp
アバター
【実践】Aurora DSQLをTerraformで構築して実運用化まで 背景 Terraform AWS Provider v6 さて、普通にアップグレードするとどうなるでしょう? 実際にアップグレードしてみると、 ソースコード例 Aurora DSQLの構築 Private DNS endpoint の爆誕 VPC Privatelink Endpoint Security Group IAMロール まとめ 最後に  こんにちは、開発本部 開発2部 RetailHUB NetSuperグループに所属するホーク🦅アイ👁️です。 背景  私の所属するチームでは、現在新プロダクトをローンチに向けて鋭意開発中なのですが、そのデータベースアーキテクチャにAurora DSQLを採用しました。その構成はシンプルにシングルリージョンシングルクラスターです。このDSQLをTerraform管理で構築すべく調査しているとawsプロバイダのバージョンを最低でもv5.100.0以降にしないといけないことがわかりました。  チームで使っているTerraform群は現在、awsプロバイダのバージョンは5.44.0でした。そこでこの際と、当時最新のバージョン6.6.0へのアップグレードを試みることにしました。  また、この最新プロバイダバージョンを使ってインフラ構築を行った際にgo言語で実装したAPIサーバからDSQL接続を確立させるまでの間にぶち当たった問題とその解決策を紹介したいと思います。 Terraform AWS Provider v6  AWSクラウド用のメジャーなプロバイダであるAWS Hashicorpプロバイダをチーム内では既に利用しています。その最新major version 6が2025年6月19日GAされました。  公式のアップグレードガイドによると多くの更新情報がありますが、特に我々のチームの既存環境にインパクトがあると思われたのは以下の抜粋にあるようにregionに関する内容でした。 registry.terraform.io Enhanced Region Support Version 6.0.0 of the Terraform AWS Provider adds region to most resources making it significantly easier to manage infrastructure across AWS Regions without requiring multiple provider configurations. さて、普通にアップグレードするとどうなるでしょう? 全リソースにリージョンを自動で埋め込んでくれる 全リソースにリージョンを自動で埋め込んでくれるが、providerブロックと重複して ERROR になる 全リソースにリージョンを埋め込め!という ERROR になる 全リソースにリージョンを埋め込むべき!という WARNING になる 実際にアップグレードしてみると、 terraform plan -refresh-only だけ実行して198のリソース差分が出ましたが、破壊的変更の対象リソースはなく、すべて内部的な管理が付加される以下の3つだけなのでstate更新だけでapplyは問題なく実行可能と判断したので実行し、無事に成功しました。 region:v6のアップグレードガイドにある通り、リソース単位でリージョンを上書き設定できるようにほぼすべてのリソースに追加された引数。Optional and Computedの引数なので設定しなくても自動で値が入りstate管理に追加される 公式のregionエンハンス bucket_region:上のregionと同じ扱い Guideのaws_s3_bucketについて export:v6.4.0以上から追加されたoptions引数でACM証明書のエクスポート可否を設定するもの。デフォルトdisabledで実環境に影響なしのデフォルト値を管理対象にするものでしかない resource:aws_acm_certificate ソースコード例  実際にapply前後でどのようなtfファイルの違いが出たかを見てみると確かにスッキリとした記述になった印象です。もともと、エイリアスを埋め込んでいたリソース全部に修正が必要でしたが、対象リソースも少なくそもそも各リソースに手を加えていたのでDRYの観点でも同様の修正にならざるを得ないのでそのまま改修を実施しました。 main.tf(before) providers = { aws . virginia = aws.virginia } main.tf(after) は、丸々その行を削除した状態 provider.tf(before) terraform { required_providers { aws = { source = "hashicorp/aws" version = "5.44.0" configuration_aliases = [ aws.virginia ] } } } provider "aws" { alias = "virginia" region = "us-east-1" profile = var.profile } provider.tf(after) terraform { required_providers { aws = { source = "hashicorp/aws" version = "6.6.0" } random = { source = "hashicorp/random" version = ">= 3.0" } } } # 以下は、丸々削除 route53/main.tf(before) resource "aws_acm_certificate" "hogehoge" { provider = aws.virginia ... } route53/main.tf(after) resource "aws_acm_certificate" "hogehoge" { region = "us-east-1" ... } Aurora DSQLの構築  さて、本題のDSQL環境構築ですが最終的には以下のアーキテクチャ構成で構築しました。 アーキテクチャ構成図  DSQLクラスター自体はリソースのArgumentも少なくとてもシンプルな設定しかなく簡単に作成はできました。すると、パブリックエンドポイントというものがデフォルトで払い出されていました。RDSでは見ない、最初からパブリックネットワークに接続されたエンドポイントがあり、むしろプライベートネットワークのエンドポイントがない状態でした。ここで2つの課題が浮き彫りになりました。 VPC内のECSクラスターからどのようにネットワーク導通を確保するか 実運用に耐えるようにセキュリティを考慮しなければならない  この2つの課題を解決させるために次に掲げる詳細設定で構築を完成させ実運用にも耐えうるセキュリティを確保するようにしました。  なお、デフォルトで払い出されるPublic endpointに対しては払い出し自体を無効化にする設定は今のところ存在しないのでそのままにしています。特にネットワークACLによるtraffic制御はしていないのでクラスターへの導通自体は可能ですが、DSQL接続時にパスワードの代わりとして使うトークン発行時の認証によりIAMロール権限不足でAccess Deniedになるので問題なしとしています。もし、運用中にDDos攻撃のようなアクセス過多による高trafficが発生した場合はその時にACL制御も検討する予定です。 Private DNS endpoint の爆誕  DSQLクラスターの詳細画面をコンソール上で確認すると、 PrivateLink という気になる項目タブがありました。公式ドキュメントによればVPCエンドポイントを使えば他のVPCにあるECSクラスターに対してあたかも同じプライベートサブネット上の対象物として接続確立可能になる仕組みでこれを使うことにしました。 docs.aws.amazon.com docs.aws.amazon.com VPC Privatelink Endpoint  VPCエンドポイントはDSQLの公式ドキュメントにもあるように以下のようにInterfaceで作成しました。vpc_idは、ECSクラスターの存在するvpc_idを設定します。service_nameは、1リージョン内のDSQLクラスター共通で作成時に自動で発行されるサービス名になります。subnet_idsは、ECSクラスターが接続しているサブネット(マルチAZ構成)を設定します。security_group_idsは、後述のセキュリティグループの設定を反映させるために変数でセキュリティグループIDを渡しています。private_dns_enabledは、デフォルトfalseで最初試してRoute53で扱いやすい自由な文字列でCNAMEを設定してみたのですが、それだとネットワーク導通せず、うまくいきそうになかったので諦めてtrue値にして自動で払い出されるプライベートDNS名を使うことにしました。tagsのName属性を使ってVPCエンドポイントのName部分を自由に編集することができます。 resource "aws_vpc_endpoint" "dsql_vpc_endpoint" { vpc_id = aws_vpc.ecs_cluster.id service_name = var.dsql_service_name vpc_endpoint_type = "Interface" subnet_ids = var.private_subnet_ids security_group_ids = [ var.dsql_vpc_endpoint_id ] private_dns_enabled = true tags = { Name = "dsql-endpoint" } } Security Group  セキュリティグループは、以下のような厳密なinbound(ingress)ルールに設定しました。source_security_group_idは、APIサーバサービスが存在するECSクラスターからのみを許可する設定です。from_port,to_portは、特定ポート接続のみを許可する設定です。outbound (egress)ルールは不要なので設定なしです。  なお、aws_security_group_ruleリソース記法は古い記法でv6.6.0ではaws_vpc_security_group_ingress_ruleリソース記法を推奨していますが、referenced_security_group_idにそのままsource_security_group_idと同じIDを設定してうまくいくかどうかは動作確認していないため採用しませんでした。おそらく旧記法のaws_security_groupリソース内のingressブロックで表現可能だったのでそのブロックで表現されたリソースの場合は指定できないかもしれません。 resource "aws_security_group" "dsql_vpc_endpoint" { name = "dsql-vpc-endpoint" vpc_id = var.vpc_id tags = { Name = "dsql-vpc-endpoint" } } resource "aws_security_group_rule" "dsql_vpc_endpoint_ingress" { type = "ingress" from_port = 5432 to_port = 5432 protocol = "tcp" security_group_id = aws_security_group.dsql_vpc_endpoint.id source_security_group_id = aws_security_group.ecs_cluster.id } IAMロール  IAMロールは、DSQLの仕様であるトークン認証・認可方式において必須設定となります。まず、ECSクラスターの特定タスクに対して接続許可ポリシー dsql:DbConnectAdmin を追加しました。そのうえで、マイグレーションをGitHub Actionsで自動化するためにそのOpenID Connectに対しても接続許可ポリシーを追加しました。また、APIサーバは、カスタムロールを使って接続させるためにカスタムロールに対しての接続許可ポリシー dsql:DbConnect も追加しました。 # 以下のポリシーをECSタスクのIAMロールに関連付け statement { sid = "DSQLConnection" effect = "Allow" actions = [ "dsql:DbConnect" , "dsql:DbConnectAdmin" ] resources = [ var.dsql_arn ] }  次に、 公式ドキュメント に従って実際にDSQLのpostgresデータベースにadminで接続してカスタムロール作成、IAMロール権限付与、特定スキーマにCRUD権限を付与しました。 CREATE ROLE example WITH LOGIN; AWS IAM GRANT example TO ' [ECSタスクのIAMロールARN] ' ; GRANT USAGE ON SCHEMA myschema TO example; GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA myschema TO example; まとめ  本ブログでは、前半では、HashiCorpが提供するTerraformのAWS Provider version 6 upgradeを実際に行った話についてでした。  また後半では、Amazon Aurora DSQLについての実運用のための構築方法について簡単ではありますが説明させていただきました。  我々の新プロダクトが無事運用開始された後の運用実談についてまた別の機会にお話できればと思います。 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv
アバター
はじめに こんにちは、2025年4月にソフトウェアエンジニアとして新卒入社した黒高です。普段は デリッシュキッチン に関する開発に携わっています。 ウェブ版のデリッシュキッチンにおいて、クライアント端末の判定ロジックを見直す機会がありました。本記事では、「どのようにクライアントを識別し、どう補完していくと良さそうか」を整理します。 端末で処理を分けたいユースケース スマホとPCで表示を切り替える場面として真っ先に思い浮かぶのはレスポンシブレイアウトですが、これは多くの場合CSSで完結します。JavaScriptの window.innerWidth を用いることで画面幅を取得し、処理を分岐させるといった方法もよく使われます。しかし、ログの収集やユーザー訴求内容の分岐、アプリストアへの誘導(例: iOS ならApp Store、AndroidならGoogle Play Store)などで、端末・機種によって処理を分けたい場面には別のアプローチが必要です。 一般的な端末判定方法 基本はUser-Agent(以降 UA )ヘッダの内容によって、クライアントを判定しています。デリッシュキッチンで利用しているNuxt.jsでも @nuxtjs/device モジュールが用意されていますが、これはクライアントから送られるUAの内容から判別しています。 UAはブラウザやデバイスがサーバーに送る「自分の情報を含んだ文字列」で、OS・ブラウザ・バージョンなどが含まれており端末を判別する手がかりになります。詳しくは以下のドキュメントを参照してください。 developer.mozilla.org User-Agentの利用方法 フロントエンドにおけるSSRではリクエストヘッダから参照し、ブラウザ実行時は navigator オブジェクトを読むだけで取得できます。 developer.mozilla.org サーバサイド (SSR時) 受信したHTTPリクエストのヘッダに user-agent が含まれているため、そのまま文字列を取り出せます。 const http = require('http'); http.createServer((req, res) => { const ua = req.headers['user-agent']; res.end(`UA: ${ua}`); }).listen(3000); クライアントサイド (ブラウザ実行時) ブラウザ環境では navigator.userAgent が同じ情報を返すので、条件分岐などに利用できます。 const ua = navigator.userAgent; User-Agentの例 デバイス ブラウザ User-Agent の一例 iPhone (iOS) Safari Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1 iPhone (iOS) Chrome Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/135.0.7049.83 Mobile/15E148 Safari/604.1 iPad (iPadOS13以前) Safari Mozilla/5.0 (iPad; CPU OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1 iPad (iPadOS13以降) Safari Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Safari/605.1.15 Android (Pixel 7) Chrome Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36 PC (Windows 11) Chrome Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36 Edg/134.0.0.0 PC (macOS) Chrome Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15 2025: List of User-Agent strings | DeviceAtlas より抜粋 ChromeのiOS向けビルドはApp Storeの制約によってWebKitを利用するため、UAには CriOS というトークンが含まれます。Windows 環境での一般的なChromeのUAは上記のように Windows NT を含むことが多いです。 ここで注意したいのが、iPadOS13以降では「iPad(ただしiPad miniを除く)」が、macOS Safari と同一のUAを送るようになっていることです。これは以下の文献で明言されています。 “With the exception of iPad mini, Safari on iPad will now send a user-agent string that is identical to Safari on macOS.” New WebKit Features in Safari 13 | WebKit より 詳細は後述しますが、この場合「タッチパネルかどうか」でiPadとMacの区別をすることができます。 User-Agentによる端末判定の一例 以下は、 next-modules/device/src/runtime/generateFlags.ts で定義された判定ロジックの一つです。 function isIos(userAgent: string): boolean { return /iPad|iPhone|iPod/.test(userAgent) } このように、端末を表す文字列の正規表現を用いた単純なロジックであることがわかります。しかし、これでは"iPad"という文字列が含まれない「iPadOS13以降」では false になってしまう問題があります。 User-Agent判定を補完する技術・アプローチ UA判定による主な問題点 UAによる判定はシンプルですが、次のような課題があります。 問題点 説明 影響例 偽装可能性 UA 文字列は開発者ツールなどで簡単に変更可能 実際は iOS なのに Android と判定される 仕様変更リスク iPadOS 13 以降のように UA 仕様が突然変わる "iPad" が含まれなくなり誤判定(前述) 情報過多とプライバシー問題 UA 文字列には不要な情報も多く含まれる プライバシー保護の観点から制限の方向へ User-Agent Client Hintsについて 従来のUser-Agent文字列は、情報が過剰で偽装も容易、さらにブラウザの仕様変更によって判定が壊れやすいという課題がありました。これに対してGoogleはPrivacy Sandboxの一環として User-Agent Reduction を提案し、その代替として User-Agent Client Hints(UA-CH) が標準化されています。 なお、UA-CHの正式な仕様は User-Agent Client Hints W3C Draft で公開されており、User-Agentの情報量削減とプライバシー配慮の観点から標準化が進められています。詳しい背景や概要は以下のサイトも参考になります。 privacysandbox.google.com UA-CHでは、必要な情報だけをブラウザが段階的に返す仕組みを導入し、プライバシー保護を考慮しつつOSやブラウザ情報を安全に取得できます。基本情報(OS名やモバイル判定)は navigator.userAgentData から取得します。 if ( navigator . userAgentData ) { const { platform , mobile } = navigator . userAgentData ; console . log ( platform , mobile ) ; // 例: "Android", true } 詳細情報は必要に応じて getHighEntropyValues() を通じて明示的にリクエストします。 if ( navigator . userAgentData ) { navigator . userAgentData . getHighEntropyValues ([ "platformVersion" , "architecture" , "model" , "uaFullVersion" ]) . then ( ua => console . log ( ua )) ; // 例: { platformVersion: "13.0.0", architecture: "arm", model: "Pixel 5", uaFullVersion: "120.0.6099.129" } } UA-CH利用によるメリット として、以下が挙げられます。 デフォルトで基本情報のみ、詳細情報は明示的要求が必要→クライアントのプライバシー保護につながる navigator.userAgentData などで正規表現パース不要、扱いやすさ向上 従来のUAから少しずつUA-CHに移行可能で、既存コードへの影響が小さい しかし、2025年9月現在ではChromium系ブラウザを中心に対応が進んでいるものの、Safariや一部ブラウザでは未対応のままになっています。したがって、判定ロジックをUA-CHだけに置き換えることは避けた方が良いでしょう。 Feature Detectionについて developer.mozilla.org こちらのドキュメントでは、UA文字列による判定の問題点と、その代替としての Feature Detection の有用性が説明されています。これは、「ブラウザ(や端末)の名前/バージョンを調べて分岐する」のではなく、「 操作中の環境が特定の機能をサポートしているかどうか を調べて、その結果に応じて処理を変える」方式です。例えば、以下のような実装例が挙げられます。 // タッチ操作が可能かどうかを直接判定 const touch = 'ontouchstart' in window || navigator.maxTouchPoints > 0; if (touch) console.log('タッチUIを有効化'); Feature Detection で解決できること として、以下が挙げられます。 機能が使えるか直接確認し、誤判定リスクを抑制できる UAでは判断しづらい最新APIの対応可否を正しく判定 UA仕様変更やリダクションの影響を受けにくい堅牢なコードにつながる OSや端末詳細の判定自体はできないものの、UAに依存しないこのような書き方は積極的に取り入れるべきだと感じました。 端末判定ロジックの実装例 私が担当した実装では、「iPhoneの場合に分岐したコードを、iPadにも適用させたい」という状況がありました。注意すべきポイントは、「iPad以外のタブレットやAndroidは条件外」「前述したiPadOS13以降の識別問題を正しく対処する」ことの2点であり、これは 前述 した関数の利用だけでは不十分です。 今回は UA-CHが利用可能なら優先 し、使えない場合は 従来のUA判定 + タッチサポート + プラットフォーム情報 を組み合わせるアプローチを取りました。なお、Macintosh系でタッチパネルをサポートするPCがないことを前提としています。 iPhone、Android、iPad、それ以外を判定したいときのコード例は以下の通りです。 export async function detectDevice () { // UA-CHが使える場合は優先 if ( navigator . userAgentData ) { const { platform , mobile } = navigator . userAgentData ; if ( platform === 'iOS' && mobile ) return 'iphone' ; if ( platform === 'iOS' && ! mobile ) return 'ipad' ; if ( platform === 'Android' ) return mobile ? 'android' : 'other' ; return 'other' ; } // UAとタッチ対応を組み合わせた判定 const ua = navigator . userAgent ; const isTouch = 'ontouchstart' in window || navigator . maxTouchPoints > 0 ; const isIpad = / iPad / . test ( ua ) || (/ Macintosh / . test ( ua ) && isTouch ) ; if (/ iPhone / . test ( ua )) return 'iphone' ; if (/ Android / . test ( ua )) return / Mobile / . test ( ua ) ? 'android' : 'other' ; if ( isIpad ) return 'ipad' ; return 'other' ; } 最後に 今回の見直しで感じたのは、ライブラリやモジュールの判定をそのまま使うだけではなく、本当に正しいか・最新仕様に追随できているかをちゃんと確認することの大切さです。 また、UA文字列に依存しすぎない設計になるよう、機能の有無を直接見るFeature Detectionを基本とする方針が良さそうです。 UA-CHの利用も一つの手段ではありますが、iOS端末で非対応の現状を踏まえると、実際に利用するメリットはまだ乏しいとも言えます。導入するならフォールバックを含めた段階的な移行を前提に、今後の対応状況を見ながら慎重に検討していきたいです。 参考文献 window.innerWidth(MDN Web Docs) @nuxtjs/device User agent (ユーザーエージェント) - MDN Web Docs 用語集 | MDN navigator(MDN Web Docs) 2025: List of User-Agent strings | DeviceAtlas New WebKit Features in Safari 13 | WebKit nuxt-modules/device/src/runtime/generateFlags.ts User-Agent Client Hints W3C Draft ユーザー エージェントの情報量削減とは | Privacy Sandbox ユーザーエージェント文字列を用いたブラウザーの判定 - HTTP | MDN
アバター
はじめに こんにちは。デリッシュキッチンでデータサイエンティストをしている古濵です。 デリッシュAIを始め、約1年ほどAIエージェントの開発に取り組んできました。 今回は、個人的に感じたAIエージェント開発のアーキテクチャについて触れようと思います。 あくまで個人的意見という点にご留意ください。 背景 AIエージェントのようなシステムでも、明確な設計思想に基づいたアーキテクチャ選択が重要です。 しかし、AIエージェント開発における確立されたアーキテクチャパターンは、まだ十分に整理されていない状況です。 一般的にアーキテクチャを考える時、クリーンアーキテクチャ(レイヤードアーキテクチャ)などに代表されるような、依存関係を明確にしたり、責務を分離することなどがメイントピックだと思います。 AIエージェント開発でも、これらの観点は重要です。 ただ、AIエージェント固有の観点もあると感じており、それを考慮したアーキテクチャ設計が必要だと思っています。 アーキテクチャ文脈でのAIエージェント固有の観点 先に述べておくと、AIエージェント固有の観点は以下の2点だと考えました。 評価しやすいこと 処理を組み替えやすいこと なぜそのように考えたかについて、以下にまとめていきます。 スコープ ひとえにAIエージェントと言っても、Workflow型とAgent型に大別されます。 これらは区別されると言っても、一定のグラデーションを持つ性質もあります。 www.anthropic.com 今回は、Workflow型を中心に考えていきます。 これは世の中のAIエージェントの中心は、まだWorkflow型だろうと考えているためです。 個人的にはLLMに全てを任せるよりも、実装側で手続的な処理を駆使した方がコントロールがしやすく、デバッグもしやすいと考えています。 RAGのアーキテクチャ Workflow型のAIエージェントの代表格といえばRAGです。 デリッシュAIもRAGを中心としたシステム構成になっています。 Langchainは、RAGの構成を以下のようにまとめています。 Query Translation Routing Query Construction Indexing Retrieval Generation https://github.com/langchain-ai/rag-from-scratch これは大きく分ければ、以下のように分けられます。 事前処理 Indexing 前処理 Query Translation Routing Query Construction 検索 Retrieval 生成 Generation 上記のように整理すれば、いわゆるFTI(Feature/Training/Inference)Pipelinesアーキテクチャのようにも見えてきます。 www.hopsworks.ai FeatureやTrainingが、事前処理や前処理にあたり、Inferenceが検索+生成にあたるという考え方です。 このような観点で整理すれば、従来のMLパイプラインと同様のアーキテクチャとして落とし込むこともできなくはありません。 とはいえ、LLMの入出力を数珠繋ぎ的に処理するWorkflow型AIエージェントの方が、従来のMLパイプラインより複雑な処理が多いと感じています。 複雑さの要因 なぜ複雑になるかは、一言で言えば「精度の向上、処理の高速化、汎用性の向上など動機がWorkflowをどう組むかに反映される」からです。 そして、変えた結果向上したかどうかを評価する必要もあります。 例えば、RAGの精度向上をさせるために、前処理で単体のLLMが処理する範囲を狭めて、複数のLLMで処理するように分けることがあります。 また、高速化するために、依存しないLLMのリクエストを並列(並行)処理して、APIの待機時間を効率的に扱えるようにすることもあります。 このように、LLMの処理で担当する範囲を狭めて並行処理することは、目的特化の処理であれば合理的です。 しかし、代償として汎用性は失われる側面もあります。 そこで、RoutingのようなWorkflowの処理自体を分岐する仕組みを作り、リクエストされたクエリに応じて呼び出すWorkflowを切り替えるというアプローチもあります。 以下の論文では、応答エージェント(質問応答エージェント、推薦応答エージェント、雑談応答エージェント)とどのエージェントで応答をするかを決める計画エージェントからなるAIエージェントを提案しています。 複数のエージェントを使い分けることで、ユーザーとコミュニケーションをとりながら、適切なアイテムを推薦するという目的を達成しています。 arxiv.org このように、工夫していけばいくほど処理が複雑になるのは避けられません。 その他にも、精度の向上や高速化を目的として、プロンプトを変更したい、新しいモデルを試してみたい、一部ルールベースに置き換えて結果が変わるか確認したい、Workflowの処理順序を変更してみたいなど、様々な変更点が出てきます。 実装のアプローチ 再掲ですが、以下を目指します。 評価しやすいこと 処理を組み替えやすいこと 1. 評価しやすい実装 評価しやすい実装の前に、評価項目について整理します。 RAGの場合は大きく以下のように分けられます。 検索の評価 検索+生成の評価 単体のLLMの評価 ここで、検索の評価は、従来の検索システムのような評価指標を適用することができます。 また、検索+生成の評価は、LLM as a Judgeを使った評価を実装しています。 具体的には、以下のテックブログをご覧ください。 tech.every.tv 単体のLLMの評価は、どのようなタスクかによって評価の仕方が変わると考えています。 それは、回答を事前に定義できる場合とできない場合です。 できない場合は、例えば要約するタスクであれば、最終的にできる要約の内容をあらかじめ定義することは難しいです。 そのため、LLM as a Judgeを使って、回答のニュアンスとして合っているかを評価するアプローチになり、検索+生成と似たような評価をすることになると思います。 できる場合は、例えば特定のキーワードを抽出するタスクであれば、抽出したい対象を正解として定義することで正答率を出すことができます。 デリッシュAIでは、このような前処理がいくつもあります。 例として、調理時間を抽出する前処理では「10分以内で作れる副菜教えて」というユーザのクエリに対して、調理時間のカラム名、不等号、値を出力します。 これらの回答は一意に定まり、事前に正解を定義することができるため、容易に評価可能です。 以下は、調理時間を抽出する前処理のコード例です。 import os from enum import Enum from pydantic import BaseModel, Field from langchain_core.messages import MessageLikeRepresentation from langchain_core.prompts import ( ChatPromptTemplate, MessagesPlaceholder, SystemMessagePromptTemplate, ) from langchain_openai import ChatOpenAI os.environ[ "OPENAI_API_KEY" ] = "your_api_key" class CookingTimeColumn ( str , Enum): cooking_time = "cooking_time_min" class CookingTimeOperator ( str , Enum): greater_than = ">" less_than = "<" greater_than_or_equal_to = ">=" less_than_or_equal_to = "<=" class CookingTimeFilter (BaseModel): column: CookingTimeColumn operator: CookingTimeOperator value: float class CookingTimeFilters (BaseModel): filters: list [CookingTimeFilter] def create_cooking_time_filter (messages: list [MessageLikeRepresentation]) -> CookingTimeFilters: system_prompt = f """ あなたは料理の知識が豊富なレシピ検索AIです。 ユーザーがレシピ検索のために入力したクエリを解読し、ユーザが**調理時間**でフィルタリングして検索したい場合は、フィルタリング条件を返してください。 ## 出力形式 * json形式で出力してください * columnにカラム名、operatorに不等号、valueにフィルタリング対象を入れてください """ prompt = ChatPromptTemplate.from_messages( [ SystemMessagePromptTemplate.from_template(system_prompt), MessagesPlaceholder(variable_name= "messages" ) ] ) llm = ChatOpenAI(model= "gpt-4o-mini" ) chain = ( prompt | llm.with_structured_output( schema=CookingTimeFilters, method= "json_schema" , ) ) return chain.invoke({ "messages" : messages, }) print (create_cooking_time_filter([( "user" , "10分以内で作れる副菜教えて" )])) # CookingTimeFilters( # filters=[ # CookingTimeFilter( # column=<CookingTimeColumn.cooking_time: 'cooking_time_min'>, # operator=<CookingTimeOperator.less_than_or_equal_to: '<='>, # value=10.0 # ) # ] # ) 事前に期待する結果を定義して一致しているかどうかを評価するということは、pytestなどのツールを使って同じ枠組みとして評価することも可能になります。 もちろん、temperatureを0にしても同じ結果が得られるとは限らないため、必ずテストとして通るとは限りません。 また、テストのたびに少なからずAPIコストがかかります。 この問題をある程度許容しつつ、pytestで評価できることを目指します。 例えば、CIではLLM関連のpytestを実行しないように設定することで、APIコストを抑えることができます。 以下は、pytestで評価できるようにするためのコード例です。 pytestの設定ファイルに llm マーカーを定義します。 # pyproject.toml [ tool.pytest.ini_options ] markers = [ "llm: LLM評価系単体テスト" , ] llm マーカーを使って、LLMの評価に関する単体テストを書きます。 # test.py @ pytest.mark.llm def test_create_cooking_time_filter (): actual = create_cooking_time_filter([( "user" , "10分以内で作れる副菜教えて" )]) expected = CookingTimeFilters( filters=[ CookingTimeFilter( column= "cooking_time_min" , operator= "<=" , value= 10.0 ) ] ) assert actual == expected pytestのコマンドラインオプションを追加します。 これにより、デフォルトではLLM評価系単体テストはskipされ、 pytest --run-llm でLLMの評価含めたテストを実行できます。 # conftest.py import pytest from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.nodes import Item def pytest_addoption (parser: Parser): """ pytest に新しいコマンドラインオプション --run-llm を追加。 action="store_true" により、オプションが指定されれば True、指定されなければ False。 これで pytest --run-llm を実行できるようになる。 """ parser.addoption( "--run-llm" , action= "store_true" , default= False , help = "Run LLM evaluation tests" , ) def pytest_collection_modifyitems (config: Config, items: list [Item]): """ 収集されたすべてのテスト関数 (items) を走査する。 各テスト関数が @pytest.mark.llm でマークされているかを判定。 --run-llm が 付いていない場合は、そのテストに skip マーカーを動的に追加。 結果として: - デフォルト (pytest) → LLM評価系単体テストは skip - 明示的に実行 (pytest --run-llm) → LLM評価系単体テストも実行 """ run_llm = config.getoption( "--run-llm" ) skip_llm = pytest.mark.skip(reason= "use --run-llm to run LLM tests" ) for item in items: if "llm" in item.keywords and not run_llm: item.add_marker(skip_llm) このようにして、評価データセットを用意できれば、pytestの枠組みでLLMの出力を評価できると思います。 評価データセットを作るのは大変な側面もありますが、何らかの変更に対して挙動が変わっていないかを、ガードレール的に確認できるという点では大きなメリットがあると思います。 2. 処理を組み替えやすい実装 処理を組み替えやすい実装をするためには、シンプルに何らかのフレームワークに頼る方が良いと考えています。 例えば、LangGraphでは、LLMの処理をNodeと、LLMの処理同士をどう繋ぐかをEdgeで整理され、大域的に扱う変数をStateとして定義することで、Workflowを組むことができます。 from langgraph.graph import StateGraph, START, END from langgraph.graph.state import CompiledStateGraph from typing import Annotated, TypedDict from langchain_core.messages import MessageLikeRepresentation from langgraph.graph.message import add_messages class State (TypedDict): messages: Annotated[ list [MessageLikeRepresentation], add_messages] node1_result: str node2_result: str node3_result: str final_result: str def node1 (state: State) -> dict : return { "node1_result" : "node1_result" } def node2 (state: State) -> dict : return { "node2_result" : "node2_result" } def node3 (state: State) -> dict : return { "node3_result" : "node3_result" } def build_graph_in_series () -> CompiledStateGraph: graph = StateGraph(State) graph.add_node( "node1" , node1) graph.add_node( "node2" , node2) graph.add_node( "node3" , node3) # 直列に繋ぐ場合 graph.add_edge(START, "node1" ) graph.add_edge( "node1" , "node2" ) graph.add_edge( "node2" , "node3" ) graph.add_edge( "node3" , END) return graph.compile() def build_graph_in_parallel () -> CompiledStateGraph: graph = StateGraph(State) graph.add_node( "node1" , node1) graph.add_node( "node2" , node2) graph.add_node( "node3" , node3) # 並行に繋ぐ場合 graph.add_edge(START, "node1" ) graph.add_edge(START, "node2" ) graph.add_edge(START, "node3" ) graph.add_edge( "node1" , END) graph.add_edge( "node2" , END) graph.add_edge( "node3" , END) return graph.compile() 処理を組み替えやすい実装をしたいというモチベーションがある時点で、複雑化を許容した上でWorkflowを組む必要が出ているということだと思います。 Workflowを組むための実装を自前で書くよりも、LangGraphを使う方が容易に実装できると考えています。 mermaidによる可視化も可能で、どんなWorkflowになったかを一目で見ることができます。 from IPython.display import Image, display def visualize_graph (graph: CompiledStateGraph): display(Image(graph.get_graph().draw_mermaid_png())) visualize_graph(build_graph_in_series()) visualize_graph(build_graph_in_parallel()) LangGraphの枠組みでcreate_cooking_time_filterを実装すると以下のようになります。 import os from typing import Annotated, TypedDict from langchain_core.messages import MessageLikeRepresentation from langchain_core.prompts import ( ChatPromptTemplate, MessagesPlaceholder, SystemMessagePromptTemplate, ) from langgraph.graph.message import add_messages from langchain_openai import ChatOpenAI os.environ[ "OPENAI_API_KEY" ] = "your_api_key" class State (TypedDict): messages: Annotated[ list [MessageLikeRepresentation], add_messages] cooking_time_filters: list [CookingTimeFilter] def create_cooking_time_filter (state: State) -> dict [ str , CookingTimeFilters]: system_prompt = f """ あなたは料理の知識が豊富なレシピ検索AIです。 ユーザーがレシピ検索のために入力したクエリを解読し、ユーザが**調理時間**でフィルタリングして検索したい場合は、フィルタリング条件を返してください。 ## 出力形式 * json形式で出力してください * columnにカラム名、operatorに不等号、valueにフィルタリング対象を入れてください """ prompt = ChatPromptTemplate.from_messages( [ SystemMessagePromptTemplate.from_template(system_prompt), MessagesPlaceholder(variable_name= "messages" ) ] ) llm = ChatOpenAI(model= "gpt-4o-mini" ) chain = ( prompt | llm.with_structured_output( schema=CookingTimeFilters, method= "json_schema" , ) ) results = chain.invoke(state[ "messages" ]) return { "cooking_time_filters" : results} このような実装で良さそうですが、これだと引数のStateは大域的に扱うため、テストコードの入力として使いたくありません。 動かすこと自体は可能かもしれませんが、Stateは機能開発すれば必ず変数が増えるため、ロジックの処理とLangGraphのワークフローは分離した方が良いと考えます。 上記を踏まえて以下のように定義しました。 ロジックの処理はcreate_cooking_time_filter、LangGraphのワークフローはcreate_cooking_time_filter_graphとして定義します。 そして、create_cooking_time_filterのみをpytestの枠組みでテストコードを書くことで評価します。 def create_cooking_time_filter (messages: list [MessageLikeRepresentation]) -> CookingTimeFilters: system_prompt = f """ あなたは料理の知識が豊富なレシピ検索AIです。 ユーザーがレシピ検索のために入力したクエリを解読し、ユーザが**調理時間**でフィルタリングして検索したい場合は、フィルタリング条件を返してください。 ## 出力形式 * json形式で出力してください * columnにカラム名、operatorに不等号、valueにフィルタリング対象を入れてください """ prompt = ChatPromptTemplate.from_messages( [ SystemMessagePromptTemplate.from_template(system_prompt), MessagesPlaceholder(variable_name= "messages" ) ] ) llm = ChatOpenAI(model= "gpt-4o-mini" ) chain = ( prompt | llm.with_structured_output( schema=CookingTimeFilters, method= "json_schema" , ) ) return chain.invoke({ "messages" : messages, }) def create_cooking_time_filter_graph (state: State) -> dict [ str , CookingTimeFilters]: messages = state[ "messages" ] cooking_time_filters = create_cooking_time_filter(messages) return { "cooking_time_filters" : cooking_time_filters} おわりに AIエージェント開発において「精度の向上、処理の高速化、汎用性の向上など動機がWorkflowをどう組むかに反映される」という課題感と、変えた結果向上したかどうかを評価する必要性を提示しました。 この課題を解決するために、以下の2点に焦点を当てた実装のアプローチについてまとめました。 評価しやすいこと 処理を組み替えやすいこと 評価しやすい実装については、事前に正解を定義できるタスクにおいて、pytestの枠組みを活用することで、LLMの出力を従来のソフトウェアテストと同様に扱えることを示しました。 これにより、何らかの変更による単体のLLMとして評価できるようになり、意図しない挙動変化を検知することができます。 処理を組み替えやすい実装については、LangGraphを活用したWorkflow管理により、複雑なAIエージェントの処理フローを柔軟に変更できることを示しました。 ロジック部分とワークフロー部分を分離することで、テスタビリティを保ちながら処理の組み替えを実現できます。 AIエージェント開発は、まだ発展途上の分野であり、最適解が見えていない部分も多いです。 しかし、従来のソフトウェア開発で培われた設計原則を適用しつつ、AIエージェント固有の課題に対応することで、保守性と拡張性を兼ね備えたシステムを構築できると考えています。 今後も、AIエージェント開発のベストプラクティスを模索しながら、より良いアーキテクチャの在り方を追求していきたいと思います。
アバター
はじめに こんにちは!株式会社エブリーで1ヶ月間 iOS アプリエンジニアとしてインターンをしている白井です。この記事では、「ヘルシカ」というヘルスケアアプリ開発において取り組んだ、「ウィジェット機能」の実装についてお話ししたいと思います。 ヘルシカとは? 本題に入る前に、今回開発に取り組んだ「ヘルシカ」とはどんなアプリでしょうか?ヘルシカとは、ダイエットや健康のために、ユーザーの食事や体重の記録を手助けするアプリです。 ヘルシカ - ダイエット&健康のためのカロリー管理 every, Inc. ヘルスケア/フィットネス 無料 ‎「ヘルシカ - ダイエット&健康のためのカロリー管理」をApp Storeで 具体的な機能として、体重の記録や、朝食・昼食・夕食・間食の食事内容を記録することができ、下の画像のように、その日の食事の摂取カロリーや、糖質、脂質などの栄養素の摂取状態について知ることができます(各栄養素の情報はプレミアムユーザーのみ閲覧可能)。 また、これに加えて、プレミアムユーザーは1食分の食事記録、無料ユーザーは3食分の食事記録をすることで、管理栄養士からの食事のアドバイスをもらうことができ、日常的に自分の食生活について見直す手助けもしてくれます。 背景 そんなヘルシカですが、現在開発チームは、「ユーザーの継続率向上」を目指し、新機能の追加や既存機能の改善に取り組んでいます。今回、その施策の一つとして、日々の食事の記録をより日常に溶け込ませるための ウィジェット機能 を開発しました。 ウィジェットとは? ウィジェットとは、スマートフォンのホーム画面に追加できるコンポーネントのことです。これにより、ユーザーはアプリを起動しなくても、今日の予定などのデータをホーム画面から直接確認できるようになったり、ウィジェットをタップするだけでアプリ内の特定の画面に素早く移動できるようになります。 今回開発したウィジェットの2つの要件 今回ヘルシカで開発したウィジェットは、食事記録を習慣化しやすくするために、以下の2つの要件を組み込みました。 食事記録画面への直接遷移 :ホーム画面からワンタップで食事記録画面に遷移し、すぐに記録を開始できる 総摂取カロリーの常時表示 :一日の総摂取カロリーをホーム画面に常に表示し、ホーム画面を見るだけで現在のカロリー状況をすぐに把握できる これらの機能により、ヘルシカのメイン機能である「食事記録」がユーザーにとってより手軽になり、振り返りも簡単になることが期待され、この体験を通じて、ユーザーが食事記録をより日常の習慣として継続しやすくなることを目指しています。 以下が、ウィジェットの実際のデザインです! では、本題であるそれぞれの機能の実装について見ていきましょう。 注: ウィジェットの基本的な実装方法についてはこの記事ではあまり触れません。代わりにこちらの記事をご参照ください。 iOS版デリッシュキッチンにウィジェット機能を追加しました - every Tech Blog 食事記録画面への直接遷移 まず、「食事記録画面への直接遷移」についてです。この機能を実装するには、 DeepLink という仕組みを利用します。詳細には触れませんが、DeepLinkは、ウェブサイトのURLのように、アプリ内の特定の画面へ直接ユーザーを誘導する機能です。 実装のプロセス ウィジェットで DeepLink の機能を利用するためには、以下の2つのステップが必要です。 ウィジェット側での Link の設定 : ウィジェット内で、各食事タイプ(朝食・昼食・夕食・間食)に対応する URL を設定し、それらの URL を用いて Link という UI コンポーネントを用意します。これにより、ユーザーが Link をタップした場合、そのタップが特定の URL を呼び出すトリガーとなります。 アプリ側での DeepLink のハンドリング : ウィジェット から送られてきた DeepLink を検知するために、アプリの SceneDelegate での処理の設定を行います。ヘルシカでは、NotificationCenter を使って、食事記録画面の管理の責務を持つ ViewController に通知を送ることで、目的の画面を開くようにしています。 つまり、以下の図のような流れでウィジェットからアプリへと遷移し、食事記録画面を開くことを実現しています。 コード例は以下です(実際のコードとは異なり簡略化しています)。 ウィジェット(Link) HStack { Link(destination: URL(string: "https://myapp.com/record?type=morning")!) { // UIを定義するコード } Link(destination: URL(string: "https://myapp.com/record?type=lunch")!) { // UIを定義するコード } // 夕食の Link ... } アプリ (SceneDelegate, NotificaitonCenter) class SceneDelegate: UIResponder, UIWindowSceneDelegate { // ... func scene(_: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { for urlContext in URLContexts { // ここの DeepLink はカスタムの enum であり、 url を扱いやすくしている。 guard let deepLink = DeepLink(url: urlContext.url) else { continue } switch deepLink { // その他の DeepLink のハンドリング // ... case .mealRecord(let type): NotificationCenter.default.post( name: .widgetMealRecordEditRequest, object: nil, userInfo: ["mealType": type] ) } } } // ... } このように「食事記録画面への直接遷移」の実装は、比較的シンプルに終えることができました。 総摂取カロリーの常時表示 次に、今回の実装で面白い部分である「1日の総摂取カロリーの常時表示」について解説します。 別プロセスで動くアプリとウィジェット 一見、「摂取カロリーを表示する」という機能は非常にシンプルに見え、アプリ側で取得している摂取カロリーのデータをそのままウィジェットで利用すれば良いだけなのでは?と思われるかもしれません。しかし、以下の理由からウィジェットはアプリのデータを直接利用することはできません。 アプリとウィジェットは それぞれ独立な環境を持つ 共有できないインメモリなデータ アプリとウィジェットが それぞれ独立な環境を持つ ことは、今回の実装において何が問題なのでしょうか?独立な環境を持つということは、 ライフサイクル管理やプロセスの管理がアプリとウィジェットで独立している ということです。そして、プロセスはそれぞれで独立したメモリ空間を持つため、 プロセス間で直接データを共有することは できません。そのため、別プロセス上で動くアプリの取得した摂取カロリーのデータを、ウィジェットは用いることはできません。 また、インメモリなデータである摂取カロリーのデータは、アプリを閉じてしまうと消えてしまう揮発性のデータでもあるため、そもそも同じプロセス上で動いていたとしても、アプリを閉じると摂取カロリーのデータが失われ、そのデータをウィジェットに表示できなくなってしまいます。 認証を必要とするデータ取得プロセス では、どうすればウィジェットで摂取カロリーのデータを取得できるでしょうか?今度は、ウィジェットが直接APIを呼び出してデータを取得すれば良いのでは? と思うかもしれません。しかし、これにも大きな課題があります。 表示したいデータに着目してみましょう。今回表示したい摂取カロリーのデータは、ユーザーが個別で記録している 個人情報 です。そのため、プライバシーの観点から、API でデータを取得するには、ユーザーがそのデータにアクセスして良いかを確認する 認証のプロセス が必要となります。しかし、認証に必要なデータは、現在アプリによって管理されているため、ウィジェットは認証に必要なデータにアクセスができず、摂取カロリーのデータを取得することができません(下図を参照)。では、ウィジェットはどのように摂取カロリーのデータを取得すれば良いのでしょうか? 解決法①:Keychain によるデータの直接共有 単純な解決方法としては、Apple から提供されている Keychain というデータベースを通じて、アプリが取得した摂取カロリーのデータをそのままウィジェットに共有する 方法があります(下図を参照)。Keychain では、データはすべて暗号化されてから保存されるため、個人情報の保存も問題ありません。しかし、この方法には問題点があります。この方法では、 ウィジェットはデータの読み取りのみを行い、API の呼び出しは全てアプリ側 で行われます。そのため、データの更新がアプリに依存し、アプリでの更新が起きなければウィジェットのデータは常に古いままになってしまいます。更新があまり必要のないデータであればこれはあまり問題にはならないかもしれませんが、ヘルシカの場合、カロリーのデータは日付が変わったタイミングで更新される必要があります。そのため、更新のタイミングがアプリに依存し、古いデータが表示される可能性もあるウィジェットは、ヘルシカのユーザー体験として望ましいものではありません。 注:図では、アプリと Keychain との認証データの受け渡しのフローを省略していますが、この場合でもアプリと Keychain 間で認証データの受け渡しは行われています。 解決法②:Keychain による認証関連データの共有 そこで、今回採用した解決手法は、 Keychain を通じて認証に必要なデータを共有し、ウィジェットから認証付きの API を直接呼び出す 手法です(下図を参照)。この手法を用いることで、先ほどのデータの更新タイミングがアプリに依存するという問題点を解決しつつ、ウィジェットで常に最新データを取得することも可能になります。 解決法②の実装 では、Keychain を利用したアプリ・ウィジェット間のデータ共有の実装方法についてみていきましょう。Keychain を通じてデータを共有するためには、 Keychain Sharing という機能を使用します。 Keychain Sharing とは? Keychain Sharing は、その名の通り、 複数のアプリ間において Keychain のアイテムを共有する方法 のことです。前提として、デフォルトでは、各アプリごとに一つの Keychain を管理する領域 (Keychain Group) が与えられており、他のアプリは原則その領域のデータを見ることはできません。しかし、Keychain Sharing を用いて複数アプリ間で共有可能な Keychain Group を作成することにより、複数のアプリで安全にデータの共有ができるようになります。 Keychain Sharing の設定 Keychain Sharing を使用するには、アプリ・ウィジェット両方において、Keychain Sharing の設定を行う必要があります。以下のステップで設定を行いましょう。 ターゲットのリストからアプリ(ウィジェット)のターゲットを選択し、”Signing & Capabilities” タブをクリックします。 左上の “+ Capability” (2025/09/11 現在) をタップし、”Keychain Sharing” を選択、追加します。 ”Signing & Capabilities” タブ内に、Keychain Sharing のセクションが出現し、Keychain Groups が設定できるようになります。 Keychain Groups の + ボタンをタップし、共有したい Keychain Group 名を入力します。(既存の Group でも、新規で作成する Group でもどちらでも共有可能です。) この設定をウィジェット、アプリの両方で行い、同一の Keychain Group を設定することで、アプリ・ウィジェット間での Keychain のデータの共有が可能になります。 Keychain Group からの取得 Keychain Sharing の設定が完了したら、実際に Keychain からデータを取得する処理を実装します。OSSのライブラリも存在しますが、ここではネイティブでの実装方法として、データの「追加」を一例に解説します。 パスワードを保存するケースを考えましょう。Keychain へのパスワード保存は、Apple が提供する SecItemAdd メソッドによって実現できます。具体的には、以下のコードのように、まず保存するデータの属性を定義するクエリを作成し、このクエリを使って先ほどのメソッドを呼び出すことによって、Keychain にアイテムを追加できます。また、Keychain Sharing で指定した Keychain Group をクエリに設定することで、アプリとウィジェットで共有されている Keychain Group への書き込みが可能になります。 import Foundation import Security func savePasswordToKeychain (service : String , account : String , data : Data , accessGroup : String ) { // 保存するデータの属性を定義するクエリ let query : [ String : Any ] = [ kSecClass as String : kSecClassGenericPassword , // 保存するデータの種類を指定(今回はパスワード) kSecAttrAccount as String : account , // ユーザーを区別するためのアカウント名 kSecAttrAccessGroup as String : accessGroup , // 保存先のアクセスグループ kSecValueData as String : data // 実際に保存するデータ ] // クエリを使用してKeychainにアイテムを追加 let status = SecItemAdd(query as CFDictionary , nil ) switch status { // エラーハンドリングなどを記述 } } これと同様に、「アイテムの削除」、「アイテムの更新」、「アイテムの検索」についても実装することで、Keychain 内のデータの管理ができるようになります。 これらの設定や実装をすべて終えることで、認証関連のデータをアプリとウィジェットで共有でき、ウィジェットからでも摂取カロリーデータを取得できるようになったため、ようやく「1日の総摂取カロリーの常時表示」という要件を満たすことができました! まとめ この記事では、1ヶ月間のインターンで行った、ヘルスケアアプリ「ヘルシカ」における「ユーザーの継続率向上」に向けた施策である「ウィジェット機能の追加」についてまとめました。 今回は、「食事記録画面への直接遷移」と「総摂取カロリーの常時表示」という2つの要件を満たすために、以下の機能を用いました: DeepLink :ウィジェットからアプリ内の特定画面への直接遷移を実現 Keychain Sharing :アプリとウィジェット間での安全なデータ共有を実現 特に「総摂取カロリーの常時表示」では、アプリとウィジェットが別プロセスで動作するという制約や、認証が必要な個人データの取得という課題に直面しましたが、これらの課題を Keychain Sharing を活用した認証データの共有によって解決し、ウィジェットから直接 API を呼び出すことで、常に最新のデータを表示できるようになりました。 エブリーでの1ヶ月間のインターンでは、ウィジェット機能をゼロから設計・実装し、期間内に無事リリースまで完了することができました。新機能の実装、そして実際のユーザーに届けるまでの一連の流れを経験できるインターンシップは貴重な機会だと思います。新しい技術にチャレンジしながら、実際のプロダクトに影響を与える開発に携わりたい方は、ぜひエブリーのインターンに応募してみてください! 参考資料
アバター