エムスリーテックブログ
エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです
2024-03-12T15:02:04+09:00
m3tech
Hatena::Blog
hatenablog://blog/8599973812294943206
フルスクラッチして理解するOpenID Connect (3) JWT編
hatenablog://entry/6801883189087697268
2024-03-12T15:02:04+09:00
2024-03-12T15:02:26+09:00 こんにちは。デジカルチームの末永(asmsuechan)です。この記事は「フルスクラッチして理解するOpenID Connect」の全4記事中の3記事目です。前回はこちら。 www.m3tech.blog
<p>こんにちは。デジカルチームの末永(<a href="https://twitter.com/asmsuechan">asmsuechan</a>)です。この記事は「フルスクラッチして理解するOpenID Connect」の全4記事中の3記事目です。前回はこちら。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2F2024%2F03%2F07%2F130000" title="フルスクラッチして理解するOpenID Connect (2) トークンエンドポイント編 - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/2024/03/07/130000">www.m3tech.blog</a></cite></p>
<ul class="table-of-contents">
<li><a href="#9-JWT-の実装">9 JWT の実装</a><ul>
<li><a href="#91-JWT概説">9.1 JWT概説</a></li>
<li><a href="#92-OpenID-Connect-の-JWT">9.2 OpenID Connect の JWT</a></li>
<li><a href="#93-ヘッダーとペイロードの実装">9.3 ヘッダーとペイロードの実装</a></li>
<li><a href="#94-署名の実装">9.4 署名の実装</a><ul>
<li><a href="#公開鍵と秘密鍵を生成する">公開鍵と秘密鍵を生成する</a></li>
<li><a href="#署名処理を作る">署名処理を作る</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#10-JWKS-URI-の実装-GET-openid-connectjwks">10 JWKS URI の実装 (GET /openid-connect/jwks)</a></li>
<li><a href="#11-RelyingParty-で-ID-トークンの検証をする">11 RelyingParty で ID トークンの検証をする</a></li>
<li><a href="#12-OpenID-Connect-Discovery-エンドポイントの実装-GET-openid-connectwell-knownopenid-configuration">12 OpenID Connect Discovery エンドポイントの実装 (GET /openid-connect/.well-known/openid-configuration)</a></li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#Were-hiring">We're hiring</a></li>
</ul>
<p>今回は全4回中の第3回目です。</p>
<ul>
<li>(1) 認可エンドポイント編</li>
<li>(2) トークンエンドポイント編</li>
<li><strong>(3) JWT編</strong></li>
<li>(4) stateとnonce編</li>
</ul>
<h2 id="9-JWT-の実装">9 JWT の実装</h2>
<p>さて、前回固定値でお茶を濁した JWT をちゃんと実装していきます。</p>
<h3 id="91-JWT概説">9.1 JWT概説</h3>
<p>JWTはJSON Web Tokenの略で、実体は次のような文字列です。</p>
<pre class="code" data-lang="" data-unlink>eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c</pre>
<p>仕様は次のRFCで定義されています。</p>
<ul>
<li><a href="https://datatracker.ietf.org/doc/html/rfc7519">https://datatracker.ietf.org/doc/html/rfc7519</a></li>
</ul>
<p><figure class="figure-image figure-image-fotolife" title="JWTの構成"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240312/20240312150208.png" width="800" height="337" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>JWTの構成</figcaption></figure></p>
<p>JWT は <code>.</code> で区切られた 3 つの部分に分けることができ、それぞれヘッダー、ペイロード、署名となっています。ヘッダーとペイロードは <strong>base64url エンコードされた JSON</strong> で、署名は<strong>ヘッダーとペイロードの電子署名を base64url エンコードしたもの</strong>です。</p>
<p>※base64url エンコードはクエリパラメーターで使うためのもので、+や/など一部の文字が置換されます。</p>
<h3 id="92-OpenID-Connect-の-JWT">9.2 OpenID Connect の JWT</h3>
<p>JWT で表される ID トークンの仕様については次のRFCで記述されています。</p>
<ul>
<li><a href="https://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#IDToken">https://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#IDToken</a></li>
</ul>
<p>OpenID Connect ではbase64 decodeしたペイロードが次の項目を含むよう定義されています。なおこの部分をClaimと言います。</p>
<table>
<thead>
<tr>
<th> 項目名 </th>
<th> 説明 </th>
<th> 例 </th>
</tr>
</thead>
<tbody>
<tr>
<td> iss </td>
<td> ID トークンの発行元 </td>
<td> <a href="http://localhost:3000">http://localhost:3000</a> </td>
</tr>
<tr>
<td> sub </td>
<td> IdP 内でユニークな文字列 </td>
<td> 363cf11c-2170-45b5-aecc-fcc66abb2654 </td>
</tr>
<tr>
<td> aud </td>
<td> ID トークンを受け取る RelyingParty の使う client_id </td>
<td> tiny-client </td>
</tr>
<tr>
<td> exp </td>
<td> ID トークンの有効期限 </td>
<td> 1706990578 </td>
</tr>
<tr>
<td> iat </td>
<td> ID トークンの発行日時 </td>
<td> 1706990278 </td>
</tr>
</tbody>
</table>
<p>ヘッダーの定義を次に示します。このヘッダーはJOSEヘッダーと呼ばれるもので、署名で使ったアルゴリズムなどについての情報を記載します。</p>
<ul>
<li><a href="https://datatracker.ietf.org/doc/html/draft-ietf-jose-json-web-signature#section-4">https://datatracker.ietf.org/doc/html/draft-ietf-jose-json-web-signature#section-4</a></li>
</ul>
<table>
<thead>
<tr>
<th> 項目名 </th>
<th> 説明 </th>
<th> 例 </th>
</tr>
</thead>
<tbody>
<tr>
<td> typ </td>
<td> JWTの種別。tiny-idpでは常にJWT。JWTのRFCで定義 </td>
<td> JWT </td>
</tr>
<tr>
<td> cty </td>
<td> JWTが暗号化される場合などに使う。tiny-idpでは使わない。JWTのRFCで定義 </td>
<td> JWT </td>
</tr>
<tr>
<td> alg </td>
<td> 署名アルゴリズム。JWAのRFCで定義 </td>
<td> RS256 </td>
</tr>
<tr>
<td> kid </td>
<td> 非対称鍵を使った署名の場合、署名の鍵を表すID。Key ID。</td>
<td> Akej922mBd </td>
</tr>
</tbody>
</table>
<ul>
<li><a href="https://openid.net/specs/openid-connect-core-1_0.html#Signing">https://openid.net/specs/openid-connect-core-1_0.html#Signing</a></li>
</ul>
<p>ヘッダーの内容により、どの方式で署名されたかなどを判別します。tiny-idpのJWTはJWSの非対称鍵で署名するようにします。ですので、ヘッダーはtiny-idpでは次のようにします。</p>
<pre class="code lang-json" data-lang="json" data-unlink><span class="synSpecial">{</span>
"<span class="synStatement">typ</span>": "<span class="synConstant">JWT</span>",
"<span class="synStatement">alg</span>": "<span class="synConstant">RS256</span>",
"<span class="synStatement">kid</span>": "<span class="synConstant">(IDトークンを一意に示すランダムな文字列、署名した鍵と紐づく)</span>"
<span class="synSpecial">}</span>
</pre>
<h3 id="93-ヘッダーとペイロードの実装">9.3 ヘッダーとペイロードの実装</h3>
<p>上記に従って JwtService クラスを作ります。まずは上記のテーブルに従ってヘッダーとClaimの型を作ります。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/services/jwt_service.ts</span>
<span class="synStatement">type</span> JwtPayload <span class="synStatement">=</span> <span class="synIdentifier">{</span>
iss: <span class="synType">string</span><span class="synStatement">;</span>
sub: <span class="synType">string</span><span class="synStatement">;</span>
aud: <span class="synType">string</span><span class="synStatement">;</span>
exp: <span class="synType">number</span><span class="synStatement">;</span>
iat: <span class="synType">number</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synStatement">type</span> JwtHeader <span class="synStatement">=</span> <span class="synIdentifier">{</span>
alg: <span class="synType">string</span><span class="synStatement">;</span>
typ: <span class="synType">string</span><span class="synStatement">;</span>
kid: <span class="synType">string</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>さて、次にJwtService本体のクラスを作ります。処理の流れを次に示します。</p>
<ul>
<li>ヘッダーとClaimのオブジェクトを作る</li>
<li>署名部分は固定値で signature (後ほど実装)</li>
<li>base64エンコードする</li>
<li>base64url形式にする</li>
<li>.で繋いだ文字列にする</li>
</ul>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/services/jwt_service.ts</span>
<span class="synStatement">export</span> <span class="synStatement">class</span> JwtService <span class="synIdentifier">{</span>
<span class="synStatement">get</span> ONE_DAY<span class="synStatement">()</span>: <span class="synType">number</span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synConstant">60</span> * <span class="synConstant">60</span> * <span class="synConstant">24</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synStatement">public</span> generate<span class="synStatement">(</span>iss: <span class="synType">string</span><span class="synStatement">,</span> aud: <span class="synType">string</span><span class="synStatement">,</span> expDuration: <span class="synType">number</span> <span class="synStatement">=</span> <span class="synIdentifier">this</span>.ONE_DAY<span class="synStatement">)</span>: <span class="synType">string</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> encodedHeader <span class="synStatement">=</span> <span class="synIdentifier">this</span>.base64urlEncode<span class="synStatement">(</span><span class="synSpecial">JSON</span>.stringify<span class="synStatement">(</span><span class="synIdentifier">this</span>.buildHeader<span class="synStatement">(</span><span class="synConstant">'2024-03-10'</span><span class="synStatement">)));</span>
<span class="synType">const</span> encodedPayload <span class="synStatement">=</span> <span class="synIdentifier">this</span>.base64urlEncode<span class="synStatement">(</span><span class="synSpecial">JSON</span>.stringify<span class="synStatement">(</span><span class="synIdentifier">this</span>.buildPayload<span class="synStatement">(</span>iss<span class="synStatement">,</span> aud<span class="synStatement">,</span> expDuration<span class="synStatement">)));</span>
<span class="synType">const</span> signTarget <span class="synStatement">=</span> <span class="synConstant">`</span><span class="synSpecial">${</span>encodedHeader<span class="synSpecial">}</span><span class="synConstant">.</span><span class="synSpecial">${</span>encodedPayload<span class="synSpecial">}</span><span class="synConstant">`</span><span class="synStatement">;</span>
<span class="synType">const</span> signature <span class="synStatement">=</span> <span class="synIdentifier">this</span>.sign<span class="synStatement">(</span>signTarget<span class="synStatement">);</span>
<span class="synStatement">return</span> <span class="synConstant">`</span><span class="synSpecial">${</span>signTarget<span class="synSpecial">}</span><span class="synConstant">.</span><span class="synSpecial">${</span><span class="synIdentifier">this</span>.base64urlEncode(signature)<span class="synSpecial">}</span><span class="synConstant">`</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synStatement">private</span> sign<span class="synStatement">(</span>target: <span class="synType">string</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synConstant">'signature'</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synStatement">private</span> buildHeader<span class="synStatement">(</span>kid: <span class="synType">string</span><span class="synStatement">)</span>: JwtHeader <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synIdentifier">{</span>
alg: <span class="synConstant">'RS256'</span><span class="synStatement">,</span>
typ: <span class="synConstant">'JWT'</span><span class="synStatement">,</span>
kid: kid
<span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synStatement">private</span> buildPayload<span class="synStatement">(</span>iss: <span class="synType">string</span><span class="synStatement">,</span> aud: <span class="synType">string</span><span class="synStatement">,</span> expDuration: <span class="synType">number</span> <span class="synStatement">=</span> <span class="synIdentifier">this</span>.ONE_DAY<span class="synStatement">)</span>: JwtPayload <span class="synIdentifier">{</span>
<span class="synType">const</span> sub <span class="synStatement">=</span> <span class="synSpecial">Math</span>.random<span class="synStatement">()</span>.toString<span class="synStatement">(</span><span class="synConstant">16</span><span class="synStatement">)</span>.slice<span class="synStatement">(</span><span class="synConstant">2</span><span class="synStatement">);</span>
<span class="synType">const</span> iat <span class="synStatement">=</span> <span class="synSpecial">Math</span>.floor<span class="synStatement">(</span><span class="synSpecial">Date</span>.now<span class="synStatement">()</span> / <span class="synConstant">1000</span><span class="synStatement">);</span>
<span class="synType">const</span> exp <span class="synStatement">=</span> iat + expDuration<span class="synStatement">;</span>
<span class="synStatement">return</span> <span class="synIdentifier">{</span> iss<span class="synStatement">,</span> sub<span class="synStatement">,</span> aud<span class="synStatement">,</span> exp<span class="synStatement">,</span> iat <span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synStatement">private</span> base64urlEncode<span class="synStatement">(</span>input: <span class="synType">string</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synSpecial">Buffer</span>.<span class="synStatement">from(</span>input<span class="synStatement">)</span>.toString<span class="synStatement">(</span><span class="synConstant">'base64'</span><span class="synStatement">)</span>.replace<span class="synStatement">(</span><span class="synConstant">/\+/g</span><span class="synStatement">,</span> <span class="synConstant">'-'</span><span class="synStatement">)</span>.replace<span class="synStatement">(</span><span class="synConstant">/\//g</span><span class="synStatement">,</span> <span class="synConstant">'_'</span><span class="synStatement">)</span>.replace<span class="synStatement">(</span><span class="synConstant">/=/g</span><span class="synStatement">,</span> <span class="synConstant">''</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span>
</pre>
<p>このクラスを使うように token_controller.ts を変更しましょう。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/token_controller.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> JwtService <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'../services/jwt_service'</span><span class="synStatement">;</span> <span class="synComment">// 追加</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synType">const</span> jwtService <span class="synStatement">=</span> <span class="synStatement">new</span> JwtService<span class="synStatement">();</span> <span class="synComment">// 追加</span>
<span class="synType">const</span> jwt <span class="synStatement">=</span> jwtService.generate<span class="synStatement">(</span><span class="synConstant">'http://localhost:3000'</span><span class="synStatement">,</span> <span class="synConstant">'tiny-client'</span><span class="synStatement">);</span> <span class="synComment">// 追加</span>
<span class="synType">const</span> data: ResponseData <span class="synStatement">=</span> <span class="synIdentifier">{</span>
id_token: jwt<span class="synStatement">,</span> <span class="synComment">// 変更</span>
access_token: accessToken.token<span class="synStatement">,</span>
token_type: <span class="synConstant">'Bearer'</span><span class="synStatement">,</span>
expires_in: <span class="synConstant">86400</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>tiny-idpを <code>npm run build && node lib/index.js</code> で再起動し、ログインを試してみます。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240312/20240312150210.png" width="800" height="577" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>JWTの文字列が出てくるようになりました。このJWTをコピーして <a href="https://jwt.io">jwt.io</a> に貼り付けると、署名部分は未実装なのでInvalid Signatureは出ますが先ほど入力した内容が出てきます。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240312/20240312150213.png" width="800" height="577" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h3 id="94-署名の実装">9.4 署名の実装</h3>
<ul>
<li><a href="https://openid.net/specs/openid-connect-core-1_0.html#Signing">https://openid.net/specs/openid-connect-core-1_0.html#Signing</a></li>
<li><a href="https://datatracker.ietf.org/doc/html/rfc7519#section-8">https://datatracker.ietf.org/doc/html/rfc7519#section-8</a></li>
<li><a href="https://openid-foundation-japan.github.io/rfc7517.ja.html#useDef">https://openid-foundation-japan.github.io/rfc7517.ja.html#useDef</a></li>
</ul>
<p>JWT の RFC には、HS256 と none は実装してね、と書かれています。RS256 と ES256 は実装を推奨されています。しかしここでは実装を簡略化するため、OpenID Connect でデフォルトとすることが推奨されている RS256 のみを実装します。</p>
<ul>
<li>HS256
<ul>
<li>HMAC-SHA256
<ul>
<li>SHA256 でハッシュ値を計算して HMAC で署名する</li>
</ul>
</li>
<li>対象アルゴリズム
<ul>
<li>署名の作成と検証に共通の鍵を使う</li>
</ul>
</li>
<li>共通鍵は client_secret の値に由来する</li>
</ul>
</li>
<li>RS256
<ul>
<li>RSA-SHA256
<ul>
<li>SHA256 でハッシュ値を計算して RSA で署名する</li>
</ul>
</li>
<li>非対称アルゴリズム
<ul>
<li>署名の作成と検証に秘密鍵と公開鍵を使う</li>
</ul>
</li>
<li>公開鍵は JWKS エンドポイントで公開される</li>
<li>ID トークンでデフォルトのアルゴリズムとして使うことが推奨されている</li>
</ul>
</li>
</ul>
<h4 id="公開鍵と秘密鍵を生成する">公開鍵と秘密鍵を生成する</h4>
<p>では、RS256 での署名と検証に使うための RSA 公開鍵と秘密鍵を生成します。openssl コマンドで生成します。</p>
<pre class="code" data-lang="" data-unlink>$ openssl genrsa -out tiny_idp_private.pem 2048
$ openssl rsa -in tiny_idp_private.pem -pubout -out tiny_idp_public.pem</pre>
<p>この公開鍵と秘密鍵は <code>keys/</code> に置きます。</p>
<h4 id="署名処理を作る">署名処理を作る</h4>
<p>次に、署名ロジックを作ります。sign メソッドを書き換えます。</p>
<p>cryptoパッケージの次の3関数を使って署名します。</p>
<ul>
<li><a href="https://nodejs.org/api/crypto.html#cryptocreatesignalgorithm-options">crypto.createSign()関数</a></li>
<li><a href="https://nodejs.org/api/crypto.html#signupdatedata-inputencoding">sign.update()関数</a></li>
<li><a href="https://nodejs.org/api/crypto.html#signsignprivatekey-outputencoding">sign.sign()関数</a></li>
</ul>
<p>実装の流れ次に示します。</p>
<ul>
<li><code>createSign()</code>関数でSignのオブジェクトを作る
<ul>
<li><code>crypto.getHashes()</code>関数を使うと第一引数で使えるハッシュ関数の名前が分かる</li>
<li>ここでは<code>'RSA-SHA256'</code>を使う</li>
</ul>
</li>
<li>Signのオブジェクトに <code>update()</code> 関数を使って署名対象の文字列を格納する</li>
<li>Signのオブジェクトに <code>sign()</code> 関数を実行し、引数で指定した秘密鍵で署名する
<ul>
<li>形式はbase64を指定。他の形式は<a href="https://nodejs.org/api/buffer.html#buffers-and-character-encodings">Buffers and character encodings</a>を参照</li>
</ul>
</li>
</ul>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/services/jwt_service.ts</span>
<span class="synStatement">import</span> crypto <span class="synStatement">from</span> <span class="synConstant">'crypto'</span><span class="synStatement">;</span>
<span class="synStatement">import</span> path <span class="synStatement">from</span> <span class="synConstant">'path'</span><span class="synStatement">;</span>
<span class="synStatement">import</span> fs <span class="synStatement">from</span> <span class="synConstant">'fs'</span><span class="synStatement">;</span>
<span class="synStatement">(</span>中略<span class="synStatement">)</span>
<span class="synStatement">public</span> sign<span class="synStatement">(</span>target: <span class="synType">string</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> privatePath <span class="synStatement">=</span> path.resolve<span class="synStatement">(</span><span class="synConstant">'./keys/tiny_idp_private.pem'</span><span class="synStatement">);</span>
<span class="synType">const</span> privateKey <span class="synStatement">=</span> fs.readFileSync<span class="synStatement">(</span>privatePath<span class="synStatement">,</span> <span class="synConstant">"utf8"</span><span class="synStatement">);</span>
<span class="synType">const</span> sign <span class="synStatement">=</span> crypto.createSign<span class="synStatement">(</span><span class="synConstant">'RSA-SHA256'</span><span class="synStatement">);</span>
sign.update<span class="synStatement">(</span>target<span class="synStatement">);</span>
<span class="synStatement">return</span> sign.sign<span class="synStatement">(</span>privateKey<span class="synStatement">,</span> <span class="synConstant">'base64'</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span>
</pre>
<p>これで RS256 による署名部分はできました。先ほどと同じように <code>npm run build && node lib/index.js</code> で再起動してJWTを取得します。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240312/20240312150217.png" width="800" height="577" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>では<a href="https://jwt.io/">jwt.io</a> でテストしてみましょう。このページでは次の手順で署名の検証もできます。</p>
<ul>
<li>JWTをEncodedに貼り付る</li>
<li>AlgorithmがRS256であることを確認する</li>
<li>VERIFY SIGNATUREに公開鍵と秘密鍵をコピペする</li>
</ul>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240312/20240312150220.png" width="800" height="518" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>「Signature Verified」と表示されますね。成功です。</p>
<h2 id="10-JWKS-URI-の実装-GET-openid-connectjwks">10 JWKS URI の実装 (GET /openid-connect/jwks)</h2>
<ul>
<li><a href="https://datatracker.ietf.org/doc/html/rfc7517">https://datatracker.ietf.org/doc/html/rfc7517</a></li>
</ul>
<p>RelyingParty で ID トークンの検証をするには、JWT を署名した秘密鍵に対応した公開鍵が必要です(RS256 など非対称鍵を使った場合)。この公開鍵は JWK (JSON Web Key) のエンドポイントにより公開されます。</p>
<p>JWKエンドポイントのレスポンスは次のようになります。</p>
<pre class="code lang-json" data-lang="json" data-unlink><span class="synSpecial">{</span>"<span class="synStatement">keys</span>":
<span class="synSpecial">[</span>
<span class="synSpecial">{</span>"<span class="synStatement">kty</span>":"<span class="synConstant">EC</span>",
"<span class="synStatement">crv</span>":"<span class="synConstant">P-256</span>",
"<span class="synStatement">x</span>":"<span class="synConstant">MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4</span>",
"<span class="synStatement">y</span>":"<span class="synConstant">4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM</span>",
"<span class="synStatement">use</span>":"<span class="synConstant">enc</span>",
"<span class="synStatement">kid</span>":"<span class="synConstant">1</span>"<span class="synSpecial">}</span>,
<span class="synSpecial">{</span>"<span class="synStatement">kty</span>":"<span class="synConstant">RSA</span>",
"<span class="synStatement">n</span>": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx
4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMs
tn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2
<span class="synError">QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbI</span>
<span class="synError">SD08qNLyrdkt</span>-<span class="synError">bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ</span>-G_xBniIqb
<span class="synError">w0Ls1jF44</span>-<span class="synError">csFCur</span>-<span class="synError">kEgU8awapJzKnqDKgw</span>",
"<span class="synStatement">e</span>":"<span class="synConstant">AQAB</span>",
"<span class="synStatement">alg</span>":"<span class="synConstant">RS256</span>",
"<span class="synStatement">kid</span>":"<span class="synConstant">2011-04-29</span>"<span class="synSpecial">}</span>
]
}
</pre>
<p>参考: <a href="https://datatracker.ietf.org/doc/html/rfc7517#appendix-A.1">https://datatracker.ietf.org/doc/html/rfc7517#appendix-A.1</a></p>
<p>これはJWK Setと呼ばれるもので、1つ1つのKeyがJWKです。JWKで公開鍵を表すための形式を次に示します。</p>
<table>
<thead>
<tr>
<th> 項目名 </th>
<th> 説明 </th>
<th> 例 </th>
</tr>
</thead>
<tbody>
<tr>
<td> kty </td>
<td> 署名で使ったアルゴリズムを表す (必須) </td>
<td> RSA </td>
</tr>
<tr>
<td> use </td>
<td> この公開鍵がどのような用途なのかを表す </td>
<td> sig </td>
</tr>
<tr>
<td> kid </td>
<td> 鍵を表すID </td>
<td> Akej922mBd </td>
</tr>
<tr>
<td> alg </td>
<td> 鍵で使ってるアルゴリズムを表す </td>
<td> RS256 </td>
</tr>
<tr>
<td> n </td>
<td> RSA公開鍵の法 (modulus) </td>
<td> 長いので<a href="https://datatracker.ietf.org/doc/html/rfc7517#appendix-A.1">RFCの付録</a>参照 </td>
</tr>
<tr>
<td> e </td>
<td> RSA公開鍵の公開指数 (public exponential) </td>
<td> AQAB </td>
</tr>
</tbody>
</table>
<p>まずは src/controllers/jwks_controller.ts を追加し、形式通りにJWKの型を作ります。key_ops, x5u, x5c, x5tは<a href="https://datatracker.ietf.org/doc/html/rfc7517#section-4">JSON Web Key (JWK) Format</a>に書いてありますがtiny-idpのJWKでは使っていません。</p>
<p>ktyが定義上は必須なのにオプション型なのは、次の実装で使っているcryptoのJsonWebKey型に合わせたためです。後の実装でktyがなければエラーにするよう実装します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/jwks_controller.ts</span>
<span class="synComment">// https://datatracker.ietf.org/doc/html/rfc7517</span>
<span class="synComment">// https://datatracker.ietf.org/doc/html/rfc7518#section-6.3.1</span>
<span class="synStatement">type</span> JWK <span class="synStatement">=</span> <span class="synIdentifier">{</span>
kty?: <span class="synType">string</span><span class="synStatement">;</span> <span class="synComment">// https://datatracker.ietf.org/doc/html/rfc7517#section-4.1</span>
use?: <span class="synType">string</span><span class="synStatement">;</span> <span class="synComment">// https://datatracker.ietf.org/doc/html/rfc7517#section-4.2</span>
kid?: <span class="synType">string</span><span class="synStatement">;</span> <span class="synComment">// https://datatracker.ietf.org/doc/html/rfc7517#section-4.5</span>
key_ops?: <span class="synType">string</span><span class="synIdentifier">[]</span><span class="synStatement">;</span>
alg?: <span class="synType">string</span><span class="synStatement">;</span>
x5u?: <span class="synType">string</span><span class="synStatement">;</span>
x5c?: <span class="synType">string</span><span class="synIdentifier">[]</span><span class="synStatement">;</span>
x5t?: <span class="synType">string</span><span class="synStatement">;</span>
n?: <span class="synType">string</span><span class="synStatement">;</span>
e?: <span class="synType">string</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synComment">// https://datatracker.ietf.org/doc/html/rfc7517#section-5.1</span>
<span class="synStatement">type</span> JWKSet <span class="synStatement">=</span> <span class="synIdentifier">{</span>
keys: JWK<span class="synIdentifier">[]</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>次にsrc/services/jwk_service.tsを作り、JWKフォーマットの公開鍵を生成する関数を作ります。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/services/jwk_service.ts</span>
<span class="synStatement">import</span> crypto <span class="synStatement">from</span> <span class="synConstant">'crypto'</span><span class="synStatement">;</span>
<span class="synStatement">export</span> <span class="synType">const</span> generateJwk <span class="synStatement">=</span> <span class="synStatement">(</span>publicKeyPem: <span class="synType">string</span><span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synType">const</span> publicKey <span class="synStatement">=</span> crypto.createPublicKey<span class="synStatement">(</span>publicKeyPem<span class="synStatement">);</span>
<span class="synStatement">return</span> publicKey.<span class="synStatement">export(</span><span class="synIdentifier">{</span> format: <span class="synConstant">'jwk'</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>さて、jwks_controller.tsに戻ってgetJwks()関数を作ります。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/jwks_controller.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> ServerResponse <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'http'</span><span class="synStatement">;</span>
<span class="synStatement">import</span> fs <span class="synStatement">from</span> <span class="synConstant">'fs'</span><span class="synStatement">;</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> generateJwk <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'../services/jwk_service'</span><span class="synStatement">;</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synStatement">export</span> <span class="synType">const</span> getJwks <span class="synStatement">=</span> <span class="synStatement">(</span>res: ServerResponse<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synType">const</span> pem <span class="synStatement">=</span> fs.readFileSync<span class="synStatement">(</span><span class="synConstant">'keys/tiny_idp_public.pem'</span><span class="synStatement">,</span> <span class="synConstant">'utf8'</span><span class="synStatement">);</span>
<span class="synComment">// NOTE: JWKとしてデータを保存して公開鍵・秘密鍵・kidを紐づけた方がいいが、ここでは処理を簡単にするために固定値としている</span>
<span class="synType">const</span> jwk <span class="synStatement">=</span> generateJwk<span class="synStatement">(</span>pem<span class="synStatement">);</span>
jwk.kid <span class="synStatement">=</span> <span class="synConstant">'2024-03-10'</span><span class="synStatement">;</span>
jwk.alg <span class="synStatement">=</span> <span class="synConstant">'RS256'</span><span class="synStatement">;</span>
jwk.use <span class="synStatement">=</span> <span class="synConstant">'sig'</span><span class="synStatement">;</span>
<span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>jwk.kty<span class="synStatement">)</span> <span class="synIdentifier">{</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">500</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synConstant">'Content-Type'</span>: <span class="synConstant">'application/json'</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
res.end<span class="synStatement">(</span><span class="synSpecial">JSON</span>.stringify<span class="synStatement">(</span><span class="synIdentifier">{</span> error: <span class="synConstant">'failed to generate jwk'</span> <span class="synIdentifier">}</span><span class="synStatement">));</span>
<span class="synIdentifier">}</span>
<span class="synComment">// NOTE: tiny-idpはRS256のみで実装しているため、ここでは公開鍵1つしか公開しない</span>
<span class="synType">const</span> jwkSet: JWKSet <span class="synStatement">=</span> <span class="synIdentifier">{</span>
keys: <span class="synIdentifier">[</span>jwk<span class="synIdentifier">]</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">200</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synConstant">'Content-Type'</span>: <span class="synConstant">'application/json'</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
res.end<span class="synStatement">(</span><span class="synSpecial">JSON</span>.stringify<span class="synStatement">(</span>jwkSet<span class="synStatement">));</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>最後にsrc/index.tsを変更すれば完成です。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/index.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> getJwks <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'./controllers/jwks_controller'</span><span class="synStatement">;</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synStatement">if</span> <span class="synStatement">(</span>req.url?.split<span class="synStatement">(</span><span class="synConstant">'?'</span><span class="synStatement">)</span><span class="synIdentifier">[</span><span class="synConstant">0</span><span class="synIdentifier">]</span> <span class="synStatement">===</span> <span class="synConstant">'/openid-connect/jwks'</span> <span class="synConstant">&&</span> req.method <span class="synStatement">===</span> <span class="synConstant">'GET'</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
getJwks<span class="synStatement">(</span>res<span class="synStatement">);</span>
</pre>
<p><code>npm run build && node lib/index.js</code>で再起動し動作確認をします。</p>
<pre class="code bash" data-lang="bash" data-unlink>~/src/asmsuechan/tiny-idp-2 > curl localhost:3000/openid-connect/jwks
{"keys":[{"kty":"RSA","n":"uBp47VGURf-RrNo4UU7ydQuj3xkf4yaeE_CLddSkQSJ2luLSic_UDMr5-2izjqAcr6U-rGA3X8LMta7Y1McnnWA1hNbG2A2vc9Or59vFTlQqbxaLkSlLFo6K4MneJd-hMIcF7CvpWXULTcDXpF5b76ULenQoLvYIsBc3lWWq_pQuMufrK-SP2ZBYeG353kRCQdfi-b9aut8jZRbOH5q04viLSsOrgjiZaV5sszq-Vl83TOOmUHtRQkPx1hioztqhOczTqxUPoSdeFdwo5wvQ0M2cBohSDAcviOQMvfLGHeciabVdBuHeuQYfMvUojhTq0Ik34c0HJahmP7RE-PmZCQ","e":"AQAB","kid":"2024-03-10","alg":"RS256","use":"sig"}]}</pre>
<p>このように返って来れば成功です。次の章で実際にJWKSエンドポイントを使ってRelyingPartyでJWTの検証をします。</p>
<h2 id="11-RelyingParty-で-ID-トークンの検証をする">11 RelyingParty で ID トークンの検証をする</h2>
<p>ここまでで ID トークンの生成と公開鍵の公開には成功しました。では、RelyingParty にこの ID トークンの署名が有効なものであるかどうかをチェックするコードを追加します。</p>
<p>verify()関数が署名検証処理の本体です。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// tiny-rp/src/index.ts</span>
<span class="synStatement">import</span> crypto <span class="synStatement">from</span> <span class="synConstant">"crypto"</span><span class="synStatement">;</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synComment">// NOTE: base64urlをデコードする便利関数</span>
<span class="synType">const</span> base64urlDecode <span class="synStatement">=</span> <span class="synStatement">(</span>input: <span class="synType">string</span><span class="synStatement">)</span> <span class="synStatement">=></span><span class="synIdentifier">{</span>
input <span class="synStatement">+=</span> <span class="synConstant">"="</span>.repeat<span class="synStatement">(</span><span class="synConstant">4</span> - <span class="synStatement">(</span>input.length % <span class="synConstant">4</span><span class="synStatement">));</span>
<span class="synStatement">return</span> <span class="synSpecial">Buffer</span>.<span class="synStatement">from(</span>
input.replace<span class="synStatement">(</span><span class="synConstant">/-/g</span><span class="synStatement">,</span> <span class="synConstant">"+"</span><span class="synStatement">)</span>.replace<span class="synStatement">(</span><span class="synConstant">/_/g</span><span class="synStatement">,</span> <span class="synConstant">"/"</span><span class="synStatement">),</span>
<span class="synConstant">"base64"</span>
<span class="synStatement">)</span>.toString<span class="synStatement">(</span><span class="synConstant">"utf-8"</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span>
<span class="synType">const</span> verifyToken <span class="synStatement">=</span> <span class="synStatement">(</span>token: <span class="synType">string</span><span class="synStatement">,</span> jwk: <span class="synType">string</span><span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synType">const</span> publicKey <span class="synStatement">=</span> crypto
.createPublicKey<span class="synStatement">(</span><span class="synIdentifier">{</span> key: jwk<span class="synStatement">,</span> format: <span class="synConstant">"jwk"</span> <span class="synIdentifier">}</span><span class="synStatement">)</span>
.<span class="synStatement">export(</span><span class="synIdentifier">{</span> format: <span class="synConstant">"pem"</span><span class="synStatement">,</span> <span class="synStatement">type</span>: <span class="synConstant">"spki"</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synType">const</span> <span class="synIdentifier">[</span>encodedHeader<span class="synStatement">,</span> encodedPayload<span class="synStatement">,</span> encodedSignature<span class="synIdentifier">]</span> <span class="synStatement">=</span> token.split<span class="synStatement">(</span><span class="synConstant">"."</span><span class="synStatement">);</span>
<span class="synType">const</span> signatureData <span class="synStatement">=</span> <span class="synConstant">`</span><span class="synSpecial">${</span>encodedHeader<span class="synSpecial">}</span><span class="synConstant">.</span><span class="synSpecial">${</span>encodedPayload<span class="synSpecial">}</span><span class="synConstant">`</span><span class="synStatement">;</span>
<span class="synType">const</span> verify <span class="synStatement">=</span> crypto.createVerify<span class="synStatement">(</span><span class="synConstant">"RSA-SHA256"</span><span class="synStatement">);</span>
verify.update<span class="synStatement">(</span>signatureData<span class="synStatement">);</span>
<span class="synType">const</span> decodedSignature <span class="synStatement">=</span> base64urlDecode<span class="synStatement">(</span>encodedSignature<span class="synStatement">);</span>
<span class="synStatement">return</span> verify.verify<span class="synStatement">(</span>publicKey<span class="synStatement">,</span> <span class="synSpecial">Buffer</span>.<span class="synStatement">from(</span>decodedSignature<span class="synStatement">,</span> <span class="synConstant">"base64"</span><span class="synStatement">));</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>callback のエンドポイントに次のコードを追加して署名の検証をします。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// tiny-rp/src/index.ts</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synType">const</span> idToken <span class="synStatement">=</span> tokenSet.id_token<span class="synStatement">;</span>
<span class="synType">const</span> jwksUri <span class="synStatement">=</span> <span class="synConstant">"http://localhost:3000/openid-connect/jwks"</span><span class="synStatement">;</span>
<span class="synType">const</span> jwks <span class="synStatement">=</span> <span class="synStatement">await</span> <span class="synStatement">(await</span> <span class="synSpecial">fetch</span><span class="synStatement">(</span>jwksUri<span class="synStatement">))</span>.json<span class="synStatement">();</span>
<span class="synType">const</span> jwk <span class="synStatement">=</span> jwks.keys.find<span class="synStatement">(</span>
<span class="synStatement">(</span>jwk: <span class="synType">any</span><span class="synStatement">)</span> <span class="synStatement">=></span>
jwk.kty <span class="synStatement">===</span> <span class="synConstant">"RSA"</span> <span class="synConstant">&&</span> jwk.alg <span class="synStatement">===</span> <span class="synConstant">"RS256"</span> <span class="synConstant">&&</span> jwk.use <span class="synStatement">===</span> <span class="synConstant">"sig"</span>
<span class="synStatement">);</span>
<span class="synType">const</span> verified <span class="synStatement">=</span> verifyToken<span class="synStatement">(</span>idToken<span class="synStatement">,</span> jwk<span class="synStatement">);</span>
<span class="synStatement">if</span> <span class="synStatement">(</span>verified<span class="synStatement">)</span> <span class="synIdentifier">{</span>
res.<span class="synStatement">status(</span><span class="synConstant">200</span><span class="synStatement">);</span>
res.json<span class="synStatement">(</span><span class="synIdentifier">{</span> tokenSet <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synStatement">return;</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span>
res.<span class="synStatement">status(</span><span class="synConstant">401</span><span class="synStatement">);</span>
res.json<span class="synStatement">(</span><span class="synIdentifier">{</span> error: <span class="synConstant">"invalid token"</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synStatement">return;</span>
<span class="synIdentifier">}</span>
</pre>
<p><code>npm run build && node lib/index.js</code> でtiny-rpを再起動し、ログインを行います。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240312/20240312150223.png" width="800" height="505" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>トークンセットが表示されれば成功です。</p>
<h2 id="12-OpenID-Connect-Discovery-エンドポイントの実装-GET-openid-connectwell-knownopenid-configuration">12 OpenID Connect Discovery エンドポイントの実装 (GET /openid-connect/.well-known/openid-configuration)</h2>
<ul>
<li><a href="https://openid.net/specs/openid-connect-discovery-1_0.html">https://openid.net/specs/openid-connect-discovery-1_0.html</a></li>
</ul>
<p>ここではディスカバリーエンドポイントを作ります。ディスカバリーエンドポイントとは、認可エンドポイントやトークンエンドポイント、各種設定を返すエンドポイントです。ディスカバリーエンドポイントで返す設定値はOpenID Provider Metadataと呼ばれるものです。</p>
<p>まずはconfiguration_controller.tsを作成します。ここでは全て固定値を返すようにします。</p>
<ul>
<li><a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata">https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata</a></li>
</ul>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/configuration_controller.ts</span>
<span class="synComment">// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> ServerResponse <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'http'</span><span class="synStatement">;</span>
<span class="synStatement">export</span> <span class="synType">const</span> getConfiguration <span class="synStatement">=</span> <span class="synStatement">(</span>res: ServerResponse<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synComment">// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata</span>
<span class="synType">const</span> configuration <span class="synStatement">=</span> <span class="synIdentifier">{</span>
issuer: <span class="synConstant">'http://localhost:3000/openid-connect'</span><span class="synStatement">,</span>
authorization_endpoint: <span class="synConstant">'http://localhost:3000/openid-connect/auth'</span><span class="synStatement">,</span>
token_endpoint: <span class="synConstant">'http://localhost:3000/openid-connect/token'</span><span class="synStatement">,</span>
jwks_uri: <span class="synConstant">'http://localhost:3000/openid-connect/jwks'</span><span class="synStatement">,</span>
response_types_supported: <span class="synIdentifier">[</span><span class="synConstant">'code'</span><span class="synIdentifier">]</span><span class="synStatement">,</span>
subject_types_supported: <span class="synIdentifier">[</span><span class="synConstant">'public'</span><span class="synIdentifier">]</span><span class="synStatement">,</span>
id_token_signing_alg_values_supported: <span class="synIdentifier">[</span><span class="synConstant">'RS256'</span><span class="synIdentifier">]</span><span class="synStatement">,</span>
scopes_supported: <span class="synIdentifier">[</span><span class="synConstant">'openid'</span><span class="synIdentifier">]</span><span class="synStatement">,</span>
<span class="synComment">// https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication</span>
token_endpoint_auth_methods_supported: <span class="synIdentifier">[</span><span class="synConstant">'client_secret_post'</span><span class="synIdentifier">]</span><span class="synStatement">,</span>
claims_supported: <span class="synIdentifier">[</span><span class="synConstant">'sub'</span><span class="synStatement">,</span> <span class="synConstant">'iss'</span><span class="synIdentifier">]</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">200</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synConstant">'Content-Type'</span>: <span class="synConstant">'application/json'</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
res.end<span class="synStatement">(</span><span class="synSpecial">JSON</span>.stringify<span class="synStatement">(</span>configuration<span class="synStatement">));</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>src/index.tsで上記のコントローラーを使うようにします。</p>
<blockquote><p>OpenID Providers supporting Discovery MUST make a JSON document available at the path formed by concatenating the string /.well-known/openid-configuration to the Issuer.</p></blockquote>
<p>とあるように、 <code>/.well-known/openid-configuration</code> をissuerに付けたものがディスカバリーエンドポイントのエンドポイントとなります。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/index.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> getConfiguration <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'./controllers/configuration_controller'</span><span class="synStatement">;</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synStatement">if</span> <span class="synStatement">(</span>req.url?.split<span class="synStatement">(</span><span class="synConstant">'?'</span><span class="synStatement">)</span><span class="synIdentifier">[</span><span class="synConstant">0</span><span class="synIdentifier">]</span> <span class="synStatement">===</span> <span class="synConstant">'/openid-connect/.well-known/openid-configuration'</span> <span class="synConstant">&&</span> req.method <span class="synStatement">===</span> <span class="synConstant">'GET'</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
getConfiguration<span class="synStatement">(</span>res<span class="synStatement">);</span>
</pre>
<p>tiny-rp側でも、jwks_uriをディスカバリーエンドポイントから取得するように変更します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// tiny-rp/src/index.ts</span>
<span class="synType">const</span> configuration <span class="synStatement">=</span> <span class="synStatement">await</span> <span class="synStatement">(</span>
<span class="synStatement">await</span> <span class="synSpecial">fetch</span><span class="synStatement">(</span>
<span class="synConstant">"http://localhost:3000/openid-connect/.well-known/openid-configuration"</span>
<span class="synStatement">)</span>
<span class="synStatement">)</span>.json<span class="synStatement">();</span>
<span class="synType">const</span> jwksUri <span class="synStatement">=</span> configuration<span class="synIdentifier">[</span><span class="synConstant">"jwks_uri"</span><span class="synIdentifier">]</span><span class="synStatement">;</span>
</pre>
<p>これで完成です。</p>
<h2 id="まとめ">まとめ</h2>
<p>今回はJWTの実装と署名の検証について取り上げました。次回はstateとnonceを実装します。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fasmsuechan%2Ftiny-idp%2Ftree%2Fblog-3" title="GitHub - asmsuechan/tiny-idp at blog-3" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/asmsuechan/tiny-idp/tree/blog-3">github.com</a></cite></p>
<h2 id="Were-hiring">We're hiring</h2>
<p>弊社では認証認可が好きな人もそうでない人もエンジニアを絶賛募集中です!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
asmsuechan
フルスクラッチして理解するOpenID Connect (2) トークンエンドポイント編
hatenablog://entry/6801883189087696829
2024-03-07T13:00:00+09:00
2024-03-11T10:45:21+09:00 こんにちは。デジカルチームの末永(asmsuechan)です。この記事は「フルスクラッチして理解するOpenID Connect」の2記事目です。前回はこちら。 www.m3tech.blog
<p>こんにちは。デジカルチームの末永(<a href="https://twitter.com/asmsuechan">asmsuechan</a>)です。この記事は「フルスクラッチして理解するOpenID Connect」の2記事目です。前回はこちら。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2F2024%2F03%2F05%2F150000" title="フルスクラッチして理解するOpenID Connect (1) 認可エンドポイント編 - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/2024/03/05/150000">www.m3tech.blog</a></cite></p>
<ul class="table-of-contents">
<li><a href="#7-トークンエンドポイントの実装POST-openid-connecttoken">7. トークンエンドポイントの実装(POST /openid-connect/token)</a><ul>
<li><a href="#71-アクセストークン">7.1 アクセストークン</a><ul>
<li><a href="#例">例</a></li>
</ul>
</li>
<li><a href="#72-ID-トークン">7.2 ID トークン</a><ul>
<li><a href="#例-1">例</a></li>
</ul>
</li>
<li><a href="#73-IDトークンを返す部分を作る">7.3 IDトークンを返す部分を作る</a></li>
<li><a href="#74-アクセストークンを返す">7.4 アクセストークンを返す</a></li>
<li><a href="#75-パラメーターの検証">7.5 パラメーターの検証</a></li>
<li><a href="#76-認可コードの検証">7.6 認可コードの検証</a></li>
<li><a href="#77-クライアント認証">7.7 クライアント認証</a></li>
</ul>
</li>
<li><a href="#8-イントロスペクションエンドポイントを作るPOST-openid-connectintrospect">8 イントロスペクションエンドポイントを作る(POST /openid-connect/introspect)</a></li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#Were-hiring">We're hiring</a></li>
</ul>
<p>今回は全4回中の第2回目です。</p>
<ul>
<li>(1) 認可エンドポイント編</li>
<li><strong>(2) トークンエンドポイント編</strong></li>
<li>(3) JWT編</li>
<li>(4) stateとnonce編</li>
</ul>
<h2 id="7-トークンエンドポイントの実装POST-openid-connecttoken">7. トークンエンドポイントの実装(POST /openid-connect/token)</h2>
<p>次にトークンエンドポイントを作ります。OIDC ではアクセストークンと ID トークンの 2 つのトークンを返します。</p>
<p>トークンエンドポイントは<strong>リクエスト</strong>で次のパラメーターを受け取ります。なお、content-type は <strong><code>application/x-www-form-urlencoded</code></strong> と定められています。</p>
<table>
<thead>
<tr>
<th> パラメーター </th>
<th> 説明 </th>
<th> 例 </th>
</tr>
</thead>
<tbody>
<tr>
<td> client_id </td>
<td> ここでは固定値の tiny-client </td>
<td> tiny-client </td>
</tr>
<tr>
<td> redirect_uri </td>
<td> RP の redirect_uri </td>
<td> <a href="http://localhost:4000/oidc/callback">http://localhost:4000/oidc/callback</a> </td>
</tr>
<tr>
<td> code </td>
<td> 認可コードを表すランダムな文字列 </td>
<td> SplxlOBeZQQYbYS6WxSbIA </td>
</tr>
<tr>
<td>grant_type</td>
<td>ここでは認可コードを使うので authorization_code 固定です</td>
<td>authorization_code</td>
</tr>
</tbody>
</table>
<p><strong>レスポンスと</strong>して次を返します。content-type は <strong><code>application/json</code></strong> です。</p>
<table>
<thead>
<tr>
<th> パラメーター </th>
<th> 説明 </th>
<th> 例 </th>
</tr>
</thead>
<tbody>
<tr>
<td> access_token </td>
<td> 発行されたアクセストークン。形式に決まりはなく、ランダムな文字列でも JWT でもよい(<a href="https://datatracker.ietf.org/doc/html/rfc9068">RFC9068</a>)。 </td>
<td> SlAV32hkKG </td>
</tr>
<tr>
<td> token_type </td>
<td> Bearer でなければならない。Case insensitive. </td>
<td> Bearer </td>
</tr>
<tr>
<td> expires_in </td>
<td> アクセストークンの有効期限 </td>
<td> 86400 </td>
</tr>
<tr>
<td> id_token </td>
<td> 発行された ID トークン。JWT が使われる </td>
<td> eyJhbGciOiJSUzI1NiIsImtp<br />ZCI6IjFlOWdkazcifQ.ewog(略) </td>
</tr>
<tr>
<td> scope </td>
<td> 発行されたトークンの scope (optional) </td>
<td> openid </td>
</tr>
<tr>
<td>refresh_token</td>
<td>新しいトークンを発行するためのトークン (optional), tiny-idpでは実装しない</td>
<td>AikqQsp3be</td>
</tr>
</tbody>
</table>
<p>また、レスポンスのヘッダーにも決まりがあります。次のヘッダーの設定は必須です。OAuth2.0 では Pragma: no-cache も必須であるようです。</p>
<table>
<thead>
<tr>
<th> ヘッダー名 </th>
<th> 値 </th>
</tr>
</thead>
<tbody>
<tr>
<td> Cache-Control </td>
<td> no-store </td>
</tr>
</tbody>
</table>
<p>次はレスポンスの仕様です。</p>
<ul>
<li><a href="https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse">https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse</a></li>
<li><a href="https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1">https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1</a></li>
</ul>
<h3 id="71-アクセストークン">7.1 アクセストークン</h3>
<ul>
<li><a href="https://www.rfc-editor.org/rfc/rfc6749#section-1.4">https://www.rfc-editor.org/rfc/rfc6749#section-1.4</a></li>
<li><a href="https://www.rfc-editor.org/rfc/rfc6750">https://www.rfc-editor.org/rfc/rfc6750</a></li>
</ul>
<p>アクセストークンはリソースサーバーに対してリクエストする時に使うトークンで、リソースサーバーに対する操作を認可するためのものです。外部サイト連携で「許可する項目: メールアドレスと名前」のような表示がでてくることがありますが、この認可に紐づいたトークンとなります。なおアクセストークンは OAuth2.0 の仕様です。</p>
<p>リソースサーバーで使う時には発行者 (ここでは IdP) にトークンの有効性を尋ねるか、JWT などを使っていてトークンが検証可能であればリソースサーバー内で検証します。</p>
<p>IdP にはアクセストークンと共にスコープと有効期限を一緒に保存します。</p>
<p>この記事ではあくまで OpenID Connect の ID Provider を作ることをメインとしているので、リソースサーバーまで作ることはしません。</p>
<h4 id="例">例</h4>
<p><figure class="figure-image figure-image-fotolife" title="Facebook連携の例"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240307/20240307130004.png" width="800" height="664" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Facebook連携の例</figcaption></figure></p>
<p>アクセストークンは連携先システムのリソースを扱いたい時に使います。例えば自前のブログシステムを作ったとします。このブログシステムに投稿した時、Facebook にも同じ内容を投稿したいとします。</p>
<ul>
<li>ブログ投稿と同時にFacebookにも同じ内容を投稿したい</li>
<li>Facebook連携が必要
<ul>
<li>投稿ができるスコープが設定されている必要がある</li>
</ul>
</li>
<li>ブログに投稿するとアクセストークンを含めた同時投稿リクエストをFacebookのリソースサーバーに送信する</li>
</ul>
<h3 id="72-ID-トークン">7.2 ID トークン</h3>
<ul>
<li><a href="https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken">https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken</a></li>
</ul>
<p>ID トークンは OpenID Connect の認証の結果出力されたトークンです。ID トークンを認証された証として、連携先の Web アプリケーションに送信することでその Web アプリケーションでの認証とできます。トークン自体に、正しい ID Provider から発行されたものであることを証明する機能を付ける必要があるため JWT が利用されます。</p>
<p>セッションの管理は ID トークンの役割ではないため IdP は ID トークンを保持していなくても問題ありません。</p>
<h4 id="例-1">例</h4>
<p><figure class="figure-image figure-image-fotolife" title="Facebook連携の例"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240307/20240307130007.png" width="800" height="733" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Facebook連携の例</figcaption></figure></p>
<p>自分の作った Web アプリケーションに Facebook ログインを実装する例で考えてみます。あくまで Facebook は例で、実装の詳細には触れません。ここで自分の Web アプリケーションが RelyingParty で、Facebook が ID Provider となります。</p>
<ul>
<li>ユーザーがFacebookにID/Passwordを入力してログインする</li>
<li>コールバックとしてWebアプリケーションに認可コードが送られてくる</li>
<li>認可コードを使ってIDトークンを取得する</li>
<li>RelyingPartyでIDトークンの検証をする</li>
<li>自分のWebアプリケーションで登録/ログイン処理が走る
<ul>
<li>データベースの users テーブルに ID トークンに含まれる sub などの一意な値と一緒に保存</li>
<li>既にユーザーが存在すれば sub を使ってログインしたユーザーを特定</li>
</ul>
</li>
</ul>
<p>これで Facebook ログインが実装できます。プロフィール画像やメールアドレス、表示名などを Facebook から受け取って Web アプリケーションでも保存したい場合はアクセストークンを使って Facebook のリソースサーバーにアクセスします。</p>
<h3 id="73-IDトークンを返す部分を作る">7.3 IDトークンを返す部分を作る</h3>
<p>上記のリクエストパラメーターとレスポンスの仕様に基づいて、トークンを返すコントローラーを作ります。アクセストークンと ID トークンはダミーを返します。</p>
<p>src/controllers/token_controller.tsを作ります。レスポンスの型を上記のテーブル通りに作ります。なおoptionalのscopeとrefresh_tokenは省きます。リクエストの型はパラメーターの検証時に作ります。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/token_controller.ts</span>
<span class="synComment">// https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse</span>
<span class="synComment">// https://openid-foundation-japan.github.io/rfc6749.ja.html#anchor25</span>
<span class="synStatement">type</span> ResponseData <span class="synStatement">=</span> <span class="synIdentifier">{</span>
id_token: <span class="synType">string</span><span class="synStatement">;</span>
access_token: <span class="synType">string</span><span class="synStatement">;</span>
token_type: <span class="synType">string</span><span class="synStatement">;</span>
expires_in: <span class="synType">number</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>ではコントローラー本体を作ります。返すトークンはアクセストークンとIDトークン共にダミーとします。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/token_controller.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> Context <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">"../models/context"</span><span class="synStatement">;</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> ServerResponse <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">"http"</span><span class="synStatement">;</span>
<span class="synStatement">(</span>型略<span class="synStatement">)</span>
<span class="synComment">// > 空の値で送信されたパラメーターは省略されたものとして扱われなければならない (MUST).</span>
<span class="synComment">// > 認可サーバーは未知のリクエストパラメーターは無視しなければならない (MUST).</span>
<span class="synComment">// > リクエストおよびレスポンスパラメーターは重複を許さない (MUST NOT).</span>
<span class="synComment">// > https://openid-foundation-japan.github.io/rfc6749.ja.html#anchor23</span>
<span class="synStatement">export</span> <span class="synType">const</span> postToken <span class="synStatement">=</span> <span class="synStatement">(</span>db: Context<span class="synStatement">,</span> params: URLSearchParams<span class="synStatement">,</span> res: ServerResponse<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synType">const</span> clientId <span class="synStatement">=</span> params.<span class="synStatement">get(</span><span class="synConstant">'client_id'</span><span class="synStatement">);</span>
<span class="synType">const</span> code <span class="synStatement">=</span> params.<span class="synStatement">get(</span><span class="synConstant">'code'</span><span class="synStatement">);</span>
<span class="synComment">// NOTE: 未使用の認可コードを見つけてくる</span>
<span class="synType">const</span> authCode <span class="synStatement">=</span> db.authCodes.find<span class="synStatement">((</span>ac<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> ac.code <span class="synStatement">===</span> code <span class="synConstant">&&</span> ac.clientId <span class="synStatement">===</span> clientId <span class="synConstant">&&</span> ac.expiresAt <span class="synStatement">></span> <span class="synStatement">new</span> <span class="synSpecial">Date</span><span class="synStatement">();</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synComment">// NOTE: 一度使用した認可コードには使用済み日時を入れる</span>
<span class="synComment">// 後ほど使用済みであればエラーにするようバリデーションを追加する</span>
authCode<span class="synConstant">!</span>.usedAt <span class="synStatement">=</span> <span class="synStatement">new</span> <span class="synSpecial">Date</span><span class="synStatement">();</span>
authCode<span class="synConstant">!</span>.save<span class="synStatement">(</span>db.authCodes<span class="synStatement">);</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">200</span><span class="synStatement">,</span> <span class="synIdentifier">{</span>
<span class="synConstant">'Content-Type'</span>: <span class="synConstant">'application/json'</span><span class="synStatement">,</span>
<span class="synConstant">'Cache-Control'</span>: <span class="synConstant">'no-store'</span><span class="synStatement">,</span>
Pragma: <span class="synConstant">'no-cache'</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synType">const</span> data: ResponseData <span class="synStatement">=</span> <span class="synIdentifier">{</span>
id_token: <span class="synConstant">'dummy-id-token'</span><span class="synStatement">,</span>
access_token: <span class="synConstant">'dummy-access-token'</span><span class="synStatement">,</span>
token_type: <span class="synConstant">'Bearer'</span><span class="synStatement">,</span>
expires_in: <span class="synConstant">86400</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
res.end<span class="synStatement">(</span><span class="synSpecial">JSON</span>.stringify<span class="synStatement">(</span>data<span class="synStatement">));</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>作ったコントローラーをindex.tsで使うようにします。エンドポイントは <code>/openid-connect/token</code> です。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/index.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> postToken <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'./controllers/token_controller'</span><span class="synStatement">;</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synStatement">if</span> <span class="synStatement">(</span>req.url?.split<span class="synStatement">(</span><span class="synConstant">'?'</span><span class="synStatement">)</span><span class="synIdentifier">[</span><span class="synConstant">0</span><span class="synIdentifier">]</span> <span class="synStatement">===</span> <span class="synConstant">'/openid-connect/token'</span> <span class="synConstant">&&</span> req.method <span class="synStatement">===</span> <span class="synConstant">'POST'</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synType">let</span> body <span class="synStatement">=</span> <span class="synConstant">''</span><span class="synStatement">;</span>
req.on<span class="synStatement">(</span><span class="synConstant">'data'</span><span class="synStatement">,</span> <span class="synStatement">(</span>chunk<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
body <span class="synStatement">+=</span> chunk<span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
req.on<span class="synStatement">(</span><span class="synConstant">'end'</span><span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synType">const</span> params <span class="synStatement">=</span> <span class="synStatement">new</span> URLSearchParams<span class="synStatement">(</span>body<span class="synStatement">);</span>
postToken<span class="synStatement">(</span>db<span class="synStatement">,</span> params<span class="synStatement">,</span> res<span class="synStatement">);</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
</pre>
<p>そしてブラウザで動作確認をするために、tiny-rpを編集します。callback先のエンドポイントで、認可コードを使ってトークンリクエストをtiny-rpに送信するようにします。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// tiny-rp/src/index.ts</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
app.<span class="synStatement">get(</span><span class="synConstant">"/oidc/callback"</span><span class="synStatement">,</span> <span class="synStatement">async</span> <span class="synStatement">(</span>req<span class="synStatement">,</span> res<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synComment">// </span><span class="synTodo">TODO</span><span class="synComment">: トークンを検証するコードは後で追加します</span>
<span class="synType">const</span> redirect_uri <span class="synStatement">=</span> <span class="synConstant">"http://localhost:4000/oidc/callback"</span><span class="synStatement">;</span>
<span class="synType">const</span> code <span class="synStatement">=</span> <span class="synSpecial">String</span><span class="synStatement">(</span>req.query.code<span class="synStatement">);</span>
<span class="synType">const</span> scope <span class="synStatement">=</span> <span class="synSpecial">String</span><span class="synStatement">(</span>req.query.scope<span class="synStatement">);</span>
<span class="synSpecial">try</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> tokenResponse <span class="synStatement">=</span> <span class="synStatement">await</span> <span class="synSpecial">fetch</span><span class="synStatement">(</span>
<span class="synConstant">"http://localhost:3000/openid-connect/token"</span><span class="synStatement">,</span>
<span class="synIdentifier">{</span>
method: <span class="synConstant">"POST"</span><span class="synStatement">,</span>
headers: <span class="synIdentifier">{</span>
<span class="synConstant">"Content-Type"</span>: <span class="synConstant">"application/x-www-form-urlencoded"</span><span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">,</span>
body: <span class="synStatement">new</span> URLSearchParams<span class="synStatement">(</span><span class="synIdentifier">{</span>
code<span class="synStatement">,</span>
redirect_uri<span class="synStatement">,</span>
scope<span class="synStatement">,</span>
grant_type: <span class="synConstant">"authorization_code"</span><span class="synStatement">,</span>
client_id: <span class="synConstant">"tiny-client"</span><span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">),</span>
<span class="synIdentifier">}</span>
<span class="synStatement">);</span>
<span class="synType">const</span> tokenSet <span class="synStatement">=</span> <span class="synStatement">await</span> tokenResponse.json<span class="synStatement">();</span>
res.json<span class="synStatement">(</span><span class="synIdentifier">{</span> tokenSet <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synStatement">return;</span>
<span class="synIdentifier">}</span> <span class="synSpecial">catch</span> <span class="synStatement">(</span>error<span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synSpecial">console</span>.error<span class="synStatement">(</span><span class="synConstant">"Access Token Error: "</span><span class="synStatement">,</span> error<span class="synStatement">);</span>
res.<span class="synStatement">status(</span><span class="synConstant">500</span><span class="synStatement">);</span>
res.json<span class="synStatement">(</span><span class="synIdentifier">{</span> error: <span class="synConstant">"Access Token Error"</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synStatement">return;</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
</pre>
<p>ここまでできたらブラウザで動作確認を行えます。</p>
<p>tiny-rpとtiny-idpの双方で <code>npm run build && node lib/index.ts</code> を実行し、アプリケーションを再起動します。</p>
<p>そしてlocalhost:4000にアクセスし、ID/Passwordを入力すると次のようにダミーのトークンが表示されるはずです。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240307/20240307130011.png" width="800" height="578" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h3 id="74-アクセストークンを返す">7.4 アクセストークンを返す</h3>
<p>上記ではダミーを返しましたが、ここでアクセストークン部分を実装していきます。tiny-idpでは、アクセストークンの本体はランダムな予想されない文字列とし、その有効期限は24時間とします。</p>
<p>アクセストークンは後で実装するイントロスペクトエンドポイントで有効かどうかをIdPで検証するのでデータとして保存しておきます。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/models/access_token.ts</span>
<span class="synType">const</span> ONE_DAY <span class="synStatement">=</span> <span class="synConstant">60</span> * <span class="synConstant">60</span> * <span class="synConstant">24</span><span class="synStatement">;</span> <span class="synComment">// 86400</span>
<span class="synStatement">export</span> <span class="synStatement">class</span> AccessToken <span class="synIdentifier">{</span>
token: <span class="synType">string</span><span class="synStatement">;</span>
expiresAt: <span class="synType">number</span><span class="synStatement">;</span>
userId: <span class="synType">number</span><span class="synStatement">;</span>
<span class="synStatement">constructor(</span>token: <span class="synType">string</span><span class="synStatement">,</span> expiresAt: <span class="synType">number</span><span class="synStatement">,</span> userId: <span class="synType">number</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synIdentifier">this</span>.token <span class="synStatement">=</span> token<span class="synStatement">;</span>
<span class="synIdentifier">this</span>.expiresAt <span class="synStatement">=</span> expiresAt<span class="synStatement">;</span>
<span class="synIdentifier">this</span>.userId <span class="synStatement">=</span> userId<span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synStatement">static</span> build<span class="synStatement">(</span>userId: <span class="synType">number</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> token <span class="synStatement">=</span> <span class="synSpecial">Math</span>.random<span class="synStatement">()</span>.toString<span class="synStatement">(</span><span class="synConstant">36</span><span class="synStatement">)</span>.slice<span class="synStatement">(</span><span class="synConstant">-8</span><span class="synStatement">);</span>
<span class="synType">const</span> expiresIn <span class="synStatement">=</span> ONE_DAY * <span class="synConstant">1000</span><span class="synStatement">;</span>
<span class="synStatement">return</span> <span class="synStatement">new</span> AccessToken<span class="synStatement">(</span>token<span class="synStatement">,</span> <span class="synStatement">new</span> <span class="synSpecial">Date</span><span class="synStatement">()</span>.getTime<span class="synStatement">()</span> + expiresIn<span class="synStatement">,</span> userId<span class="synStatement">);</span>
<span class="synIdentifier">}</span>
save<span class="synStatement">(</span>db: AccessToken<span class="synIdentifier">[]</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synStatement">if</span> <span class="synStatement">(</span>db.some<span class="synStatement">((</span>at<span class="synStatement">)</span> <span class="synStatement">=></span> at.userId <span class="synStatement">===</span> <span class="synIdentifier">this</span>.userId<span class="synStatement">))</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> index <span class="synStatement">=</span> db.findIndex<span class="synStatement">((</span>at<span class="synStatement">)</span> <span class="synStatement">=></span> at.userId <span class="synStatement">===</span> <span class="synIdentifier">this</span>.userId<span class="synStatement">);</span>
db<span class="synIdentifier">[</span>index<span class="synIdentifier">]</span> <span class="synStatement">=</span> <span class="synIdentifier">this</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span>
db.push<span class="synStatement">(</span><span class="synIdentifier">this</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span>
isValid<span class="synStatement">()</span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synIdentifier">this</span>.expiresAt <span class="synStatement">></span> <span class="synSpecial">Date</span>.now<span class="synStatement">();</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span>
</pre>
<p>モデルを作ったので Context 型と index.ts のデータ初期化をしておきます。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/models/context.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> AccessToken <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">"./access_token"</span><span class="synStatement">;</span> <span class="synComment">// 追加</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> AuthCode <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">"./auth_code"</span><span class="synStatement">;</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> User <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">"./user"</span><span class="synStatement">;</span>
<span class="synStatement">export</span> <span class="synStatement">type</span> Context <span class="synStatement">=</span> <span class="synIdentifier">{</span>
users: User<span class="synIdentifier">[]</span><span class="synStatement">;</span>
authCodes: AuthCode<span class="synIdentifier">[]</span><span class="synStatement">;</span>
accessTokens: AccessToken<span class="synIdentifier">[]</span><span class="synStatement">;</span> <span class="synComment">// 追加</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>index.tsのデータ側にも追加しておきます。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/index.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> AccessToken <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'./models/access_token'</span><span class="synStatement">;</span> <span class="synComment">// 追加</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synType">const</span> users: User<span class="synIdentifier">[]</span> <span class="synStatement">=</span> <span class="synIdentifier">[{</span> id: <span class="synConstant">1</span><span class="synStatement">,</span> email: <span class="synConstant">'tiny-idp@asmsuechan.com'</span><span class="synStatement">,</span> password: <span class="synConstant">'p@ssw0rd'</span><span class="synStatement">,</span> clientId: <span class="synConstant">'tiny-client'</span> <span class="synIdentifier">}]</span><span class="synStatement">;</span>
<span class="synType">const</span> authCodes: AuthCode<span class="synIdentifier">[]</span> <span class="synStatement">=</span> <span class="synIdentifier">[]</span><span class="synStatement">;</span>
<span class="synType">const</span> accessTokens: AccessToken<span class="synIdentifier">[]</span> <span class="synStatement">=</span> <span class="synIdentifier">[]</span><span class="synStatement">;</span> <span class="synComment">// 追加</span>
<span class="synType">const</span> db <span class="synStatement">=</span> <span class="synIdentifier">{</span>
users<span class="synStatement">,</span>
authCodes<span class="synStatement">,</span>
accessTokens<span class="synStatement">,</span> <span class="synComment">// 追加</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>ここまでできたら、token_controller.tsでアクセストークンを生成してレスポンスに含めましょう。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/token_controller.ts</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synType">const</span> accessToken <span class="synStatement">=</span> AccessToken.build<span class="synStatement">(</span>authCode<span class="synConstant">!</span>.userId<span class="synStatement">);</span> <span class="synComment">// 追加</span>
accessToken.save<span class="synStatement">(</span>db.accessTokens<span class="synStatement">);</span> <span class="synComment">// 追加</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">200</span><span class="synStatement">,</span> <span class="synIdentifier">{</span>
<span class="synConstant">'Content-Type'</span>: <span class="synConstant">'application/json'</span><span class="synStatement">,</span>
<span class="synConstant">'Cache-Control'</span>: <span class="synConstant">'no-store'</span><span class="synStatement">,</span>
Pragma: <span class="synConstant">'no-cache'</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synType">const</span> data: ResponseData <span class="synStatement">=</span> <span class="synIdentifier">{</span>
id_token: <span class="synConstant">'dummy-id-token'</span><span class="synStatement">,</span>
access_token: accessToken.token<span class="synStatement">,</span> <span class="synComment">// 追加</span>
token_type: <span class="synConstant">'Bearer'</span><span class="synStatement">,</span>
expires_in: <span class="synConstant">86400</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>これでアクセストークンの生成はできたので、検証してみます。<code>npm run build && node lib/index.js</code> して localhost:4000 にアクセスしましょう。</p>
<p>ログインすると、ダミーではないアクセストークンが返ってきました。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240307/20240307130016.png" width="800" height="578" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h3 id="75-パラメーターの検証">7.5 パラメーターの検証</h3>
<p>認可リクエストと同じように、トークンリクエストでもリクエストパラメーターの検証をします。検証の仕様はOAuthとOpenID Connect双方のRFCで定められています。</p>
<ul>
<li><a href="https://openid.net/specs/openid-connect-core-1_0.html#TokenRequestValidation">https://openid.net/specs/openid-connect-core-1_0.html#TokenRequestValidation</a></li>
<li><a href="https://openid-foundation-japan.github.io/rfc6749.ja.html#token-errors">https://openid-foundation-japan.github.io/rfc6749.ja.html#token-errors</a></li>
</ul>
<p>まずはリクエストの型を作ります。リクエストパラメーターについてはこの章の上の方にあるテーブルに記載しています。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/token_controller.ts</span>
<span class="synComment">// https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest</span>
<span class="synComment">// https://openid-foundation-japan.github.io/rfc6749.ja.html#token-req</span>
<span class="synStatement">type</span> RequestParams <span class="synStatement">=</span> <span class="synIdentifier">{</span>
grantType: <span class="synType">string</span> | <span class="synType">null</span><span class="synStatement">;</span>
code: <span class="synType">string</span> | <span class="synType">null</span><span class="synStatement">;</span>
redirectUri: <span class="synType">string</span> | <span class="synType">null</span><span class="synStatement">;</span>
clientId: <span class="synType">string</span> | <span class="synType">null</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>検証エラー時の型を追加します。エラーコードとエラーレスポンスの形式は認可リクエストと同じくRFCで定められているのでそれに従います。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/token_controller.ts</span>
<span class="synComment">// https://openid.net/specs/openid-connect-core-1_0.html#TokenErrorResponse</span>
<span class="synComment">// https://openid-foundation-japan.github.io/rfc6749.ja.html#token-errors</span>
<span class="synStatement">type</span> TokenError <span class="synStatement">=</span>
| <span class="synConstant">'invalid_request'</span>
| <span class="synConstant">'invalid_client'</span>
| <span class="synConstant">'invalid_grant'</span>
| <span class="synConstant">'unauthorized_client'</span>
| <span class="synConstant">'unsupported_grant_type'</span>
| <span class="synConstant">'invalid_scope'</span><span class="synStatement">;</span>
<span class="synStatement">type</span> ErrorResponse <span class="synStatement">=</span> <span class="synIdentifier">{</span>
error: TokenError<span class="synStatement">;</span>
error_description?: <span class="synType">string</span><span class="synStatement">;</span>
error_uri?: <span class="synType">string</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>次にtoken_controller.tsに実際の処理となるvalidate()関数を追加します。次の2点を確認します。</p>
<ul>
<li>必須パラメーターの存在チェック</li>
<li>grant_typeが認可コードであること</li>
</ul>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/token_controller.ts</span>
<span class="synComment">// https://openid.net/specs/openid-connect-core-1_0.html#TokenRequestValidation</span>
<span class="synComment">// https://openid-foundation-japan.github.io/rfc6749.ja.html#token-errors</span>
<span class="synComment">// 実装しない仕様</span>
<span class="synComment">// * Authorization リクエストヘッダーでの認証</span>
<span class="synType">const</span> validate <span class="synStatement">=</span> <span class="synStatement">(</span>requestParams: RequestParams<span class="synStatement">)</span>: TokenError | <span class="synType">null</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>requestParams.clientId <span class="synConstant">||</span> <span class="synConstant">!</span>requestParams.code <span class="synConstant">||</span> <span class="synConstant">!</span>requestParams.grantType <span class="synConstant">||</span> <span class="synConstant">!</span>requestParams.redirectUri<span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synConstant">'invalid_request'</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synStatement">if</span> <span class="synStatement">(</span>requestParams.grantType <span class="synStatement">!==</span> <span class="synConstant">'authorization_code'</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synConstant">'unsupported_grant_type'</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synStatement">return</span> <span class="synType">null</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>コントローラーでvalidate()関数を使うように変更します。問題があった場合はエラーを返します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">export</span> <span class="synType">const</span> postToken <span class="synStatement">=</span> <span class="synStatement">(</span>db: Context<span class="synStatement">,</span> params: URLSearchParams<span class="synStatement">,</span> res: ServerResponse<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synType">const</span> clientId <span class="synStatement">=</span> params.<span class="synStatement">get(</span><span class="synConstant">'client_id'</span><span class="synStatement">);</span>
<span class="synType">const</span> code <span class="synStatement">=</span> params.<span class="synStatement">get(</span><span class="synConstant">'code'</span><span class="synStatement">);</span>
<span class="synComment">// NOTE: ここから追加</span>
<span class="synType">const</span> grantType <span class="synStatement">=</span> params.<span class="synStatement">get(</span><span class="synConstant">'grant_type'</span><span class="synStatement">);</span>
<span class="synType">const</span> redirectUri <span class="synStatement">=</span> params.<span class="synStatement">get(</span><span class="synConstant">'redirect_uri'</span><span class="synStatement">);</span>
<span class="synType">const</span> requestParams: RequestParams <span class="synStatement">=</span> <span class="synIdentifier">{</span> grantType<span class="synStatement">,</span> code<span class="synStatement">,</span> redirectUri<span class="synStatement">,</span> clientId <span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synType">const</span> validated <span class="synStatement">=</span> validate<span class="synStatement">(</span>requestParams<span class="synStatement">);</span>
<span class="synStatement">if</span> <span class="synStatement">(</span>validated<span class="synStatement">)</span> <span class="synIdentifier">{</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">400</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synConstant">'Content-Type'</span>: <span class="synConstant">'application/json'</span><span class="synStatement">,</span> <span class="synConstant">'Cache-Control'</span>: <span class="synConstant">'no-store'</span><span class="synStatement">,</span> Pragma: <span class="synConstant">'no-cache'</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synType">const</span> response: ErrorResponse <span class="synStatement">=</span> <span class="synIdentifier">{</span> error: validated <span class="synIdentifier">}</span><span class="synStatement">;</span>
res.end<span class="synStatement">(</span><span class="synSpecial">JSON</span>.stringify<span class="synStatement">(</span>response<span class="synStatement">));</span>
<span class="synStatement">return;</span>
<span class="synIdentifier">}</span>
<span class="synComment">// NOTE: ここまで追加</span>
</pre>
<p>では <code>npm run build && node lib/index.js</code> で再起動して検証エラーが出るか試してみます。</p>
<p>ブラウザ上からトークンリクエストのパラメーターを変えるのは難しいので、tiny-rpのリクエスト部分を少し変更します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// tiny-rp/src/index.ts</span>
grant_type: <span class="synConstant">"invalid_grant"</span><span class="synStatement">,</span> <span class="synComment">// authorization_codeから変更</span>
</pre>
<p>tiny-rpも再起動して、localhost:4000より動作を確認します。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240307/20240307130021.png" width="800" height="578" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>無事実装したエラーが出ました。</p>
<h3 id="76-認可コードの検証">7.6 認可コードの検証</h3>
<p>トークンエンドポイントには認可エンドポイントで生成した認可コードが送られてきます。まずはこの認可コードが正しい値であるかどうかを検証します。ここでは</p>
<ul>
<li>認可コードが存在するか</li>
<li>認可コードが未使用であるか</li>
<li>認可リクエストで指定されたredirect_uriとトークンリクエストで指定されたredirect_uriが等しいか</li>
</ul>
<p>を確認します。</p>
<p>validate()関数を変更します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/token_controller.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> AuthCode <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'../models/auth_code'</span><span class="synStatement">;</span> <span class="synComment">// 追加</span>
<span class="synType">const</span> validate <span class="synStatement">=</span> <span class="synStatement">(</span>requestParams: RequestParams<span class="synStatement">,</span> authCode?: AuthCode<span class="synStatement">)</span>: TokenError | <span class="synType">null</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span> <span class="synComment">// authCodeを追加</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synComment">// https://openid-foundation-japan.github.io/rfc6749.ja.html#code-authz-resp</span>
<span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>authCode <span class="synConstant">||</span> authCode.usedAt <span class="synConstant">||</span> authCode.redirectUri <span class="synStatement">!==</span> requestParams.redirectUri<span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synConstant">'invalid_grant'</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span>
</pre>
<p>validate()関数に引数を追加したので、使う側でauthCodeを入れるようにします。変数の場所などは適宜調整してください。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/token_controller.ts</span>
<span class="synType">const</span> validated <span class="synStatement">=</span> validate<span class="synStatement">(</span>requestParams<span class="synStatement">,</span> authCode<span class="synStatement">);</span> <span class="synComment">// authCodeを追加</span>
</pre>
<p>これも動作確認してみます。先ほどと同じようにtiny-rp側でコードを適当な値に変えます。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// tiny-rp/src/index.ts</span>
code: <span class="synConstant">"invalid_code"</span><span class="synStatement">,</span>
</pre>
<p><code>npm run build && node lib/index.js</code> でtiny-rpとtiny-idpを再起動してlocalhost:4000よりログインしてみます。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240307/20240307130027.png" width="800" height="578" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>想定通り、invalid_grantが出ました。</p>
<h3 id="77-クライアント認証">7.7 クライアント認証</h3>
<ul>
<li><a href="https://datatracker.ietf.org/doc/html/rfc6749#section-2.1">https://datatracker.ietf.org/doc/html/rfc6749#section-2.1</a></li>
<li><a href="https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1">https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1</a></li>
</ul>
<p>tiny-idpのトークンエンドポイントではクライアント認証を実装します。クライアント認証をするにはリクエストボディに client_id と client_secret を含めます。</p>
<blockquote><p>o require client authentication for confidential clients or for any<br/>
client that was issued client credentials (or with other<br/>
authentication requirements),<br/>
<a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3">https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3</a></p></blockquote>
<p>クライアント認証を実現するため、client_idとclient_secretを含むClientというデータを作ります。ここではclient_idとclient_secretは固定値とします。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/models/client.ts</span>
<span class="synStatement">export</span> <span class="synStatement">class</span> Client <span class="synIdentifier">{</span>
clientId: <span class="synType">string</span><span class="synStatement">;</span>
clientSecret: <span class="synType">string</span><span class="synStatement">;</span>
<span class="synStatement">constructor(</span>clientId: <span class="synType">string</span><span class="synStatement">,</span> clientSecret: <span class="synType">string</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synIdentifier">this</span>.clientId <span class="synStatement">=</span> clientId<span class="synStatement">;</span>
<span class="synIdentifier">this</span>.clientSecret <span class="synStatement">=</span> clientSecret<span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span>
</pre>
<p>モデルを追加したのでContext型にも追加します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/models/context.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> Client <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'./client'</span><span class="synStatement">;</span> <span class="synComment">// 追加</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> AccessToken <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'./access_token'</span><span class="synStatement">;</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> AuthCode <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'./auth_code'</span><span class="synStatement">;</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> User <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'./user'</span><span class="synStatement">;</span>
<span class="synStatement">export</span> <span class="synStatement">type</span> Context <span class="synStatement">=</span> <span class="synIdentifier">{</span>
users: User<span class="synIdentifier">[]</span><span class="synStatement">;</span>
authCodes: AuthCode<span class="synIdentifier">[]</span><span class="synStatement">;</span>
accessTokens: AccessToken<span class="synIdentifier">[]</span><span class="synStatement">;</span>
clients: Client<span class="synIdentifier">[]</span><span class="synStatement">;</span> <span class="synComment">// 追加</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>Clientのデータをdb変数に追加しておきます。固定値です。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/index.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> Client <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'./models/client'</span><span class="synStatement">;</span>
<span class="synType">const</span> clients: Client<span class="synIdentifier">[]</span> <span class="synStatement">=</span> <span class="synIdentifier">[{</span> clientId: <span class="synConstant">'tiny-client'</span><span class="synStatement">,</span> clientSecret: <span class="synConstant">'c1!3n753cr37'</span> <span class="synIdentifier">}]</span><span class="synStatement">;</span> <span class="synComment">// 追加</span>
<span class="synType">const</span> db <span class="synStatement">=</span> <span class="synIdentifier">{</span>
users<span class="synStatement">,</span>
authCodes<span class="synStatement">,</span>
accessTokens<span class="synStatement">,</span>
clients <span class="synComment">// 追加</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>クライアント認証をするコードをvalidate()関数内に追加します。リクエストに含まれるclient_secretとデータのclient_secretが異なっていればエラーを返します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/token_controller.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> Client <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'../models/client'</span><span class="synStatement">;</span> <span class="synComment">// 追加</span>
<span class="synComment">// https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest</span>
<span class="synComment">// https://openid-foundation-japan.github.io/rfc6749.ja.html#token-req</span>
<span class="synStatement">type</span> RequestParams <span class="synStatement">=</span> <span class="synIdentifier">{</span>
grantType: <span class="synType">string</span> | <span class="synType">null</span><span class="synStatement">;</span>
code: <span class="synType">string</span> | <span class="synType">null</span><span class="synStatement">;</span>
redirectUri: <span class="synType">string</span> | <span class="synType">null</span><span class="synStatement">;</span>
clientId: <span class="synType">string</span> | <span class="synType">null</span><span class="synStatement">;</span>
clientSecret: <span class="synType">string</span> | <span class="synType">null</span><span class="synStatement">;</span> <span class="synComment">// 追加</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synComment">// postToken()関数内に以下を追加</span>
<span class="synType">const</span> clientSecret <span class="synStatement">=</span> params.<span class="synStatement">get(</span><span class="synConstant">'client_secret'</span><span class="synStatement">);</span>
<span class="synType">const</span> requestParams: RequestParams <span class="synStatement">=</span> <span class="synIdentifier">{</span> grantType<span class="synStatement">,</span> code<span class="synStatement">,</span> redirectUri<span class="synStatement">,</span> clientId<span class="synStatement">,</span> clientSecret <span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synType">const</span> client <span class="synStatement">=</span> db.clients.find<span class="synStatement">((</span>c<span class="synStatement">)</span> <span class="synStatement">=></span> c.clientId <span class="synStatement">===</span> clientId<span class="synStatement">);</span>
<span class="synType">const</span> validated <span class="synStatement">=</span> validate<span class="synStatement">(</span>requestParams<span class="synStatement">,</span> authCode<span class="synStatement">,</span> client<span class="synStatement">);</span> <span class="synComment">// clientを追加</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synType">const</span> validate <span class="synStatement">=</span> <span class="synStatement">(</span>requestParams: RequestParams<span class="synStatement">,</span> authCode?: AuthCode<span class="synStatement">,</span> client?: Client<span class="synStatement">)</span>: TokenError | <span class="synType">null</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span> <span class="synComment">// clientを追加</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synComment">// 以下の条件文を追加</span>
<span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>client <span class="synConstant">||</span> client.clientSecret <span class="synStatement">!==</span> requestParams.clientSecret<span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synConstant">'invalid_client'</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span>
</pre>
<p>client_secretはtiny-rpだとまだ渡していないので、渡すように変更します。まずは検証エラーになることを確認したいので、無効なclient_secretを入れてみます。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// tiny-rp/src/index.ts</span>
body: <span class="synStatement">new</span> URLSearchParams<span class="synStatement">(</span><span class="synIdentifier">{</span>
code<span class="synStatement">,</span>
redirect_uri<span class="synStatement">,</span>
scope<span class="synStatement">,</span>
grant_type: <span class="synConstant">"authorization_code"</span><span class="synStatement">,</span>
client_id: <span class="synConstant">"tiny-client"</span><span class="synStatement">,</span>
client_secret: <span class="synConstant">"invalid_secret"</span><span class="synStatement">,</span> <span class="synComment">// 追加</span>
<span class="synIdentifier">}</span><span class="synStatement">),</span>
</pre>
<p>この状態で保存し、tiny-rpとtiny-idp共に <code>npm run build && node lib/index.js</code> で再起動し、localhost:4000から動作確認します。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240307/20240307130030.png" width="800" height="578" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>無事にinvalid_clientエラーがでました。では次にclient_secretを正しく設定し、クライアント認証が通ることを確認します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// tiny-rp/src/index.ts</span>
body: <span class="synStatement">new</span> URLSearchParams<span class="synStatement">(</span><span class="synIdentifier">{</span>
code<span class="synStatement">,</span>
redirect_uri<span class="synStatement">,</span>
scope<span class="synStatement">,</span>
grant_type: <span class="synConstant">"authorization_code"</span><span class="synStatement">,</span>
client_id: <span class="synConstant">"tiny-client"</span><span class="synStatement">,</span>
client_secret: <span class="synConstant">"c1!3n753cr37"</span><span class="synStatement">,</span> <span class="synComment">// 変更</span>
<span class="synIdentifier">}</span><span class="synStatement">),</span>
</pre>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240307/20240307130034.png" width="800" height="578" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>トークンが取得できることを確認できました。</p>
<h2 id="8-イントロスペクションエンドポイントを作るPOST-openid-connectintrospect">8 イントロスペクションエンドポイントを作る(POST /openid-connect/introspect)</h2>
<ul>
<li><a href="https://datatracker.ietf.org/doc/html/rfc7662">https://datatracker.ietf.org/doc/html/rfc7662</a></li>
</ul>
<p>トークンエンドポイントを実装しました。これは OAuth 2.0 の仕様ですが、アクセストークンもレスポンスに含まれます。ここではこのアクセストークンが有効なものかどうかを確認するためにイントロスペクションエンドポイントを実装します。</p>
<p>次のように introspect_controller.ts を作成します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/introspect_controller.ts</span>
<span class="synComment">// https://datatracker.ietf.org/doc/html/rfc7662</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> ServerResponse <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'http'</span><span class="synStatement">;</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> Context <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'../models/context'</span><span class="synStatement">;</span>
<span class="synStatement">export</span> <span class="synType">const</span> postIntrospect <span class="synStatement">=</span> <span class="synStatement">(</span>db: Context<span class="synStatement">,</span> params: URLSearchParams<span class="synStatement">,</span> res: ServerResponse<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synType">const</span> accessToken <span class="synStatement">=</span> params.<span class="synStatement">get(</span><span class="synConstant">'token'</span><span class="synStatement">);</span>
<span class="synType">const</span> foundToken <span class="synStatement">=</span> db.accessTokens.find<span class="synStatement">((</span>ac<span class="synStatement">)</span> <span class="synStatement">=></span> ac.token <span class="synStatement">===</span> accessToken<span class="synStatement">);</span>
<span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>foundToken <span class="synConstant">||</span> foundToken.expiresAt <span class="synStatement"><</span> <span class="synStatement">new</span> <span class="synSpecial">Date</span><span class="synStatement">()</span>.getTime<span class="synStatement">())</span> <span class="synIdentifier">{</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">401</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synConstant">'Content-Type'</span>: <span class="synConstant">'application/json'</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synType">const</span> response <span class="synStatement">=</span> <span class="synIdentifier">{</span> active: <span class="synConstant">false</span> <span class="synIdentifier">}</span><span class="synStatement">;</span>
res.end<span class="synStatement">(</span><span class="synSpecial">JSON</span>.stringify<span class="synStatement">(</span>response<span class="synStatement">));</span>
<span class="synStatement">return;</span>
<span class="synIdentifier">}</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">200</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synConstant">'Content-Type'</span>: <span class="synConstant">'application/json'</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synType">const</span> response <span class="synStatement">=</span> <span class="synIdentifier">{</span> active: <span class="synConstant">true</span> <span class="synIdentifier">}</span><span class="synStatement">;</span>
res.end<span class="synStatement">(</span><span class="synSpecial">JSON</span>.stringify<span class="synStatement">(</span>response<span class="synStatement">));</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>やっていることは単純で、トークンを DB から探して、有効期限中であれば active: true を返し、期限切れであれば active: false を返します。</p>
<p>RFC によるとリクエストは <code>application/x-www-form-urlencoded</code> で POST メソッドです。レスポンスは <code>application/json</code> で active パラメーターだけが必須です。</p>
<p>このリクエストを受け取る口を index.ts に作れば完成です。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/index.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> postIntrospect <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'./controllers/introspect_controller'</span><span class="synStatement">;</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synStatement">if</span> <span class="synStatement">(</span>req.url?.split<span class="synStatement">(</span><span class="synConstant">'?'</span><span class="synStatement">)</span><span class="synIdentifier">[</span><span class="synConstant">0</span><span class="synIdentifier">]</span> <span class="synStatement">===</span> <span class="synConstant">'/openid-connect/introspect'</span> <span class="synConstant">&&</span> req.method <span class="synStatement">===</span> <span class="synConstant">'POST'</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synType">let</span> body <span class="synStatement">=</span> <span class="synConstant">''</span><span class="synStatement">;</span>
req.on<span class="synStatement">(</span><span class="synConstant">'data'</span><span class="synStatement">,</span> <span class="synStatement">(</span>chunk<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
body <span class="synStatement">+=</span> chunk<span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
req.on<span class="synStatement">(</span><span class="synConstant">'end'</span><span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synType">const</span> params <span class="synStatement">=</span> <span class="synStatement">new</span> URLSearchParams<span class="synStatement">(</span>body<span class="synStatement">);</span>
postIntrospect<span class="synStatement">(</span>db<span class="synStatement">,</span> params<span class="synStatement">,</span> res<span class="synStatement">);</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
</pre>
<p>これはcurlで動作確認しましょう。<code>npm run build && node lib/index.js</code> でtiny-idpを再起動します。一度localhost:4000から再ログインしてアクセストークンを取得します。</p>
<p>取得したアクセストークンをイントロスペクトエンドポイントに送るとactive: trueが返ってきました。</p>
<pre class="code shell" data-lang="shell" data-unlink>~/src/asmsuechan/tiny-idp-2 > curl localhost:3000/openid-connect/introspect -X POST -d 'token=8unyqw8h'
{"active":true}</pre>
<h2 id="まとめ">まとめ</h2>
<p>トークンエンドポイントとイントロスペクトエンドポイントを実装しました。また、パラメーターの検証も行いました。次はJWTの実装をします。</p>
<p>この記事で作成したコードはGitHubに上げています。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fasmsuechan%2Ftiny-idp%2Ftree%2Fblog-2" title="GitHub - asmsuechan/tiny-idp at blog-2" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/asmsuechan/tiny-idp/tree/blog-2">github.com</a></cite></p>
<h2 id="Were-hiring">We're hiring</h2>
<p>弊社では認証認可が好きな人もそうでない人もエンジニアを絶賛募集中です!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
asmsuechan
フルスクラッチして理解するOpenID Connect (1) 認可エンドポイント編
hatenablog://entry/6801883189086526832
2024-03-05T15:00:00+09:00
2024-03-10T08:49:08+09:00 こんにちは。デジカルチームの末永(asmsuechan)です。 この記事では、OpenID Connect の ID Provider を標準ライブラリ縛りでフルスクラッチすることで OpenID Connect の仕様を理解することを目指します。実装言語は TypeScript です。 記事のボリュームを減らすため、OpenID Connect の全ての仕様を網羅した実装はせず、よく使われる一部の仕様のみをピックアップして実装します。この記事は全4回中の第1回となります。 なお、ここで実装する ID Provider は弊社内で使われているものではなく、筆者が趣味として作ったものです。ですの…
<p>こんにちは。デジカルチームの末永(<a href="https://twitter.com/asmsuechan">asmsuechan</a>)です。</p>
<p>この記事では、OpenID Connect の ID Provider を<strong>標準ライブラリ縛りでフルスクラッチ</strong>することで OpenID Connect の仕様を理解することを目指します。実装言語は TypeScript です。</p>
<p>記事のボリュームを減らすため、OpenID Connect の全ての仕様を網羅した実装はせず、よく使われる一部の仕様のみをピックアップして実装します。この記事は全4回中の第1回となります。</p>
<p>なお、ここで実装する ID Provider は弊社内で使われているものではなく、筆者が趣味として作ったものです。ですので本番環境で使用されることを想定したものではありません。なんなら私は ID Provider を運用する仕事もしておりません。</p>
<ul class="table-of-contents">
<li><a href="#1-OAuth-20-と-OpenID-Connect">1 OAuth 2.0 と OpenID Connect</a><ul>
<li><a href="#11-用語の整理">1.1 用語の整理</a></li>
<li><a href="#12-連携の流れ-認可コードフロー">1.2 連携の流れ (認可コードフロー)</a></li>
</ul>
</li>
<li><a href="#2-tiny-idpことはじめ">2 tiny-idpことはじめ</a><ul>
<li><a href="#21-注意事項">2.1 注意事項</a></li>
<li><a href="#22-作るもの">2.2 作るもの</a></li>
<li><a href="#23-実装するエンドポイント">2.3 実装するエンドポイント</a></li>
</ul>
</li>
<li><a href="#3-tiny-rp-を作る">3 tiny-rp を作る</a><ul>
<li><a href="#31-TypeScript-プロジェクトを始める">3.1 TypeScript プロジェクトを始める</a></li>
<li><a href="#32-RelyingParty-本体の実装">3.2 RelyingParty 本体の実装</a></li>
</ul>
</li>
<li><a href="#4-tiny-idp-を作る">4 tiny-idp を作る</a><ul>
<li><a href="#41-TypeScript-プロジェクトを始める">4.1 TypeScript プロジェクトを始める</a></li>
<li><a href="#42-Web-アプリケーションサーバーを作る">4.2 Web アプリケーションサーバーを作る</a></li>
</ul>
</li>
<li><a href="#5-ユーザーとユーザーログインを作るPOST-login">5 ユーザーとユーザーログインを作る(POST /login)</a></li>
<li><a href="#6-認可エンドポイントの実装GET-openid-connectauth">6 認可エンドポイントの実装(GET /openid-connect/auth)</a><ul>
<li><a href="#61-リクエストとレスポンス">6.1 リクエストとレスポンス</a></li>
<li><a href="#62-ログインページと認可エンドポイントを作る">6.2 ログインページと認可エンドポイントを作る</a></li>
<li><a href="#63-パラメーターの検証">6.3 パラメーターの検証</a></li>
<li><a href="#64-ログイン成功時に認可コードを生成しリダイレクトする">6.4 ログイン成功時に認可コードを生成しリダイレクトする</a><ul>
<li><a href="#641-認可コード">6.4.1 認可コード</a></li>
<li><a href="#642-実装する">6.4.2 実装する</a></li>
</ul>
</li>
<li><a href="#65-動作確認">6.5 動作確認</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#Were-hiring">We're hiring</a></li>
</ul>
<p>完成した自作ID Providerはこちらになります。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fasmsuechan%2Ftiny-idp%2Ftree%2Fmain" title="GitHub - asmsuechan/tiny-idp" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/asmsuechan/tiny-idp/tree/main">github.com</a></cite></p>
<p>今回は全4回中の第1回目です。</p>
<ul>
<li><strong>(1) 認可エンドポイント編</strong></li>
<li>(2) トークンエンドポイント編</li>
<li>(3) JWT編</li>
<li>(4) stateとnonce編</li>
</ul>
<h2 id="1-OAuth-20-と-OpenID-Connect">1 OAuth 2.0 と OpenID Connect</h2>
<p>よくある説明ですが、OAuth は<strong>認可</strong>のための仕様で、OpenID Connect は<strong>認証</strong>のための仕様です。OpenID Connect は OAuth 2.0 の仕様を元に作られています。</p>
<p>実装者側の具体的な説明をすると、連携相手のデータを操作するのが OAuth で、連携相手のユーザー ID とパスワードを使い自分のアプリケーションを操作するのが OpenID Connect です。OAuth ではアクセストークンを連携相手に送信し、OpenID Connect では OpenID Connect のサーバーから降ってきた ID トークンを使って自分のサーバーにデータを作ります。</p>
<ul>
<li>OAuth 2.0
<ul>
<li>認可のための仕様</li>
<li>アクセストークン</li>
<li>アクセストークンを使って連携先と通信</li>
</ul>
</li>
<li>OpenID Connect
<ul>
<li>認証のための仕様</li>
<li>OAuth 2.0 を元に作られた</li>
<li>ID トークン</li>
<li>ID トークンを使って自分のアプリケーションにユーザーを作る</li>
</ul>
</li>
</ul>
<p>OAuth は<strong>「連携相手の機能と連携できるため、自分のアプリケーションの機能が増えて便利になる」</strong>ことが多く、OpenID Connect は<strong>「連携相手の ID とパスワードで自分のアプリケーションにログインできるため管理が楽になる」</strong>ことが多くなるという認識です。</p>
<table>
<thead>
<tr>
<th> 仕様 </th>
<th> 操作 </th>
<th> トークン </th>
<th> 例 </th>
</tr>
</thead>
<tbody>
<tr>
<td> OAuth </td>
<td> 連携先 </td>
<td> アクセストークン </td>
<td> ブログシステムで Facebook への同時投稿 </td>
</tr>
<tr>
<td> OpenID Connect </td>
<td> 自分のアプリ </td>
<td> ID トークン </td>
<td> Facebook ログイン </td>
</tr>
</tbody>
</table>
<p>結局のところ、どちらの仕様も「安全にアクセストークン or/and ID トークンをアプリケーションに渡す」ことがゴールです。</p>
<h3 id="11-用語の整理">1.1 用語の整理</h3>
<table>
<thead>
<tr>
<th> 用語(OAuth での名称) </th>
<th> 説明 </th>
<th> 補足 </th>
</tr>
</thead>
<tbody>
<tr>
<td> ID Provider(認可サーバー) </td>
<td> OpenID Connect において ID トークンを発行するサーバー </td>
<td> IdP と略される </td>
</tr>
<tr>
<td> RelyingParty(クライアント) </td>
<td> ID Provider から ID トークンを受け取るアプリケーション </td>
<td> RP と略される </td>
</tr>
<tr>
<td> アクセストークン </td>
<td> OAuth の文脈で発行されるトークン </td>
<td> トークンの形式は定められていない </td>
</tr>
<tr>
<td> ID トークン </td>
<td> OpenID Connect の文脈で発行されるトークン </td>
<td> トークン自体に署名が必要。JWT が使われる </td>
</tr>
</tbody>
</table>
<h3 id="12-連携の流れ-認可コードフロー">1.2 連携の流れ (認可コードフロー)</h3>
<p>OAuth と OpenID Connect にはトークンを取得するためにいくつかのフローがあります。ここでは<strong>認可コードフロー</strong>の流れの図を示します。</p>
<p><figure class="figure-image figure-image-fotolife" title="認可コードフローの流れ(1)"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240305/20240305150010.png" width="800" height="703" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>認可フローの流れ(1)</figcaption></figure></p>
<p><figure class="figure-image figure-image-fotolife" title="認可コードフローの流れ(2)"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240305/20240305150014.png" width="800" height="711" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>認可コードフローの流れ(2)</figcaption></figure></p>
<p>認可コードフローはざっくり言うと</p>
<ul>
<li>RelyingParty が認可コードを取得する</li>
<li>RelyingParty は認可コードを使ってアクセストークン/ID トークンを取得する</li>
</ul>
<p>の流れでトークンを取得するフローです。</p>
<h2 id="2-tiny-idpことはじめ">2 tiny-idpことはじめ</h2>
<p>さて、それではこれから ID Provider をフルスクラッチで実装していきます。この ID Provider は tiny-idp という名前にします。</p>
<h3 id="21-注意事項">2.1 注意事項</h3>
<p>前提として、これから実装する tiny-idp は筆者が OpenID Connect の理解に重要だと思った仕様をメインに実装していっています。</p>
<ul>
<li>認可コードフローのみ実装</li>
<li>クライアント認証は client_secret_post のみ実装</li>
<li>prompt や display などの細かい仕様は実装しない</li>
<li>リフレッシュトークン、ログアウトも実装しない</li>
<li>client_id は tiny-client で固定</li>
<li>HTTP 通信部分は標準ライブラリの http パッケージを利用
<ul>
<li>express とか使ってもよかったけど縛りプレイしたかったため</li>
</ul>
</li>
<li>データはインメモリに保存して RDB などは使わない</li>
</ul>
<p>などの制限をし、OpenID Connect の全仕様を網羅した実装はしていません。仕様で MUST になっている部分はなるべく実装します。</p>
<h3 id="22-作るもの">2.2 作るもの</h3>
<p>次のものを作ります。</p>
<ul>
<li>ID Provider の Web アプリケーションサーバー (tiny-idp)</li>
<li>署名ロジックを含んだ JWT の生成クラス</li>
<li>JWT を検証するコード</li>
<li>検証用の RelyingParty (tiny-rp)</li>
</ul>
<p>ID Provider の Web アプリケーション的な部分だけではなく、JWT の生成部分も標準ライブラリを使って作ります。また、検証用に RelyingParty も作っていきます(こっちは少しライブラリを使います)。</p>
<h3 id="23-実装するエンドポイント">2.3 実装するエンドポイント</h3>
<p>tiny-idp では次のエンドポイントを実装します。</p>
<ul>
<li>POST /login
<ul>
<li>ログインページから呼ばれる</li>
<li>成功したら RelyingParty の <code>redirect_uri</code> にリダイレクトする</li>
</ul>
</li>
<li>GET /openid-connect/auth
<ul>
<li>認可エンドポイント</li>
<li>ログインページを表示する</li>
</ul>
</li>
<li>POST /openid-connect/token
<ul>
<li>トークンエンドポイント</li>
<li>アクセストークンと ID トークンを生成する</li>
<li><code>redirect_uri</code>のエンドポイントからアクセスされる</li>
</ul>
</li>
<li>POST /openid-connect/introspect
<ul>
<li>アクセストークンの有効性を検証するためのエンドポイント</li>
</ul>
</li>
<li>GET /openid-connect/jwks.json
<ul>
<li>JWT を署名した秘密鍵に対応した公開鍵を公開するためのエンドポイント</li>
</ul>
</li>
<li>GET /openid-connect/.well-known/openid-configuration
<ul>
<li>各種設定値や接続先などを公開するためのエンドポイント</li>
</ul>
</li>
</ul>
<h2 id="3-tiny-rp-を作る">3 tiny-rp を作る</h2>
<p>まず最初に、発行されたトークンを受け取るサーバーを立てておくと動作確認に便利なので RelyingParty も簡単に作っておきます。ザッと作って後でちょくちょく修正します。</p>
<p>この RelyingParty が一般的に OpenID Connect を利用する場合に実装する部分となります。</p>
<h3 id="31-TypeScript-プロジェクトを始める">3.1 TypeScript プロジェクトを始める</h3>
<p><a href="https://typescript-jp.gitbook.io/deep-dive/nodejs">https://typescript-jp.gitbook.io/deep-dive/nodejs</a></p>
<p>上記を参考に TypeScript で新規プロジェクトを作ります。</p>
<pre class="code bash" data-lang="bash" data-unlink>$ npm install express openid-client</pre>
<p>標準ライブラリ縛りは tiny-idp だけなのでここでは express と openid-client を使っています。かなり薄くしか使っていないので実装の概要を理解する妨げにはならないと思います。</p>
<h3 id="32-RelyingParty-本体の実装">3.2 RelyingParty 本体の実装</h3>
<p>src/index.ts に RelyingParty の本体を実装します。</p>
<p>詳しい説明は省きますが、次のことをしています。</p>
<ul>
<li>ログインボタンを押すと IdP のログインページが開く</li>
<li>IdP からのリダイレクトを受け取ってトークン取得リクエストを IdP に送る</li>
<li>取得したトークンを検証する</li>
</ul>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">import</span> express <span class="synStatement">from</span> <span class="synConstant">"express"</span><span class="synStatement">;</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> Issuer <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">"openid-client"</span><span class="synStatement">;</span>
<span class="synType">const</span> app <span class="synStatement">=</span> express<span class="synStatement">();</span>
<span class="synType">const</span> port <span class="synStatement">=</span> <span class="synConstant">4000</span><span class="synStatement">;</span>
<span class="synType">const</span> issuer <span class="synStatement">=</span> <span class="synStatement">new</span> Issuer<span class="synStatement">(</span><span class="synIdentifier">{</span>
issuer: <span class="synConstant">"http://localhost:3000"</span><span class="synStatement">,</span>
authorization_endpoint: <span class="synConstant">"http://localhost:3000/openid-connect/auth"</span><span class="synStatement">,</span>
token_endpoint: <span class="synConstant">"http://localhost:3000/openid-connect/token"</span><span class="synStatement">,</span>
jwks_uri: <span class="synConstant">"http://localhost:3000/openid-connect/jwks"</span><span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synType">const</span> <span class="synIdentifier">{</span> Client <span class="synIdentifier">}</span> <span class="synStatement">=</span> issuer<span class="synStatement">;</span>
<span class="synType">const</span> client <span class="synStatement">=</span> <span class="synStatement">new</span> Client<span class="synStatement">(</span><span class="synIdentifier">{</span>
client_id: <span class="synConstant">"tiny-client"</span><span class="synStatement">,</span>
client_secret: <span class="synConstant">"hoge"</span><span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
app.<span class="synStatement">get(</span><span class="synConstant">"/"</span><span class="synStatement">,</span> <span class="synStatement">async</span> <span class="synStatement">(</span>req<span class="synStatement">,</span> res<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synType">const</span> authorizationUri <span class="synStatement">=</span> client.authorizationUrl<span class="synStatement">(</span><span class="synIdentifier">{</span>
redirect_uri: <span class="synConstant">"http://localhost:4000/oidc/callback"</span><span class="synStatement">,</span>
scope: <span class="synConstant">"openid"</span><span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
res.send<span class="synStatement">(</span><span class="synConstant">`<!DOCTYPE html></span>
<span class="synConstant"><html></span>
<span class="synConstant"><head></span>
<span class="synConstant"> <title>tiny-rp</title></span>
<span class="synConstant"></head></span>
<span class="synConstant"><body></span>
<span class="synConstant"> <div><h1>tiny-idp Login</h1></div></span>
<span class="synConstant"> <div><a href="</span><span class="synSpecial">${</span>authorizationUri<span class="synSpecial">}</span><span class="synConstant">">Login</a></div></span>
<span class="synConstant"></body></span>
<span class="synConstant"></html>`</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synComment">// redirect_uriをここに実装</span>
<span class="synComment">// トークンエンドポイントを叩く</span>
app.<span class="synStatement">get(</span><span class="synConstant">"/oidc/callback"</span><span class="synStatement">,</span> <span class="synStatement">async</span> <span class="synStatement">(</span>req<span class="synStatement">,</span> res<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synType">const</span> redirect_uri <span class="synStatement">=</span> <span class="synConstant">"http://localhost:4000/oidc/callback"</span><span class="synStatement">;</span>
<span class="synType">const</span> code <span class="synStatement">=</span> <span class="synSpecial">String</span><span class="synStatement">(</span>req.query.code<span class="synStatement">);</span>
<span class="synType">const</span> scope <span class="synStatement">=</span> <span class="synSpecial">String</span><span class="synStatement">(</span>req.query.scope<span class="synStatement">);</span>
<span class="synSpecial">try</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> tokenResponse <span class="synStatement">=</span> <span class="synStatement">await</span> <span class="synSpecial">fetch</span><span class="synStatement">(</span>
<span class="synConstant">"http://localhost:3000/openid-connect/token"</span><span class="synStatement">,</span>
<span class="synIdentifier">{</span>
method: <span class="synConstant">"POST"</span><span class="synStatement">,</span>
headers: <span class="synIdentifier">{</span>
<span class="synConstant">"Content-Type"</span>: <span class="synConstant">"application/x-www-form-urlencoded"</span><span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">,</span>
body: <span class="synStatement">new</span> URLSearchParams<span class="synStatement">(</span><span class="synIdentifier">{</span>
code<span class="synStatement">,</span>
redirect_uri<span class="synStatement">,</span>
scope<span class="synStatement">,</span>
grant_type: <span class="synConstant">"authorization_code"</span><span class="synStatement">,</span>
client_id: <span class="synConstant">"tiny-client"</span><span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">),</span>
<span class="synIdentifier">}</span>
<span class="synStatement">);</span>
<span class="synType">const</span> tokenSet <span class="synStatement">=</span> <span class="synStatement">await</span> tokenResponse.json<span class="synStatement">();</span>
<span class="synSpecial">console</span>.log<span class="synStatement">(</span>tokenSet<span class="synStatement">);</span>
<span class="synComment">// </span><span class="synTodo">TODO</span><span class="synComment">: トークンを検証するコードは後で追加します</span>
res.<span class="synStatement">status(</span><span class="synConstant">200</span><span class="synStatement">);</span>
res.json<span class="synStatement">(</span><span class="synIdentifier">{</span> tokenSet <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synStatement">return;</span>
<span class="synIdentifier">}</span> <span class="synSpecial">catch</span> <span class="synStatement">(</span>error<span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synSpecial">console</span>.error<span class="synStatement">(</span><span class="synConstant">"Access Token Error: "</span><span class="synStatement">,</span> error<span class="synStatement">);</span>
res.<span class="synStatement">status(</span><span class="synConstant">500</span><span class="synStatement">);</span>
res.json<span class="synStatement">(</span><span class="synIdentifier">{</span> error: <span class="synConstant">"Access Token Error"</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synStatement">return;</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
app.listen<span class="synStatement">(</span>port<span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synSpecial">console</span>.log<span class="synStatement">(</span><span class="synConstant">`Example app listening on port </span><span class="synSpecial">${</span>port<span class="synSpecial">}</span><span class="synConstant">`</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
</pre>
<p>後でトークンの検証を自前実装する予定なので、トークン取得部分は取り回しやすいよう openid-client ではなく fetch を使っています。</p>
<p>関係ないですが、RelyingParty をちゃんと実装したサンプルってネット上を探しても少ないですよね。</p>
<p>localhost:4000 にアクセスすると次の画面が表示されます。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240305/20240305150018.png" width="800" height="577" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h2 id="4-tiny-idp-を作る">4 tiny-idp を作る</h2>
<p>では、手を動かしながら tiny-idp を作っていきます。</p>
<h3 id="41-TypeScript-プロジェクトを始める">4.1 TypeScript プロジェクトを始める</h3>
<p><a href="https://typescript-jp.gitbook.io/deep-dive/nodejs">https://typescript-jp.gitbook.io/deep-dive/nodejs</a></p>
<p>tiny-rp と同様に上記を参考に TypeScript で新規プロジェクトを作ります。</p>
<h3 id="42-Web-アプリケーションサーバーを作る">4.2 Web アプリケーションサーバーを作る</h3>
<p>まずは Web アプリケーションの雛形を作ります。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/index.ts</span>
<span class="synStatement">import</span> http<span class="synStatement">,</span> <span class="synIdentifier">{</span> IncomingMessage<span class="synStatement">,</span> ServerResponse <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">"http"</span><span class="synStatement">;</span>
<span class="synType">const</span> server <span class="synStatement">=</span> http.createServer<span class="synStatement">(</span>
<span class="synStatement">(</span>req: IncomingMessage<span class="synStatement">,</span> res: ServerResponse<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synSpecial">console</span>.log<span class="synStatement">(</span><span class="synConstant">`[</span><span class="synSpecial">${</span><span class="synStatement">new</span> <span class="synSpecial">Date</span>()<span class="synSpecial">}</span><span class="synConstant">] </span><span class="synSpecial">${</span>req.url<span class="synSpecial">}</span><span class="synConstant">`</span><span class="synStatement">);</span>
<span class="synStatement">if</span> <span class="synStatement">(</span>req.url <span class="synStatement">===</span> <span class="synConstant">"/"</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">200</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synConstant">"Content-Type"</span>: <span class="synConstant">"text/plain"</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
res.end<span class="synStatement">(</span><span class="synConstant">"Hello tiny openid provider!"</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">404</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synConstant">"Content-Type"</span>: <span class="synConstant">"text/plain"</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
res.end<span class="synStatement">(</span><span class="synConstant">"Page not found"</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span>
<span class="synStatement">);</span>
server.listen<span class="synStatement">(</span><span class="synConstant">3000</span><span class="synStatement">);</span>
</pre>
<p><code>npm run build && node lib/index.js</code> して立ち上げると localhost:3000 にアクセスできるようになりました。</p>
<pre class="code bash" data-lang="bash" data-unlink>~/src/asmsuechan/tiny-idp > curl localhost:3000
Hello tiny openid provider!</pre>
<h2 id="5-ユーザーとユーザーログインを作るPOST-login">5 ユーザーとユーザーログインを作る(POST /login)</h2>
<p>ID Provider に簡単にユーザー管理を実装します。</p>
<p>ユーザー管理は OIDC の本筋ではないので次のように機能を絞ります。</p>
<ul>
<li>データはインメモリで扱う (MySQL などは使わない)</li>
<li>ユーザーの ID/Password は固定値とする</li>
<li>パスワードも平文で文字列比較するのみ</li>
<li>セッションの保持はしない</li>
</ul>
<p>簡単なユーザー機能を実装するため、User クラスを作ります。User クラスはプロパティとして id, email, password, clientId を持ちます。password は上述の通り平文です。</p>
<table>
<thead>
<tr>
<th> プロパティ名 </th>
<th> 型 </th>
<th> 補足 </th>
</tr>
</thead>
<tbody>
<tr>
<td> id </td>
<td> number </td>
<td> 一意の数字 </td>
</tr>
<tr>
<td> email </td>
<td> string </td>
<td> 一意のメールアドレス。ログイン ID として機能する </td>
</tr>
<tr>
<td> password </td>
<td> string </td>
<td> パスワード。ここでは平文 </td>
</tr>
<tr>
<td> cleintId </td>
<td> string </td>
<td> どの client に作られたユーザーなのかを表す </td>
</tr>
</tbody>
</table>
<p>では、<code>src/models/user.ts</code>を作り次のように記述します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/models/user.ts</span>
<span class="synStatement">export</span> <span class="synStatement">class</span> User <span class="synIdentifier">{</span>
id: <span class="synType">number</span><span class="synStatement">;</span>
email: <span class="synType">string</span><span class="synStatement">;</span>
password: <span class="synType">string</span><span class="synStatement">;</span>
clientId: <span class="synType">string</span><span class="synStatement">;</span>
<span class="synStatement">constructor(</span>id: <span class="synType">number</span><span class="synStatement">,</span> email: <span class="synType">string</span><span class="synStatement">,</span> password: <span class="synType">string</span><span class="synStatement">,</span> clientId: <span class="synType">string</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synIdentifier">this</span>.id <span class="synStatement">=</span> id<span class="synStatement">;</span>
<span class="synIdentifier">this</span>.email <span class="synStatement">=</span> email<span class="synStatement">;</span>
<span class="synIdentifier">this</span>.password <span class="synStatement">=</span> password<span class="synStatement">;</span>
<span class="synIdentifier">this</span>.clientId <span class="synStatement">=</span> clientId<span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synStatement">static</span> findByEmail<span class="synStatement">(</span>db: User<span class="synIdentifier">[]</span><span class="synStatement">,</span> email: <span class="synType">string</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> result <span class="synStatement">=</span> db.find<span class="synStatement">((</span>u<span class="synStatement">)</span> <span class="synStatement">=></span> u.email <span class="synStatement">===</span> email<span class="synStatement">);</span>
<span class="synStatement">if</span> <span class="synStatement">(</span>result<span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synStatement">new</span> User<span class="synStatement">(</span>result?.id<span class="synStatement">,</span> result?.email<span class="synStatement">,</span> result?.password<span class="synStatement">,</span> result?.clientId<span class="synStatement">);</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span>
<span class="synSpecial">throw</span> <span class="synSpecial">Error</span><span class="synStatement">(</span><span class="synConstant">"User Not Found"</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synStatement">static</span> login<span class="synStatement">(</span>db: User<span class="synIdentifier">[]</span><span class="synStatement">,</span> email: <span class="synType">string</span><span class="synStatement">,</span> password: <span class="synType">string</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> user <span class="synStatement">=</span> db.find<span class="synStatement">((</span>u<span class="synStatement">)</span> <span class="synStatement">=></span> u.email <span class="synStatement">===</span> email <span class="synConstant">&&</span> u.password <span class="synStatement">===</span> password<span class="synStatement">);</span>
<span class="synStatement">if</span> <span class="synStatement">(</span>user<span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synConstant">true</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synConstant">false</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span>
</pre>
<p>login()メソッドはパスワードを平文で比較してユーザーの認証を確認する関数です。</p>
<p>コントローラーでデータを使うための取りまとめとして Context 型を作って使います。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/models/context.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> User <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">"./user"</span><span class="synStatement">;</span>
<span class="synStatement">export</span> <span class="synStatement">type</span> Context <span class="synStatement">=</span> <span class="synIdentifier">{</span>
users: User<span class="synIdentifier">[]</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>次に login_controller.ts を作ります。なお構成は MVC にしています。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/login_controller.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> ServerResponse <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'http'</span><span class="synStatement">;</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> ParsedUrlQuery <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'querystring'</span><span class="synStatement">;</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> User <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'../models/user'</span><span class="synStatement">;</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> Context <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'../models/context'</span><span class="synStatement">;</span>
<span class="synStatement">export</span> <span class="synType">const</span> login <span class="synStatement">=</span> <span class="synStatement">(</span>db: Context<span class="synStatement">,</span> query: ParsedUrlQuery<span class="synStatement">,</span> params: URLSearchParams<span class="synStatement">,</span> res: ServerResponse<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synType">const</span> email <span class="synStatement">=</span> params.<span class="synStatement">get(</span><span class="synConstant">'email'</span><span class="synStatement">);</span>
<span class="synType">const</span> password <span class="synStatement">=</span> params.<span class="synStatement">get(</span><span class="synConstant">'password'</span><span class="synStatement">);</span>
<span class="synSpecial">console</span>.log<span class="synStatement">(</span>email<span class="synStatement">,</span> password<span class="synStatement">,</span> db.users<span class="synStatement">);</span>
<span class="synStatement">if</span> <span class="synStatement">(</span>email <span class="synConstant">&&</span> password <span class="synConstant">&&</span> User.login<span class="synStatement">(</span>db.users<span class="synStatement">,</span> email<span class="synStatement">,</span> password<span class="synStatement">))</span> <span class="synIdentifier">{</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">200</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synConstant">'Content-Type'</span>: <span class="synConstant">'application/json'</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
res.end<span class="synStatement">(</span><span class="synSpecial">JSON</span>.stringify<span class="synStatement">(</span><span class="synIdentifier">{</span> user: User.findByEmail<span class="synStatement">(</span>db.users<span class="synStatement">,</span> email<span class="synStatement">)</span> <span class="synIdentifier">}</span><span class="synStatement">));</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">403</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synConstant">'Content-Type'</span>: <span class="synConstant">'application/json'</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
res.end<span class="synStatement">(</span><span class="synSpecial">JSON</span>.stringify<span class="synStatement">(</span><span class="synIdentifier">{</span> error: <span class="synConstant">'Unauthorized'</span> <span class="synIdentifier">}</span><span class="synStatement">));</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>そして最後に <code>src/index.ts</code> に POST /login のエンドポイントを追加します。if-else の中に以下を追記します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/index.ts</span>
<span class="synStatement">import</span> url <span class="synStatement">from</span> <span class="synConstant">'url'</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> login <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'./controllers/login_controller'</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synComment">// NOTE: インメモリDBを初期化する</span>
<span class="synType">const</span> users: User<span class="synIdentifier">[]</span> <span class="synStatement">=</span> <span class="synIdentifier">[{</span> id: <span class="synConstant">1</span><span class="synStatement">,</span> email: <span class="synConstant">'tiny-idp@asmsuechan.com'</span><span class="synStatement">,</span> password: <span class="synConstant">'p@ssw0rd'</span><span class="synStatement">,</span> clientId: <span class="synConstant">'tiny-client'</span> <span class="synIdentifier">}]</span><span class="synStatement">;</span>
<span class="synType">const</span> db <span class="synStatement">=</span> <span class="synIdentifier">{</span> users <span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synStatement">if</span> <span class="synStatement">(</span>req.url?.split<span class="synStatement">(</span><span class="synConstant">'?'</span><span class="synStatement">)</span><span class="synIdentifier">[</span><span class="synConstant">0</span><span class="synIdentifier">]</span> <span class="synStatement">===</span> <span class="synConstant">'/login'</span> <span class="synConstant">&&</span> req.method <span class="synStatement">===</span> <span class="synConstant">'POST'</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> query <span class="synStatement">=</span> url.parse<span class="synStatement">(</span>req.url<span class="synStatement">,</span> <span class="synConstant">true</span><span class="synStatement">)</span>.query<span class="synStatement">;</span>
<span class="synType">let</span> body <span class="synStatement">=</span> <span class="synConstant">''</span><span class="synStatement">;</span>
req.on<span class="synStatement">(</span><span class="synConstant">'data'</span><span class="synStatement">,</span> <span class="synStatement">(</span>chunk<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
body <span class="synStatement">+=</span> chunk<span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">)</span>
req.on<span class="synStatement">(</span><span class="synConstant">'end'</span><span class="synStatement">,</span> <span class="synStatement">()</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synType">const</span> params <span class="synStatement">=</span> <span class="synStatement">new</span> URLSearchParams<span class="synStatement">(</span>body<span class="synStatement">);</span>
login<span class="synStatement">(</span>db<span class="synStatement">,</span> query<span class="synStatement">,</span> params<span class="synStatement">,</span> res<span class="synStatement">);</span>
<span class="synIdentifier">}</span><span class="synStatement">)</span>
</pre>
<p><code>npm run build && node lib/index.js</code> で起動して、curl で動作確認をしてみます。</p>
<pre class="code bash" data-lang="bash" data-unlink>~/src/asmsuechan/tiny-idp > curl localhost:3000/login -X POST -d 'email=tiny-idp@asmsuechan.com' -d 'password=p@ssw0rd'
{"user":{"id": 1, "email":"tiny-idp@asmsuechan.com","password":"p@ssw0rd","clientId":"tiny-client"}}
~/src/asmsuechan/tiny-idp > curl localhost:3000/login -X POST -d 'email=a' -d 'password=a'
{"error":"Unauthorized"}</pre>
<p>ID とパスワードが正しい場合はユーザーの情報が返ってきて、間違っているときはエラーが返ってきました。</p>
<h2 id="6-認可エンドポイントの実装GET-openid-connectauth">6 認可エンドポイントの実装(GET /openid-connect/auth)</h2>
<p>認可エンドポイントとは、認可コードを生成して RelyingParty に送るためのエンドポイントです。</p>
<h3 id="61-リクエストとレスポンス">6.1 リクエストとレスポンス</h3>
<p>認可エンドポイントでは次のパラメーターを受け取ります(<strong>state や nonce は後で実装します</strong>)。</p>
<table>
<thead>
<tr>
<th> パラメーター </th>
<th> 説明 </th>
<th> 例 </th>
</tr>
</thead>
<tbody>
<tr>
<td> client_id </td>
<td> </td>
<td> tiny-client </td>
</tr>
<tr>
<td> scope </td>
<td> </td>
<td> openid </td>
</tr>
<tr>
<td> response_type </td>
<td> </td>
<td> code </td>
</tr>
<tr>
<td> redirect_uri </td>
<td> </td>
<td> <a href="http://localhost:4000/oidc/callback">http://localhost:4000/oidc/callback</a> </td>
</tr>
</tbody>
</table>
<p>リクエストの Content-Type は <code>application/x-www-form-urlencoded</code> です。</p>
<p>そしてそのレスポンスとして、ログインページが表示されます。</p>
<ul>
<li><a href="https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.3.1.2.5">https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.3.1.2.5</a></li>
</ul>
<p>なお、tiny-idp ではセッションを保持しないため、認可エンドポイントを叩くと毎回ログインページが表示されます。</p>
<h3 id="62-ログインページと認可エンドポイントを作る">6.2 ログインページと認可エンドポイントを作る</h3>
<p>認可エンドポイントのレスポンスとして、ログインのページを返します。ですのでログインページを作っていきます。</p>
<p>client_id や redirect_uri を渡すため、独自に簡単なテンプレートエンジンを実装しています。</p>
<p>この辺りで express などのライブラリを非常に使いたくなったのですが、標準ライブラリ縛りは崩したくなかったのでこのまま進めていきます。お付き合いください。</p>
<p>まずはログインページを作ります。次のような form が 1 つあるシンプルなページです。</p>
<pre class="code lang-html" data-lang="html" data-unlink><span class="synComment"><!-- src/views/login.html --></span>
<span class="synComment"><!DOCTYPE html></span>
<span class="synIdentifier"><</span><span class="synStatement">html</span><span class="synIdentifier">></span>
<span class="synIdentifier"><</span><span class="synStatement">head</span><span class="synIdentifier">></span>
<span class="synPreProc"> </span><span class="synIdentifier"><</span><span class="synStatement">title</span><span class="synIdentifier">></span>tiny-idp Login<span class="synIdentifier"></</span><span class="synStatement">title</span><span class="synIdentifier">></span>
<span class="synPreProc"> </span><span class="synIdentifier"></</span><span class="synStatement">head</span><span class="synIdentifier">></span>
<span class="synIdentifier"><</span><span class="synStatement">body</span><span class="synIdentifier">></span>
<span class="synIdentifier"><</span><span class="synStatement">div</span><span class="synIdentifier">></span>
<span class="synIdentifier"><</span><span class="synStatement">h1</span><span class="synIdentifier">></span>tiny-idp Login<span class="synIdentifier"></</span><span class="synStatement">h1</span><span class="synIdentifier">></span>
<span class="synIdentifier"></</span><span class="synStatement">div</span><span class="synIdentifier">></span>
<span class="synIdentifier"><</span><span class="synStatement">form</span>
<span class="synIdentifier"> </span><span class="synType">action</span><span class="synIdentifier">=</span><span class="synConstant">"http://localhost:3000/login?client_id={client_id}&redirect_uri={redirect_uri}&scope={scope}"</span>
<span class="synIdentifier"> </span><span class="synType">method</span><span class="synIdentifier">=</span><span class="synConstant">"post"</span>
<span class="synIdentifier"> ></span>
<span class="synIdentifier"><</span><span class="synStatement">div</span><span class="synIdentifier">></span>
<span class="synIdentifier"><</span><span class="synStatement">label</span><span class="synIdentifier"> </span><span class="synType">for</span><span class="synIdentifier">=</span><span class="synConstant">"email"</span><span class="synIdentifier">></span>Email<span class="synIdentifier"></</span><span class="synStatement">label</span><span class="synIdentifier">></span>
<span class="synIdentifier"><</span><span class="synStatement">input</span><span class="synIdentifier"> email=</span><span class="synConstant">"email"</span><span class="synIdentifier"> </span><span class="synType">type</span><span class="synIdentifier">=</span><span class="synConstant">"text"</span><span class="synIdentifier"> </span><span class="synType">name</span><span class="synIdentifier">=</span><span class="synConstant">"email"</span><span class="synIdentifier"> /></span>
<span class="synIdentifier"></</span><span class="synStatement">div</span><span class="synIdentifier">></span>
<span class="synIdentifier"><</span><span class="synStatement">div</span><span class="synIdentifier">></span>
<span class="synIdentifier"><</span><span class="synStatement">label</span><span class="synIdentifier"> </span><span class="synType">for</span><span class="synIdentifier">=</span><span class="synConstant">"password"</span><span class="synIdentifier">></span>Password<span class="synIdentifier"></</span><span class="synStatement">label</span><span class="synIdentifier">></span>
<span class="synIdentifier"><</span><span class="synStatement">input</span><span class="synIdentifier"> </span><span class="synType">id</span><span class="synIdentifier">=</span><span class="synConstant">"password"</span><span class="synIdentifier"> </span><span class="synType">type</span><span class="synIdentifier">=</span><span class="synConstant">"password"</span><span class="synIdentifier"> </span><span class="synType">name</span><span class="synIdentifier">=</span><span class="synConstant">"password"</span><span class="synIdentifier"> /></span>
<span class="synIdentifier"></</span><span class="synStatement">div</span><span class="synIdentifier">></span>
<span class="synIdentifier"><</span><span class="synStatement">div</span><span class="synIdentifier">></span>
<span class="synIdentifier"><</span><span class="synStatement">input</span><span class="synIdentifier"> </span><span class="synType">name</span><span class="synIdentifier">=</span><span class="synConstant">"login"</span><span class="synIdentifier"> </span><span class="synType">type</span><span class="synIdentifier">=</span><span class="synConstant">"submit"</span><span class="synIdentifier"> /></span>
<span class="synIdentifier"></</span><span class="synStatement">div</span><span class="synIdentifier">></span>
<span class="synIdentifier"></</span><span class="synStatement">form</span><span class="synIdentifier">></span>
<span class="synIdentifier"></</span><span class="synStatement">body</span><span class="synIdentifier">></span>
<span class="synIdentifier"></</span><span class="synStatement">html</span><span class="synIdentifier">></span>
</pre>
<p>次に、この view を表示するための controller を書きます。上述のパラメーターを受け取るようにします。</p>
<p>このエンドポイントはログインページを返すので、login.html を作ります。簡単にテンプレートエンジンを実装しており、正規表現でログインに使うエンドポイントのパラメーターを書き換えています。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/auth_controller.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> ParsedUrlQuery <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'querystring'</span><span class="synStatement">;</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> Context <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'../models/context'</span><span class="synStatement">;</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> ServerResponse <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'http'</span><span class="synStatement">;</span>
<span class="synStatement">import</span> fs <span class="synStatement">from</span> <span class="synConstant">'fs'</span><span class="synStatement">;</span>
<span class="synStatement">export</span> <span class="synType">const</span> getAuth <span class="synStatement">=</span> <span class="synStatement">(</span>db: Context<span class="synStatement">,</span> query: ParsedUrlQuery<span class="synStatement">,</span> res: ServerResponse<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synSpecial">try</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> scope <span class="synStatement">=</span> query.scope<span class="synStatement">;</span>
<span class="synType">const</span> clientId <span class="synStatement">=</span> query.client_id<span class="synStatement">;</span>
<span class="synType">const</span> redirectUri <span class="synStatement">=</span> query.redirect_uri<span class="synStatement">;</span>
<span class="synComment">// </span><span class="synTodo">TODO</span><span class="synComment">: バリデーションの追加</span>
<span class="synType">const</span> loginPage <span class="synStatement">=</span> fs.readFileSync<span class="synStatement">(</span><span class="synConstant">'src/views/login.html'</span><span class="synStatement">,</span> <span class="synConstant">'utf8'</span><span class="synStatement">);</span>
<span class="synComment">// NOTE: 簡易テンプレートエンジン</span>
<span class="synType">let</span> template <span class="synStatement">=</span> loginPage<span class="synStatement">;</span>
template <span class="synStatement">=</span> template.replace<span class="synStatement">(</span><span class="synConstant">/{client_id}/g</span><span class="synStatement">,</span> <span class="synSpecial">String</span><span class="synStatement">(</span>clientId<span class="synStatement">));</span>
template <span class="synStatement">=</span> template.replace<span class="synStatement">(</span><span class="synConstant">/{redirect_uri}/g</span><span class="synStatement">,</span> <span class="synSpecial">String</span><span class="synStatement">(</span>redirectUri<span class="synStatement">));</span>
template <span class="synStatement">=</span> template.replace<span class="synStatement">(</span><span class="synConstant">/{scope}/g</span><span class="synStatement">,</span> <span class="synSpecial">String</span><span class="synStatement">(</span>scope<span class="synStatement">));</span>
res.end<span class="synStatement">(</span>template<span class="synStatement">);</span>
<span class="synIdentifier">}</span> <span class="synSpecial">catch</span> <span class="synStatement">(</span>e<span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synComment">// NOTE: エラー時はserver_errorを返すという仕様も決まっている</span>
<span class="synComment">// https://openid-foundation-japan.github.io/rfc6749.ja.html#code-authz-resp</span>
<span class="synSpecial">console</span>.error<span class="synStatement">(</span>e<span class="synStatement">);</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">500</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synConstant">'Content-Type'</span>: <span class="synConstant">'application/x-www-form-urlencoded'</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synType">const</span> responseData <span class="synStatement">=</span> <span class="synIdentifier">{</span> error: <span class="synConstant">'server_error'</span> <span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synType">const</span> response <span class="synStatement">=</span> <span class="synStatement">new</span> URLSearchParams<span class="synStatement">(</span>responseData<span class="synStatement">)</span>.toString<span class="synStatement">();</span>
res.end<span class="synStatement">(</span>response<span class="synStatement">);</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>リクエストパラメーターの validation についても RFC で決まっているので、後で実装します。</p>
<p>さて、index.ts を編集して GET /openid-connect/auth にアクセスされたら上記のページを返すようにします。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/index.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> getAuth <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'./controllers/auth_controller'</span><span class="synStatement">;</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synStatement">if</span> <span class="synStatement">(</span>req.url?.split<span class="synStatement">(</span><span class="synConstant">'?'</span><span class="synStatement">)</span><span class="synIdentifier">[</span><span class="synConstant">0</span><span class="synIdentifier">]</span> <span class="synStatement">===</span> <span class="synConstant">'/openid-connect/auth'</span> <span class="synConstant">&&</span> <span class="synStatement">(</span>req.method <span class="synStatement">===</span> <span class="synConstant">'GET'</span> <span class="synConstant">||</span> req.method <span class="synStatement">===</span> <span class="synConstant">'POST'</span><span class="synStatement">))</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> query <span class="synStatement">=</span> url.parse<span class="synStatement">(</span>req.url<span class="synStatement">,</span> <span class="synConstant">true</span><span class="synStatement">)</span>.query<span class="synStatement">;</span>
getAuth<span class="synStatement">(</span>db<span class="synStatement">,</span> query<span class="synStatement">,</span> res<span class="synStatement">);</span>
</pre>
<p><a href="http://localhost:3000/openid-connect/auth?client_id=tiny-client&redirect_uri=http://localhost:4000/oidc/callback">http://localhost:3000/openid-connect/auth?client_id=tiny-client&redirect_uri=http://localhost:4000/oidc/callback</a> にアクセスしてみます。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240305/20240305150021.png" width="800" height="577" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>先ほどのユーザー(tiny-idp@asmsuechan.com, p@ssw0rd)でログインしてみます。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240305/20240305150025.png" width="800" height="577" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>ログインできました。なお、ログインに失敗すると次のように表示されます。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240305/20240305150029.png" width="800" height="577" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h3 id="63-パラメーターの検証">6.3 パラメーターの検証</h3>
<p>リクエスト時に付与したパラメーターをチェックして、適切なものかどうかを判別します。リクエストパラメーターの仕様は以下です。</p>
<table>
<thead>
<tr>
<th> パラメーター </th>
<th> ルール </th>
<th> 補足 </th>
</tr>
</thead>
<tbody>
<tr>
<td> client_id (必須) </td>
<td> tiny-client であること </td>
<td> client は tiny-client で固定するため </td>
</tr>
<tr>
<td> redirect_uri (必須) </td>
<td> <a href="http://localhost:4000/oidc/callback">http://localhost:4000/oidc/callback</a> </td>
<td> RelyingParty のエンドポイント </td>
</tr>
<tr>
<td> response_type (必須) </td>
<td> code であること </td>
<td> 認可コードフローのみサポートするため </td>
</tr>
<tr>
<td> scope </td>
<td> openid を含むこと </td>
<td> スコープの区切り文字はスペース </td>
</tr>
</tbody>
</table>
<p>この scope の値によって userinfo endopoint のレスポンスに含めることができる内容を制御できます。</p>
<blockquote><p>For OpenID Connect, scopes can be used to request that specific sets of information be made available as Claim Values.
via 5.4. Requesting Claims using Scope Values</p></blockquote>
<p>まず、リクエストの型を作ります。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/auth_controller.ts</span>
<span class="synComment">// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest</span>
<span class="synComment">// https://openid-foundation-japan.github.io/rfc6749.ja.html#code-authz-req</span>
<span class="synStatement">type</span> QueryParams <span class="synStatement">=</span> <span class="synIdentifier">{</span>
scope: <span class="synType">string</span> | <span class="synType">string</span><span class="synIdentifier">[]</span> | <span class="synType">undefined</span><span class="synStatement">;</span>
responseType: <span class="synType">string</span> | <span class="synType">string</span><span class="synIdentifier">[]</span> | <span class="synType">undefined</span><span class="synStatement">;</span>
clientId: <span class="synType">string</span> | <span class="synType">string</span><span class="synIdentifier">[]</span> | <span class="synType">undefined</span><span class="synStatement">;</span>
redirectUri: <span class="synType">string</span> | <span class="synType">string</span><span class="synIdentifier">[]</span> | <span class="synType">undefined</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>RFC にしたがってエラー文字列を AuthCodeError 型にします。全部並べると次のようになります。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/auth_controller.ts</span>
<span class="synComment">// https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.2.1</span>
<span class="synComment">// https://openid.net/specs/openid-connect-core-1_0.html#AuthCodeError</span>
<span class="synStatement">type</span> AuthCodeError <span class="synStatement">=</span>
| <span class="synConstant">"invalid_request"</span>
| <span class="synConstant">"unauthorized_client"</span>
| <span class="synConstant">"access_denied"</span>
| <span class="synConstant">"unsupported_response_type"</span>
| <span class="synConstant">"invalid_scope"</span>
| <span class="synConstant">"server_error"</span>
| <span class="synConstant">"temporarily_unavailable"</span>
| <span class="synConstant">"interaction_required"</span>
| <span class="synConstant">"login_required"</span>
| <span class="synConstant">"account_selection_required"</span>
| <span class="synConstant">"consent_required"</span>
| <span class="synConstant">"invalid_request_uri"</span>
| <span class="synConstant">"invalid_request_object"</span>
| <span class="synConstant">"request_not_supported"</span>
| <span class="synConstant">"request_uri_not_supported"</span>
| <span class="synConstant">"registration_not_supported"</span><span class="synStatement">;</span>
</pre>
<p>次に、バリデーションのレスポンスの型を作ります。エラーの通知先は、リソースオーナーかリダイレクト URI の 2 通りあり、バリデーションエラーの結果によってどちらにエラーを返すか変わります。</p>
<blockquote><p>リクエストが, リダイレクト URI の欠落 / 不正 / ミスマッチによって失敗した場合, もしくはクライアント識別子が不正な場合は, 認可サーバーはリソースオーナーにエラーを通知すべきである (SHOULD). 不正なリダイレクト URI に対してユーザーエージェントを自動的にリダイレクトさせてはならない (MUST NOT).<br/>
リソースオーナーがアクセス要求を拒否した場合, もしくはリダイレクト URI の欠落や不正以外でリクエストが失敗した場合は, 認可サーバーは application/x-www-form-urlencoded (Appendix B) フォーマットを用いてリダイレクト URI のクエリーコンポーネントに次のようなパラメーターを付与してクライアントに返却する.<br/>
<a href="https://openid-foundation-japan.github.io/rfc6749.ja.html#rfc.section.4.1.2.1">https://openid-foundation-japan.github.io/rfc6749.ja.html#rfc.section.4.1.2.1</a></p></blockquote>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/auth_controller.ts</span>
<span class="synStatement">type</span> ErrorTarget <span class="synStatement">=</span> <span class="synConstant">"resourceOwner"</span> | <span class="synConstant">"redirectUri"</span><span class="synStatement">;</span>
<span class="synStatement">type</span> ValidateError <span class="synStatement">=</span> <span class="synIdentifier">{</span>
authCodeError: AuthCodeError<span class="synStatement">;</span>
target: ErrorTarget<span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>さて、では validation 本体を作ります。ちょっと込み入っていますが RFC を読みながら 1 つ 1 つ作ります。</p>
<p>まずは <code>invalid_request</code> についての検証を作ります。仕様は以下です。</p>
<blockquote><p>invalid_request<br/>
リクエストに必須パラメーターが含まれていない, サポート外のパラメーターが付与されている, 同一のパラメーターが複数含まれる場合, その他不正な形式であった場合もこれに含まれる.</p></blockquote>
<p>「同一のパラメーターが複数含まれる場合」は <code>?client_id=hoge&client_id=huga</code> のパターンです。また、上記の必須パラメーターの存在も確認します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/auth_controller.ts</span>
<span class="synComment">// (略)</span>
<span class="synComment">// NOTE: エラーの返却先はリソースオーナーとredirect_uriの2種類ある</span>
<span class="synComment">// https://openid-foundation-japan.github.io/rfc6749.ja.html#code-authz-resp</span>
<span class="synComment">// https://openid.net/specs/openid-connect-core-1_0.html#AuthCodeError</span>
<span class="synType">const</span> validate <span class="synStatement">=</span> <span class="synStatement">(</span>query: QueryParams<span class="synStatement">)</span>: ValidateError | <span class="synType">null</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synType">const</span> validRedirectUris <span class="synStatement">=</span> <span class="synIdentifier">[</span><span class="synConstant">"http://localhost:4000/oidc/callback"</span><span class="synIdentifier">]</span><span class="synStatement">;</span>
<span class="synType">const</span> validClientIds <span class="synStatement">=</span> <span class="synIdentifier">[</span><span class="synConstant">"tiny-client"</span><span class="synIdentifier">]</span><span class="synStatement">;</span>
<span class="synType">const</span> redirectUri <span class="synStatement">=</span> query.redirectUri<span class="synStatement">;</span>
<span class="synType">const</span> clientId <span class="synStatement">=</span> query.clientId<span class="synStatement">;</span>
<span class="synStatement">if</span> <span class="synStatement">(</span>
<span class="synConstant">!</span>redirectUri <span class="synConstant">||</span>
<span class="synSpecial">Array</span>.isArray<span class="synStatement">(</span>redirectUri<span class="synStatement">)</span> <span class="synConstant">||</span>
<span class="synConstant">!</span>validRedirectUris.includes<span class="synStatement">(</span>redirectUri<span class="synStatement">)</span> <span class="synConstant">||</span>
<span class="synConstant">!</span>clientId <span class="synConstant">||</span>
<span class="synSpecial">Array</span>.isArray<span class="synStatement">(</span>clientId<span class="synStatement">)</span> <span class="synConstant">||</span>
<span class="synConstant">!</span>validClientIds.includes<span class="synStatement">(</span>clientId<span class="synStatement">)</span>
<span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synIdentifier">{</span> authCodeError: <span class="synConstant">"invalid_request"</span><span class="synStatement">,</span> target: <span class="synConstant">"resourceOwner"</span> <span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synComment">// </span><span class="synTodo">TODO</span><span class="synComment">: 他のバリデーションを追加していく</span>
<span class="synStatement">return</span> <span class="synType">null</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>次に、エラーをリダイレクト先に返すパターンを実装します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/auth_controller.ts</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synType">const</span> responseType <span class="synStatement">=</span> query.responseType<span class="synStatement">;</span>
<span class="synComment">// NOTE: scopeの区切り文字はスペース</span>
<span class="synType">const</span> scope <span class="synStatement">=</span> query.scope<span class="synStatement">;</span>
<span class="synComment">// NOTE: パラメーターがnullでない、かつ?client_id=a&client_id=bのように複数指定されていないことの確認</span>
<span class="synComment">// > リクエストに必須パラメーターが含まれていない, サポート外のパラメーターが付与されている, 同一のパラメーターが複数含まれる場合, その他不正な形式であった場合もこれに含まれる.</span>
<span class="synComment">// https://openid-foundation-japan.github.io/rfc6749.ja.html#code-authz-resp</span>
<span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>responseType <span class="synConstant">||</span> <span class="synConstant">!</span>scope <span class="synConstant">||</span> <span class="synSpecial">Array</span>.isArray<span class="synStatement">(</span>responseType<span class="synStatement">)</span> <span class="synConstant">||</span> <span class="synSpecial">Array</span>.isArray<span class="synStatement">(</span>scope<span class="synStatement">))</span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synIdentifier">{</span> authCodeError: <span class="synConstant">'invalid_request'</span><span class="synStatement">,</span> target: <span class="synConstant">'redirectUri'</span> <span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synComment">// ここまで追加</span>
<span class="synStatement">return</span> <span class="synType">null</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>最後に response_type と scope の値が tiny-idp で有効なものかを検証します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/auth_controller.ts</span>
<span class="synType">const</span> validResponseTypes <span class="synStatement">=</span> <span class="synIdentifier">[</span><span class="synConstant">'code'</span><span class="synIdentifier">]</span><span class="synStatement">;</span>
<span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>validResponseTypes.includes<span class="synStatement">(</span>responseType<span class="synStatement">))</span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synIdentifier">{</span> authCodeError: <span class="synConstant">'unsupported_response_type'</span><span class="synStatement">,</span> target: <span class="synConstant">'redirectUri'</span> <span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synType">const</span> validScopes <span class="synStatement">=</span> <span class="synIdentifier">[</span><span class="synConstant">'openid'</span><span class="synIdentifier">]</span><span class="synStatement">;</span>
<span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>validScopes.includes<span class="synStatement">(</span>scope<span class="synStatement">))</span> <span class="synIdentifier">{</span>
<span class="synStatement">return</span> <span class="synIdentifier">{</span> authCodeError: <span class="synConstant">'invalid_scope'</span><span class="synStatement">,</span> target: <span class="synConstant">'redirectUri'</span> <span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synComment">// ここまで追加</span>
<span class="synStatement">return</span> <span class="synType">null</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>では次にこのバリデーションを使ってリクエストの検証をするコードを controller に追加します。</p>
<p>まず、エラーレスポンス用の ErrorResponse 型を作ります。この型も RFC によって定められています。</p>
<p>なおエラーは <code>application/x-www-form-urlencoded</code> で返す仕様となっています。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/auth_controller.ts</span>
<span class="synComment">// https://openid.net/specs/openid-connect-core-1_0.html#AuthError</span>
<span class="synComment">// https://openid-foundation-japan.github.io/rfc6749.ja.html#code-authz-error</span>
<span class="synStatement">type</span> ErrorResponse <span class="synStatement">=</span> <span class="synIdentifier">{</span>
error: AuthCodeError<span class="synStatement">;</span>
error_description?: <span class="synType">string</span><span class="synStatement">;</span>
error_uri?: <span class="synType">string</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>さて、ここまで作った関数をまとめて実装を追加します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/auth_controller.ts</span>
<span class="synStatement">export</span> <span class="synType">const</span> getAuth <span class="synStatement">=</span> <span class="synStatement">(</span>db: Context<span class="synStatement">,</span> query: ParsedUrlQuery<span class="synStatement">,</span> res: ServerResponse<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synSpecial">try</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> scope <span class="synStatement">=</span> query.scope<span class="synStatement">;</span>
<span class="synType">const</span> clientId <span class="synStatement">=</span> query.client_id<span class="synStatement">;</span>
<span class="synType">const</span> redirectUri <span class="synStatement">=</span> query.redirect_uri<span class="synStatement">;</span>
<span class="synComment">// ここから追加する</span>
<span class="synType">const</span> responseType <span class="synStatement">=</span> query.response_type<span class="synStatement">;</span>
<span class="synType">const</span> queryParams: QueryParams <span class="synStatement">=</span> <span class="synIdentifier">{</span> scope<span class="synStatement">,</span> responseType<span class="synStatement">,</span> clientId<span class="synStatement">,</span> redirectUri <span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synType">const</span> validated <span class="synStatement">=</span> validate<span class="synStatement">(</span>queryParams<span class="synStatement">);</span>
<span class="synStatement">if</span> <span class="synStatement">(</span>validated<span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> responseData: ErrorResponse <span class="synStatement">=</span> <span class="synIdentifier">{</span> error: validated.authCodeError <span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synStatement">if</span> <span class="synStatement">(</span>validated.target <span class="synStatement">===</span> <span class="synConstant">'redirectUri'</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> response <span class="synStatement">=</span> <span class="synStatement">new</span> URLSearchParams<span class="synStatement">(</span>responseData<span class="synStatement">)</span>.toString<span class="synStatement">();</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">302</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synConstant">'Content-Type'</span>: <span class="synConstant">'application/x-www-form-urlencoded'</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
res.end<span class="synStatement">(</span><span class="synConstant">`</span><span class="synSpecial">${</span>redirectUri<span class="synSpecial">}</span><span class="synConstant">?</span><span class="synSpecial">${</span>response<span class="synSpecial">}</span><span class="synConstant">`</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span>
<span class="synComment">// リソースオーナーは今操作している人である</span>
<span class="synComment">// ここのレスポンスは仕様がないためJSONを返す</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">400</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synConstant">'Content-Type'</span>: <span class="synConstant">'application/json'</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
res.end<span class="synStatement">(</span><span class="synSpecial">JSON</span>.stringify<span class="synStatement">(</span>responseData<span class="synStatement">));</span>
<span class="synIdentifier">}</span>
<span class="synStatement">return;</span>
<span class="synIdentifier">}</span>
</pre>
<p>これでログインページを開くコントローラーは完成です。なお、認可コードはログイン時に生成するようにします。</p>
<p>無効なパラメーターでアクセスしてみます。</p>
<p><a href="http://localhost:3000/openid-connect/auth?client_id=invalid-client&redirect_uri=http://localhost:4000/oidc/callback&scope=openid&response_type=code">http://localhost:3000/openid-connect/auth?client_id=invalid-client&redirect_uri=http://localhost:4000/oidc/callback&scope=openid&response_type=code</a></p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240305/20240305150032.png" width="800" height="577" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>想定通りエラーのレスポンスが返ってきました。次に有効なパラメーターでアクセスします。</p>
<p><a href="http://localhost:3000/openid-connect/auth?client_id=tiny-client&redirect_uri=http://localhost:4000/oidc/callback&scope=openid&response_type=code">http://localhost:3000/openid-connect/auth?client_id=tiny-client&redirect_uri=http://localhost:4000/oidc/callback&scope=openid&response_type=code</a></p>
<p>有効なパラメーターでアクセスするとログイン画面が開きました。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240305/20240305150037.png" width="800" height="577" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h3 id="64-ログイン成功時に認可コードを生成しリダイレクトする">6.4 ログイン成功時に認可コードを生成しリダイレクトする</h3>
<p>次に、POST /login の実装を変更して、ログイン成功時に redirect_uri にリダイレクトするようにします。</p>
<h4 id="641-認可コード">6.4.1 認可コード</h4>
<p>認可コードとは、OAuth / OpenID Connect においてトークンを取得するために使用する有効期限の短いコードです。トークンの取得にのみ使用されます。</p>
<h4 id="642-実装する">6.4.2 実装する</h4>
<p>まずは認可コード用のモデルを作ります。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/models/auth_code.ts</span>
<span class="synStatement">export</span> <span class="synStatement">class</span> AuthCode <span class="synIdentifier">{</span>
code: <span class="synType">string</span><span class="synStatement">;</span>
userId: <span class="synType">number</span><span class="synStatement">;</span>
clientId: <span class="synType">string</span><span class="synStatement">;</span>
expiresAt: <span class="synSpecial">Date</span><span class="synStatement">;</span>
usedAt: <span class="synSpecial">Date</span> | <span class="synType">null</span> <span class="synStatement">=</span> <span class="synType">null</span><span class="synStatement">;</span>
redirectUri: <span class="synType">string</span><span class="synStatement">;</span>
<span class="synStatement">constructor(</span>code: <span class="synType">string</span><span class="synStatement">,</span> userId: <span class="synType">number</span><span class="synStatement">,</span> clientId: <span class="synType">string</span><span class="synStatement">,</span> expiresAt: <span class="synSpecial">Date</span><span class="synStatement">,</span> redirectUri: <span class="synType">string</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synIdentifier">this</span>.code <span class="synStatement">=</span> code<span class="synStatement">;</span>
<span class="synIdentifier">this</span>.userId <span class="synStatement">=</span> userId<span class="synStatement">;</span>
<span class="synIdentifier">this</span>.clientId <span class="synStatement">=</span> clientId<span class="synStatement">;</span>
<span class="synIdentifier">this</span>.expiresAt <span class="synStatement">=</span> expiresAt<span class="synStatement">;</span>
<span class="synIdentifier">this</span>.redirectUri <span class="synStatement">=</span> redirectUri<span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synStatement">static</span> build<span class="synStatement">(</span>userId: <span class="synType">number</span><span class="synStatement">,</span> clientId: <span class="synType">string</span><span class="synStatement">,</span> redirectUri: <span class="synType">string</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> code <span class="synStatement">=</span> <span class="synSpecial">Math</span>.random<span class="synStatement">()</span>.toString<span class="synStatement">(</span><span class="synConstant">36</span><span class="synStatement">)</span>.slice<span class="synStatement">(</span><span class="synConstant">-8</span><span class="synStatement">);</span>
<span class="synType">const</span> oneMin <span class="synStatement">=</span> <span class="synConstant">1</span> * <span class="synConstant">60</span> * <span class="synConstant">1000</span><span class="synStatement">;</span>
<span class="synType">const</span> expiresAt <span class="synStatement">=</span> <span class="synStatement">new</span> <span class="synSpecial">Date</span><span class="synStatement">(</span><span class="synSpecial">Date</span>.now<span class="synStatement">()</span> + oneMin<span class="synStatement">);</span>
<span class="synType">const</span> authCode <span class="synStatement">=</span> <span class="synStatement">new</span> AuthCode<span class="synStatement">(</span>code<span class="synStatement">,</span> userId<span class="synStatement">,</span> clientId<span class="synStatement">,</span> expiresAt<span class="synStatement">,</span> redirectUri<span class="synStatement">);</span>
<span class="synStatement">return</span> authCode<span class="synStatement">;</span>
<span class="synIdentifier">}</span>
<span class="synComment">// 既存レコードがあれば上書きし、なければ新規に保存する</span>
save<span class="synStatement">(</span>db: AuthCode<span class="synIdentifier">[]</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synStatement">if</span> <span class="synStatement">(</span>db.some<span class="synStatement">((</span>ac<span class="synStatement">)</span> <span class="synStatement">=></span> ac.code <span class="synStatement">===</span> <span class="synIdentifier">this</span>.code<span class="synStatement">))</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> index <span class="synStatement">=</span> db.findIndex<span class="synStatement">((</span>ac<span class="synStatement">)</span> <span class="synStatement">=></span> ac.code <span class="synStatement">===</span> <span class="synIdentifier">this</span>.code<span class="synStatement">)</span>
db<span class="synIdentifier">[</span>index<span class="synIdentifier">]</span> <span class="synStatement">=</span> <span class="synIdentifier">this</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span>
db.push<span class="synStatement">(</span><span class="synIdentifier">this</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span>
</pre>
<p>このモデルのデータを使うように Context と index.ts を修正します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/models/context.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> AuthCode <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">"./auth_code"</span><span class="synStatement">;</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> User <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">"./user"</span><span class="synStatement">;</span>
<span class="synStatement">export</span> <span class="synStatement">type</span> Context <span class="synStatement">=</span> <span class="synIdentifier">{</span>
users: User<span class="synIdentifier">[]</span><span class="synStatement">;</span>
authCodes: AuthCode<span class="synIdentifier">[]</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>データの初期化をします。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/index.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> AuthCode <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'./models/auth_code'</span><span class="synStatement">;</span>
<span class="synStatement">(</span>略<span class="synStatement">)</span>
<span class="synType">const</span> users: User<span class="synIdentifier">[]</span> <span class="synStatement">=</span> <span class="synIdentifier">[{</span> id: <span class="synConstant">1</span><span class="synStatement">,</span> email: <span class="synConstant">'tiny-idp@asmsuechan.com'</span><span class="synStatement">,</span> password: <span class="synConstant">'p@ssw0rd'</span><span class="synStatement">,</span> clientId: <span class="synConstant">'tiny-client'</span> <span class="synIdentifier">}]</span><span class="synStatement">;</span>
<span class="synComment">// NOTE: 以下を追加する</span>
<span class="synType">const</span> authCodes: AuthCode<span class="synIdentifier">[]</span> <span class="synStatement">=</span> <span class="synIdentifier">[]</span><span class="synStatement">;</span>
<span class="synType">const</span> db <span class="synStatement">=</span> <span class="synIdentifier">{</span>
users<span class="synStatement">,</span>
authCodes<span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>そしてこの認可コードを含めてログイン成功時に redirect_uri にリダイレクトするようにします。</p>
<p>このコードをコントローラーに書きます。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// src/controllers/login_controller.ts</span>
<span class="synStatement">import</span> <span class="synIdentifier">{</span> AuthCode <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'../models/auth_code'</span><span class="synStatement">;</span>
<span class="synStatement">export</span> <span class="synType">const</span> login <span class="synStatement">=</span> <span class="synStatement">(</span>db: Context<span class="synStatement">,</span> query: ParsedUrlQuery<span class="synStatement">,</span> params: URLSearchParams<span class="synStatement">,</span> res: ServerResponse<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synType">const</span> email <span class="synStatement">=</span> params.<span class="synStatement">get(</span><span class="synConstant">'email'</span><span class="synStatement">);</span>
<span class="synType">const</span> password <span class="synStatement">=</span> params.<span class="synStatement">get(</span><span class="synConstant">'password'</span><span class="synStatement">);</span>
<span class="synComment">// 以下の4変数を追加</span>
<span class="synType">const</span> redirectUri <span class="synStatement">=</span> query.redirect_uri<span class="synStatement">;</span>
<span class="synType">const</span> scope <span class="synStatement">=</span> query.scope<span class="synStatement">;</span>
<span class="synType">const</span> clientId <span class="synStatement">=</span> query.client_id<span class="synStatement">;</span>
<span class="synType">const</span> issuer <span class="synStatement">=</span> <span class="synConstant">'http://localhost:3000'</span><span class="synStatement">;</span>
<span class="synStatement">if</span> <span class="synStatement">(</span>email <span class="synConstant">&&</span> password <span class="synConstant">&&</span> User.login<span class="synStatement">(</span>db.users<span class="synStatement">,</span> email<span class="synStatement">,</span> password<span class="synStatement">))</span> <span class="synIdentifier">{</span>
<span class="synComment">// このif文の中身を全て置き換える</span>
<span class="synType">const</span> user <span class="synStatement">=</span> User.findByEmail<span class="synStatement">(</span>db.users<span class="synStatement">,</span> email<span class="synStatement">)</span> <span class="synStatement">as</span> User<span class="synStatement">;</span>
<span class="synType">const</span> authCode <span class="synStatement">=</span> AuthCode.build<span class="synStatement">(</span>user.id<span class="synStatement">,</span> clientId <span class="synStatement">as</span> <span class="synType">string</span><span class="synStatement">,</span> redirectUri <span class="synStatement">as</span> <span class="synType">string</span><span class="synStatement">);</span>
authCode.save<span class="synStatement">(</span>db.authCodes<span class="synStatement">);</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">302</span><span class="synStatement">,</span> <span class="synIdentifier">{</span>
Location: <span class="synConstant">`</span><span class="synSpecial">${</span>redirectUri<span class="synSpecial">}</span><span class="synConstant">?code=</span><span class="synSpecial">${</span>authCode.code<span class="synSpecial">}</span><span class="synConstant">&iss=</span><span class="synSpecial">${</span>issuer<span class="synSpecial">}</span><span class="synConstant">&scope=</span><span class="synSpecial">${</span>scope<span class="synSpecial">}</span><span class="synConstant">`</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
res.end<span class="synStatement">();</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span>
res.writeHead<span class="synStatement">(</span><span class="synConstant">403</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> <span class="synConstant">'Content-Type'</span>: <span class="synConstant">'application/json'</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
res.end<span class="synStatement">(</span><span class="synSpecial">JSON</span>.stringify<span class="synStatement">(</span><span class="synIdentifier">{</span> error: <span class="synConstant">'Unauthorized'</span> <span class="synIdentifier">}</span><span class="synStatement">));</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>さて、これで認可エンドポイントの完成です。</p>
<h3 id="65-動作確認">6.5 動作確認</h3>
<p>tiny-rpを少し変更して認可コードが発行されたか確認できるようにします。</p>
<p>tiny-rpは、トークンエンドポイントをまだ作っていないため動きません。ですのでcallbackを受け取る部分を次のように書き換えます。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// tiny-rp/src/index.ts</span>
app.<span class="synStatement">get(</span><span class="synConstant">"/oidc/callback"</span><span class="synStatement">,</span> <span class="synStatement">async</span> <span class="synStatement">(</span>req<span class="synStatement">,</span> res<span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
<span class="synComment">// </span><span class="synTodo">TODO</span><span class="synComment">: トークンを検証するコードは後で追加します</span>
<span class="synType">const</span> code <span class="synStatement">=</span> <span class="synSpecial">String</span><span class="synStatement">(</span>req.query.code<span class="synStatement">);</span>
res.<span class="synStatement">status(</span><span class="synConstant">200</span><span class="synStatement">);</span>
res.json<span class="synStatement">(</span><span class="synIdentifier">{</span> code <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synStatement">return;</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
</pre>
<p><a href="http://localhost:3000/openid-connect/auth?client_id=tiny-client&redirect_uri=http://localhost:4000/oidc/callback&scope=openid&response_type=code">http://localhost:3000/openid-connect/auth?client_id=tiny-client&redirect_uri=http://localhost:4000/oidc/callback&scope=openid&response_type=code</a> ログイン画面よりemail / passwordを入力すると、認可コードが表示できました。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240305/20240305150041.png" width="800" height="577" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h2 id="まとめ">まとめ</h2>
<p>この記事ではOpenID Connectの基礎を簡単に説明し、RelyingPartyの実装と、認可エンドポイントの実装をしました。次回はトークンエンドポイントです。</p>
<p>この記事で作成した部分は以下です。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fasmsuechan%2Ftiny-idp%2Ftree%2Fblog-1" title="GitHub - asmsuechan/tiny-idp at blog-1" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/asmsuechan/tiny-idp/tree/blog-1">github.com</a></cite></p>
<h2 id="Were-hiring">We're hiring</h2>
<p>弊社では認証認可が好きな人もそうでない人もエンジニアを絶賛募集中です!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
asmsuechan
間質性肺炎を検出するAIを開発し、その有効性を検証した研究を論文化しました
hatenablog://entry/6801883189082030817
2024-02-21T17:00:00+09:00
2024-02-21T17:00:21+09:00 こんにちは、AI・機械学習チームの浮田です。最近、私が筆頭著者の論文が公開されたので、今回はその紹介をします。 発表した論文はこちらです: www.ncbi.nlm.nih.gov この論文では、 胸部X線 (レントゲン) から間質性肺炎を検出するAIの評価を行いました。 結果、このAIを使うことで医師の読影成績が統計的有意に改善しました。 このAIを使うことで間質性肺炎の見落としを減らすことができることが期待されます。 エンジニアリンググループで論文を書くのは珍しい機会でしたが、査読対応など大変な時も経て無事公開することができました。 図1. 今回開発・検証した医療AIの実際の画面。プレスリ…
<p>こんにちは、AI・機械学習チームの浮田です。最近、私が筆頭著者の論文が公開されたので、今回はその紹介をします。</p>
<p>発表した論文はこちらです:</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.ncbi.nlm.nih.gov%2Fpmc%2Farticles%2FPMC10806761%2F" title="Original research: Detection of fibrosing interstitial lung disease-suspected chest radiographs using a deep learning-based computer-aided detection system: a retrospective, observational study" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC10806761/">www.ncbi.nlm.nih.gov</a></cite></p>
<p>この論文では、</p>
<ul>
<li>胸部X線 (レントゲン) から間質性肺炎を検出するAIの評価を行いました。</li>
<li>結果、このAIを使うことで医師の読影成績が統計的有意に改善しました。</li>
<li>このAIを使うことで間質性肺炎の見落としを減らすことができることが期待されます。</li>
</ul>
<p>エンジニアリンググループで論文を書くのは珍しい機会でしたが、査読対応など大変な時も経て無事公開することができました。</p>
<p><figure class="figure-image figure-image-fotolife" title="図1. 今回開発・検証した医療AIの実際の画面。[プレスリリース](https://corporate.m3.com/assets.ctfassets.net/1pwj74siywcy/3VBNJt9btm7bysLNmhvvKh/989544909b7775933c77373fe7a9e8bc/20231130_Public_J.pdf)より転載"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240221/20240221170011.png" width="800" height="465" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>図1. 今回開発・検証した医療AIの実際の画面。<a href="https://corporate.m3.com/assets.ctfassets.net/1pwj74siywcy/3VBNJt9btm7bysLNmhvvKh/989544909b7775933c77373fe7a9e8bc/20231130_Public_J.pdf">プレスリリース</a>より転載</figcaption></figure></p>
<ul class="table-of-contents">
<li><a href="#今回開発検証した医療AIの概要">今回開発・検証した医療AIの概要</a></li>
<li><a href="#有効性を検証するための臨床試験">有効性を検証するための臨床試験</a><ul>
<li><a href="#目的">目的</a></li>
<li><a href="#データセット実験設定">データセット、実験設定</a></li>
<li><a href="#結果">結果</a></li>
<li><a href="#評価方法の詳細">評価方法の詳細</a></li>
</ul>
</li>
<li><a href="#感想">感想</a></li>
<li><a href="#Were-hiring">We're hiring!</a></li>
</ul>
<h1 id="今回開発検証した医療AIの概要">今回開発・検証した医療AIの概要</h1>
<p>「線維化を伴う間質性肺炎」(以下、間質性肺炎) という肺疾患の有無を、胸部X線 (レントゲン) 画像から判断するAIになります。<a href="https://www.m3tech.blog/entry/2022/12/16/110000#%E9%96%93%E8%B3%AA%E6%80%A7%E8%82%BA%E7%96%BE%E6%82%A3%E8%A8%BA%E6%96%ADAI%E3%81%AE%E9%96%8B%E7%99%BA">以前のブログ</a>で紹介したモデルをベースに改善を加え、また医師が診療中に見る画面上に予測結果が表示されるようにしています。</p>
<p>実際の画面は図1のような見た目になっており、AIが解析した間質性肺炎の確信度 (0以上1以下の値) がILD Score<a href="#f-34b8ccbe" id="fn-34b8ccbe" name="fn-34b8ccbe" title="ILDは間質性肺炎を表します">*1</a>として右上に表示されます。また、この確信度が閾値を超えた場合にはアラートマークが左上に表示されるようになっています。</p>
<p>このようなAIを私達のチームで開発し、本AIは現在、BMAXという名前の医療機器として製造販売承認を得て、実際に提供され始めています。詳細は<a href="https://corporate.m3.com/assets.ctfassets.net/1pwj74siywcy/3VBNJt9btm7bysLNmhvvKh/989544909b7775933c77373fe7a9e8bc/20231130_Public_J.pdf">プレスリリース</a>をご参照ください。</p>
<h1 id="有効性を検証するための臨床試験">有効性を検証するための臨床試験</h1>
<h2 id="目的">目的</h2>
<p>このように開発したAIですが、実際に使ってもらうにはその有用性を示す必要があります。有用性を示すには「AI単体の予測精度」よりも「<strong>AIを使った医師の読影成績</strong>」が最も重要になります。</p>
<p>例えば、仮に「AI単体の予測精度」が95%と高くても、そもそも医師が99%の精度で読影できている状況ならそのAIの必要性は小さいかもしれません。逆に医師の精度が70%の状況なら有用かもしれません。このように、「AI単体の予測精度」そのものよりも、実用上どれだけ有用かの方が大事になります。これはちょうど、推薦システムなどにおいて予測精度そのものだけでなく「そのモデルを使うことでビジネス指標 (Click-through rateなど) がどれぐらい向上するか」が重要なのと似ていると思います。</p>
<p>そのため今回、実際の医師にAIなし・AIありで胸部X線画像を読影していただき、その読影成績の差を統計的に評価する臨床試験を行いました。</p>
<h2 id="データセット実験設定">データセット、実験設定</h2>
<p>データセットには120枚の胸部X線画像 (うち24枚が間質性肺炎の兆候あり、96枚が間質性肺炎の兆候なし) を用意しました。各画像に対して25名の医師 (5名の専門医と20名の非専門医) それぞれに、</p>
<ul>
<li>まずAIなしの状態で間質性肺炎の兆候があるか無いかを判断</li>
<li>次にAIありの状態で同様に判断</li>
</ul>
<p>していただきました。このように記録したデータを統計的に解析しました。</p>
<h2 id="結果">結果</h2>
<p>非専門医におけるAIなし時、AIあり時のROC-AUC、感度、特異度の平均は次の表のようになりました。AIなし時に比べてAIあり時ではROC-AUCが<strong>統計的有意に改善しました</strong>。また感度はAIありで有意に改善した一方、特異度はほとんど変わらない結果となりました。このことから本AIを使うことで、間質性肺炎の見落としを減らすことができることが期待できます。</p>
<table>
<thead>
<tr>
<th>指標</th>
<th>AIなし</th>
<th>AIあり</th>
<th>P値</th>
</tr>
</thead>
<tbody>
<tr>
<td>ROC-AUC</td>
<td>0.795</td>
<td> 0.825 </td>
<td> 0.005 </td>
</tr>
<tr>
<td>感度</td>
<td>0.744 </td>
<td>0.802 </td>
<td> 0.003 </td>
</tr>
<tr>
<td>特異度</td>
<td>0.846 </td>
<td>0.847 </td>
<td> 0.690 </td>
</tr>
</tbody>
</table>
<p>さらに層別解析を行うと、間質性肺炎が軽度な場合に感度の改善効果が顕著に見られることも分かりました。</p>
<p>これらの結果をまとめて、冒頭に示した論文に発表しました。詳細な層別解析や、専門医に対する解析なども行っていますので、興味がある方は論文をご覧ください。</p>
<h2 id="評価方法の詳細">評価方法の詳細</h2>
<p>この試験では、AIによって「非専門医が間質性肺炎の有無を判断するROC-AUCが統計的に改善するか」を主な評価指標としました。</p>
<p>統計学や機械学習においてROC-AUCは、連続値の予測値を正解ラベルと比較する際に用いられることが一般的です。今回の試験では各医師の判断は「間質性肺炎の兆候のあり」「なし」の二値であり、これを正解と比較する状況なので、ROC-AUCの使い方としては少し特殊な使い方となっています。ただ、通常の連続値の場合のROC曲線とAUCの定義を拡張することで二値の場合にも用いることができます (図2)。そして実は、この場合のROC-AUCは感度 (sensitivity) と特異度 (specificity) の平均と等しい値になります。</p>
<div onclick="obj=document.getElementById('20211211_folding_text').style; obj.display=(obj.display=='none')?'block':'none';">
<a style="cursor:pointer;"> > 今回のROC-AUCが感度と特異度の平均になる証明はこちら < </a>
</div>
<div id="20211211_folding_text" style="display:none;clear:both;">
<ul>
<li>確率を用いた証明はこちらの論文にあります:<a href="https://link.springer.com/article/10.1007/s00357-019-09345-1">ROC and AUC with a Binary Predictor: a Potentially Misleading Metric | Journal of Classification</a> </li>
<li>図2右を見ると図形的に考えることも出来ます。図2右の予測値の点 (黒点) をAとすると、「(0, 0), (1, 0), 点A」の三角形の面積が感度/2となり、「(点A, (1, 0), (1, 1) 」の三角形の面積が特異度/2になることから、ROC-AUCの値である影の部分の面積はちょうど感度と特異度の平均となります。 </li>
</ul>
</div>
<p><figure class="figure-image figure-image-fotolife" title="図2. 左:一般的な連続値の予測値に対するROC-AUC、右:今回の二値に対するROC-AUC"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240221/20240221170014.png" width="723" height="340" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>図2. 左:一般的な連続値の予測値に対するROC-AUC、右:今回の二値に対するROC-AUC</figcaption></figure></p>
<p>このようなROC-AUCをAIなしの時、AIありの時の各医師に対してそれぞれ計算し、AIなしの時の平均値とAIありの時の平均値の差を、Wilcoxon sign-rank testにより検定しました。</p>
<p>なお他の評価指標としては、感度 (= recall) や特異度、F1 scoreなどの指標が候補に挙がるかと思います。しかし、</p>
<ul>
<li>感度と特異度はトレードオフであり、片方の指標だけで性能を測ることはできないという欠点があります。</li>
<li>F1 scoreは感度と陽性的中率 (= precision) の調和平均です。陽性的中率は今回の120枚のデータセット中の陽性率に大きく依存します。例えば病院の環境 (例えば大学病院かクリニックか) が異なると陽性率は大きく変わり、陽性的中率、引いてはF1 Scoreの値も変わることが想定され、扱いにくいという欠点があります。</li>
</ul>
<p>以上から、二値の場合のROC-AUCは特殊ですが、今回はROC-AUCを用いることにしました。ちょうど感度と特異度の平均と言う意味でも、トレードオフに左右されにくいがシンプルな指標かと思われます。</p>
<h1 id="感想">感想</h1>
<p>今回エンジニアリンググループでは珍しく、論文を発表する機会に恵まれました。査読対応など大変なこともありましたが、無事発表できてよかったです。共著者の皆様、特に札幌医科大学の皆様にはとてもお世話になりました。</p>
<p>またROC-AUCの考え方や試験設計や検定方法など、統計的な考察をする機会も多く学びにもなりました。</p>
<h1 id="Were-hiring">We're hiring!</h1>
<p>AI・機械学習チームでは、統計や機械学習を用いて様々な医療課題に取り組んでいます。興味を持った方は、次のリンクからご応募お待ちしています! インターンも通年募集中です!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<div class="footnote">
<p class="footnote"><a href="#fn-34b8ccbe" id="f-34b8ccbe" name="f-34b8ccbe" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">ILDは間質性肺炎を表します</span></p>
</div>
uKita
Goaのlinterを作った: Goalint
hatenablog://entry/6801883189078914338
2024-01-30T14:00:00+09:00
2024-01-30T14:00:14+09:00 永山です。 本記事では筆者の開発した、Go製のWebフレームワーク Goa (v3) 向けのlinterツール goalint を紹介します。 些細な間違いの検知を人間に頼ることはやめたい Goa とは モチベーション 既存のlinter goavl IBM OpenAPI Validator goalint 使用方法 goavlとのアプローチの違い 結果 We are hiring!!
<p>永山です。</p>
<p>本記事では筆者の開発した、Go製のWebフレームワーク <a href="https://goa.design/">Goa</a> (v3) 向けのlinterツール <a href="https://github.com/NagayamaRyoga/goalint">goalint</a> を紹介します。</p>
<p><figure class="figure-image figure-image-fotolife" title="些細な間違いの検知を人間に頼ることはやめたい"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240130/20240130140008.jpg" width="800" height="576" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>些細な間違いの検知を人間に頼ることはやめたい</figcaption></figure></p>
<ul class="table-of-contents">
<li><a href="#Goa-とは">Goa とは</a></li>
<li><a href="#モチベーション">モチベーション</a></li>
<li><a href="#既存のlinter">既存のlinter</a><ul>
<li><a href="#goavl">goavl</a></li>
<li><a href="#IBM-OpenAPI-Validator">IBM OpenAPI Validator</a></li>
</ul>
</li>
<li><a href="#goalint">goalint</a><ul>
<li><a href="#使用方法">使用方法</a></li>
<li><a href="#goavlとのアプローチの違い">goavlとのアプローチの違い</a></li>
</ul>
</li>
<li><a href="#結果">結果</a></li>
<li><a href="#We-are-hiring">We are hiring!!</a></li>
</ul>
<h2 id="Goa-とは">Goa とは</h2>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgoa.design%2F" title="Goa :: Design first." class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://goa.design/">goa.design</a></cite></p>
<p>Goaは、GoのDSLでHTTPやgRPCのAPIの仕様を記述するというアプローチが特徴的なGo製のWebフレームワークです。
昨今ではWeb APIの仕様の記述に <a href="https://www.openapis.org/">OpenAPI</a> を使用することも少なくないかと思いますが、GoaはそれをGo上で行うようなものであるといえます。</p>
<pre class="code lang-go" data-lang="go" data-unlink><span class="synComment">// Goaで記述されたAPI仕様の例 (以下より, 一部改変)</span>
<span class="synComment">// https://github.com/goadesign/examples/blob/master/basic/design/design.go</span>
<span class="synStatement">package</span> design
<span class="synStatement">import</span> (
. <span class="synConstant">"goa.design/goa/v3/dsl"</span>
)
<span class="synStatement">var</span> _ = Service(<span class="synConstant">"calc"</span>, <span class="synType">func</span>() {
<span class="synComment">// パラメータ a, b の積を返す API</span>
Method(<span class="synConstant">"multiply"</span>, <span class="synType">func</span>() {
Payload(<span class="synType">func</span>() {
Attribute(<span class="synConstant">"a"</span>, Int, <span class="synConstant">"Left operand"</span>)
Attribute(<span class="synConstant">"b"</span>, Int, <span class="synConstant">"Right operand"</span>)
Required(<span class="synConstant">"a"</span>, <span class="synConstant">"b"</span>)
})
Result(Int)
HTTP(<span class="synType">func</span>() {
GET(<span class="synConstant">"/multiply/{a}/{b}"</span>)
Response(StatusOK)
})
})
})
</pre>
<p>Goa DSLによって記述されたAPI仕様からはサーバー・クライアントのGoコードを生成でき、またOpenAPI (HTTP APIの場合) や protobuf (gRPCの場合) も出力できます。</p>
<h2 id="モチベーション">モチベーション</h2>
<p>さて、APIを作成するにあたって重要なことの1つがインタフェースの統一性です:</p>
<ul>
<li>HTTP APIのパスのケーシング (snake_case / kebab-case / lowerCamelCase / ...)</li>
<li>JSONのキー名のケーシング (snake_case / lowerCamelCase / PascalCase / ...)</li>
</ul>
<p>Web APIは様々な箇所から呼ばれることもあるため、これらの統一性がおざなりになっていた場合あとから変更するのに不必要に大きいコストを払わなければならないようなことも決して珍しくはないでしょう (そして割に合わず放置されてしまいます)。</p>
<p>これらの表現が混在しているものはコードレビューの段階ですべて弾いてしまえるのが理想的ですが、そのような指摘の網羅性を実装者やレビュアーの集中力に頼るのも現実的ではありません。
可能であれば機械的に検知できることが望ましいです。</p>
<h2 id="既存のlinter">既存のlinter</h2>
<p>まず次のような既存のlinterを用いてGoaのAPI仕様をチェックできないかを考えました。</p>
<h3 id="goavl">goavl</h3>
<p>goavlはGoa v1のlinter/validatorです。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fnao1215%2Fgoavl" title="GitHub - nao1215/goavl: goavl: Goa framework (version1) linter" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/nao1215/goavl">github.com</a></cite></p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdebimate.jp%2F2022%2F02%2F11%2F%25E3%2580%2590golang%25E3%2580%2591goa%25EF%25BC%2588ver-1-x%25EF%25BC%2589framework%25E3%2581%25AElinter%25EF%25BC%2588goavl%25EF%25BC%2589%25E3%2582%2592%25E8%25A9%25A6%25E4%25BD%259C%25E3%2580%2590go-ast%25E3%2582%2592%25E5%2588%25A9%25E7%2594%25A8%25E3%2580%2591%2F" title="【Golang】goa(ver 1.x)frameworkのlinter(goavl)を試作【go/astを利用】" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://debimate.jp/2022/02/11/%E3%80%90golang%E3%80%91goa%EF%BC%88ver-1-x%EF%BC%89framework%E3%81%AElinter%EF%BC%88goavl%EF%BC%89%E3%82%92%E8%A9%A6%E4%BD%9C%E3%80%90go-ast%E3%82%92%E5%88%A9%E7%94%A8%E3%80%91/">debimate.jp</a></cite></p>
<p>GoaのDSLをGoのASTから解析するパワフルなアプローチで実装されています。</p>
<p>しかし、対応しているのはGoa v1のみで、現在のメジャーバージョンであるv3に対応する予定はなさそうでした <a href="#f-bbc2130b" id="fn-bbc2130b" name="fn-bbc2130b" title="https://debimate.jp/2022/02/05/%E3%80%90golang%E3%80%912022%E5%B9%B4%E3%81%AB%E9%96%8B%E7%99%BA%E3%81%97%E3%81%9F%E8%87%AA%E4%BD%9Ccli%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89%EF%BC%8F%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA/">*1</a>。</p>
<h3 id="IBM-OpenAPI-Validator">IBM OpenAPI Validator</h3>
<p>IBM OpenAPI ValidatorはNode.js製のOpenAPIのlinter/validatorです。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2FIBM%2Fopenapi-validator" title="GitHub - IBM/openapi-validator: Configurable and extensible validator/linter for OpenAPI documents" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/IBM/openapi-validator">github.com</a></cite></p>
<p>前述の通りGoaはOpenAPIフォーマットのファイルを出力できるため、それに対して適用することで間接的にGoa DSLをチェックできないかと考えました。</p>
<p>IBM OpenAPI Validatorは実装されているルール数が豊富で、チーム内でもOpenAPIを導入している他のプロジェクトへの採用経験がありました。
一方で、指摘は当然OpenAPIファイルに対して行われるため、警告が発生した場合はOpenAPIの出力から逆算してGoaの定義を修正しなければなりません。
またGoaの出力する形式の都合上、対応が不可能なルールも少なからず存在しています。</p>
<h2 id="goalint">goalint</h2>
<p>上記のような点を鑑みて、今回は自前でlinterを実装することにしました。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2FNagayamaRyoga%2Fgoalint" title="GitHub - NagayamaRyoga/goalint: Goa lint plugin/CLI for Goa v3" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/NagayamaRyoga/goalint">github.com</a></cite></p>
<p>現在はIBM OpenAPI Validatorなどを参考に<a href="https://github.com/NagayamaRyoga/goalint/blob/main/README_ja.md">18のルール</a>が実装してあります。</p>
<h3 id="使用方法">使用方法</h3>
<p>goalintは以下の2通りの使用方法をサポートしています。</p>
<ul>
<li>Goaのコード生成時にチェックを行うプラグインとして実行する</li>
<li>独立したCLIとして実行する</li>
</ul>
<p>どちらとして使用する場合も以下のようなファイルを作成することでgoalintを導入できます。</p>
<pre class="code lang-go" data-lang="go" data-unlink><span class="synComment">// design/goalint.go</span>
<span class="synStatement">package</span> design
<span class="synStatement">import</span> (
<span class="synConstant">"github.com/NagayamaRyoga/goalint"</span>
<span class="synComment">// プラグインとして使用する場合は以下をimportする</span>
<span class="synComment">// CLIとして使用する場合は不要</span>
_ <span class="synConstant">"github.com/NagayamaRyoga/goalint/plugin"</span>
)
<span class="synStatement">var</span> _ = goalint.Configure(<span class="synType">func</span>(c *goalint.Config) {
<span class="synComment">// ルールの設定を変更できる</span>
<span class="synComment">// APIのパス名は snake_case</span>
c.HTTPPathCasingConvention.WordCase = goalint.SnakeCase
})
</pre>
<pre class="code lang-sh" data-lang="sh" data-unlink><span class="synComment"># goalintをコマンドラインから実行する</span>
$ go run github.com/NagayamaRyoga/goalint/cmd/goalint <span class="synStatement"><</span>package名<span class="synStatement">></span>/design
</pre>
<h3 id="goavlとのアプローチの違い">goavlとのアプローチの違い</h3>
<p>先に説明した通り、goavlは (静的な) GoのASTからDSLを解析しています。
対して、goalintはGoaのDSLの評価結果として組み立てられる (動的な) 内部表現を解析して警告を出力します。</p>
<p>このような異なるアプローチを取ったのは、Goaの表現に (悪い意味で) 柔軟さがあるためです。
例えば以下の2つのコードはどちらも同じ意味を持ちます:</p>
<pre class="code lang-go" data-lang="go" data-unlink>Payload(<span class="synType">func</span>() {
Attribute(<span class="synConstant">"a"</span>, Int, <span class="synConstant">"Left operand"</span>)
<span class="synComment">// ↑と同じ</span>
Attribute(<span class="synConstant">"a"</span>, Int, <span class="synType">func</span>() {
Description(<span class="synConstant">"Left operand"</span>)
})
})
</pre>
<p>また、DSLには任意のGoのコードを書けるため、ファイルの分割や、関数化・変数化などの共通化を行った場合、ASTからAPI定義の出力結果を静的に推測するのはさらに難しくなります。</p>
<p>そのためgoalintはGoaがDSLからコードを生成する時のように、一通りDSLの評価をしたあと解析する方法を選択しています。
これによって実装をかなり単純にできました (設計開始から2日程度でおおよそ動くものができました)。</p>
<p>一方、Goaの内部表現からはそれの書かれたファイル名や行数などの復元ができません。
それによって、警告の原因となっている具体的な位置をエラーメッセージに表示出来ないという課題が残っています (エラー位置を特定するのに実用上十分な情報は出力できていますが……)。</p>
<p>エラーメッセージの例:</p>
<pre class="code" data-lang="" data-unlink>Found 5 errors and 0 warnings
[MethodDescriptionExists]: error in service "calc" method "multiply": Method should have non-empty description
[NoUnnamedMethodPayloadType]: error in service "calc" method "multiply": Method payload should be an user defined type
[NoUnnamedMethodResultType]: error in service "calc" method "multiply": Method result should be an user defined type
[TypeAttributeExampleExists]: error in attribute "a" in Object: Attribute of type Int should have examples
[TypeAttributeExampleExists]: error in attribute "b" in Object: Attribute of type Int should have examples
goalint failed</pre>
<h2 id="結果">結果</h2>
<p>実際にチーム内のGoaを採用しているサービス2つにgoalintを導入しました。
結果、コードレビュー時に注意すべき事柄が減り、より本質的な実装のレビューに集中できるようになりました。</p>
<p>また、初見ではわかりにくいGoaのプラクティス (型の命名やExampleの明示など) のガイド役としても一定の働きをしてくれています。</p>
<h2 id="We-are-hiring">We are hiring!!</h2>
<p>エムスリーではlinterが好きなエンジニアを募集しています。
Go/Goa以外にも様々な技術スタックのプロダクトがありますので、ご興味ある方は是非こちらからお願いします。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<div class="footnote">
<p class="footnote"><a href="#fn-bbc2130b" id="f-bbc2130b" name="f-bbc2130b" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://debimate.jp/2022/02/05/%E3%80%90golang%E3%80%912022%E5%B9%B4%E3%81%AB%E9%96%8B%E7%99%BA%E3%81%97%E3%81%9F%E8%87%AA%E4%BD%9Ccli%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89%EF%BC%8F%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA/">https://debimate.jp/2022/02/05/%E3%80%90golang%E3%80%912022%E5%B9%B4%E3%81%AB%E9%96%8B%E7%99%BA%E3%81%97%E3%81%9F%E8%87%AA%E4%BD%9Ccli%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89%EF%BC%8F%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA/</a></span></p>
</div>
ryoga-nagayama
PIVOT Growth DriversにVPoE河合とCTO山崎がダブルで出演しました
hatenablog://entry/6801883189077936330
2024-01-26T16:43:06+09:00
2024-01-26T17:06:46+09:00 はじめに 最近アークナイツというゲームにハマっています、VPoEの河合(@vaaaaanquish)です。 皆さんは、PIVOTというYouTubeチャネルをご存知でしょうか。 PIVOT株式会社さんが運営しているビジネスチャンネルで、登録者100万人超え、様々なスペシャリストや経営者が出演するビジネスマンに人気のチャンネルです。私も良く見ています笑 www.youtube.com 今回、PIVOTさんが別途運営している『PIVOT Growth Drivers』というPodcast番組に、私河合とCTO兼VPoPの山崎が出演しました。 本記事では、その内容の紹介や感想をざっくばらんに書いてい…
<h1 id="はじめに">はじめに</h1>
<p>最近アークナイツというゲームにハマっています、VPoEの河合(<a href="https://twitter.com/vaaaaanquish">@vaaaaanquish</a>)です。</p>
<p>皆さんは、PIVOTというYouTubeチャネルをご存知でしょうか。
PIVOT株式会社さんが運営しているビジネスチャンネルで、登録者100万人超え、様々なスペシャリストや経営者が出演するビジネスマンに人気のチャンネルです。私も良く見ています笑
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.youtube.com%2F%40pivot8935" title="PIVOT 公式チャンネル - YouTube" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.youtube.com/@pivot8935">www.youtube.com</a></cite></p>
<p>今回、PIVOTさんが別途運営している『PIVOT Growth Drivers』というPodcast番組に、私河合とCTO兼VPoPの山崎が出演しました。
本記事では、その内容の紹介や感想をざっくばらんに書いていく記事になっています。</p>
<p><figure class="figure-image figure-image-fotolife" title="VPoE河合が出演した時の写真です。推せ!"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240126/20240126164309.png" width="800" height="534" loading="lazy" title="" class="hatena-fotolife" style="width:400px" itemprop="image"></span><figcaption>VPoE河合が出演した時の写真です。推せ!</figcaption></figure></p>
<ul class="table-of-contents">
<li><a href="#はじめに">はじめに</a></li>
<li><a href="#PIVOT-Growth-Driversについて">PIVOT Growth Driversについて</a></li>
<li><a href="#河合出演回の紹介">河合出演回の紹介</a></li>
<li><a href="#山崎出演回の紹介">山崎出演回の紹介</a></li>
<li><a href="#おわりに">おわりに</a></li>
<li><a href="#We-are-hiring">We are hiring</a></li>
</ul>
<p> </p>
<h1 id="PIVOT-Growth-Driversについて">PIVOT Growth Driversについて</h1>
<p>PIVOT Growth Driversは、<strong>日本のモノづくりの発展に焦点を当てたPodcast番組</strong>です。</p>
<p>プロダクト開発に関する考え方やナレッジのお話を様々なスペシャリストに聞いていく形で発信されています。</p>
<p>最近ですと、LayerX CTOの松本さん(<a href="https://twitter.com/y_matsuwitter">@y_matsuwitter</a>)の<a href="https://open.spotify.com/episode/3WGGyKj9a6SUN0fik9U2X3">生成AIトーク</a>やバイセルCTOの今村さん(<a href="https://twitter.com/kyuns">@kyuns</a>)の<a href="https://open.spotify.com/episode/7kRy8CLuZUm0W4E9u0cauv">データドリブン組織トーク</a>といったエンジニアリング組織のトップランナーによるお話も面白かったですし、fukabori.fmを運営するNTT岩瀬さん(<a href="https://twitter.com/iwashi86">@iwashi86</a>)による<a href="https://open.spotify.com/episode/27RWtuwfq0JnDzqGnfUlfc?si=u8jvZE-VR9Gg1YjlALO0sw">ドキュメントライティングについてのお話</a>など、IT業界の最先端の考え方から現場で使えるナレッジも学べる番組になっています。</p>
<ul>
<li>Apple Podcastはこちら: <a href="https://podcasts.apple.com/jp/podcast/pivot-growth-drivers/id1685600082">‎Apple Podcast内のPIVOT Growth Drivers</a></li>
<li>Spotifyはこちら: <a href="https://open.spotify.com/show/7iwpayjH52FENYKgCxb1N7">PIVOT Growth Drivers | Podcast on Spotify</a></li>
</ul>
<p> </p>
<h1 id="河合出演回の紹介">河合出演回の紹介</h1>
<p>私、河合が出演した回では、私がキャリアの上で経験してきた「エンジニアリング」という物事への解釈の拡げ方や、<strong>エンジニアとビジネスを繋げる難しさに対する今現時点での私なりの回答</strong>をお話しています。</p>
<p>エムスリーに戻ってきて改めて思うのですが「<strong>エンジニアリングは非常に楽しい</strong>」ものです。
この楽しさを、資本主義の中で更に楽しむにはどうしたら良いのか、これからも考えたいと思っていますし、みなさんと共有して一緒に進んでいければとも思っています。</p>
<p>私としても、エンジニアリングとは何か改めて考えるきっかけになり、楽しく非常に有意義な時間でした。
妻からは「PIVOT名物のピンク背景の部屋行くの!?」と聞かれて、少し自慢気に写真を送ったのも記憶に新しいです笑</p>
<p>本当に良い時間だったなと思っておりますので、是非聞いて頂ければ幸いです。</p>
<p><iframe style="border-radius: 12px" width="100%" height="152" title="Spotify Embed: #8-1:【僕にとって、技術は推し活】キャリアの選択肢を広げる、ばんくし流キャリア指針 with ばんくしさん" frameborder="0" allowfullscreen allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy" src="https://open.spotify.com/embed/episode/4ik5hdiTwdeyjmT3XyVU8f?utm_source=oembed"></iframe><cite class="hatena-citation"><a href="https://open.spotify.com/episode/4ik5hdiTwdeyjmT3XyVU8f?si=5769aa64dbd84dcf&nd=1&dlsi=9ff9b400dc314615">open.spotify.com</a></cite></p>
<p>第2回は<a href="https://open.spotify.com/episode/1IA0hq9JETi6Pb9wkD6XRv">こちら</a>、第3回は<a href="https://open.spotify.com/episode/5gqj1Q8xHtNMFFMKTHaOR2">こちら</a>です!
</p>
<h1 id="山崎出演回の紹介">山崎出演回の紹介</h1>
<p>山崎さん出演の回は、2024年1月から第3回に分けて配信されており、記事執筆時点では1回目放送が終わった所になっています。</p>
<p>第1回は、正に今話題となっている生成AIとソフトウェアエンジニアリングの歴史について山崎が語りながら、<strong>未来のソフトウェアエンジニアリングを大予想</strong>していく、という内容です。
ジェネレーティブAIプログラミングが一般的になった時代にどういった事が起こるのかという話を面白おかしく展開しています。</p>
<p>エムスリーでもChatGPTやGitHub Copilotの導入は進んでおり、こういった話題も注意深くウォッチしています。</p>
<p>未来のエンジニアリングを楽しむために、是非聞いて一緒に考えて頂ければ幸いです。</p>
<p><iframe style="border-radius: 12px" width="100%" height="152" title="Spotify Embed: #13-1: 【生成AIはインターネットの再来】ビジネスとエンジニアに巻き起こる2024年3つの二極化 with 山崎聡さん" frameborder="0" allowfullscreen allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy" src="https://open.spotify.com/embed/episode/5jKcvl1liKobqsjXTiIM4K?utm_source=oembed"></iframe><cite class="hatena-citation"><a href="https://open.spotify.com/episode/5jKcvl1liKobqsjXTiIM4K">open.spotify.com</a></cite></p>
<p> </p>
<h1 id="おわりに">おわりに</h1>
<p>本記事は、私VPoE河合とCTO兼VPoPの山崎が出演したPIVOTの紹介でした。</p>
<p>エムスリーでは正にPIVOTの内容の通り、エンジニアリングの楽しさやイノベーションを体現する事を意識しています。</p>
<p>また、PIVOTのような学びの機会を応援すると共に、ご縁を頂けた事も感謝しています。引き続きエムスリーをよろしくお願いします。</p>
<p> </p>
<h1 id="We-are-hiring">We are hiring</h1>
<p>エムスリーは、エンジニアリングを楽しむ人達が、未来を変えるイノベーションを起こすと信じて、エンジニアリング組織を運営しています。</p>
<p>ギークでスマートな皆さんのジョインを心からお待ちしております!</p>
<p>カジュアル面談は、私VPoE河合が対応しますので以下リンクから是非どうぞ!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
vaaaaaanquish
型を少し工夫して、より安全なコードへ
hatenablog://entry/6801883189075248950
2024-01-16T09:45:41+09:00
2024-01-16T12:19:11+09:00 こんにちは、デジスマチームでエンジニアをやっている堀田です。 これまで、TypeScriptの型で色々試したことがあります。 2年前: TSの型で麻雀の点数計算 最近: 型で足し算 遊ぶことの方が多かったですが、先日M3 TechTalkで実用的かも?と思える話をしました。 そこでは、3つの場面を想定して、それぞれの場面でより安全なコードを書くための型定義を提案しました。 この記事では、その時の話をまとめて紹介します。
<p>こんにちは、デジスマチームでエンジニアをやっている堀田です。</p>
<p>これまで、TypeScriptの型で色々試したことがあります。</p>
<ul>
<li>2年前: <a href="https://www.m3tech.blog/entry/2022/05/27/110916">TSの型で麻雀の点数計算</a></li>
<li>最近: <a href="https://hrtyy.dev/anything/typed-circuit/">型で足し算</a></li>
</ul>
<p>遊ぶことの方が多かったですが、先日<a href="https://www.m3tech.blog/entry/techtalk-offline-2023">M3 TechTalk</a>で実用的かも?と思える話をしました。
そこでは、3つの場面を想定して、それぞれの場面でより安全なコードを書くための型定義を提案しました。</p>
<p>この記事では、その時の話をまとめて紹介します。</p>
<ul class="table-of-contents">
<li><a href="#1-key">1. key?</a></li>
<li><a href="#2-ログ送信">2. ログ送信</a><ul>
<li><a href="#Rest-parameters-with-tuple-types">Rest parameters with tuple types</a></li>
<li><a href="#Function-Overloadsでも実現可能">Function Overloadsでも実現可能</a></li>
</ul>
</li>
<li><a href="#3-画面遷移パス生成">3. 画面遷移パス生成</a></li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#We-are-hiring">We are hiring!!</a></li>
</ul>
<h2 id="1-key">1. key?</h2>
<p>早速、1つ目の場面です。
ここでは、「パラメータを受け取って、その値によって生成か更新か挙動が変わる」以下のような関数を扱うことを考えます。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">function</span> createOrUpdate<span class="synStatement">(</span>params: <span class="synIdentifier">{</span> id?: <span class="synType">string</span><span class="synStatement">,</span> name: <span class="synType">string</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synStatement">if</span> <span class="synStatement">(</span>params.id <span class="synStatement">===</span> <span class="synType">undefined</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
create<span class="synStatement">(</span>params.id<span class="synStatement">);</span>
<span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span>
update<span class="synStatement">(</span>params.id<span class="synStatement">,</span> params.name<span class="synStatement">);</span>
<span class="synIdentifier">}</span>
<span class="synIdentifier">}</span>
</pre>
<p>UIを実装して、buttonが押される場合を考えると</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synType">const</span> onClickCreate <span class="synStatement">=</span> <span class="synStatement">()</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
createOrUpdate<span class="synStatement">(</span><span class="synIdentifier">{</span>
name: <span class="synConstant">"Digisma"</span><span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span>
<span class="synType">const</span> onClickUpdate <span class="synStatement">=</span> <span class="synStatement">(</span>id: <span class="synType">string</span><span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
createOrUpdate<span class="synStatement">(</span><span class="synIdentifier">{</span>
id<span class="synStatement">,</span>
name: <span class="synConstant">"Digisma2"</span><span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span>
</pre>
<p>このような使われ方がされそうです。
実際、これらは正常に動きます。</p>
<p>次に、以下のような実装をしてしまったと仮定します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synType">const</span> onClickUpdate <span class="synStatement">=</span> <span class="synStatement">(</span>id: <span class="synType">string</span><span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
createOrUpdate<span class="synStatement">(</span><span class="synIdentifier">{</span>
name: <span class="synConstant">"Digisma2"</span><span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span>
</pre>
<p>idを <code>createOrUpdate</code> に渡し忘れているので、Updateを実行したい場面ですが、Createの処理が実行されてしまいます。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">function</span> createOrUpdate<span class="synStatement">(</span>params: <span class="synIdentifier">{</span> id?: <span class="synType">string</span><span class="synStatement">,</span> name: <span class="synType">string</span> <span class="synIdentifier">}</span><span class="synStatement">)</span>
</pre>
<p>元の関数定義を見てみましょう。<code>params</code> の型定義が <code>{ id?: string, name: string }</code> となっています。 <code>id</code>の後ろに <code>?</code> がありますね。</p>
<p>TSではkeyの後ろの <code>?</code> は省略可能であることを意味します。ただ単にundefinedを受け付けたいだけならば、 <code>{ id: string | undefined, name: string }</code> のように、明示的にUnion型で書いた方が安全かもしれません。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synType">const</span> onClickCreate <span class="synStatement">=</span> <span class="synStatement">()</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
createOrUpdate<span class="synStatement">(</span><span class="synIdentifier">{</span>
id: <span class="synType">undefined</span><span class="synStatement">,</span>
name: <span class="synConstant">"Digisma"</span><span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span>
<span class="synType">const</span> onClickUpdate <span class="synStatement">=</span> <span class="synStatement">(</span>id: <span class="synType">string</span><span class="synStatement">)</span> <span class="synStatement">=></span> <span class="synIdentifier">{</span>
createOrUpdate<span class="synStatement">(</span><span class="synIdentifier">{</span>
id<span class="synStatement">,</span>
name: <span class="synConstant">"Digisma2"</span><span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synIdentifier">}</span>
</pre>
<p>Createの方でも <code>id: undefined</code> を渡す必要が出てきますが、「idが無いので、Createする」という風に読めるので全く問題無さそうです。</p>
<p>undefinedを受け付けたいだけなのか、省略可能にしたいのか区別して書くと良さそうですね。</p>
<h2 id="2-ログ送信">2. ログ送信</h2>
<p>2つ目は、ログを送信する場面を考えます。</p>
<p>サービスを開発していると、「あるページが表示された時は <code>page_view</code> ログを、 このボタンが押された時は <code>click</code> ログを送信したい」のような状況に遭遇すると思います。</p>
<p>ここではシンプルに、以下のようなログを送信したくなったとしましょう。</p>
<table>
<thead>
<tr>
<th>EventName</th>
<th>Payload</th>
</tr>
</thead>
<tbody>
<tr>
<td>page_view</td>
<td>{ id: string } </td>
</tr>
<tr>
<td>click</td>
<td>-</td>
</tr>
</tbody>
</table>
<p><code>page_view</code> ログを送るときはidが追加で必要で、<code>click</code> ログを送る時は追加のデータは何も必要無いと言う場面です。(サービス開発が進むにつれて、ログの種類を増やす可能性はありますが、現時点ではこの2種類だけとします。)</p>
<p>ログ送信の実装は複数箇所に散らせたく無いので、以下のように共通のログ送信関数を用意することにします。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">function</span> sendLog0<span class="synStatement">(</span>event: <span class="synType">string</span><span class="synStatement">,</span> payload?: Record<span class="synStatement"><</span><span class="synType">string</span><span class="synStatement">,</span> <span class="synType">string</span><span class="synStatement">>)</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> data <span class="synStatement">=</span> <span class="synIdentifier">{</span>
eventName: event<span class="synStatement">,</span>
...payload<span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synSpecial">console</span>.log<span class="synStatement">(</span>data<span class="synStatement">);</span> <span class="synComment">// 実際には、サーバーにリクエストを送る想定</span>
<span class="synIdentifier">}</span>
</pre>
<p>次のような呼び出しで、ログを送信できます。</p>
<p>ただし、最初の2つは正常ですが、残りの3つは異常系です。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink>sendLog0<span class="synStatement">(</span><span class="synConstant">"page_view"</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> id: <span class="synConstant">"123"</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
sendLog0<span class="synStatement">(</span><span class="synConstant">"click"</span><span class="synStatement">);</span>
sendLog0<span class="synStatement">(</span><span class="synConstant">"page_view"</span><span class="synStatement">);</span> <span class="synComment">// 1</span>
sendLog0<span class="synStatement">(</span><span class="synConstant">"click"</span><span class="synStatement">,</span> <span class="synType">undefined</span><span class="synStatement">);</span> <span class="synComment">// 2</span>
sendLog0<span class="synStatement">(</span><span class="synConstant">"click"</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> id: <span class="synConstant">"123"</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> <span class="synComment">// 3</span>
</pre>
<p>これらは、それぞれ別々の理由で異常系と分類しました。</p>
<ol>
<li>page_viewは、送信時にidを付与する必要があるがそれが抜けているので、不具合</li>
<li>clickは、送信時に何も送る必要が無いため、冗長</li>
<li>clickは、送信時に何も送る必要が無いが、idが送られてしまっている。 (clickをpage_viewと勘違いしている可能性も否定できず)</li>
</ol>
<p>あり得ないパターンは、ビルド時に弾けると嬉しいモチベーションがあるので、型を強化してみましょう。</p>
<p>まず、送信する可能性のある<code>EventName</code> と、それに対応する<code>EventPayload</code>の型定義をします。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">type</span> EventName <span class="synStatement">=</span> <span class="synConstant">"page_view"</span> | <span class="synConstant">"click"</span><span class="synStatement">;</span>
<span class="synStatement">type</span> EventPayload <span class="synStatement">=</span> <span class="synIdentifier">{</span>
page_view: <span class="synIdentifier">{</span> id: <span class="synType">string</span> <span class="synIdentifier">}</span><span class="synStatement">;</span>
click: <span class="synType">undefined</span><span class="synStatement">;</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
</pre>
<p>今後のログ仕様の拡張も想定して、新しい型を定義しています。</p>
<p>次に、これらの型を使ったバージョンのログ送信関数を実装します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">function</span> sendLog<span class="synStatement"><</span>E <span class="synStatement">extends</span> EventName<span class="synStatement">>(</span>
event: E<span class="synStatement">,</span>
...payloads: EventPayload<span class="synIdentifier">[</span>E<span class="synIdentifier">]</span> <span class="synStatement">extends</span> <span class="synType">undefined</span> ? <span class="synIdentifier">[]</span> : <span class="synIdentifier">[</span>EventPayload<span class="synIdentifier">[</span>E<span class="synIdentifier">]]</span><span class="synStatement">,</span>
<span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> data <span class="synStatement">=</span> <span class="synIdentifier">{</span>
eventName: event<span class="synStatement">,</span>
...payloads<span class="synIdentifier">[</span><span class="synConstant">0</span><span class="synIdentifier">]</span><span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synSpecial">console</span>.log<span class="synStatement">(</span>data<span class="synStatement">);</span>
<span class="synIdentifier">}</span>
</pre>
<p><code>...payloads</code> の部分は、 後述する <a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-0.html#rest-parameters-with-tuple-types"><code>Rest parameters with tuple types</code></a> と呼ばれるテクニックです。</p>
<p>この関数を使うと、上の呼び出しパターンは以下のようになります。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink>sendLog<span class="synStatement">(</span><span class="synConstant">"page_view"</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> id: <span class="synConstant">"123"</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
sendLog<span class="synStatement">(</span><span class="synConstant">"click"</span><span class="synStatement">);</span>
<span class="synComment">// Build Error</span>
<span class="synComment">// sendLog("page_view"); // 1</span>
<span class="synComment">// sendLog("click", undefined); // 2</span>
<span class="synComment">// sendLog("click", { id: "123" }); // 3</span>
</pre>
<p>想定していないケースでは、ビルドエラーにできました。
この時、内部の実装にはほとんど変化が無いことが分かります。</p>
<p>同じロジックでも、型の付け方を変えるだけで安全に書けるようになった例の1つでした。</p>
<h3 id="Rest-parameters-with-tuple-types">Rest parameters with tuple types</h3>
<p>このテクニックを紹介します。まず、TSにおける<a href="https://www.typescriptlang.org/docs/handbook/2/objects.html#tuple-types">Tuple型</a>を知る必要あります。Tuple型はArray型を少し厳格にした型で、以下のような性質があります。</p>
<ul>
<li>ビルド時に長さが決定する</li>
<li>ビルド時に、どの位置に何の型が存在するか決定する</li>
</ul>
<p>例えば、次のようにArgs型を定義した場合</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">type</span> Args <span class="synStatement">=</span> <span class="synIdentifier">[</span><span class="synType">number</span><span class="synStatement">,</span> <span class="synType">string</span><span class="synStatement">,</span> <span class="synType">boolean</span><span class="synIdentifier">]</span><span class="synStatement">;</span>
</pre>
<ul>
<li>Args型の長さは 3</li>
<li>Args[0]はnumber型、Args[1]はstring型、Args[2]はboolean型</li>
</ul>
<p>のような性質がビルド時に決定します。</p>
<p><code>Rest parameters with tuple types</code> は、Tuple型をrest parametersの型として扱うテクニックです。</p>
<p>以下の<a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-0.html#rest-parameters-with-tuple-types">引用</a> のように、</p>
<blockquote><p>When a rest parameter has a tuple type, the tuple type is expanded into a sequence of discrete parameters. For example the following two declarations are equivalent:</p></blockquote>
<p>「Tuple型を関数のrest parametersの型として扱った場合、そのTuple型の各要素を順番に定義した場合と同等とする」という仕様です。</p>
<p>例えば、次のfoo関数を定義した場合、これらはどちらも同じということです。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">declare</span> <span class="synStatement">function</span> foo<span class="synStatement">(</span>...args: <span class="synIdentifier">[</span><span class="synType">number</span><span class="synStatement">,</span> <span class="synType">string</span><span class="synStatement">,</span> <span class="synType">boolean</span><span class="synIdentifier">]</span><span class="synStatement">)</span>: <span class="synType">void</span><span class="synStatement">;</span>
<span class="synStatement">declare</span> <span class="synStatement">function</span> foo<span class="synStatement">(</span>args_0: <span class="synType">number</span><span class="synStatement">,</span> args_1: <span class="synType">string</span><span class="synStatement">,</span> args_2: <span class="synType">boolean</span><span class="synStatement">)</span>: <span class="synType">void</span><span class="synStatement">;</span>
</pre>
<p>先ほど実装した <code>sendLog</code> 関数では、<code>...payloads</code> に対して以下のような型定義をしました。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink> ...payloads: EventPayload<span class="synIdentifier">[</span>E<span class="synIdentifier">]</span> <span class="synStatement">extends</span> <span class="synType">undefined</span> ? <span class="synIdentifier">[]</span> : <span class="synIdentifier">[</span>EventPayload<span class="synIdentifier">[</span>E<span class="synIdentifier">]]</span>
</pre>
<p>これは、「あるEvent、Eに対応するEventPayloadがundefinedであれば空のTuple型を、何か定義されていればそれ自身だけを持つ長さ1のTuple型」を示しています。</p>
<p>これを <code>...</code> で展開することで、「EventPayloadが必要無い時は余計な引数を受け付けず、何か定義されていればそれだけを必ず渡す」ことが実現できました。</p>
<h3 id="Function-Overloadsでも実現可能">Function Overloadsでも実現可能</h3>
<p>今回のケースであれば、<a href="https://www.typescriptlang.org/docs/handbook/2/functions.html#function-overloads">overload</a>でも実現できます</p>
<p>TSのoverloadは以下のように、いくつかの関数定義を重ねて書いていき、一番下の定義に実装を書くという形式で実装します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">function</span> sendLog2<span class="synStatement"><</span>E <span class="synStatement">extends</span> EventName<span class="synStatement">>(</span>
event: EventPayload<span class="synIdentifier">[</span>E<span class="synIdentifier">]</span> <span class="synStatement">extends</span> <span class="synType">undefined</span> ? <span class="synType">never</span> : E<span class="synStatement">,</span>
payload: EventPayload<span class="synIdentifier">[</span>E<span class="synIdentifier">]</span><span class="synStatement">,</span>
<span class="synStatement">)</span>: <span class="synType">void</span><span class="synStatement">;</span>
<span class="synStatement">function</span> sendLog2<span class="synStatement"><</span>E <span class="synStatement">extends</span> EventName<span class="synStatement">>(</span>
event: EventPayload<span class="synIdentifier">[</span>E<span class="synIdentifier">]</span> <span class="synStatement">extends</span> <span class="synType">undefined</span> ? E : <span class="synType">never</span><span class="synStatement">,</span>
<span class="synStatement">)</span>: <span class="synType">void</span><span class="synStatement">;</span>
<span class="synStatement">function</span> sendLog2<span class="synStatement"><</span>E <span class="synStatement">extends</span> EventName<span class="synStatement">>(</span>event: E<span class="synStatement">,</span> payload?: EventPayload<span class="synIdentifier">[</span>E<span class="synIdentifier">]</span><span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> data <span class="synStatement">=</span> <span class="synIdentifier">{</span>
eventName: event<span class="synStatement">,</span>
...payload<span class="synStatement">,</span>
<span class="synIdentifier">}</span><span class="synStatement">;</span>
<span class="synSpecial">console</span>.log<span class="synStatement">(</span>data<span class="synStatement">);</span>
<span class="synIdentifier">}</span>
</pre>
<p>この時、一番下の実装が書かれた関数定義を <code>Implementation Signature</code>、それ以外の重ねて書いた部分を <code>Overload Signature</code> と呼びます。
重要な点としては、使う側からは <code>Overload Signature</code> の部分しか見えないということです。</p>
<p>今回のケースで <code>sendLog2</code> を呼び出す場合は</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">function</span> sendLog2<span class="synStatement"><</span>E <span class="synStatement">extends</span> EventName<span class="synStatement">>(</span>
event: EventPayload<span class="synIdentifier">[</span>E<span class="synIdentifier">]</span> <span class="synStatement">extends</span> <span class="synType">undefined</span> ? <span class="synType">never</span> : E<span class="synStatement">,</span>
payload: EventPayload<span class="synIdentifier">[</span>E<span class="synIdentifier">]</span><span class="synStatement">,</span>
<span class="synStatement">)</span>: <span class="synType">void</span><span class="synStatement">;</span>
<span class="synStatement">function</span> sendLog2<span class="synStatement"><</span>E <span class="synStatement">extends</span> EventName<span class="synStatement">>(</span>
event: EventPayload<span class="synIdentifier">[</span>E<span class="synIdentifier">]</span> <span class="synStatement">extends</span> <span class="synType">undefined</span> ? E : <span class="synType">never</span><span class="synStatement">,</span>
<span class="synStatement">)</span>: <span class="synType">void</span><span class="synStatement">;</span>
</pre>
<p>この2つの内、どちらかの形式で呼び出す必要があります。</p>
<p>例えば以下のように、1つだけの引数で呼び出した場合、2番目のSignatureで呼ばれたと解釈されます。このSignatureではEventPayloadが定義されている場合の第一引数の型は <code>event: never</code> です。</p>
<p>never型にstring型を渡そうとしているのでエラーになります。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink>sendLog2<span class="synStatement">(</span><span class="synConstant">"page_view"</span><span class="synStatement">);</span>
</pre>
<p>次は以下のように、2つの引数で呼び出してみます。この場合は、1番目のSignatureで呼ばれたと解釈されます。このSignatureでは、EventPayloadがundefinedの時に第一引数の型が <code>event: never</code> となるので、上と同じ理屈でエラーになります。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink>sendLog2<span class="synStatement">(</span><span class="synConstant">"click"</span><span class="synStatement">,</span> <span class="synType">undefined</span><span class="synStatement">);</span>
sendLog2<span class="synStatement">(</span><span class="synConstant">"click"</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> id: <span class="synConstant">"123"</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
</pre>
<p>最終的に、EventPayloadが存在する場合は必ず2つの引数を、存在しない場合は1つの引数だけを渡す制約がかかることになります。</p>
<h2 id="3-画面遷移パス生成">3. 画面遷移パス生成</h2>
<p>最後は、画面遷移パスを生成するケースを考えます。</p>
<p>例えば、Next.js や React Routerなどを使っていると、</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink>router.push<span class="synStatement">(</span><span class="synConstant">"/users/123"</span><span class="synStatement">);</span>
</pre>
<p>みたいに、画面遷移のためのパスを生成したいことがあると思います。シンプルにやるならば、</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synConstant">`/users/</span><span class="synSpecial">${</span>userId<span class="synSpecial">}</span><span class="synConstant">`</span>
</pre>
<p>このように直接埋め込む形式を取れますが、他の案を考えてみましょう。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">function</span> createPath0<span class="synStatement">(</span>path: <span class="synType">string</span><span class="synStatement">,</span> param?: Record<span class="synStatement"><</span><span class="synType">string</span><span class="synStatement">,</span> <span class="synType">string</span><span class="synStatement">>)</span>: <span class="synType">string</span> <span class="synIdentifier">{</span>
<span class="synType">let</span> p: <span class="synType">string</span> <span class="synStatement">=</span> path<span class="synStatement">;</span>
<span class="synStatement">for</span> <span class="synStatement">(</span><span class="synType">const</span> key <span class="synStatement">in</span> param<span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> value <span class="synStatement">=</span> param<span class="synIdentifier">[</span>key<span class="synIdentifier">]</span><span class="synStatement">;</span>
p <span class="synStatement">=</span> p.replace<span class="synStatement">(</span><span class="synConstant">`:</span><span class="synSpecial">${</span>key<span class="synSpecial">}</span><span class="synConstant">`</span><span class="synStatement">,</span> value<span class="synStatement">);</span>
<span class="synIdentifier">}</span>
<span class="synStatement">return</span> p<span class="synStatement">;</span>
<span class="synIdentifier">}</span>
</pre>
<p>例えば、こういう「特定の文字列をreplaceして生成する」方式も考えられます。
いくつかのパターンで、この関数を呼び出してみます。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// output: "/users/user1"</span>
createPath0<span class="synStatement">(</span><span class="synConstant">"/users/:userId"</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> userId: <span class="synConstant">"user1"</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synComment">// output: "/users/:userId"</span>
createPath0<span class="synStatement">(</span><span class="synConstant">"/users/:userId"</span><span class="synStatement">);</span>
createPath0<span class="synStatement">(</span><span class="synConstant">"/users/:userId"</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> userid: <span class="synConstant">"user1"</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
</pre>
<p>最初の呼び出しは、正常に処理されています。ただ、2番目、3番目の呼び出しでは渡したパスのフォーマットがそのまま返されてしまいます。</p>
<ul>
<li>2番目の呼び出し: replaceされるべき文字列 ":userId" が存在するが、それに対応するパラメータを渡し忘れている</li>
<li>3番目の呼び出し: replaceされるべき文字列 ":userId" が存在し、パラメータも渡しているが "userid" にタイポしている</li>
</ul>
<p>想定していないパターンは、ビルド時に検知できると嬉しいので型付けをしていきます。</p>
<p>まず、可能性のあるパスのフォーマット型、Path型を定義します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">type</span> Path <span class="synStatement">=</span> <span class="synConstant">"/users"</span> | <span class="synConstant">"/users/:userId"</span> | <span class="synConstant">"/users/:userId/items/:itemId"</span><span class="synStatement">;</span>
</pre>
<p>次に、渡されたパスのフォーマット型からreplaceすべき文字列を抽出する型、PathParams型を定義します。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">type</span> PathParams<span class="synStatement"><</span>PATH <span class="synStatement">extends</span> <span class="synType">string</span><span class="synStatement">></span> <span class="synStatement">=</span> PATH <span class="synStatement">extends</span> <span class="synConstant">`</span><span class="synSpecial">${</span><span class="synType">string</span><span class="synSpecial">}</span><span class="synConstant">:</span><span class="synSpecial">${</span>infer Param<span class="synSpecial">}</span><span class="synConstant">/</span><span class="synSpecial">${</span>infer Rest<span class="synSpecial">}</span><span class="synConstant">`</span>
? Param | PathParams<span class="synStatement"><</span><span class="synConstant">`/</span><span class="synSpecial">${</span>Rest<span class="synSpecial">}</span><span class="synConstant">`</span><span class="synStatement">></span>
: PATH <span class="synStatement">extends</span> <span class="synConstant">`</span><span class="synSpecial">${</span><span class="synType">string</span><span class="synSpecial">}</span><span class="synConstant">:</span><span class="synSpecial">${</span>infer Param<span class="synSpecial">}</span><span class="synConstant">`</span>
? Param
: <span class="synType">never</span><span class="synStatement">;</span>
</pre>
<p>この型は、渡された型パラメータが</p>
<ul>
<li>:A/B の形式である場合、Aを抽出し、Bを再帰的に処理する</li>
<li>:Aの形式である場合、Aを抽出する</li>
<li>それ以外の場合、neverを返す</li>
</ul>
<p>というルールで処理し、最終的に抽出したパラメータのUnion型 ( <code>P1 | P2 | never</code> )を返す型です。</p>
<p>先ほど定義したPathを渡してみると、次のような結果になります。replaceすべき文字列のUnion型が得られたことが分かります。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// never</span>
<span class="synStatement">type</span> Example1 <span class="synStatement">=</span> PathParams<span class="synStatement"><</span><span class="synConstant">"/users"</span><span class="synStatement">>;</span>
<span class="synComment">// "userId"</span>
<span class="synStatement">type</span> Example2 <span class="synStatement">=</span> PathParams<span class="synStatement"><</span><span class="synConstant">"/users/:userId"</span><span class="synStatement">>;</span>
<span class="synComment">// "userId" | "itemId"</span>
<span class="synStatement">type</span> Example3 <span class="synStatement">=</span> PathParams<span class="synStatement"><</span><span class="synConstant">"/users/:userId/items/:itemId"</span><span class="synStatement">>;</span>
</pre>
<p>次に、これらの型を使ってcreatePathを実装します。 <a href="#f-e50b5f5f" id="fn-e50b5f5f" name="fn-e50b5f5f" title=" exntends [Never]、Never型の判定について ">*1</a></p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">function</span> createPath<span class="synStatement"><</span>PATH <span class="synStatement">extends</span> Path<span class="synStatement">>(</span>
path: PATH<span class="synStatement">,</span>
...params: <span class="synIdentifier">[</span>PathParams<span class="synStatement"><</span>PATH<span class="synStatement">></span><span class="synIdentifier">]</span> <span class="synStatement">extends</span> <span class="synIdentifier">[</span><span class="synType">never</span><span class="synIdentifier">]</span> ? <span class="synIdentifier">[]</span> : <span class="synIdentifier">[{</span> <span class="synIdentifier">[</span>key <span class="synStatement">in</span> PathParams<span class="synStatement"><</span>PATH<span class="synStatement">></span><span class="synIdentifier">]</span>: <span class="synType">string</span> <span class="synIdentifier">}]</span>
<span class="synStatement">)</span>: <span class="synType">string</span> <span class="synIdentifier">{</span>
<span class="synType">let</span> p: <span class="synType">string</span> <span class="synStatement">=</span> path<span class="synStatement">;</span>
<span class="synType">let</span> param <span class="synStatement">=</span> params<span class="synIdentifier">[</span><span class="synConstant">0</span><span class="synIdentifier">]</span> ?? <span class="synStatement">(</span><span class="synIdentifier">{}</span> <span class="synStatement">as</span> <span class="synIdentifier">{</span> <span class="synIdentifier">[</span>key: <span class="synType">string</span><span class="synIdentifier">]</span>: <span class="synType">string</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synStatement">for</span> <span class="synStatement">(</span><span class="synType">const</span> key <span class="synStatement">in</span> param<span class="synStatement">)</span> <span class="synIdentifier">{</span>
<span class="synType">const</span> value <span class="synStatement">=</span> param<span class="synIdentifier">[</span>key<span class="synIdentifier">]</span><span class="synStatement">;</span>
p <span class="synStatement">=</span> p.replace<span class="synStatement">(</span><span class="synConstant">`:</span><span class="synSpecial">${</span>key<span class="synSpecial">}</span><span class="synConstant">`</span><span class="synStatement">,</span> value<span class="synStatement">);</span>
<span class="synIdentifier">}</span>
<span class="synStatement">return</span> p<span class="synStatement">;</span>
<span class="synIdentifier">}</span>
</pre>
<p>何パターンかで呼び出してみます。</p>
<pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// "/users" </span>
createPath<span class="synStatement">(</span><span class="synConstant">"/users"</span><span class="synStatement">);</span>
<span class="synComment">// "/users/user1"</span>
createPath<span class="synStatement">(</span><span class="synConstant">"/users/:userId"</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> userId: <span class="synConstant">"user1"</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synComment">// "/users/user1/items/1"</span>
createPath<span class="synStatement">(</span><span class="synConstant">"/users/:userId/items/:itemId"</span><span class="synStatement">,</span> <span class="synIdentifier">{</span> userId: <span class="synConstant">"user1"</span><span class="synStatement">,</span> itemId: <span class="synConstant">"1"</span> <span class="synIdentifier">}</span><span class="synStatement">);</span>
<span class="synComment">// Build Error</span>
<span class="synComment">// createPath("/users", { userId: "user0" }); // 4</span>
<span class="synComment">// createPath("/users/:userId/items/:itemId", { userId: "user0" }); // 5</span>
</pre>
<p>最初の3つの呼び出しでは正常にパスを取得できています。また、このコードを書く時に補完が効くようになるというありがたい副産物も得られました。
4番目と5番目の呼び出しはビルド時にエラーになります。</p>
<ul>
<li>4番目: replaceすべき文字列が無いのに、パラメータを渡しているのでエラー</li>
<li>5番目: replaceすべき文字列は、"userId"と"itemId"の2つ。"userId"の方は渡しているが、"itemId"を渡していないのでエラー</li>
</ul>
<h2 id="まとめ">まとめ</h2>
<p>3つの場面を仮定して、普通の型で実装したパターンと強化版の型で実装したパターンを比べました。Before/Afterで比べてみると、内部のロジックはほとんど同じであることが分かります。</p>
<p>内部ロジックの書き換えはほとんどしていないですが、型の付け方を工夫するだけで呼び出し側の振る舞いを大きく改善できました。</p>
<p>やり過ぎない程度の型付けで、安全なコードを書けるようになるのがベストですね。</p>
<h1 id="We-are-hiring">We are hiring!!</h1>
<p>エムスリーでは絶賛エンジニアを募集中です!
今回紹介した技術スタック・アーキテクチャ以外にも様々な構成のプロダクトがありますので、ご興味ある方は是非こちらからお願いします!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<div class="footnote">
<p class="footnote"><a href="#fn-e50b5f5f" id="f-e50b5f5f" name="f-e50b5f5f" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text"> exntends [Never]、<a href="https://www.m3tech.blog/entry/2023/03/10/142235">Never型の判定について</a> </span></p>
</div>
m3yuyahorita
230回続く社内LT大会の忘年会が盛り上がった件について
hatenablog://entry/6801883189073647355
2024-01-10T13:01:19+09:00
2024-01-10T14:01:43+09:00 オフライン用にSlackのTechTalkチャンネルから情報取得して名札を作るスクリプトを書いたところ出来上がってしまったSimple Pollさんの名札。本文とは関係ありません。 新年あけましておめでとうございます。 年末年始は『BURN THE WITCH #0.8』が最高でした、エムスリーVPoE 河合(@vaaaaanquish)です。 さて、エムスリーエンジニアリンググループには『Tech Talk』という技術LT大会文化がありまして、隔週でエンジニアの多くが参加して技術の話で盛り上がっています。 本記事は、忘年会を含めて開催しましたTech Talk オフライン回の開催報告記です。…
<p><figure class="figure-image figure-image-fotolife" title="オフライン用にSlackのTechTalkチャンネルから情報取得して名札を作るスクリプトを書いたところ出来上がってしまったSimple Pollさんの名札。本文とは関係ありません。"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240110/20240110140133.png" width="787" height="520" loading="lazy" title="" class="hatena-fotolife" style="width:450px" itemprop="image"></span><figcaption>オフライン用にSlackのTechTalkチャンネルから情報取得して名札を作るスクリプトを書いたところ出来上がってしまったSimple Pollさんの名札。本文とは関係ありません。</figcaption></figure></p>
<p>新年あけましておめでとうございます。
年末年始は『BURN THE WITCH #0.8』が最高でした、エムスリーVPoE 河合(<a href="https://twitter.com/vaaaaanquish">@vaaaaanquish</a>)です。</p>
<p>さて、エムスリーエンジニアリンググループには『Tech Talk』という技術LT大会文化がありまして、隔週でエンジニアの多くが参加して技術の話で盛り上がっています。</p>
<p>本記事は、忘年会を含めて開催しましたTech Talk オフライン回の開催報告記です。</p>
<ul class="table-of-contents">
<li><a href="#Tech-Talkについて">Tech Talkについて</a></li>
<li><a href="#オフライン開催">オフライン開催</a></li>
<li><a href="#個人的な感想">個人的な感想</a></li>
<li><a href="#おわりに">おわりに</a></li>
<li><a href="#We-are-hiring-">We are hiring !!</a></li>
</ul>
<p> </p>
<h1 id="Tech-Talkについて">Tech Talkについて</h1>
<p>先にも紹介しました『Tech Talk』は、エムスリー社内で行われているLT大会です。</p>
<p>盛り上がった一部の発表についてはYouTubeでも公開しておりますので、是非チャンネル登録よろしくお願いします!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.youtube.com%2F%40m3techchannel160%2Fvideos" title="エムスリー公式テックチャンネル 【M3 Tech Channel】 - YouTube" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.youtube.com/@m3techchannel160/videos">www.youtube.com</a></cite></p>
<p>下記のツイートの通り、2週間に1回、金曜17:00から1時間、技術トークを肴にSlackで盛り上がっています。</p>
<p><blockquote data-conversation="none" class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">「社内勉強会って活発ですか?」とよく聞かれるんですが、隔週金曜17時からエンジニアの半数以上が集まるLT会が230回続いてます!<br><br>1ヶ月先まで登壇予約がある人気っぷりでタイトルも一部公開してます!<a href="https://t.co/8qpKb6E3Dj">https://t.co/8qpKb6E3Dj</a><br><br>選りすぐりの発表はYouTubeにも!!!<a href="https://t.co/w6i4T2lq7J">https://t.co/w6i4T2lq7J</a></p>— ばんくし王 (@vaaaaanquish) <a href="https://twitter.com/vaaaaanquish/status/1743096227401723931?ref_src=twsrc%5Etfw">2024年1月5日</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> </p>
<p>登壇内容は、スプレッドシートの機能を使い、運用シートの一部公開できないものを除いたタイトル、スライドをpublicな状態にしています。以下のシートから、230回の開催と常に1ヶ月以上先まで埋まっている実情を外部からもチェックできるようになっています。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdocs.google.com%2Fspreadsheets%2Fd%2Fe%2F2PACX-1vQMVA_Rn4wvut3EIlNxuTgp4tVc5HDoZflFLaMFiqk4MzCF49z90Myo0lioO08gO9X1rhJHjcwLxiRN%2Fpubhtml" title="M3 Tech Talk 発表者立候補管理(公開) - Google ドライブ" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://docs.google.com/spreadsheets/d/e/2PACX-1vQMVA_Rn4wvut3EIlNxuTgp4tVc5HDoZflFLaMFiqk4MzCF49z90Myo0lioO08gO9X1rhJHjcwLxiRN/pubhtml">docs.google.com</a></cite></p>
<p>上記シートからタイトル一覧を見てもらえれば分かるかと思いますが、フレームワークやプログラミング言語、実務に関連した話をしている人も居れば、低レイヤ、IoT、セキュリティ、自作キーボード、数学、アルゴリズム、エディタ、登壇記、…と内容は多種多様です。外部の方による講演、ビジネス職による書籍紹介、果てはVTuberによるLTなども過去実施されています笑</p>
<p>個人的には『技術の無駄使い』な発表が好きで、vimに手書き文字認識を実装したり、オタマトーンを魔改造する事例辺りが今でも記憶に残っています。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdic.nicovideo.jp%2Fa%2F%25E6%258A%2580%25E8%25A1%2593%25E3%2581%25AE%25E7%2584%25A1%25E9%25A7%2584%25E9%2581%25A3%25E3%2581%2584" title="技術の無駄遣いとは (ギジュツノムダヅカイとは) [単語記事] - ニコニコ大百科" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://dic.nicovideo.jp/a/%E6%8A%80%E8%A1%93%E3%81%AE%E7%84%A1%E9%A7%84%E9%81%A3%E3%81%84">dic.nicovideo.jp</a></cite></p>
<p>ちなみにvim手書き文字認識の発表は<a href="https://www.youtube.com/watch?v=MQvl7dFfrXQ&t=563s">こちら</a>、オタマトーンは<a href="https://www.youtube.com/watch?v=iXzADTQY_Gw">こちら</a>です。</p>
<p> </p>
<h1 id="オフライン開催">オフライン開催</h1>
<p>元々Tech Talk自体は2016年からオフラインで開催されていましたが、コロナ禍でのリモートワーク化に伴って、最近はZoomとSlackを用いたオンライン開催が主流になっていました。オンライン開催も毎回盛り上がるのですが、やはり「皆で集まって技術の話をしたい」という想いがあり、2023年の忘年会を含めてオフライン版を企画、実施しました。</p>
<p><figure class="figure-image figure-image-fotolife" title="AI・機械学習チームによるベスト改修大賞の発表"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240110/20240110140123.jpg" width="800" height="600" loading="lazy" title="" class="hatena-fotolife" style="width:450px" itemprop="image"></span><figcaption>AI・機械学習チームによるベスト改修大賞の発表</figcaption></figure></p>
<p>当日は、「Redashを使ったExportable TSVの話」や「プロダクトのクラウド化の話」「フロントエンドにおけるUIフレームワークとCSSの仕様について」など、技術のコアな話と笑いを織り交ぜたトークが5本ありました。オンラインとも接続し、<strong>Slackでは1時間で500以上ものコメントが付くほど盛り上がりました</strong>。取締役CTOの山崎も参加し、LTにツッコミを入れるなど盛り上げていました。</p>
<p>その後、同じ会場にてピザを食べながら2023年の忘年会を開催しました。中継ぎとして、2023年に入ってくれたエンジニアの新しい仲間や多く生まれた新任TL、CISO就任、エンジニア組織からの執行役員就任を祝ったりもしました。</p>
<p><figure class="figure-image figure-image-fotolife" title="盛り上がる会場の中で激写される私"><div class="images-row mceNonEditable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240110/20240110140126.jpg" width="800" height="600" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20240110/20240110140129.jpg" width="800" height="600" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></div><figcaption>盛り上がる会場の中で激写される私</figcaption></figure></p>
<p>オフラインの会場では、名札を通して「今までSlack上でしかコミュニケーションがなかった人」や「新しいメンバー」「チーム間」での交流が行われ、技術を通して盛り上がる事が出来ました。</p>
<p> </p>
<h1 id="個人的な感想">個人的な感想</h1>
<p>オフライン回は非常に好評で、特にリアルでは技術に対するトーンや表現も伝わりやすく良いなと改めて感じさせられる会となりました。</p>
<p>エムスリーの魅力としても長らく感じていたのですが、Slackコメントや忘年会のトークからも技術の「コミケ感」が表現されていた時間になったなと思います。
ソフトウェアエンジニア、QA、PdM、皆1人1人が主人公で、<strong>全員から「俺達がコミケを作っている」という雰囲気を感じられる</strong>のが非常に楽しく幸せに感じられました。
技術という広く深い分野を通して、1つの組織、1つのコミケを皆で作る、という形を今後も継続していきたいなと思いました。</p>
<p>今後、私としても年に数回オフラインも実施できればなと思っています。こういった活動1つ1つが、エムスリーのギーク文化を支え、イノベーションの源泉になると信じています。</p>
<p> </p>
<h1 id="おわりに">おわりに</h1>
<p>エムスリーは、Tech Talkが続く技術大好き、ギークでスマートな文化の会社です。</p>
<p>今回はご紹介出来ませんでしたが、輪読会や開発合宿、負債返却Dayなんかもチーム単位で実施されています。</p>
<p>また、それぞれでブログが書けるといいなと思っておりますので、是非最新の発信をウォッチできるXアカウント <a href="https://twitter.com/m3_engineering">@m3_engineering</a> のフォローをお願いします!</p>
<p><a href="https://twitter.com/m3_engineering?ref_src=twsrc%5Etfw" class="twitter-follow-button" data-show-count="false">Follow @m3_engineering</a><script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script></p>
<p> </p>
<h1 id="We-are-hiring-">We are hiring !!</h1>
<p>技術が大好きな皆さんのご応募をお待ちしております。一緒にコミケ感を作っていきましょう!</p>
<p>カジュアル面談では、私、河合(<a href="https://twitter.com/vaaaaanquish">@vaaaaanquish</a>)とざっくばらんに話せます。お気軽にご応募下さい!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
vaaaaaanquish
AI・機械学習チーム最強MR(Merge Request)決定戦2023
hatenablog://entry/6801883189066743424
2023-12-27T18:06:25+09:00
2023-12-27T18:07:05+09:00 AI・機械学習チームの(中村@po3rin)です。 今年もこの季節がやってきました。エムスリーAI・機械学習チームの最強MR決定戦のお時間です。 MRとはMerge Requestの略称です。 GitHubでいうところのPR (Pull Request) にあたります。 この記事ではAI・機械学習チームが毎年恒例で行なっているベストMRのトップ10について発表します。 このベストMRはチーム内でこれは最高だった!というMRをノミネートしていき、その中で決選投票をしてベスト10を決めました。 今年も熾烈な闘いを勝ち上がった至極のMRがノミネートされました。
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231227/20231227180633.png" width="800" height="472" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>AI・機械学習チームの(中村<a href="https://twitter.com/po3rin">@po3rin</a>)です。</p>
<p>今年もこの季節がやってきました。エムスリーAI・機械学習チームの最強MR決定戦のお時間です。</p>
<p>MRとはMerge Requestの略称です。
GitHubでいうところのPR (Pull Request) にあたります。</p>
<p>この記事ではAI・機械学習チームが毎年恒例で行なっているベストMRのトップ10について発表します。
このベストMRはチーム内でこれは最高だった!というMRをノミネートしていき、その中で決選投票をしてベスト10を決めました。
今年も熾烈な闘いを勝ち上がった至極のMRがノミネートされました。</p>
<p>では、10位からご覧ください。と、言いたいところですが、今年は同票のため11位から紹介します。
このブログの内容は、投票した人の中から紹介者を決めて、実装者のやったMRを紹介するスタイルで書かれました。</p>
<ul class="table-of-contents">
<li><a href="#第11位-CronJobの監視シート記入漏れを事前警告する">第11位 CronJobの監視シート記入漏れを事前警告する</a></li>
<li><a href="#第10位-機械学習モデルのアンサンブルを高速化">第10位 機械学習モデルのアンサンブルを高速化</a></li>
<li><a href="#第9位-社内ライブラリにpanderaを導入">第9位 社内ライブラリにpanderaを導入</a></li>
<li><a href="#第8位-Cruftの導入">第8位 Cruftの導入</a></li>
<li><a href="#第7位-不定期な依頼ベースの実行をslackワークフロー化">第7位 不定期な依頼ベースの実行をslackワークフロー化</a></li>
<li><a href="#第6位-スクリプト1発でユーザーグループ毎に予測モデルを最適化">第6位 スクリプト1発で、ユーザーグループ毎に予測モデルを最適化</a></li>
<li><a href="#第5位-Prometheusを利用してカスタムエラー通知を作成する">第5位 Prometheusを利用して、カスタムエラー通知を作成する</a></li>
<li><a href="#第4位-ChatGPT連携SlackBotの爆速爆誕">第4位 ChatGPT連携SlackBotの爆速爆誕</a></li>
<li><a href="#第3位-可能な限り公式PyPIを使う">第3位 可能な限り公式PyPIを使う</a></li>
<li><a href="#第2位-社内ライブラリの負債を一気に解消">第2位 社内ライブラリの負債を一気に解消</a></li>
<li><a href="#第1位-poetry-lock-を高速化するための制約を追加">第1位 poetry lock を高速化するための制約を追加</a></li>
<li><a href="#まとめ-MLエンジンを高速リリースするための基礎開発とは">まとめ: MLエンジンを高速リリースするための基礎開発とは</a></li>
<li><a href="#We-are-hiring">We are hiring!!!</a></li>
</ul>
<h2 id="第11位-CronJobの監視シート記入漏れを事前警告する">第11位 CronJobの監視シート記入漏れを事前警告する</h2>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231227/20231227180657.png" width="800" height="431" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<ul>
<li>紹介者: 高田</li>
<li>実装者: 三浦</li>
</ul>
<p>AI・機械学習チームでは数多くのプロダクトでBatchやAPIをGKE上で運用しています。特にBatchについてはスケジューリング実行にCronJobを採用しており、
CronJobが想定の時間内に終わったかをモニタリングするSLO監視も内製しています。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2Fai-slo-monitoring" title="MLのバッチでもSLO監視はじめました。 - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/ai-slo-monitoring">www.m3tech.blog</a></cite></p>
<p>SLO監視の手順として次の2ステップが必要になります。</p>
<ol>
<li>CronJobを作成する</li>
<li>スプレッドシートにSLO基準の終了時刻を記入する</li>
</ol>
<p>しかし1で満足してしまい2を忘れてしまうケースが定期的に発生していました。仮に正常にJobが終了していても、2を忘れるとslackのエラー通知チャンネルに通知が飛んで寿命が縮む思いをしてしまいます。</p>
<p>そこで本MRでは、チームの通常業務用チャンネルに警告の意味合いで記入漏れを通知することでスプレッドシートへの記入を促し、エラー通知チャンネルへの通知という一発レッドカードを回避することが可能になりました。</p>
<p>各メンバーが多くのプロダクト開発を担当するAI・機械学習チームにとって、運用負荷を下げたことが評価され入賞しました。</p>
<h2 id="第10位-機械学習モデルのアンサンブルを高速化">第10位 機械学習モデルのアンサンブルを高速化</h2>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231227/20231227180647.png" width="782" height="89" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<ul>
<li>紹介者: 氏家</li>
<li>実装者: 池嶋</li>
</ul>
<p>AI・機械学習チームでは多くの機械学習モデルを開発、運用しています。その中で、精度が高くなるようにいくつかのモデルを組み合わせる(アンサンブル)ことが頻繁に起こります。
n個のモデルのm個の組み合わせを考慮するためにはnCm通りの計算が必要であり、当初は愚直に全探索する実装だったためにmが大きくなるにつれて膨大な時間がかかっていました。
このMRでは、そのアンサンブルを行列積としてリファクタリングすることで計算を効率化し、結果として数分かかっていたアンサンブルが数秒まで短縮されました。
非効率な実装をアルゴリズム力で効率化しているというAIチームらしさから堂々の10位入賞となりました。</p>
<h2 id="第9位-社内ライブラリにpanderaを導入">第9位 社内ライブラリにpanderaを導入</h2>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231227/20231227180653.png" width="800" height="217" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<ul>
<li>紹介者: 農見</li>
<li>実装者: 池嶋</li>
</ul>
<p>今までも独自のラッパーを作成して、型チェックは行ってたのですが、nullがないか、数値が0以上か等の確認はできてませんでした。そこで<a href="https://github.com/unionai-oss/pandera">pandera</a>を導入して全てのカラムを色んな条件で型チェックできるようにしました。panderaはデータフレームのバリデーションをするのに、とても便利なのでおすすめです。</p>
<h2 id="第8位-Cruftの導入">第8位 Cruftの導入</h2>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231227/20231227180638.png" width="800" height="211" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<ul>
<li>紹介者: 池嶋</li>
<li>実装者: 中村</li>
</ul>
<p><a href="https://cruft.github.io/cruft/">cruft</a>というツールを導入して、最新cookiecutter templateへの追従を楽にできるようにしました。
最新cookiecutter templateに追従してない場合、警告してくれるようにCIを設定し、templateを更新するだけで、他プロジェクトにその更新を促せるようになりました。これに関してはブログも公開しています。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2Fcruft" title="cruft実践入門 ~cookiecutter templateの変更に追従する~ - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/cruft">www.m3tech.blog</a></cite></p>
<h2 id="第7位-不定期な依頼ベースの実行をslackワークフロー化">第7位 不定期な依頼ベースの実行をslackワークフロー化</h2>
<ul>
<li>紹介者: 池嶋</li>
<li>実装者: 須藤</li>
</ul>
<p>AIチームではビジネスサイドとの連携が密接になっており、依頼ベースでの不定期な機械学習バッチが多数存在しています。
これらはKubernetes上へバッチのデプロイが必要だったため、AIチームのエンジニアによってデプロイが行われていました。
しかし、依頼が増えるに連れ、段々と対応の工数が増えて来る問題が発生していました。</p>
<p>そこで、これらのバッチの一部をslackワークフローと連携させることで、エンジニアでなくても簡単にKubernetesへのバッチデプロイを可能にしました。
依頼が多数重なり、AIチームのエンジニアが対応に追われる、なんてケースはもうなくなりました。
また、ビジネスサイドのメンバーが設定を多少調整して試行錯誤することも可能なので、AIチームとビジネスサイドの間の相談に起因するリードタイムも大幅に短くすることができました。</p>
<h2 id="第6位-スクリプト1発でユーザーグループ毎に予測モデルを最適化">第6位 スクリプト1発で、ユーザーグループ毎に予測モデルを最適化</h2>
<p><figure class="figure-image figure-image-fotolife" title="改善したセグメンテーションワークフロー"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231227/20231227180630.png" width="800" height="267" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>改善したセグメンテーションワークフロー</figcaption></figure></p>
<ul>
<li>紹介者: 謝</li>
<li>実装者: 池嶋</li>
</ul>
<p>ユーザーの興味にあったコンテンツを予測するために、ユーザーのクラスタリングスコアのセグメンテーションが必要です。ユーザグループごとに異なるセグメンテーションの閾値を決定することが必要なので、セグメンテーションは恐らく分析プロセスで最も時間がかかる部分です。過去には、AIチームのエンジニアは以下の手順が必要でした:</p>
<ol>
<li>ユーザーグループごとにセグメンテーションコンフィグを用意します。</li>
<li>ユーザーグループごとにセグメンテーションスクリプト一つずつを実行します。</li>
<li>各グループの出力ディレクトリの性能グラフをチェックします。</li>
<li>各グループの結果を結合するためのカスタムスクリプトを作成、実行します。</li>
</ol>
<p>以上の作業手順を減らすために、このMRではセグメンテーションのワークフローを改善しています。今では、1つのコンフィグを用意するだけで済み、スクリプトを1回だけ実行すればよいようになりました。結果は自動的に結合され、すべての性能グラフは1つのディレクトリにまとめられます。これにより効率が二倍以上大幅に向上し、コンフィグの作成や結果の結合時に発生する手動ミスも防止できます。</p>
<h2 id="第5位-Prometheusを利用してカスタムエラー通知を作成する">第5位 Prometheusを利用して、カスタムエラー通知を作成する</h2>
<ul>
<li>紹介者: 浮田</li>
<li>実装者: 北川</li>
</ul>
<p>このMRが対象としたプロダクトでは、ビジネスサイドに入力してもらったデータをDBに投入し、そのDBをAPIから叩くというアーキテクチャになっています。その際、誤って不正なデータが入力されてしまうことがあり、その場合APIのリクエストに失敗するので、何かしら不正を検知して通知をすることが必要になります (今回はSlack通知を使用)。</p>
<p>単純にDBと整合していないAPIリクエストが来たときにSlack通知をするようにしてしまうと、リクエスト数ぶんの通知が飛んできてしまい、通知が見にくくなってしまいます。</p>
<p>そこでこのMRでは、PrometheusとGoogle Cloud Monitoringを使ってカスタムエラー通知を作ることで、そのような通知を実現しています。チームで新しいツールを導入して問題を解決したという点も良く、堂々の5位入賞となりました。</p>
<h2 id="第4位-ChatGPT連携SlackBotの爆速爆誕">第4位 ChatGPT連携SlackBotの爆速爆誕</h2>
<ul>
<li>紹介者: 横本</li>
<li>実装者: 北川</li>
</ul>
<p>ChatGPTは言わずもがな生産性を大きく高めてくれるサービスで、エムスリーでも最初期から導入が検討されました。
現在ではChatGPT Enterpriseなどの整備が進んでいるChatGPTですが、サービス開始当初の(特にWeb UI版の)利用規約はエムスリーでそのまま導入するにはハードルの高いものとなっていました。そこで、ガバナンスを担保しつつChatGPTを利用できる方法としてSlack Botを間に挟むことにしました。</p>
<p>という方針が決まって「誰かつくらない?」と話が出た翌日には稼働開始という爆速っぷりで開発されたMR(というかリポジトリ)が第4位に選ばれました。</p>
<h2 id="第3位-可能な限り公式PyPIを使う">第3位 可能な限り公式PyPIを使う</h2>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231227/20231227180641.png" width="800" height="284" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<ul>
<li>紹介者: 須藤</li>
<li>実装者: 三浦</li>
</ul>
<p>Pythonのパッケージ管理は、開発プロセスにおいて欠かせない要素です。AI・機械学習チームでは、Pythonのパッケージ管理にPoetryを利用していますが、lock(パッケージ間の依存関係の解決)に非常に時間がかかっていたことが問題でした。その原因の1つは、すべてのパッケージを社内のPyPIから取得していたことでした。公式のPyPIからパッケージを取得する際は、JSON APIを使ってパッケージ情報を取得できるのですが、社内のPyPIでは、パッケージ情報を取得するためにパッケージそのものをダウンロードする必要があるためです。この問題を解決するために、社内で利用するパッケージに関しては社内のPyPIから取得し、それ以外のパッケージについては公式のPyPIから取得するように変更しました。この変更により、lockの実行が劇的に高速化されました。AI・機械学習チームでは、ほぼすべてのプロジェクトでPythonが使用されているため、チームの生産性向上に大きく貢献しました。</p>
<h2 id="第2位-社内ライブラリの負債を一気に解消">第2位 社内ライブラリの負債を一気に解消</h2>
<p><figure class="figure-image figure-image-fotolife" title="関係者の多さを表すMRのdescriptionと変更ファイルの多さ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231227/20231227180644.png" width="800" height="493" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>関係者の多さを表すMRのdescriptionと変更ファイルの多さ</figcaption></figure></p>
<ul>
<li>紹介者: 北川</li>
<li>実装者: 三浦, 農見, 横本, 謝, 高田, 池嶋, 北川</li>
</ul>
<p>AI・機械学習チームでは特徴量を作成する部分を共通ライブラリ化しています。このライブラリも歴史が長く、Gitのログを見ると西場さんが2018年に作成したようです。
現在チームでは30前後のプロダクトを運用していますが、共通ライブラリの欠点として互換性をどこまで担保するかなど様々な問題があります。
例えば、このライブラリ作成当時はBigQueryのStorage APIが存在しないため、巨大なデータをロードする場合は一度GCSにデータをcsvを圧縮した状態で送り、そこからデータをダウンロードする必要がありました。csvは悪名高い通り、型がめちゃくちゃになることがあります。
このMRでは当時の取得方法からStorage API経由で取得するなど互換性を保つことが出来ない様々な変更を、痛みを一度で受け入れようとチーム一丸で行った変更になっており、おそらくチーム史上最も関係者が多かったMRになっていました。</p>
<p>実際どのように変更を本番に取り込んでいくかはかなり議論が行われ、影響はほとんど出さずにマージされました。
そして、チーム内ライブラリは初めてメジャーバージョンが1になりました!</p>
<h2 id="第1位-poetry-lock-を高速化するための制約を追加">第1位 poetry lock を高速化するための制約を追加</h2>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231227/20231227180650.png" width="573" height="357" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<ul>
<li>紹介者: 三浦</li>
<li>実装者: 高田・農見</li>
</ul>
<p>AI・機械学習チームではPythonのパッケージ管理にPoetryを利用していますが、管理するパッケージの量や組み合わせによってしばしばlock(パッケージ間の依存関係の解決)に時間がかかってしまうことが問題でした。ログを調査したところ、ある孫依存のパッケージについて許容できるバージョンの範囲がかなり狭いことが原因だと判明しました。さらに、同パッケージへの依存を明記すると探索順が入れ替わることを発見し、結果としてlockにかかる時間を大幅に短縮できました<a href="#f-d1d9fef9" id="fn-d1d9fef9" name="fn-d1d9fef9" title="ここで興味深いのは、本来はこのパッケージに依存することを宣言する必要がない一方で、特にバージョンを制約しなくても(つまり package = "*" と書いても)十分に高速化できたことです">*1</a>。チーム全員の待ち時間を減らし生産性を向上するという効果はもちろんのこと、仕組みの調査や解法はまさにソフトウェアエンジニアリング、MR提出者が病欠になるや他のメンバーが即座に巻き取るというチームワークと、三拍子揃ったMRということで多くの票を集めました。</p>
<h2 id="まとめ-MLエンジンを高速リリースするための基礎開発とは">まとめ: MLエンジンを高速リリースするための基礎開発とは</h2>
<p>改めて顔ぶれを眺めてみます</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231227/20231227180626.png" width="800" height="510" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>このベストMRは全チームメンバーからの投票制なので、チームから多く感謝されたMRはどのようなものかと言うのがわかります。
とくに今回上位に来た改善は以下の2パターンに思います。</p>
<ul>
<li>サービスレベルを高めるもの(エラー監視[5]、SLA監視[11]、型[9])</li>
<li>開発者体験を高めるもの(lockの高速化[1,3]、共通ライブラリ[2,8])</li>
</ul>
<p>AI・機械学習チームはエムスリーのサービスの中で使われるMLエンジンを開発することがミッションで、今年も20以上のプロダクトをリリースしてきました。
ですので、基盤づくりはそのための手段であり、基盤を作る事自体は担当者が居るのものではないです。
にも関わらずベストMRではむしろ監視や生産性に寄与するMRが中心となっていることは、いかにプロダクト開発の品質・速度のために基盤整備が重要だと皆が認識しているかを表しているかなと思います。</p>
<h2 id="We-are-hiring">We are hiring!!!</h2>
<p>エムスリーAI・機械学習チームでは「こんなMRはヌルい!俺こそが来年のベストMRを出すぜ!」というエンジニアを募集しています!
以下のURLからカジュアル面談お待ちしています!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<div class="footnote">
<p class="footnote"><a href="#fn-d1d9fef9" id="f-d1d9fef9" name="f-d1d9fef9" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">ここで興味深いのは、本来はこのパッケージに依存することを宣言する必要がない一方で、特にバージョンを制約しなくても(つまり package = "*" と書いても)十分に高速化できたことです</span></p>
</div>
abctail30
DMARCの対応って進んでますか?
hatenablog://entry/6801883189062632743
2023-12-26T08:30:00+09:00
2023-12-27T08:56:47+09:00 こんにちは。エムスリーでSREやセキュリティに従事している山本です。 以前に、「Gmailのメール認証規制強化への対応って終わってますか?」という記事を書かせていただいておりますが、そこでちょい出しだけしたDMARCについて書かせていただきたいと思います。 www.m3tech.blog Gmailへの対応を実施するだけならば、「とりあえずよくわかんないけど入れておけばOK」なのですが、そもそもDMARCは何のために存在していてどのように活用にするのかというところに触れていきたいと思います。 DMARCとは SPF/DKIM DMARC登場 DMARCで実施できるポリシー三種 ポリシーの強化 …
<p>こんにちは。エムスリーでSREやセキュリティに従事している山本です。</p>
<p>以前に、「Gmailのメール認証規制強化への対応って終わってますか?」という記事を書かせていただいておりますが、そこでちょい出しだけしたDMARCについて書かせていただきたいと思います。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2F2023%2F10%2F24%2F110000" title="Gmailのメール認証規制強化への対応って終わってますか? - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/2023/10/24/110000">www.m3tech.blog</a></cite></p>
<p>Gmailへの対応を実施するだけならば、「とりあえずよくわかんないけど入れておけばOK」なのですが、そもそもDMARCは何のために存在していてどのように活用にするのかというところに触れていきたいと思います。</p>
<ul class="table-of-contents">
<li><a href="#DMARCとは">DMARCとは</a><ul>
<li><a href="#SPFDKIM">SPF/DKIM</a></li>
<li><a href="#DMARC登場">DMARC登場</a></li>
<li><a href="#DMARCで実施できるポリシー三種">DMARCで実施できるポリシー三種</a></li>
<li><a href="#ポリシーの強化">ポリシーの強化</a></li>
<li><a href="#強化できるか">強化できるか</a></li>
</ul>
</li>
<li><a href="#DMARCレポート">DMARCレポート</a><ul>
<li><a href="#RUARUFの二種のレポート">RUA/RUFの二種のレポート</a></li>
<li><a href="#DMARCレポートの確認ツール">DMARCレポートの確認ツール</a></li>
<li><a href="#どう判断するか">どう判断するか</a></li>
<li><a href="#メール転送">メール転送</a></li>
<li><a href="#今後">今後</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#We-are-hiring">We are hiring!</a></li>
</ul>
<h2 id="DMARCとは">DMARCとは</h2>
<p>DMARCの日本における普及は進んでいないと言われることがあります。それ以前に「何のために存在するのか」「どのような動きをするのか」というところから知られていない部分もあります。</p>
<h3 id="SPFDKIM">SPF/DKIM</h3>
<p>前回の記事で触れたSPF/DKIMについておさらいします。</p>
<ul>
<li>SPF : メール送信元のIPを自ドメインのDNSに登録しておくことで、そのドメイン権利者からの送信であることを担保する</li>
<li>DKIM : メールに署名を付与し、その署名をした秘密鍵と対になる公開鍵を自ドメインのDNSに登録しておくことで、そのドメイン権利者からの送信であることを担保する</li>
</ul>
<p>つまりどちらも目的は <strong>自社のメールが自社から送られたことを保証する</strong>ものです。</p>
<p>これはもちろん正しいことであり、ブログでもその方法について書いてきました。しかしそれは「なりすましで自社ドメインからメールを送る人たち」に対する直接的な防御策にはなりません。</p>
<h3 id="DMARC登場">DMARC登場</h3>
<p>DMARCはSPF/DKIMを使った技術ですが、少し毛色が異なります。SPFやDKIMをアラインメントを含めて正しく設定すればDMARCはPASSするのですが、目的とするところに違いがあります。</p>
<ul>
<li>SPF/DKIM → 自社のメールが自社から送られたことを保証する</li>
<li>DMARC → なりすました自社ドメインのメール(DMARCをPASSしないメール)について拒否/隔離するように指示ができる</li>
</ul>
<p><figure class="figure-image figure-image-fotolife" title="DMARCにはSPF/DKIMの処理結果を受けてどうすべきかが書かれている"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231226/20231226083015.png" width="772" height="431" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>DMARCにはSPF/DKIMの処理結果を受けてどうすべきかが書かれている</figcaption></figure></p>
<p>自社のメールでDMARCをPASSさせる努力というのは、裏を返せばDMARCにPASSしないメールは偽物だ、と言い切るための努力です。
実際に有名な大手企業を名乗る迷惑メールも存在いたします。迷惑メールでなく、実際の企業名を偽る詐欺師かもしれません。そのようなメールを可能な限り削減するためにできる対策なのです。</p>
<h3 id="DMARCで実施できるポリシー三種">DMARCで実施できるポリシー三種</h3>
<p>DMARCのDNSレコードではポリシーの指定が実施されます。以下の三種です。</p>
<ul>
<li>p=none : DMARCにPASSしなくても特に何もしない(メールサーバの基準に沿って迷惑メール扱いなどをすることを妨げるわけではない)</li>
<li>p=quarantine : DMARCにPASSしないなら隔離する(迷惑メールフォルダなど)</li>
<li>p=reject : DMARCにPASSしないメールは拒否する</li>
</ul>
<p>実際に各社を見てみましょう。我らがエムスリーは <code>none</code>、Googleは <code>reject</code>、Appleは <code>quarantine</code>です。</p>
<pre class="code" data-lang="" data-unlink>$ dig +short txt _dmarc.m3.com
"v=DMARC1;p=none;rua=mailto:dmarc@m3.com;ruf=mailto:dmarc@m3.com;rf=afrf;pct=1"
$ dig +short txt _dmarc.google.com
"v=DMARC1; p=reject; rua=mailto:mailauth-reports@google.com"
$ dig +short txt _dmarc.apple.com
"v=DMARC1; p=quarantine; sp=reject; rua=mailto:d@rua.agari.com; ruf=mailto:d@ruf.agari.com;"</pre>
<h3 id="ポリシーの強化">ポリシーの強化</h3>
<p>今回、Gmailでのメール認証で大量送信にはDMARCが必須となりましたが、「とりあえず入れとけ」については <code>p=none</code>もありです。
しかし、本来は <code>none</code> よりも強いポリシーの方が効果的です。</p>
<p>自社のDMARCポリシーを仮に <code>reject</code> や <code>quarantine</code> にすれば、少なくとも対応したメールサーバにおいて、明確になりすましメールを判別できるようになります。それは、例えば <code>XXXX@m3.com</code> を名乗ってフィッシングサイトに誘導するようなスパムメールを排除できるわけですし、社員を名乗る詐欺師が @m3.com を騙ったメールを出す事も困難となります。</p>
<p>これがメールセキュリティの現状での目的地です。</p>
<h3 id="強化できるか">強化できるか</h3>
<blockquote><p>自社のメールでDMARCをPASSさせる努力というのは、裏を返せばDMARCにPASSしないメールは偽物だ、と言い切るための努力です。</p></blockquote>
<p>上でこのように書きましたが、Gmailの規制強化に伴って自社のメールサービスが完璧にSPF/DKIMに対応できれば、確実に「DMARCにPASSしないメールは偽物」と言い切れるはずです。それならば <code>p=none</code> と日和ってないでさっさと <code>p=reject</code>などにすればよいはずです。</p>
<p>それができないのは、必ずしも全ての正しいメールがSPF/DKIMに対応できていないかもしれないので自信がないという事です。メールを送信する側は勝手に送信していますが、相手のメールボックスで迷惑メールフォルダに入ってしまっているとすれば、それは検知できません。だから「自信がない」ので断行できません。エムスリーに限らずグローバルに展開している場合には、<code>m3.com</code> を使っているのが国内に限らなかったりしますので、全体的な影響が出てきます。</p>
<p>ちなみに、デフォルトではDMARCのポリシーはサブドメインに継承されます。<code>usa.m3.com</code> などといったドメインについても <code>m3.com</code> のポリシーがそのまま継承されますが、もしもそれが困る場合にはポリシーを上書きするような設定も可能です。ですので、サブドメインごとに分割した運用がなされている場合は影響度を小さくしながら進行させる事も可能です。</p>
<h2 id="DMARCレポート">DMARCレポート</h2>
<p>影響するかもしれないから断行できないといっているといつまでたっても実施できないのですが、そのためにDMARCレポートと呼ばれるものが用意されています。</p>
<h3 id="RUARUFの二種のレポート">RUA/RUFの二種のレポート</h3>
<pre class="code" data-lang="" data-unlink>$ dig +short txt _dmarc.m3.com
"v=DMARC1;p=none;rua=mailto:dmarc@m3.com;ruf=mailto:dmarc@m3.com;rf=afrf;pct=1"</pre>
<p>DMARCの指定の中で <code>ruf</code> や <code>rua</code> というものがあります。この <code>f</code> の方が失敗メール、 <code>a</code> の方が集約情報のメールです。DMARCに対応したメールサーバが日々、 @m3.com からのメールをチェックしてレポートとして送信してくれるのです。このレポートをチェックいたします。多くのメールを送信している場合とても大量に受信することになりますので、受信先のメールアドレスは注意してください。</p>
<p>念のためですが、RUAレポートのXMLには個人情報や機密情報は一切含まれていません。(メールを送信した先のドメイン名などは含まれます。)
RUFレポートにも基本的には含まれませんが、もう少し詳細な情報があります。</p>
<p>このレポートを使って次の確認ができます。</p>
<ul>
<li>自社から送信したメールでSPF/DKIM/DMARCで失敗しているものはないか?</li>
<li>なりすましその他が横行して怪しいメールが飛び交っていないか?</li>
</ul>
<h3 id="DMARCレポートの確認ツール">DMARCレポートの確認ツール</h3>
<p>XMLなので、そのまま目で眺めてもちょっと意味がわかりません。</p>
<p>以下はMicrosoftから送信されてきたRUAレポートのごく一部です。</p>
<pre class="code" data-lang="" data-unlink><?xml version="1.0"?>
<feedback xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<version>1.0</version>
<report_metadata>
<org_name>Enterprise Outlook</org_name>
<email>dmarcreport@microsoft.com</email>
<report_id>b64757d736234cc382af6df3d7049a5b</report_id>
<date_range>
<begin>1700956800</begin>
<end>1701043200</end>
</date_range>
</report_metadata>
<policy_published>
<domain>m3.com</domain>
<adkim>r</adkim>
<aspf>r</aspf>
<p>none</p>
<sp>none</sp>
<pct>1</pct>
<fo>0</fo>
</policy_published>
<record>
<row>
<source_ip>35.76.16.148</source_ip>
<count>1</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>pass</spf>
</policy_evaluated>
</row>
<identifiers>
<envelope_to>XXXX.co.jp</envelope_to>
<envelope_from>bounce.m3.com</envelope_from>
<header_from>m3.com</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>m3.com</domain>
<selector>awsmail-20180830</selector>
<result>pass</result>
</dkim>
<spf>
<domain>bounce.m3.com</domain>
<scope>mfrom</scope>
<result>pass</result>
</spf>
</auth_results>
</record>
...</pre>
<p>よく見るとなんとなく意味はわかりますが、非常に大量なので全てをチェックするわけにはいきません。これを解決するためにはDMARCのレポート解析ツールを使うことになります。受信したレポートメールを自動的にツールに流し込んで…、ということです。
有償のツールが多いと思いますがちょっと手動で見てみるレベルならば以下のものでも良いと思います。どちらもアメリカの企業の提供するものです。</p>
<ul>
<li><a href="https://mxtoolbox.com/DmarcReportAnalyzer.aspx">https://mxtoolbox.com/DmarcReportAnalyzer.aspx</a></li>
<li><a href="https://us.dmarcian.com/xml-to-human-converter/">https://us.dmarcian.com/xml-to-human-converter/</a></li>
</ul>
<p>では実際に、Microsoftから来たRUAレポートを後者のサイト(dmarcian)で処理してみます。このサイトではレポートをきれいに可視化してくれます。ただし、10MBが上限ですので多すぎる場合は適当に区切ってください。複数のレポートファイルをアップロードできます。</p>
<p>念のためXMLの要素のうちenvelope-toをsalt+hash化して隠して<code><envelope_to>b94fdc2eac3669a489d7ff566c40c4ee6729d8dc.com</envelope_to></code> のような形にしてからアップロードしました。実際にアップロードされたのはヘッダFromドメイン、envelope-from、送信元IP、DKIMドメイン、DKIMセレクタあたりです。送信先はMicrosoftメールを使っているとはいえ、多種多様のドメインとなります。ここを隠しておきました。
レポートURLが生成されますのでそれはシェア可能ですが広く公開されます。確認が終わったら消しておくと良いでしょう。</p>
<p><figure class="figure-image figure-image-fotolife" title="dmarcianのレポート"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231226/20231226083004.png" width="800" height="437" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>dmarcianのレポート</figcaption></figure></p>
<p>この場合はThreat/Unknownのタブ(赤いところ)を選択して中身をよく見てみます。</p>
<p><figure class="figure-image figure-image-fotolife" title="SPF/DKIMともPASSしているが…"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231226/20231226083009.png" width="800" height="226" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>SPF/DKIMともPASSしているが…</figcaption></figure></p>
<p>これは、SPF/DKIMが <code>Pass</code> かつ <code>fail-unaligned</code> です。簡単に言えば、次の状態です。</p>
<ul>
<li>ヘッダFromは m3.com(元々m3.comのレポートですのでそうなります)</li>
<li>envelope-fromとして <code>willap.jp</code>を使っていて、<code>willap.jp</code>のSPFに合致します(つまり、WiLL Mailです)</li>
<li><code>dkim1._domainkey.willap.jp</code> というDKIMのセレクタに対応する鍵で署名を実施されています</li>
</ul>
<p>SPF/DKIMともに問題ありませんが、ヘッダドメインと不一致です。(つまり、アラインメントチェックで失敗しています。)</p>
<h3 id="どう判断するか">どう判断するか</h3>
<p>そのようなメールがレポートされていたとして、次のどちらかです。</p>
<ul>
<li>誰か悪い人が m3.com を名乗って WiLL Mailで送信している</li>
<li>M3で設定してWiLL Mailで送信しているが、DKIMの設定を作成者署名にしていない</li>
</ul>
<p>WiLL Mailはenvelope-fromが <code>bounce@willap.jp</code> となりますが、DKIMは変更可能です。
おそらくはこの変更もれなのでどこの部署がこのメールを送信しているのかを探すということになってくるのです。</p>
<p>もしも仮に前者だとしたら、それはそれで問題ですが、送信できる以上は直接的には手出しできません。
しかし m3.com から送信しているメールから撲滅した時に DMARC設定を変更して <code>reject</code> などにすることでDMARCに対応したメールサーバで拒否させることができるというわけです。</p>
<h3 id="メール転送">メール転送</h3>
<p>他にもレポートに検出された怪しいメールがいくつかあります。</p>
<p><figure class="figure-image figure-image-fotolife" title="メール転送?"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231226/20231226083012.png" width="800" height="68" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>メール転送?</figcaption></figure></p>
<p>例えば同じようなWiLL Mailからのメール送信でも、これは我々の管理しないIP(その逆引きは <code>XXXX.go.jp</code>) からの送信となっており、envelope-fromである <code>willap.jp</code>とは一致しません。</p>
<p>断言はできませんが、おそらくは <code>XXXX.go.jp</code> で受けたメールをMicrosoftに転送したのでしょうか。そのために送信IPが変化してしまったと。SPFは転送に弱いのでこのようなことが発生します。DKIMについても転送でヘッダ等が変化した際に、電子署名とずれてしまうことがあります。(DKIMはヘッダやボディの中身に署名しますので、そこが書き換えられると改ざんと判断されます。)</p>
<p>このようなものはそれなりに散見されますが、直接的には手出しできませんので今回は静観しています。</p>
<h3 id="今後">今後</h3>
<p>エムスリーでもDMARCについてももちろん考慮していますが、現状では <code>p=none</code> になっているという状態です。
それは問題のあるメールの存在を知っていたためなのですが、問題のあるメールを今回のGmail対応で一掃しようとしています。</p>
<p>Gmail対応のあかつきにはさらにDMARCの強化を図っていただけると、世界からなりすましメールが少しでも減るということですので、特に影響力あるドメインをお持ちの会社の方々はどんどん強化していただけると少しでも世界がよくなりそうです。</p>
<p>私は現在は有償のサービスを使っているわけではありませんが、そのようなサービスも多々ありますので、その検討も良いかと思います。</p>
<h2 id="まとめ">まとめ</h2>
<ul>
<li>来年2月からGmailへの大量送信は規制が始まる →
<a href="https://www.m3tech.blog/entry/2023/10/24/110000">Gmailのメール認証規制強化への対応って終わってますか? - エムスリーテックブログ</a></li>
<li>DMARCへの対応も必須になるけれど、DMARCってそもそも何なんだっけ?</li>
<li>DMARCはSPF/DKIMに対応して「いない」メールを排除するための仕組みだ</li>
<li>DMARCレポートを使って自ドメインのメールの対応状況や迷惑メール状況を確認できる</li>
<li>DMARC対応で世界がよくなる</li>
</ul>
<h2 id="We-are-hiring">We are hiring!</h2>
<p>エムスリーやそのグループ会社では常に素敵なエンジニアを募集しております! 興味あればぜひお越しください!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<p><iframe src="https://open.talentio.com/r/1/c/m3-inc/embed/pages/57602" width="100%" height="300" frameborder=0 title="%E3%82%A8%E3%83%A0%E3%82%B9%E3%83%AA%E3%83%BC+%7C+%E3%82%BB%E3%82%AD%E3%83%A5%E3%83%AA%E3%83%86%E3%82%A3%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2"></iframe><cite class="hatena-citation"><a href="https://open.talentio.com/r/1/c/m3-inc/pages/57602">open.talentio.com</a></cite></p>
atzy-yama
2023年は3段階でシフトチェンジ!2024年はさらに加速してやっていきます!
hatenablog://entry/6801883189069625481
2023-12-25T16:28:00+09:00
2023-12-25T16:28:15+09:00 皆さんこんにちは、こんばんは。昨年、スノーピークのIGTフレームのノーマル(3ユニットのほう)を2つ買ったにも関わらず、年内途中でVERNEのVST Maestro SYSTEM TABLE Blackを2つ調達し、4ユニットx2になってしまって相変わらずな取締役CTO兼VPoPの山崎です。 本ブログはエムスリー Advent Calendar 2023の25日目の記事です。 ちなみに昨日は弊社VPoEばんくしこと河合さんの素晴らしい記事でした!エムスリーエンジニアリンググループの文化がよくまとまっておりますので、まだ読んでいない人は是非ご一読ください^^。 www.m3tech.blog D…
<p>皆さんこんにちは、こんばんは。昨年、スノーピークのIGTフレームのノーマル(3ユニットのほう)を2つ買ったにも関わらず、年内途中でVERNEのVST Maestro SYSTEM TABLE Blackを2つ調達し、4ユニットx2になってしまって相変わらずな取締役CTO兼VPoPの山崎です。</p>
<p>本ブログは<a href="https://qiita.com/advent-calendar/2023/m3">エムスリー Advent Calendar 2023</a>の25日目の記事です。</p>
<p>ちなみに昨日は弊社VPoEばんくしこと河合さんの素晴らしい記事でした!エムスリーエンジニアリンググループの文化がよくまとまっておりますので、まだ読んでいない人は是非ご一読ください^^。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2Fhit-geek-and-smart" title="ギークでスマートな人達が活躍する組織を支える3つのポイント - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/hit-geek-and-smart">www.m3tech.blog</a></cite></p>
<p><figure class="figure-image figure-image-fotolife" title="DD SuperLight Tarp 3x2.9をダイヤモンド張り。デイキャンプだと軽量装備です。"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231225/20231225162805.jpg" width="800" height="600" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>DD SuperLight Tarp 3x2.9をダイヤモンド張り。デイキャンプだと軽量装備です。</figcaption></figure></p>
<ul class="table-of-contents">
<li><a href="#はじめに">はじめに</a></li>
<li><a href="#ファーストステップばんくしこと河合さんがエンジニアリンググループVPoEに就任組織マネジメントが大幅に強化">ファーストステップ:ばんくしこと河合さんがエンジニアリンググループVPoEに就任!組織マネジメントが大幅に強化!</a></li>
<li><a href="#セカンドステップ前VPoE岩佐さんがエムスリーキャリア取締役に就任グループ会社支援がますます活発に">セカンドステップ:前VPoE岩佐さんがエムスリーキャリア取締役に就任!グループ会社支援がますます活発に!</a></li>
<li><a href="#サードステップデジスマ診療を中心にエムスリーのプロダクト開発が加速pmconf2023も大盛りあがり">サードステップ:デジスマ診療を中心にエムスリーのプロダクト開発が加速!pmconf2023も大盛りあがり!</a></li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#We-are-hiring">We are hiring!</a></li>
</ul>
<h3 id="はじめに">はじめに</h3>
<p>2022年に引き続き、2023年もエムスリーエンジニアリンググループに所属する全員の努力のお陰で、エンジニアリンググループにとって飛躍の1年となりました!特に今年は、大きな組織的意思決定もあり、エンジニアリンググループの影響力を社内、社外に拡大するために重要な年になった認識です。本ブログでは、そのための主な取り組みを3つ、3段階のシフトチェンジになぞらえてご紹介して、本年を締めくくりたいと思います。</p>
<p><figure class="figure-image figure-image-fotolife" title="ソロストーブキャンプファイヤーで作ったおでん。真っ暗だけどデイキャンプ。"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231225/20231225162802.jpg" width="800" height="600" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ソロストーブキャンプファイヤーで作ったおでん。真っ暗だけどデイキャンプ。</figcaption></figure></p>
<h3 id="ファーストステップばんくしこと河合さんがエンジニアリンググループVPoEに就任組織マネジメントが大幅に強化">ファーストステップ:ばんくしこと河合さんがエンジニアリンググループVPoEに就任!組織マネジメントが大幅に強化!</h3>
<p>まずは何と言っても、ばんくしこと河合さんがエンジニアリンググループVPoEとしてエムスリーに戻ってきてくれたことでしょう。河合さんのVPoE就任をきっかけとして、エムスリーのエンジニア文化や組織の強化、採用プロモーションなどが大きく前進した1年となりました。</p>
<p>具体的には月に1回行っているエンジニアリングG全体で行っているミーティングや、週1回チームリーダーが集まるミーティング、私達グループリーダーで運営しているマネジメント定例など多くの会議でリーダーシップを発揮いただき、その間も、私との1on1を始め、数多くのメンバーとの1on1や対話を通じて、エンジニア理解を深め、議論し、新たなアイデアを実行に移して来ました。</p>
<p>その結果、VPoE河合さんを中心に、皆の力が集結し、組織的に大きな成果を出しやすい良い環境にシフト出来たと考えています。特に、チームリーダーの日を制定<a href="#f-4f91d6b3" id="fn-4f91d6b3" name="fn-4f91d6b3" title="https://www.m3tech.blog/entry/2023/07/19/160000">*1</a>し、メンバーからの感謝を直接チームリーダーに伝えられる文化を早期に作った点など、本当に素晴らしかったです。個人的にも大きく助けられており、本当に戻ってきてもらってよかったな、と日々感謝しております。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2Fthe-reason-why-vaaaaanquish-came-back-as-VPoE-in-M3" title="ばんくしこと河合俊典さんにエムスリー3代目VPoEとして戻ってきてもらった理由 - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/the-reason-why-vaaaaanquish-came-back-as-VPoE-in-M3">www.m3tech.blog</a></cite></p>
<h3 id="セカンドステップ前VPoE岩佐さんがエムスリーキャリア取締役に就任グループ会社支援がますます活発に">セカンドステップ:前VPoE岩佐さんがエムスリーキャリア取締役に就任!グループ会社支援がますます活発に!</h3>
<p>次に、前VPoEの岩佐さんがエムスリーキャリア取締役に就任したことをきっかけに、エンジニアリンググループによるグループ会社支援がますます活発になった点が挙げられます。</p>
<p>エンジニアリンググループによるグループ会社支援は数年前から私が中心となって実施してきた施策ですが、グループ会社に単独で落下傘できる経験者が限られるという課題がありました。この課題に対して、グループ会社支援チームを強化し、少しづつ、経験者を増やしているところだったのですが、こちらの推進を岩佐さん率いる新生グループ会社支援チームにお任せすることで、今年は大幅にカバー範囲を拡大、セキュリティ関連の支援も含めて、一定の拡大を達成することが出来ました。</p>
<p>エムスリーグループ全体の事業成長において、グループ会社のエンジニアリング的な支援は最重要項目の一つなので、今後10年20年の継続的成長のために、2023年仕込みが出来たのは大きいのでは無いかと考えています。</p>
<p>ますます広がるエムスリーのグループ会社一覧については下記をご覧ください^^。
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcorporate.m3.com%2Fcorporate%2F" title="企業情報 | エムスリー株式会社" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://corporate.m3.com/corporate/">corporate.m3.com</a></cite></p>
<h3 id="サードステップデジスマ診療を中心にエムスリーのプロダクト開発が加速pmconf2023も大盛りあがり">サードステップ:デジスマ診療を中心にエムスリーのプロダクト開発が加速!pmconf2023も大盛りあがり!</h3>
<p>上記の通り、エムスリー本体のエンジニアリンググループの運営を河合さんにシフト、グループ会社のエンジニアリング的な支援を岩佐さんにシフトすることで、私はデジスマ診療を中心にエムスリーのプロダクト開発に集中して取り組むことが出来ました。</p>
<p>プロダクト開発やそのためのプロダクトマネジメントもまた、エムスリー及びエムスリーグループの継続的な成長にとって、ますます重要な位置付けとなっています。</p>
<p>先日のpmconf2023でも「プロダクトと事業を無限にスケールするための最強のロードマップの作り方」と題して、それらの取り組みの一部をご紹介させて頂きました。リンク先から登壇資料とアーカイブ動画が閲覧できますので、興味のある方は是非ご覧ください。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2F2023.pmconf.jp%2Fsession%2FzBfEEEcp" title="プロダクトと事業を無限にスケールするための最強のロードマップの作り方丨SESSION 講演内容丨プロダクトマネージャーカンファレンス 2023" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://2023.pmconf.jp/session/zBfEEEcp">2023.pmconf.jp</a></cite></p>
<p>追伸:ちなみにpmconf2023の参加者アンケートにて満足度回答数同率1位だった<a href="#f-031838c0" id="fn-031838c0" name="fn-031838c0" title="https://note.com/pmconfjp/n/nd05297d9d909">*2</a>、とのことでした!これは嬉しい&ありがたいですね!</p>
<h3 id="まとめ">まとめ</h3>
<p>いかがでしたでしょうか?</p>
<p>本ブログでは、エムスリーエンジニアリンググループの影響力を社内、社外に拡大するために重要な3つの取組みについて、3段階のシフトチェンジになぞらえてご紹介させて頂きました。</p>
<p>エムスリーグループ全体が、プロダクト開発に集中できる環境が整ってきていますので、2024年も引き続き、エムスリー本体のエンジニア組織の強化、グループ会社のエンジニアリング的支援、そしてプロダクト開発を加速していきたいと思います。</p>
<p>最後に、エムスリーエンジニアリンググループに所属している素晴らしいエンジニアの皆さんに感謝を込めて、2024年もエムスリーグループやっていき!
そしてメリークリスマス!</p>
<p><figure class="figure-image figure-image-fotolife" title="焚き火台はピコグリル398を愛用。この日は例のアレでレインボーしてみました。"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231225/20231225162809.jpg" width="800" height="600" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>焚き火台はピコグリル398を愛用。この日は例のアレでレインボーしてみました。</figcaption></figure></p>
<h3 id="We-are-hiring">We are hiring!</h3>
<p>エムスリーでは、ギークでスマートなエンジニアを中心に、多数のチャレンジに果敢にアタックするプロダクト開発メンバーを絶賛募集中です。</p>
<p>エンジニア、QA、デザイナー、プロダクトマネージャーどの職種でも打席数が多く、プロダクト開発の基礎知識を習得しながら、社会にインパクトのある収益性の高いプロダクトを生み出す経験、ホームランの打ち方を学べるを学べる良い環境だと思います。</p>
<p>ご興味のある方は、下記のサイトから是非カジュアル面談等、お申し込みください!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<div class="footnote">
<p class="footnote"><a href="#fn-4f91d6b3" id="f-4f91d6b3" name="f-4f91d6b3" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://www.m3tech.blog/entry/2023/07/19/160000">https://www.m3tech.blog/entry/2023/07/19/160000</a></span></p>
<p class="footnote"><a href="#fn-031838c0" id="f-031838c0" name="f-031838c0" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://note.com/pmconfjp/n/nd05297d9d909">https://note.com/pmconfjp/n/nd05297d9d909</a></span></p>
</div>
yamazaki-m3
ギークでスマートな人達が活躍する組織を支える3つのポイント
hatenablog://entry/6801883189068648835
2023-12-24T12:19:19+09:00
2023-12-24T17:37:26+09:00 長女と2人で水族館に行ったときの写真。帰路のバスで「2人でまた来たいねえ」と言われて泣きました。例のごとく本文とは全く関係がありません。 はじめに こんにちは。最近、ダンダダンのアニメ化が発表され、嬉しい気持ちのエムスリー エンジニアリンググループ VPoE 河合(@vaaaaaanquish)です。 皆さんは『Hit Refresh』という書籍をご存知でしょうか。 現Microsoft CEOであるサティア・ナデラの自伝であり、OpenAIやGitHubと現在"Hit"を続けているMicrosoftに成る過程において、会社を"Refresh"してきた物語が書かれています*1。 その中にあるサ…
<p><figure class="figure-image figure-image-fotolife" title="長女と2人で水族館に行ったときの写真。帰路のバスで「2人でまた来たいねえ」と言われて泣きました。例のごとく本文とは全く関係がありません。"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231224/20231224121934.png" width="800" height="446" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>長女と2人で水族館に行ったときの写真。帰路のバスで「2人でまた来たいねえ」と言われて泣きました。例のごとく本文とは全く関係がありません。</figcaption></figure></p>
<h1 id="はじめに">はじめに</h1>
<p>こんにちは。最近、ダンダダンのアニメ化が発表され、嬉しい気持ちのエムスリー エンジニアリンググループ VPoE 河合(<a href="https://twitter.com/vaaaaanquish">@vaaaaaanquish</a>)です。</p>
<p>皆さんは『Hit Refresh』という書籍をご存知でしょうか。<br/>
現Microsoft CEOであるサティア・ナデラの自伝であり、OpenAIやGitHubと現在"Hit"を続けているMicrosoftに成る過程において、会社を"Refresh"してきた物語が書かれています<a href="#f-1c17ba71" id="fn-1c17ba71" name="fn-1c17ba71" title="まぁHit Refreshは正確には「リフレッシュボタンを押せ」ということなんですけどそれはそれとしてHitが続いてますからね笑">*1</a>。
その中にあるサティア・ナデラのテクノロジー文化をリスペクトした一節が、私は大好きです。</p>
<blockquote><p>テクノロジーは魅力的だが、 それ以上に魅力的なのがそれを設計した人達の深いこだわりだ。</p></blockquote>
<p>実際にサティア・ナデラがMicrosoftに「こだわり」の塊であるギーク文化を取り戻しリーダーシップを注入する事でイノベーションを作ってきたという本になっており、是非多くの方に読んで欲しいと思っています。</p>
<p><div class="hatena-asin-detail"><a href="https://www.amazon.co.jp/dp/B076Q6M456?tag=hatena-22&linkCode=ogi&th=1&psc=1" class="hatena-asin-detail-image-link" target="_blank" rel="noopener"><img src="https://m.media-amazon.com/images/I/41ffP5GLTwL._SL500_.jpg" class="hatena-asin-detail-image" alt="Hit Refresh(ヒット リフレッシュ)" title="Hit Refresh(ヒット リフレッシュ)"></a><div class="hatena-asin-detail-info"><p class="hatena-asin-detail-title"><a href="https://www.amazon.co.jp/dp/B076Q6M456?tag=hatena-22&linkCode=ogi&th=1&psc=1" target="_blank" rel="noopener">Hit Refresh(ヒット リフレッシュ)</a></p><ul class="hatena-asin-detail-meta"><li><span class="hatena-asin-detail-label">作者:</span><a href="https://d.hatena.ne.jp/keyword/%A5%B5%A5%C6%A5%A3%A5%A2%A1%A6%A5%CA%A5%C7%A5%E9" class="keyword">サティア・ナデラ</a>,<a href="https://d.hatena.ne.jp/keyword/%A5%B0%A5%EC%A5%C3%A5%B0%A1%A6%A5%B7%A5%E7%A1%BC" class="keyword">グレッグ・ショー</a>,<a href="https://d.hatena.ne.jp/keyword/%A5%B8%A5%EB%A1%A6%A5%C8%A5%EC%A5%A4%A5%B7%A1%BC%A1%A6%A5%CB%A5%B3%A5%EB%A5%BA" class="keyword">ジル・トレイシー・ニコルズ</a></li><li>日経BP</li></ul><a href="https://www.amazon.co.jp/dp/B076Q6M456?tag=hatena-22&linkCode=ogi&th=1&psc=1" class="asin-detail-buy" target="_blank" rel="noopener">Amazon</a></div></div></p>
<p>私達エムスリーエンジニアリンググループでも「<strong>ギークでスマートな人達が活躍する事</strong>」を非常に大切にしており、採用から組織設計、コミュニケーションを含めてギーク文化が中心にあります。</p>
<p><a href="https://qiita.com/advent-calendar/2023/m3">エムスリー Advent Calendar 2023</a> 24日目の本記事では、その文化の根底を支える3つのポイントを紹介して25日目、取締役CTO&VPoPの山崎(<a href="https://twitter.com/yamamuteking">@yamamuteking</a>)に打席を繋いでいきます。<br/>
<ul class="table-of-contents">
<li><a href="#はじめに">はじめに</a></li>
<li><a href="#1-全員がレギュラーユニフォームを着ている状態">1. 全員がレギュラーユニフォームを着ている状態</a><ul>
<li><a href="#打席の例としての技術的選択">打席の例としての技術的選択</a></li>
<li><a href="#チャレンジマネジメント">チャレンジマネジメント</a></li>
</ul>
</li>
<li><a href="#2-生まれ続ける新しい試合">2. 生まれ続ける新しい試合</a></li>
<li><a href="#3-背番号に対する考え方がない">3. 背番号に対する考え方がない</a></li>
<li><a href="#おわりに">おわりに</a><ul>
<li><a href="#We-are-hiring-">We are hiring !!</a></li>
</ul>
</li>
</ul>
<p> </p>
<h1 id="1-全員がレギュラーユニフォームを着ている状態">1. 全員がレギュラーユニフォームを着ている状態</h1>
<p>エンジニアリンググループには、約100名のエンジニア、PdM、QAが在籍しています。</p>
<p>企業規模の大きさであったり、医療分野における多くの事業を持っている事から、人数が少なく感じるかもしれませんが、ことソフトウェア開発においてはソニックガーデン 倉貫さんが書籍で書かれている通り「<strong>人が増えても速くならない</strong>」が通説です。</p>
<p><div class="hatena-asin-detail"><a href="https://www.amazon.co.jp/dp/B0C3ZFZH37?tag=hatena-22&linkCode=ogi&th=1&psc=1" class="hatena-asin-detail-image-link" target="_blank" rel="noopener"><img src="https://m.media-amazon.com/images/I/418cIfbD1oL._SL500_.jpg" class="hatena-asin-detail-image" alt="人が増えても速くならない ~変化を抱擁せよ~" title="人が増えても速くならない ~変化を抱擁せよ~"></a><div class="hatena-asin-detail-info"><p class="hatena-asin-detail-title"><a href="https://www.amazon.co.jp/dp/B0C3ZFZH37?tag=hatena-22&linkCode=ogi&th=1&psc=1" target="_blank" rel="noopener">人が増えても速くならない ~変化を抱擁せよ~</a></p><ul class="hatena-asin-detail-meta"><li><span class="hatena-asin-detail-label">作者:</span><a href="https://d.hatena.ne.jp/keyword/%C1%D2%B4%D3%20%B5%C1%BF%CD" class="keyword">倉貫 義人</a></li><li>技術評論社</li></ul><a href="https://www.amazon.co.jp/dp/B0C3ZFZH37?tag=hatena-22&linkCode=ogi&th=1&psc=1" class="asin-detail-buy" target="_blank" rel="noopener">Amazon</a></div></div></p>
<p>1チーム5~10人の規模を保つ事が高速なプロダクト開発に繋がるという話は、現代のエンジニアリング業界で広く語られている通りであり、エムスリーのプロダクト開発はまさにそれを体現しています。</p>
<p><figure class="figure-image figure-image-fotolife" title="1チーム約5~10名が19チームある100人体制"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231224/20231224121923.png" width="800" height="419" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>1チーム約5~10名が19チームある100名体制</figcaption></figure></p>
<p>エムスリーでは、この少ない人数で高速に開発する体制を取りながら、複数のプロダクトを開発し続ける事で、<strong>全員がホームランを生み出す打席に定期的に立つ状態</strong>を実現しています。
ユニフォームを着て何度も打席に立つ機会を経て、リーダーが生まれ、リーダーを中心にイノベーションが生まれる構図とも言え、これが1つ目のポイントになっています。</p>
<h2 id="打席の例としての技術的選択">打席の例としての技術的選択</h2>
<p>打席の代表的な例として、「技術的選択」があります。</p>
<p>開発計画や技術選定など技術に関する方向性を選択する行為は、ビジネスの将来を決める1つの重要な意思決定です。
自身のプロダクトや顧客、チーム、キャリア、ROIについて考える機会にもなり得ます。</p>
<p>エムスリーでは、<strong>技術的選択はチームひいては各エンジニアに完全に任せており、自分達にとって最適なモノを選ぶ</strong>という意思決定の打席に何度も立つことになります。</p>
<p><figure class="figure-image figure-image-fotolife" title="実際AWS/GCP/オンプレ、多種多様な言語、フレームワークと技術が分散しています"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231224/20231224121930.png" width="800" height="437" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>実際AWS/GCP/オンプレ、多種多様な言語、フレームワークと技術が分散しています</figcaption></figure></p>
<p>こだわりを追求してPDCAを回す機会が増える、という所がポイントです。
実際、この意思決定の繰り返しを経てCTO級のエンジニアが生まれています。</p>
<p>何より、私がエンジニアとして活動していた時、技術的選択は楽しいものでした。
広く調べたり、検証したり、過去の負債を深堀ったり、技術と上手にハマる形を見つけるあの時間ってワクワクしますよね。<br/>
私は「技術者として技術的選択の楽しさを謳歌して欲しい」とも強く思っています。</p>
<h2 id="チャレンジマネジメント">チャレンジマネジメント</h2>
<p>人数が少なく、チャレンジマネジメントが分散しない事もポイントになります。
TLやHRBPを通してwillを聞き出し、マネジメントチームで一人一人に向き合う時間も作っています。</p>
<p><figure class="figure-image figure-image-fotolife" title="配属においてもwillを聞き、数あるチームから提案、相談します"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231224/20231224121927.png" width="800" height="405" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>配属においてもwillを聞き、数あるチームから提案、相談します</figcaption></figure></p>
<p><strong>「打席に立たされる」という状態ではなく「ユニフォームを着て定期的に打席に立つ」</strong>ことを理想とし、大切にしています。これもまた、「こだわり」を発揮してもらう上で重要だと日々感じています。</p>
<p> </p>
<h1 id="2-生まれ続ける新しい試合">2. 生まれ続ける新しい試合</h1>
<p>1つ目のポイントは打席でしたが、エムスリーでは新しい試合も多く発生します。</p>
<p>エムスリーでは、新しいソフトウェアプロダクトが生まれ続けています。</p>
<p>2000年創業の企業ですが、現在30以上のプロダクトを開発、運用しています。
近年では、私達一般患者も利用できるサービスも開発しており、実際に『デジスマ診療』などが急拡大を続けています。</p>
<iframe class="speakerdeck-iframe" frameborder="0" src="https://speakerdeck.com/player/c5409819c6da4b8092565f370304d72a?slide=8" title="エムスリーデジスマ診療チーム紹介資料 / Introduction of M3 DigiKar Smart Development Team" allowfullscreen="true" style="border: 0px; background: padding-box padding-box rgba(0, 0, 0, 0.1); margin: 0px; padding: 0px; border-radius: 6px; box-shadow: rgba(0, 0, 0, 0.2) 0px 5px 40px; width: 100%; height: auto; aspect-ratio: 560 / 315;" data-ratio="1.7777777777777777"></iframe>
<p>こういった新しいプロダクトを生み続ける事が、エンジニア組織においてギークでスマートな人達が活躍するポイントであると、日々感じています。
先のような技術的選択の機会だけでなく、チーム構成や新しい開発手法に目を向けたり、新しいユーザ層や価値観に触れるきっかけになっています。まさに試合です<a href="#f-f9afee0d" id="fn-f9afee0d" name="fn-f9afee0d" title="試合と言えば私はブルーロックの2次選考最終戦が好きです。なお本文とは関係ありません。">*2</a>。</p>
<p>現代風に言うのであれば、コンパウンドスタートアップでしょうか。
他社で言えば、LayerXさんやestieさんも同じ方針で、キーワードとして耳にした事があるエンジニアも多いかなと思います。
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcomemo.nikkei.com%2Fn%2Fn7332c93f50c7" title="コンパウンドスタートアップというLayerXの挑戦|福島良典 | LayerX" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://comemo.nikkei.com/n/n7332c93f50c7">comemo.nikkei.com</a></cite></p>
<p>複数の関連プロダクトを並行して開発する戦略は、事業拡大において良い影響を与える戦略として広まりつつあります。これは、別の側面としてエンジニア組織に多くの打席をもたらし、イノベーションの源泉に繋がっているというのがエムスリーの所感です。</p>
<p>エムスリーは、流石にスタートアップと言える規模ではありませんが、100以上のグループ会社、事業があり、ソフトウェア開発の手が及んでいない領域もまだまだ沢山あります。
その中でPdMが常にチャレンジの場所を生むことで、技術的選択や様々なフェーズでの開発を経験できる、という点が2つ目のポイントになっています。</p>
<p>気持ちは圧倒的コンパウンドベンチャーです笑</p>
<p> </p>
<h1 id="3-背番号に対する考え方がない">3. 背番号に対する考え方がない</h1>
<p>人、プロダクトだけでなく、制度においてもギークでスマートな人達が活躍できる環境について考えていることが最後のポイントです。</p>
<p>代表的な例として、エンジニアにおいては<strong>画一的なジョブグレードがありません</strong>。<br/>
「〇〇は出来るけどXXがネックでグレードが上がらない」という事はありません。
また、「エンジニアとして広い技術をバランス良く扱える方が優れている」「専門性が深い方が優れている」といった考え方でもありません。
<strong>インパクトの大きなプロダクトを作るために動いている人が評価される</strong>制度を運用しています。
背番号3番が繋いで4番が打って、ではなく、全員がホームランバッターを目指す形です。</p>
<p>実際にエンジニアとしての専門を深める瞬間もあれば、基盤/セキュリティチームがプロダクトコードを改善したり、機械学習エンジニアがフロントエンドに触れる事もありますし、PdM・QAチームと同じ動きを兼任して活躍しているエンジニアも居ます。技術が好きな人達が、様々な形で打席に立っています。</p>
<p>評価の軸である事業インパクトを見るにあたっては、全社的にROI文化があり、エンジニアリンググループでも日常的にROIの話をするほど浸透しています。
ROI文化の実態については、最近YouTubeで公開している「CTO&VPoEに聞いてみた」シリーズで、技術的負債や中長期チャレンジ、リファクタリングや各職種の特性の話題を絡めながら話しています。
<iframe width="560" height="315" src="https://www.youtube.com/embed/8kNAHbplDIU?start=694&feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen title="第1回 エムスリーエンジニアリンググループ CTO&VPoEに聞いてみた"></iframe><cite class="hatena-citation"><a href="https://youtu.be/8kNAHbplDIU?si=C0xpZaZc5h71HXWs&t=694">youtu.be</a></cite></p>
<p> <br/>
是非、イイネとチャンネル登録をよろしくお願いします笑</p>
<p> </p>
<h1 id="おわりに">おわりに</h1>
<p>いかがでしたでしょうか。</p>
<p>よくカジュアル面談等々でも聞かれます「なぜギークでスマートな人達が活躍できると言えるのか」について、ざっくり「人」「プロダクト」「制度」の側面から、私が感じている3つのポイントをまとめました。</p>
<p>ギークでスマートな人、Hit Refreshの言い方をすれば「深いこだわりをもって設計できる人達」が活躍する魅力的で偉大な組織になるためには、サティア・ナデラのようにこだわりをリスペクトし、活かすための土壌が大切だと思っています。
その結果として、ギークがイノベーションで世界を変えていく事を信じています。</p>
<p>そのために、より良いエンジニアリング組織の形を模索し続ける事を引き続きやっていきたいなと思う次第です。</p>
<h2 id="We-are-hiring-">We are hiring !!</h2>
<p>エンジニア絶賛採用中です。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<p>私自身もそうですが、新しい技術やアルゴリズム、アーキテクチャ、開発手法を知り実践する事を楽しみとしている人ばかりです<a href="#f-80616791" id="fn-80616791" name="fn-80616791" title="時にオタクとも呼んでいます笑">*3</a>。<br/>
深くこだわりながら、その共有を楽しめる文化があります。
実際、情報系出身も多いですが、医学や物理、数学出身者もいますし、史学やそれこそ最初の職業が営業というキャリアのエンジニアも在籍しています。
ギークであること、技術が好きが集まっていることが<a href="https://www.m3tech.blog">Tech Blog</a>や<a href="https://www.youtube.com/channel/UC_DkAOcwgmtQnJLDctci4rQ">YouTube</a>からも感じて貰えればと思っております。</p>
<p>私がVPoE就任時に撮影したCTO対談動画でも語っており、徹底してずっとこの「ギークでスマートな人達に活躍してもらう」をやっています。</p>
<p><iframe width="560" height="315" src="https://www.youtube.com/embed/ywStynQS490?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen title="【エンジニアリングG】山崎 聡(CTO VPoP)×河合 俊典(VPoE)1on1"></iframe><cite class="hatena-citation"><a href="https://www.youtube.com/watch?v=ywStynQS490">www.youtube.com</a></cite></p>
<p>気になった方は、是非<a href="https://jobs.m3.com/engineer/">採用サイト</a>右上よりカジュアル面談を申し込んでいただけると、基本的に私、河合(<a href="https://twitter.com/vaaaaanquish">@vaaaaaanquish</a>)が対応致します。
何でもお話しましょう!</p>
<p> </p>
<p>明日の<a href="https://qiita.com/advent-calendar/2023/m3">エムスリー Advent Calendar 2023</a> は、取締役CTO&VPoPの山崎(<a href="https://twitter.com/yamamuteking">@yamamuteking</a>)です!</p>
<p>良いお年を!</p>
<p> </p>
<div class="footnote">
<p class="footnote"><a href="#fn-1c17ba71" id="f-1c17ba71" name="f-1c17ba71" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">まぁHit Refreshは正確には「リフレッシュボタンを押せ」ということなんですけどそれはそれとしてHitが続いてますからね笑</span></p>
<p class="footnote"><a href="#fn-f9afee0d" id="f-f9afee0d" name="f-f9afee0d" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">試合と言えば私はブルーロックの2次選考最終戦が好きです。なお本文とは関係ありません。</span></p>
<p class="footnote"><a href="#fn-80616791" id="f-80616791" name="f-80616791" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">時にオタクとも呼んでいます笑</span></p>
</div>
vaaaaaanquish
ノーコードツールの高度な処理をコードで実装!iOS・macOSのショートカットアプリで手軽に定形プロンプト
hatenablog://entry/6801883189068480121
2023-12-23T00:00:00+09:00
2023-12-23T00:01:11+09:00 ChatGPTにダジャレを生成してもらうためのプロンプトを作ったので、iOS・macOS向けノーコードツールのショートカットアプリを使って、即座にダジャレを生成できるようにします。
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231223/20231223000104.png" width="800" height="420" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>この記事は<a href="https://qiita.com/advent-calendar/2023/m3">エムスリーAdvent Calendar 2023</a>の23日目の記事です。</p>
<p>エンジニアの星川 (<a href="http://blog.hatena.ne.jp/oboenikui/">id:oboenikui</a>) です。</p>
<p>突然ですが、弊社では毎日ダジャレが飛び交っています。</p>
<p><blockquote data-conversation="none" class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">より良い仕事を目指しています <a href="https://twitter.com/hashtag/%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2%E6%8E%A1%E7%94%A8%E5%B7%9D%E6%9F%B3?src=hash&ref_src=twsrc%5Etfw">#エンジニア採用川柳</a> <a href="https://t.co/x5hik0KQOA">pic.twitter.com/x5hik0KQOA</a></p>— エムスリー エンジニア公式 (@m3_engineering) <a href="https://twitter.com/m3_engineering/status/1567331825768333312?ref_src=twsrc%5Etfw">2022年9月7日</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> </p>
<p>ダジャレが飛び交うことの良し悪しはさておき、こんな会社の中なら思わず唸ってしまうような上手いダジャレを日常的に投稿できれば、社内で尊敬の眼差しを向けられること間違いなしです。</p>
<p>そこで今回は、「ChatGPTに上手いダジャレを作らせて、チャットに即座に投稿できる機能」を作っていきたいと思います。</p>
<ul class="table-of-contents">
<li><a href="#ChatGPTにダジャレを作らせる">ChatGPTにダジャレを作らせる</a></li>
<li><a href="#iOS-macOSのショートカットアプリ">iOS, macOSのショートカットアプリ</a></li>
<li><a href="#Cherriを使ってショートカットを実装">Cherriを使ってショートカットを実装</a><ul>
<li><a href="#下準備">下準備</a></li>
<li><a href="#処理の流れ">処理の流れ</a></li>
</ul>
</li>
<li><a href="#We-are-hiring">We are hiring!</a></li>
</ul>
<h2 id="ChatGPTにダジャレを作らせる">ChatGPTにダジャレを作らせる</h2>
<p>ChatGPTを触ったことのある方なら、必ず一度は試したことがあるであろうダジャレ生成。「〜でダジャレを作って」のような質問をしてしても、ダジャレになっていない文章が生成された、という経験はないでしょうか。</p>
<p>私が手元で <code>gpt-4-1106-preview</code> モデルで試してみたところ、以下のようなレスポンスが返ってきました。</p>
<pre class="code" data-lang="" data-unlink>input >
採用 (読み:さいよう) を使って、ダジャレを5文作ってください
output >
1. 野菜が高いので「菜葉のサイヨウ」も困りますね。
2. サイを飼っている動物園は、「サイヨウ」の条件が厳しいみたいです。
3. 料理の腕が上がって、採用と味噌が合いました。「さいようみそ」合わせの才能ですね。
4. 「サイヨウ」面接でサイの着ぐるみを着ていったら、面接官も困惑していました。
5. 新しいポスターに才能ある画家を採用しようとしたら、「さい画う」なんて言われてしまいました。</pre>
<p>1は菜葉(なっぱ?)を音読みにして、ダジャレとして成立しているような気もします。2, 4番のサイの文章もなんとか成立しているでしょうか。しかしダジャレのクオリティは何度か試しましたがあまり高くなく、そのまま使ったら気まずい空気になること間違いなしです。</p>
<p>そこで、ChatGPTへの指示を、普段私がダジャレを作成しているときの手順を基に具体化してみます。</p>
<pre class="code" data-lang="" data-unlink>input >
ダジャレとは、同じ読み方で別の意味の単語やフレーズを使った文のことです。
以下に良いダジャレと悪いダジャレの例を挙げます。
|ダジャレ|良し悪し|理由|
|------|-----|-----|
|布団が吹っ飛んだ|良い|「布団」と「吹っ飛ん」が似た読み方で別の意味であるため|
|ふとんな布団|悪い|「ふとんな」というフレーズは日本語として不適切|
|布団は布団|悪い|同じ意味の単語の繰り返し|
ダジャレを考える時のフローは以下の通りです。
1. まず対象の単語と読み方が完全に一致、もしくは同じ読みを含む、存在する単語やフレーズを10個まで探します。元と同じ単語は除外してください。
ない場合は、1字を同じ母音の別の文字に置き換えた単語やフレーズを探します。例えば「未完」、「ミラン」、「ミトン」の中で「蜜柑」と似ているのは、どちらも「みかん」と読む「未完」、次いで「か」を「ら」に置き換えた「ミラン」の2つです。
2. 対象の単語と、見つけた単語を組み合わせた文章を作成します。より意味が通っているほど好ましいです。
以上の情報に従って、「採用」を使ったダジャレを5文作成してください。
1, 2のそれぞれのプロセスで結果を出力してください。
> output
まず、ステップ1で「採用」という単語に関連したダジャレができそうな単語やフレーズを探します。読みが「さいよう」となる単語を探しますが、完全一致する単語の候補は限られているでしょう。一文字を母音で置き換えるか、類似の発音を探し出します。
「採用」に似た発音の単語・フレーズの例:
1. 西洋 (せいよう) - 「い」と「え」の母音が異なるが、似た音
2. サヨウナラ (さようなら) - 発音の一部が似ている
3. 細腰 (さいよう / ほそごし) - 読みが「さいよう」であるものの意味が違う
4. 最優 (さいゆう) - 発音が似ている
5. 再葉 (さいよう) - 読みが「さいよう」で同じだが意味が異なる
6. 西葉 (さいよう) - 地名等で存在する可能性があり、読みが「さいよう」で同じ
では、これらの単語を用いてダジャレを作成します。
ステップ2のダジャレ作成:
1. 新しいスーツを着たら、西洋アートに詳しくなったような採用面接だった。
- 「西洋」と「採用」を掛け合わせています。
2. サヨウナラだけは言いたくないけれど、君を採用する時は必ずやってくる。
- 別れを惜しむ気持ちと採用決定のシーンを結び付けています。
3. 細腰を強調したドレスに心惹かれたけれど、私が本当に欲しかったのは新しい仕事の採用通知だった。
- 「細腰」と「採用」を掛け合わせています。
4. 最優秀賞を獲得した彼は、運も実力もあるから次の採用試験にもきっと合格するだろう。
- 「最優」と「採用」が似た響きを持ちますが、文脈としては異なる使われ方をしています。
5. 春が来てもうすぐ草木が再葉するけど、うちの会社は年中無休で新しい人材を採用するよ。
- 春と新しい出発、採用するという意味合いを繋げました。
この5つは採用をテーマにしたダジャレで、それぞれ文脈に合わせたワードプレイが施されています。</pre>
<p>見違えるほどクオリティの高いダジャレが生成できました。ところどころ造語が生まれてしまっていますが、意味は通じるレベルの造語で個人的には許せる範囲です。</p>
<p>ただ、この長文をChatGPTに毎回送るのは面倒ですよね。そこで、次は入力文字列に対してダジャレを生成するショートカットを作ってみます。</p>
<h2 id="iOS-macOSのショートカットアプリ">iOS, macOSのショートカットアプリ</h2>
<p><span itemscope itemtype="http://schema.org/Photograph"><a href="http://f.hatena.ne.jp/m3tech/20231223000019" class="hatena-fotolife" itemprop="url"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231223/20231223000019.png" width="400" height="400" loading="lazy" title="" class="hatena-fotolife" style="width:180px" itemprop="image"></a></span></p>
<p>iOSユーザーであれば知ってる方も多いと思いますが、iOSやmacOSには「ショートカット」というノーコードツールがプリインストールされています。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fsupport.apple.com%2Fja-jp%2Fguide%2Fshortcuts%2Fwelcome%2Fios" title="ショートカット ユーザガイド" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://support.apple.com/ja-jp/guide/shortcuts/welcome/ios">support.apple.com</a></cite></p>
<p>このツール、名前からは想像し難いですが、かなり多彩な機能を備えています。例えば、プログラミング寄りの機能だと指定したURLに指定したヘッダー付きでHTTPリクエストを送ったり、WebページでJSを実行したりできますし、アプリケーション寄りの機能だと文字列からQRコードを生成したり他の対応するアプリケーションを呼び出したりといったことが可能です。もちろんif文やfor文に相当する機能もあります。</p>
<p>しかし、これらの設定はすべてGUIでポチポチ触りながら構築する必要があり、コードに慣れているエンジニアからするとまどろっこしい作業になります。文字列を加工したり、HTTPリクエストを送ってJSONをパースしたりといった処理をGUIで書いていくのは、かなり心が折れる作業になります。</p>
<p>特に、そこまで高度な操作を実装することが想定されていないためか、多くの操作を追加していくとエディタがもっさりしてしまいます。</p>
<p>そこで「ショートカット」を生成するプログラミング言語やライブラリがないか探し、いくつか候補を見つけました。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Falexander-akhmetov%2Fpython-shortcuts" title="GitHub - alexander-akhmetov/python-shortcuts: Create Siri Shortcuts with Python" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/alexander-akhmetov/python-shortcuts">github.com</a></cite></p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fgonzula%2Fsplash" title="GitHub - gonzula/splash: Simple Programming LAnguage for SHortcuts" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/gonzula/splash">github.com</a></cite></p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Felectrikmilk%2Fcherri" title="GitHub - electrikmilk/cherri: iOS Siri Shortcuts Programming Language 🍒" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/electrikmilk/cherri">github.com</a></cite></p>
<p>このうち、Cherri以外はメンテナンスされていないようでしたので、今回はCherriを使うことにしました。</p>
<h2 id="Cherriを使ってショートカットを実装">Cherriを使ってショートカットを実装</h2>
<p>CherriはGoで実装されている.shortcutファイルを生成するコンパイラです。VSCode向けにSyntax Highlightingの拡張機能も提供されています。</p>
<p>以下、公式のサンプルコードです。</p>
<pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synSpecial">/*</span><span class="synConstant"> Hello, Cherri! </span><span class="synSpecial">*/</span>
<span class="synComment">#define glyph smiley</span>
<span class="synComment">#define color yellow</span>
<span class="synIdentifier">@message</span> = <span class="synSpecial">"</span><span class="synConstant">Hello!</span><span class="synSpecial">"</span>
alert(<span class="synSpecial">"</span><span class="synConstant">Message: {message}</span><span class="synSpecial">"</span>, <span class="synSpecial">"</span><span class="synConstant">Alert</span><span class="synSpecial">"</span>)
</pre>
<iframe style="width: 100%;height: 400px;" src="https://playground.cherrilang.org/preview?code=%2F*%20Hello%2C%20Cherri!%20*%2F%0A%0A%23define%20glyph%20smiley%0A%23define%20color%20yellow%0A%0A%40message%20%3D%20%22Hello!%22%0Aalert(%22Message%3A%20%7Bmessage%7D%22%2C%20%22Alert%22)%0A"></iframe>
<p>サンプルコードからもわかるように、他の主流なプログラミング言語を扱っている人なら違和感なく書ける文法となっています。</p>
<p>実際に実装してみると、まだ開発途上でバグや使いづらい部分もあることがわかりましたが、GUIでポチポチやるよりは楽ですので、ぜひみなさんも試してみてください。</p>
<p>それでは、実装していきましょう。</p>
<h3 id="下準備">下準備</h3>
<p>ダジャレ生成プロンプトを、自動化用に書き換えます。また今回は入力を文章にして、そこからダジャレに使用する単語もChatGPTに選択してもらうようにします。</p>
<p>以下のようにプロンプトを書き換え、JSONが出力されるようにしました。<a href="#f-167cd837" id="fn-167cd837" name="fn-167cd837" title="gpt-4-1106-preview および gpt-3.5-turbo-1106 では確実にJSONで出力させるオプションが追加されました。 https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format">*1</a></p>
<pre class="code" data-lang="" data-unlink>ダジャレとは、同じ読み方で別の意味の単語やフレーズを使った文のことです。
以下に良いダジャレと悪いダジャレの例を挙げます。
|ダジャレ|良し悪し|理由|
|------|-----|-----|
|布団が吹っ飛んだ|良い|「布団」と「吹っ飛ん」が似た読み方で別の意味であるため|
|ふとんな布団|悪い|「ふとんな」というフレーズは日本語として不適切|
|布団は布団|悪い|同じ意味の単語の繰り返し|ダジャレを考える時のフローは以下の通りです。
1. 【入力】文の中から特徴的な単語を1個抽出します。
2. 抽出した単語語と読み方が完全に一致、もしくは同じ読みを含む、実在する単語やフレーズを10個まで探します。元と同じ単語は除外してください。
例えば、「蜜柑」と同じ読みを含む単語として、「未刊」や「未完成」、「アルミ缶」を得られます。
3. 見つけた単語を使って【入力】への返信として適切な文章を作成します。より意味が通っているほど好ましいです。
以上の情報に従って、ダジャレの返信文を5文作成してください。
出力は、以下のJSON形式で行います。JSON以外は出力しないでください。
{ "result": [{ "text": "作成した文", "keyword": "作成した文で使用した単語" }, ...] }
【入力】「{input}」</pre>
<h3 id="処理の流れ">処理の流れ</h3>
<p>以下の流れでダジャレを生成します。</p>
<ol>
<li>ダジャレを作るベースの文を取得する。共有メニューから起動した場合、その入力を採用する。そうでなければ、テキスト入力、音声入力、クリップボードから選択して入力する。</li>
<li>ダジャレ解説プロンプトに、1で取得したベースの文を埋め込む。</li>
<li>ChatGPT APIに、2のプロンプトを投げる。</li>
<li>レスポンスのJSONをパースし、生成されたダジャレ文のリストを得る</li>
<li>4のリストを表示してユーザーに選択させる。</li>
<li>選択したダジャレ文をクリップボードにコピーして終了。</li>
</ol>
<p>そしてこの流れを実装したのがこちらのコードです。実際に使用する場合は、OpenAIのAPIトークンが必要ですので、外部に漏洩してしまわないようお気をつけください。</p>
<p><a href="https://gist.github.com/oboenikui/a3378e1f7baffe5f778dedc563845c67">CherriでSiriショートカットを実装 · GitHub</a></p>
<p>ショートカットに変換すると、約80個の命令ブロックで構成されたとても大きなショートカットになっています。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231223/20231223000023.gif" width="600" height="389" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>Cherriのコードでも構造化されたJSONのデータを取得するのが少し煩雑ですが、GUIで設定するよりは遥かに楽でした。</p>
<p>それでは早速、実際に返信で使ってみましょう。なお、iPhone 15 Proからアクションボタンを使ってショートカットを呼び出せるようになったので、もちろんいつでも瞬時に呼び出せるように設定しています。</p>
<iframe src="https://www.youtube.com/embed/5rbfp80F68E?enablejsapi=1" width="560" height="315" frameborder="0" allowfullscreen></iframe>
<p><br><a href="https://www.youtube.com/watch?v=5rbfp80F68E">www.youtube.com</a></p>
<p><del>クソリプ</del> 素晴らしいダジャレが生成されましたね! なおこちらのショートカットで生成された文章を使用したことによる損害について、弊社および星川は一切の責任を負いません。</p>
<p>文章を入力にしたことでダジャレの品質が激落ちしてしまったので、今後の改善点とします。</p>
<h2 id="We-are-hiring">We are hiring!</h2>
<p>エムスリーでは、エンジニアを絶賛募集中です! ダジャレの経験年数は不問ですので、ご興味ある方は是非カジュアル面談等ご応募ください。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<div class="footnote">
<p class="footnote"><a href="#fn-167cd837" id="f-167cd837" name="f-167cd837" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">gpt-4-1106-preview および gpt-3.5-turbo-1106 では確実にJSONで出力させるオプションが追加されました。 <a href="https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format">https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format</a></span></p>
</div>
oboenikui
3年間Stripe Connectを運用した経験を共有します
hatenablog://entry/6801883189067572088
2023-12-22T08:00:00+09:00
2023-12-22T09:00:53+09:00 こちらはエムスリー Advent Calendar 2023の22日目の記事です。 こんにちは、エムスリーエンジニアリンググループ、デジスマ診療チームの山本 (id:shunyy) です。 医療機関向けSaaSであるデジスマ診療は、開発開始からちょうど3年が経ち、現在では予約・問診等、多様な機能を提供していますがリリース当初は決済機能のみを提供していました。そんなデジスマのコア機能である決済機能はStripe Connectを利用しており、今回は3年間運用した学びを共有したいと思います。 デジスマ診療のプロダクトの内容は以下のスライドを御覧ください。 speakerdeck.com そもそもS…
<p>こちらは<a href="https://qiita.com/advent-calendar/2023/m3">エムスリー Advent Calendar 2023</a>の22日目の記事です。</p>
<p>こんにちは、エムスリーエンジニアリンググループ、デジスマ診療チームの山本 (<a href="http://blog.hatena.ne.jp/shunyy/">id:shunyy</a>) です。</p>
<p>医療機関向けSaaSであるデジスマ診療は、開発開始からちょうど3年が経ち、現在では予約・問診等、多様な機能を提供していますがリリース当初は決済機能のみを提供していました。そんなデジスマのコア機能である決済機能はStripe Connectを利用しており、今回は3年間運用した学びを共有したいと思います。</p>
<p>デジスマ診療のプロダクトの内容は以下のスライドを御覧ください。</p>
<p><iframe id="talk_frame_958677" class="speakerdeck-iframe" src="//speakerdeck.com/player/c5409819c6da4b8092565f370304d72a" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/m3_engineering/introduction-of-m3-digikar-smart-development-team">speakerdeck.com</a></cite></p>
<ul class="table-of-contents">
<li><a href="#そもそもStripe-Connectとは何なのか">そもそもStripe Connectとは何なのか</a></li>
<li><a href="#Stripe-Connectのアカウントタイプ支払いタイプについて">Stripe Connectのアカウントタイプ・支払いタイプについて</a></li>
<li><a href="#デジスマ診療の全体構成">デジスマ診療の全体構成</a></li>
<li><a href="#追加開発事例">追加開発事例</a><ul>
<li><a href="#Amazon-AppFlowを利用したBigQueryとのデータ連携">Amazon AppFlowを利用したBigQueryとのデータ連携</a><ul>
<li><a href="#Amazon-AppFlow">Amazon AppFlow</a></li>
</ul>
</li>
<li><a href="#複数プラットフォームアカウントの併用">複数プラットフォームアカウントの併用</a><ul>
<li><a href="#最終的な処理の流れ">最終的な処理の流れ</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#教訓Tips">教訓・Tips</a><ul>
<li><a href="#Webhookのイベント逆転問題に気をつけよう">Webhookのイベント逆転問題に気をつけよう</a><ul>
<li><a href="#解決">解決</a></li>
</ul>
</li>
<li><a href="#on_behalf_ofを指定するかの決定は慎重に">on_behalf_ofを指定するかの決定は慎重に</a></li>
<li><a href="#各種リジェクトはSlack通知等で気付けるようにようにしよう">各種リジェクトはSlack通知等で気付けるようにようにしよう</a></li>
<li><a href="#Idempotency-Keyの設計に気をつけよう">Idempotency Keyの設計に気をつけよう</a></li>
<li><a href="#Stripeメタデータを使おう">Stripeメタデータを使おう</a></li>
<li><a href="#開発ステージング用アカウントは分けよう">開発/ステージング用アカウントは分けよう</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#We-are-hiring">We are hiring!!</a></li>
</ul>
<h2 id="そもそもStripe-Connectとは何なのか">そもそもStripe Connectとは何なのか</h2>
<blockquote><p>Stripe Connect は、不特定多数の販売者と購入者を管理するプラットフォームビジネスのためのソリューションです。オンライン上の顧客管理や複雑なお金の流れの自動化を、簡単に実現することができます。</p>
<p><a href="https://stripe.com/jp/connect">https://stripe.com/jp/connect</a></p></blockquote>
<p>つまり、多数の店子(デジスマ診療だと各医療機関、ECサイトだと各売り主)の商品を、単一のプラットフォーム上で提供する場合に課題となる、以下のような業務をまるっとカバーしてくれるサービスです。</p>
<ol>
<li>各店子の本人確認、振込先口座情報の登録</li>
<li>プラットフォーム上での決済機能</li>
<li><ol>
<li>であがった売上の各店子の銀行口座への入金</li>
</ol>
</li>
<li>各店子が売上等の各種統計情報を確認できるダッシュボード</li>
</ol>
<p>また、資金の流れをざっくり図にすると以下のようなものになります。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231222/20231222090047.png" width="564" height="261" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>基本的にプラットフォームとしては、このときの決済金額から各種手数料を引いたものと入金金額の差分が売上となるわけです。</p>
<h2 id="Stripe-Connectのアカウントタイプ支払いタイプについて">Stripe Connectのアカウントタイプ・支払いタイプについて</h2>
<p>Stripe Connectを使うに当たってまず、2つのタイプを決める必要があります。詳しい説明は<a href="https://stripe.com/docs/connect/accounts">公式ページ</a>が詳しいのでざっくりとした説明になりますが、</p>
<ul>
<li>アカウントタイプ
<ul>
<li>上記業務のどこまでをStripe上で行うか、プラットフォームサイト上で行うか。
<ul>
<li>Stripeに任せる画面が多いほど実装は容易になりますが、ユーザー体験を制御することが難しくなります</li>
</ul>
</li>
<li>Express, Custom, Standardのどれか</li>
</ul>
</li>
<li>支払いタイプ
<ul>
<li>顧客の支払った金額をどのようにプラットフォーム・店子側で分配するか。
<ul>
<li>決済ごとにプラットフォーム手数料を徴収するか or 月ごとにまとめて, etc.</li>
</ul>
</li>
<li><a href="https://stripe.com/docs/connect/direct-charges">ダイレクト支払い</a>、<a href="https://stripe.com/docs/connect/destination-charges">デスティネーション支払い</a>、<a href="https://stripe.com/docs/connect/separate-charges-and-transfers">支払いと送金別方式</a>のどれか
<ul>
<li>アカウントタイプによって選択できるものが違うので任意のものが選べるわけではない</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>のそれぞれを作成するプロダクトに応じて事前に決める必要があります。</p>
<p>デジスマ診療はアカウントタイプはExpress、支払いタイプは「支払いと送金別方式」を選択しました。これは、デジスマの手数料徴収の仕組みが、決済ごとの割合の手数料と、決済タイミングで確定しない固定の手数料の2種類があり、実現するにはこの方式しかなかったためです。が、基本的にこの連携方式はおすすめしません。シンプルな決済ごとの利用料徴収であれば、Express + 「デスティネーション支払い」を利用することをおすすめします。</p>
<h2 id="デジスマ診療の全体構成">デジスマ診療の全体構成</h2>
<p>デジスマ診療での全体のシステム構成は以下のようになっています。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231222/20231222090036.png" width="636" height="400" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p><a href="https://www.m3tech.blog/entry/2022/12/24/110000">デジスマ診療で採用しているイベント駆動</a>でのPush/メールの配信システム以外は一般的なStripe組み込み時の構成となっていると思います。図としては省きましたが、細かくは支払いの信頼性向上のためにSQSでの非同期支払いリトライ等も行っています。これだけシンプルに決済サービスを提供できているのは、StripeのIdempotency Keyを利用した冪等リクエストのための仕組みや、Webhookによるイベント通知と自動リトライといったStripeプラットフォーム側の強力なバックアップがあるためで、改めて非常に使いやすいプラットフォームだなと感じます。</p>
<h2 id="追加開発事例">追加開発事例</h2>
<p>サービスが拡大するに連れていくつかの大型開発を経てきているのでそのいくつかを紹介します。</p>
<h3 id="Amazon-AppFlowを利用したBigQueryとのデータ連携">Amazon AppFlowを利用したBigQueryとのデータ連携</h3>
<p>サービスの成長に伴い、当然Stripe上のデータと他のデータとjoinした分析を投げたいというニーズが高まります。特に会計周りでは、売上、手数料、最終的なStripe口座残高を正確に把握したいというニーズがあります。PaymentIntentに関わるようなデータ(決済額・決済手数料)はWebhook経由で取得しているものもありますが、以下のようなデータは性質上PaymentIntentのライフサイクルとは独立して徴収されるため把握が難しいです。</p>
<ul>
<li>Connect口座管理費用
<ul>
<li>月に一度まとめて請求</li>
</ul>
</li>
<li>Connect入金手数料
<ul>
<li>月に一度まとめて請求</li>
</ul>
</li>
<li>Radar利用料
<ul>
<li>一日分まとめて数日後に請求</li>
</ul>
</li>
<li>3D Secure利用料
<ul>
<li>一日分まとめて数日後に請求</li>
</ul>
</li>
</ul>
<p>またWebhook経由でのデータ連携ではデータの完全性の保証が難しく、そういった理由でも別経路でのデータの連携の必要がありました。</p>
<h4 id="Amazon-AppFlow">Amazon AppFlow</h4>
<p>Stripeの公式機能として<a href="https://stripe.com/ja-nz/newsroom/news/stripe-data-pipeline">SnowflakeとRedshift</a>との連携機能は提供されていますが、デジスマはDWHとしてBigQueryを利用しているため、AWSのマネージドなサービス間データフローであるAppFlowを利用しました。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231222/20231222090039.png" width="765" height="235" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>S3 -> BQ間もLambdaで実装したためサーバーレスにデータフローを構築でき、低コストで安定して運用できています。AppFlowでのS3 -> BQ間連携や、<a href="https://cloud.google.com/bigquery/docs/s3-transfer?hl=ja">BigQuery Data Transfer Service</a> などは検討しましたが、日別テーブルやスキーマ自動検出等を利用したかったため、現状は単にLambdaでloadしています。</p>
<p>BigQueryにさえ入ってしまえばGoogle SpreadsheetのConnected Sheets機能を使って自動で経理に提出するデータを生成できるので、これまでは毎回経理チームにStripeのダッシュボードから支払い関連のデータをダウンロードしてもらっていましたが、手作業が不要になりました。</p>
<h3 id="複数プラットフォームアカウントの併用">複数プラットフォームアカウントの併用</h3>
<p>デジスマ診療サービスでは、提供先医療機関の多様化に伴い、複数のプラットフォームアカウントを作成する必要がありました。かなり特殊な状況ではあるので何を言っているんだという感じだと思いますが、事実そういう状況でした。</p>
<p>複数プラットフォームを併用するとなると以下のような問題があります</p>
<ol>
<li>ユーザーの登録した決済手段はプラットフォームに紐づくため、決済先の医療機関によっては、ユーザーに再度決済情報を入力して貰う必要がありそう</li>
<li>Webフロントエンドやアプリのバイナリに組み込まれたPublishable keyはプラットフォームに紐づくため、Stripe.initのような初期化を医療機関ごとに複数回行う必要がありそう</li>
<li>プラットフォームとしての認証や、WebhookのEndpoint追加、上記データフロー等、1から設定・構築をやり直す必要がある</li>
</ol>
<p>3.に関しては、やるだけ、ではありますし、2.に関してもAPIから医療機関ごとに動的に公開可能キーを返す仕組みに変更することで対応できました。</p>
<p>しかし1.に関しては当初解決方法が思いつかず、解決できない場合、ECサイトに例えると</p>
<ol>
<li>商品Aを購入しようとするときにクレカ情報を入力する</li>
<li>商品Bを購入しようとすると、再度クレカ情報を要求される</li>
<li>以下略</li>
</ol>
<p>というかなりありえないUXとなり、サービスとして許容できないとして行き詰まっていました。図にすると以下のような感じで</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231222/20231222090042.png" width="541" height="383" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>言われてみればそう簡単にプラットフォーム間で決済手段が共有できても困るなという状況ではあります。</p>
<p>ということで、困り果ててStripeの担当の方に相談したところ、プラットフォームアカウント同士を連結することで、アカウント間で決済方法をコピーできるということを教えていただきました。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231222/20231222090045.png" width="621" height="383" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>つまり上の図のように、プラットフォームアカウント同士を、互いに店子として連結することで、通常のConnectでダイレクト支払いで決済方法を共有するときに利用するような、<a href="https://stripe.com/docs/payments/payment-methods/connect#cloning-payment-methods">PaymentMethodの複製</a>を利用できるということでした。プラットフォームアカウントもConnectも同様に <code>acct_***</code> のようなIDで識別することは認識していましたが、これらを並列に扱えるという発想がなかったので、すぐには理解できませんでしたが、この事実のお陰で比較的シンプルに実装できました。</p>
<h4 id="最終的な処理の流れ">最終的な処理の流れ</h4>
<p>最終的な処理の流れとしては以下のようになりました</p>
<ol>
<li>サブのプラットフォームでユーザー(決済者・患者)作成済みかチェックする</li>
<li>1.がnoの場合、サブプラットフォームでユーザーを作成する
<ul>
<li><pre><code class="sh">curl https://api.stripe.com/v1/customers \
-u {メインプラットフォーム用のクレデンシャル} \
-H "Stripe-Account: {サブアカウントID}" \
--data-urlencode description="Test Customer"
</code></pre></li>
</ul>
</li>
<li>該当ユーザーに決済情報が複製済みかチェックする</li>
<li>3.がnoの場合サブプラットフォームのユーザーにPaymentMethodをコピーする
<ul>
<li><pre><code class="sh">curl https://api.stripe.com/v1/payment_methods \
-u {メインプラットフォーム用のクレデンシャル} \
-d customer={サブアカウント上のユーザーID} \
-d payment_method={メインプラットフォーム上のPaymentMethod ID} \
-H "Stripe-Account: {サブアカウントID}"
</code></pre></li>
</ul>
</li>
<li>決済するときはダイレクト支払いと同じようにStripe-Accountヘッダーを追加する
<ul>
<li><pre><code class="sh">curl https://api.stripe.com/v1/payment_intents \
-u {メインプラットフォーム用のクレデンシャル} \
-d amount=1000 \
-d currency=jpy \
-d on_behalf_of={サブアカウント上の店子アカウントID} \
-d payment_method={4.で複製したPaymentMethod ID}
-d customer={サブアカウント上のユーザーID} \
-d payment_method={メインプラットフォーム上のPaymentMethod ID} \
-H "Stripe-Account: {サブアカウントID}"
</code></pre></li>
</ul>
</li>
</ol>
<p>こうすることで、APIとしてはメインプラットフォーム上で決済を行っている感覚で、サブプラットフォーム上での決済を実現できます。</p>
<h2 id="教訓Tips">教訓・Tips</h2>
<p>ここまではデジスマ特有の事象も多かったですが、以降は3年間の運用の中で学んだ、一般的に適用できそうな、いくつかの教訓/Tipsの共有です。</p>
<h3 id="Webhookのイベント逆転問題に気をつけよう">Webhookのイベント逆転問題に気をつけよう</h3>
<p>Webhookで各種イベントが受け取れることはとても便利ではありますが、StripeのWebhookの仕組み上、予期せぬエラーでのリトライやネットワークの状況次第では、<a href="https://stripe.com/docs/webhooks#event-ordering">実際の事象の時系列とは逆順にイベントが届くことがあります</a>。</p>
<p>デジスマでは <code>payment_intent.created</code> で決済処理中という表示を出し、<code>payment_intent.succeeded</code> で決済完了という表示にするという要件があります。このときイベントが逆順に届くと、ずっと決済処理中になってしまうという不具合がありました。</p>
<h4 id="解決">解決</h4>
<p>デジスマでは単にWebhook受信後にStripe APIを介して最新の情報を問い合わせることで、情報の先祖返りを防いでいます。</p>
<p>一方で少し複雑にはなりますが、各種event通知にはcreatedにタイムスタンプが入るため、こちらのフィールドをDBにも保持することで状態の先祖返りを防ぐことができると思います(秒単位でしか無いですし、同じタイムスタンプで来る可能性は0ではないので、上記方法のほうが確実ではあると思います。)</p>
<pre class="code lang-json" data-lang="json" data-unlink><span class="synSpecial">{</span>
"<span class="synStatement">id</span>": "<span class="synConstant">evt_***</span>",
"<span class="synStatement">object</span>": "<span class="synConstant">event</span>",
"<span class="synStatement">api_version</span>": "<span class="synConstant">2020-08-27</span>",
"<span class="synStatement">created</span>": <span class="synConstant">1702818630</span>,
"<span class="synStatement">data</span>": <span class="synSpecial">{</span>...<span class="synSpecial">}</span>
<span class="synSpecial">}</span>
</pre>
<h3 id="on_behalf_ofを指定するかの決定は慎重に">on_behalf_ofを指定するかの決定は慎重に</h3>
<p>デジスマのようにExpress/支払いと送金別方式を採用している場合、決済作成時のon_behalf_ofは<a href="https://stripe.com/docs/connect/separate-charges-and-transfers?locale=ja-JP#on-behalf-of">必須ではありません</a>。 このオプションを使用しない場合、クレカ利用明細がプラットフォーム名で統一できるなどのメリットも有るため、あえて指定しないこともあるでしょう。(逆に使用した場合は各店子の名称がクレカ明細に記載されます)</p>
<p>一方でJCB決済において、on_behalf_ofの指定が<a href="https://support.stripe.com/questions/integrating-jcb-payments-for-connect-platforms-and-connected-accounts?locale=ja-JP">必須となることがあります</a>プラットフォームの性質によって、必要・不要は決定されるようです。</p>
<p>デジスマ診療は当初JCBに対応していなかったのでon_behalf_ofを指定していませんでしたが、後日対応する際にon_behalf_ofが必須であることがわかり対応が必要でした。</p>
<p>on_behalf_ofを設定すればいいじゃんという話に聞こえますが、クレカ明細に記載される店子の名称(漢字・ローマ字・カタカナ)を追加で医療機関から収集する必要があり、意外と大変な移行作業でした。</p>
<h3 id="各種リジェクトはSlack通知等で気付けるようにようにしよう">各種リジェクトはSlack通知等で気付けるようにようにしよう</h3>
<p>決済に例外はつきものです。以下のようなイベントは必ず気付けるようにしましょう。</p>
<ol>
<li>Connectアカウントの本人確認情報の不備等に依る非承認</li>
<li>決済の不審申請</li>
</ol>
<p>1.はサービス上で不備がある・再申請をしてくださいと表示するだけでは不十分なことが多いです。Connectの店子申請画面は非常によくできていますが、利用される方によっては何を提出すればよいのかわからないこともあります。この場合、非効率に思えますが、人力での対応が必要になります、適切にカスタマーサクセスチーム等にエスカレできる仕組みを作成するべきです。</p>
<blockquote><p>不審請求の申請 (チャージバックとも呼ばれます) は、カード保有者がカード発行会社に対して、お客様への支払いについて疑問を呈したときに発生します。</p></blockquote>
<p>2.はかなり頻度も低いため、後回しにされがちですが、いつかは必要になります。不審申請の場合は<a href="https://stripe.com/docs/disputes/how-disputes-work?locale=ja-JP#%E3%82%BF%E3%82%A4%E3%83%9F%E3%83%B3%E3%82%B0">明確に対応する期日(通常7日程度)</a>があるため、確実に対応できるよう通知を設定しておきましょう。</p>
<h3 id="Idempotency-Keyの設計に気をつけよう">Idempotency Keyの設計に気をつけよう</h3>
<p>Stripe APIのすべてのPOSTエンドポイントは、<a href="https://stripe.com/docs/api/idempotent_requests"><code>Idempotency-Key</code> ヘッダーをつけることで安全にリトライ</a> を行うことができます。</p>
<p>こちらの仕組みは、システマティックな自動リトライによるリソースの重複を防ぐという利点もありますが、RDBにおけるユニークキーのような役割を果たします。</p>
<p>例えば、デジスマのようなサービスで1つの予約に対しては、1請求しかできないような制約をつけたい場合</p>
<pre class="code lang-sh" data-lang="sh" data-unlink>curl https://api.stripe.com/v1/payment_intents <span class="synStatement">\</span>
<span class="synSpecial">-d</span> <span class="synIdentifier">amount</span>=<span class="synConstant">5000000000000000</span> <span class="synStatement">\</span>
<span class="synSpecial">-d</span> <span class="synIdentifier">currency</span>=jpy <span class="synStatement">\</span>
<span class="synSpecial">-H</span> <span class="synStatement">"</span><span class="synConstant">Idempotency-Key: {予約ID}</span><span class="synStatement">"</span> <span class="synStatement">\</span>
</pre>
<p>のようなヘッダーを付けることで、同一の予約IDでの複数リクエストは同一視され2回目以降は請求が発生しません。また、同一Idempotency-Keyでamountが違うようなリクエストはエラーが発生<a href="#f-4d81069b" id="fn-4d81069b" name="fn-4d81069b" title="正確には最初のリクエストから24時間以内だけ重複チェックが行われるので挙動としてはより複雑になります。">*1</a>するようになります。</p>
<p>とはいえこれはシンプルなケースです。例えば返金機能を実装したとして</p>
<ol>
<li>payment_intent作成</li>
<li>返金</li>
<li>再度payment_intent作成</li>
</ol>
<p>1.と3.で同じImpotency-Keyを指定すると、金額が違えば当然エラーになりますし、同じ場合は成功したように見えて再請求が実施されないという状況になりえます。
デジスマでは別途IdempotencyKeyを発番する仕組みを作っていますが、このように便利な半面、予期せぬエラー・不整合を招くことがあり注意が必要です。</p>
<h3 id="Stripeメタデータを使おう">Stripeメタデータを使おう</h3>
<p>Stripeには<a href="https://stripe.com/docs/api/metadata">メタデータ</a>という仕組みがあり、PaymentIntentのような主要なオブジェクトにはkey/valueで任意の文字列を設定できます。</p>
<p>例えばPaymentIntentにアプリケーション側で利用する決済IDをメタデータとして付与することで、サポート対応時のStripeダッシュボード上での検索キーとして使うことができます。また、有事の際のデータ整合性チェックにも使えるので設定しておくと安心です。</p>
<h3 id="開発ステージング用アカウントは分けよう">開発/ステージング用アカウントは分けよう</h3>
<p>Stripeには<a href="https://stripe.com/docs/test-mode?locale=ja-JP">テスト環境</a>の仕組みがあり、1つのアカウントで本番・開発環境を実現できるようになっています。</p>
<p>当然といえば当然ですが、1つのアカウントしかない場合テスト環境も1つしかできないので、開発環境・ステージング環境のように複数のテスト環境を実現したい場合は、Webhookや顧客データを共有できないのでアカウントを分ける必要があります。</p>
<p>テスト利用でも本番申請が事前に必要な場合があり、面倒ではありますが、権限管理もし易いですし、普通に分けましょう。</p>
<h2 id="まとめ">まとめ</h2>
<p>ということで、デジスマ診療におけるStripeの事例・学びをいくつか紹介しました。</p>
<p>いくつかのひっかかりポイントは紹介しましたが、改めて総合的に見るとStripeは非常に素晴らしい決済プラットフォームです。冪等キーやWebhookによる安全なアプリケーション側との結合。各種データ連携といった拡張できる余地を持ちながら、単体で使いやすいダッシュボード。素早く安定したサポート体制。など、SaaSを提供する一エンジニアとしても見習うべき点が非常に多いなと感じます。</p>
<p>引き続きStripeと共にやっていきできればいいんじゃないかなと思っております。</p>
<h2 id="We-are-hiring">We are hiring!!</h2>
<p>エムスリーでは絶賛エンジニアを募集中です!
デジスマ診療以外にも様々なプロダクトがありますので、ご興味ある方は是非カジュアル面談等ご応募ください!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<div class="footnote">
<p class="footnote"><a href="#fn-4d81069b" id="f-4d81069b" name="f-4d81069b" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://stripe.com/docs/api/idempotent_requests#:~:text=You%20can%20remove%20keys%20from%20the%20system%20automatically%20after%20they%E2%80%99re%20at%20least%2024%20hours%20old.%20We%20generate%20a%20new%20request%20if%20a%20key%20is%20reused%20after%20the%20original%20is%20pruned.">正確には最初のリクエストから24時間以内だけ重複チェックが行われるので挙動としてはより複雑になります。</a></span></p>
</div>
shunyy
無駄な仕事を増やしてませんか? ~ MLの実行パイプラインで重複作業をなくす ~
hatenablog://entry/6801883189068197573
2023-12-21T11:00:00+09:00
2023-12-21T11:50:20+09:00 DALL-Eで作成した「並列でジョブを実行しているときに、重複するタスクを省略している様子」のイラスト。並行して働く2組のwoker達が、重複タスクを見つけて整理しているようです。 こちらはエムスリー Advent Calendar 2023 21日目の記事です。 こんにちは。AI・機械学習チーム(以下AIチーム)の池嶋です。 仕事で、誰か一人がやればいい作業を、気がついたら同僚と同じタイミングでやっていた、という経験はありませんか? とくにリモートワークが増えてきた昨今、そういうことが起きやすいように感じます。 でも、実際はあんまりないような*1…いや、ここではあるとしてください。 この場合…
<p><figure class="figure-image figure-image-fotolife" title="図1. DALL-Eで作成した「並列でジョブを実行しているときに、重複するタスクを省略している様子」のイラスト。並行して働く2組のwoker達が、重複タスクを見つけて整理しているようです。"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mski_iksm/20231219/20231219224334.png" width="1024" height="1024" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption> DALL-Eで作成した「並列でジョブを実行しているときに、重複するタスクを省略している様子」のイラスト。並行して働く2組のwoker達が、重複タスクを見つけて整理しているようです。</figcaption></figure></p>
<p>こちらはエムスリー Advent Calendar 2023 21日目の記事です。</p>
<p>こんにちは。AI・機械学習チーム(以下AIチーム)の池嶋です。</p>
<p>仕事で、誰か一人がやればいい作業を、気がついたら同僚と同じタイミングでやっていた、という経験はありませんか?
とくにリモートワークが増えてきた昨今、そういうことが起きやすいように感じます。
でも、実際はあんまりないような<a href="#f-5980f4bd" id="fn-5980f4bd" name="fn-5980f4bd" title="あんまりないなぁと思ったのは、エムスリーではそれぞれのメンバーがプロダクト毎の責任を持っているので、タスクの衝突は起きにくいのかもです。">*1</a>…いや、ここではあるとしてください。
この場合、どちらか一方の作業がムダになってしまい、嫌ですよね。
当然、あなたの上司もチームメンバーのリソースがムダになってしまい嫌がっているはずです。</p>
<p>それは、あなたが今並列に回しているバッチでも同じです。
workerであるインスタンスたちは同じタスクをやらされて不満を持っているかもしれないし、指示をしたあなたもインスタンス料金のムダ遣いからワナワナしていることと思います。</p>
<p>この記事では、AIチームが機械学習モデルの同時実行時に同じタスクを重複して実行しないための工夫を紹介します。</p>
<h2 id="gokartでバッチを記述している">gokartでバッチを記述している</h2>
<p>エムスリーのAIチームでは、パイプラインツールとしてgokartを採用しています。
これは、エムスリーメンバーを中心にOSSで開発が進められている、Pythonのパイプラインライブラリです。
Taskというクラスの単位で処理を記述し、その依存関係を定義することで、依存を解決しながら順に実行していってくれるという特徴を持っています。
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fm3dev%2Fgokart" title="GitHub - m3dev/gokart: Gokart solves reproducibility, task dependencies, constraints of good code, and ease of use for Machine Learning Pipeline." class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/m3dev/gokart">github.com</a></cite></p>
<p>AIチームのベースとなるライブラリの1つなので、当ブログでも度々取り上げられています。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2Fgokart" title="gokart 1.0.0 をリリースしました - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/gokart">www.m3tech.blog</a></cite>
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2F2023%2F03%2F22%2F173000" title="僕がAIチームで知っている技術スタックまとめ - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/2023/03/22/173000">www.m3tech.blog</a></cite>
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2F2023%2F06%2F13%2F170000" title="Titanicで学ぶ、実務で使えるgokartの書き方 - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/2023/06/13/170000">www.m3tech.blog</a></cite></p>
<p>Task単位に処理を切り分けることで、単一責任の原則を強く意識したclass設計ができるというのが、gokartの大きな特徴の1つです。
図1のように、誰もがデータ収集・特徴量作成・モデル学習といった、処理をTask単位で分割して記述しやすくなっています。</p>
<p><figure class="figure-image figure-image-fotolife" title="図1. gokartではデータ収集・特徴量作成・モデル学習を別Taskとして記述"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mski_iksm/20231219/20231219224758.png" width="814" height="742" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>図1. gokartではデータ収集・特徴量作成・モデル学習を別Taskとして記述</figcaption></figure></p>
<h2 id="gokartの同時実行では重複Taskが発生する">gokartの同時実行では、重複Taskが発生する</h2>
<p>AIチームでは、このgokartのパイプラインを同時に多数走らせています。
たとえば、同じ機械学習モデルを異なるパラメータで学習させることで、ハイパーパラメータチューニングを行うなどしています。
この場合、図2のように同一のTaskが多くのパイプラインで実行されることになります。
<figure class="figure-image figure-image-fotolife" title="図2. 同一のTaskが多くのパイプラインで実行される"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mski_iksm/20231219/20231219224917.png" width="1200" height="630" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>図2. 同一のTaskが多くのパイプラインで実行される</figcaption></figure></p>
<p>gokartでは、Taskの完了チェックがあるので、一度実行して、完了したTaskは実行されません。
しかし、この完了チェックは各gokartパイプラインの一番初めの実行計画のタイミングでしか実施されません。
今回のように、同時にgokartパイプラインを多数実行開始した場合、各パイプラインの実行計画の段階では、まだ各Taskは完了していない判定になります。
その結果、各パイプラインで重複して同じTaskが実行されてしまいます。</p>
<p><figure class="figure-image figure-image-fotolife" title="図3. 各パイプラインの実行計画の段階では、まだ各Taskは完了していないので、重複したTaskが実行計画されてしまう"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mski_iksm/20231220/20231220164548.png" width="1200" height="497" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>図3. 各パイプラインの実行計画の段階では、まだ各Taskは完了していないので、重複したTaskが実行計画されてしまう</figcaption></figure></p>
<p>同じTaskが別々のworkerで同じようなタイミングで実行されると、以下のような問題が生じます</p>
<ul>
<li>Task完了時のキャッシュ書き込みが競合して、キャッシュが破壊される</li>
<li>同じTaskを各workerで実行しているのはリソースのムダ遣い</li>
</ul>
<p>gokartでは、Taskの実行結果をキャッシュファイルに保存します。
このキャッシュファイルへの書き込みや読み取りを同時に行うと、キャッシュファイルが破損する可能性があるというのが、前者の問題です。
この問題は過去の記事で取り上げられ、解決策も提案されていますので、ここでは省略します。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2F2023%2F07%2F25%2F170000" title="ファイル競合を防ぐロック技術 in gokart - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/2023/07/25/170000">www.m3tech.blog</a></cite></p>
<p>後者は、単純に計算資源のムダ遣いがもったいないという問題です。
並列数が2-3ぐらいであれば多少のインスタンス料金の増加も許容できるかもしれませんが、機械学習モデルのパラメータチューニングのように、パターン数が何十~何百という数になってくると許容できなくなってきます。
ここでは、このリソースのムダ遣いを防ぐ方法を検討します。</p>
<h2 id="重複Taskを省略する方法">重複Taskを省略する方法</h2>
<p>ここでは同じTaskが二重に実行されないようにするため、以下の2手法を組み合わせます。</p>
<ul>
<li>complete_check_at_runで完了済みのタスクをスキップする</li>
<li>Taskの実行順を可能な範囲でランダムにする</li>
</ul>
<h3 id="complete_check_at_runで完了済みのタスクをスキップする">complete_check_at_runで完了済みのタスクをスキップする</h3>
<p>gokartでは、パイプライン開始時に、各Taskの完了チェックを行います。
完了済みのTaskに関しては実行結果のキャッシュファイルを読み込むだけで済ませ、未完了のTaskに対して依存関係を解決するようにTaskの実行順を計画します。</p>
<p>gokart Taskの、complete_check_at_runというフラグをTrueに設定すると、各Taskの実行時にも再度Taskの完了確認を行なうようになります。
これにより、各Taskを実行しようとしたタイミングで完了したことが判明したら、そのタスクの実行をスキップするようになります。
「自分のgokartパイプライン開始時にはまだ誰も実行していなかったが、いざそのTaskをrun()するタイミングになってみたら別のworkerで実行されていたgokartパイプラインにより、そのTaskが実行完了されていた」といったような場合、後から実行しようとした方のTaskの実行を省略できます。</p>
<p><figure class="figure-image figure-image-fotolife" title="図4. 各Taskを実行しようとしたタイミングで完了していたことが判明したら、そのタスクの実行をスキップする"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mski_iksm/20231221/20231221034536.png" width="1200" height="508" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>図4. 各Taskを実行しようとしたタイミングで完了していたことが判明したら、そのタスクの実行をスキップする</figcaption></figure></p>
<p>これは、以下のように<code>TaskOnKart.complete_check_at_run</code>をTrueに設定するだけで実現可能です。</p>
<pre class="code lang-python" data-lang="python" data-unlink><span class="synStatement">class</span> <span class="synIdentifier">SampleTask1</span>(gokart.TaskOnKart):
complete_check_at_run = <span class="synIdentifier">True</span>
</pre>
<p>これにより、実行タイミングにズレがあれば、重複Taskを省略できるようになりました。
しかし、同時に開始した複数のgokartパイプラインは、同じTaskには同じぐらいのタイミングで着手することになりそうと考えられます。
この場合、あまり<code>complete_check_at_run</code>の効果は発揮されないかもしれません。</p>
<p>したがって、<code>complete_check_at_run</code>を有効にするには、workerごとに同一タスクを実行するタイミングをずらす必要がありそうです。</p>
<h3 id="Taskの実行順を可能な範囲でランダムにする">Taskの実行順を可能な範囲でランダムにする</h3>
<p>gokartでは、Taskの実行順序は、依存関係にしたがって決まります。
たとえば、Task1がTask2に依存している場合、Task1のrun()が呼ばれる前には、必ずTask2のrun()が呼ばれます。</p>
<p>一方、依存関係順位が同じTaskも機械学習ではしばしば発生します。
たとえば、期間を区切ってのデータダウンロードなどは、それぞれ期間で独立しているので依存関係はありません。
つまり、これらはどの順番に実行しても問題ありません。
これらのTaskは実行順をランダムにすることで、workerごとに同じTaskに到達するタイミングをずらすことができます。</p>
<p>同一Taskの実行開始タイミングがズレることで、<code>complete_check_at_run</code>により、2番目以降にそのTaskに到達したworkerでは実行をスキップできます。
これは、workerごとに異なるTaskを実行する、あたかも並列実行しているかのような高速化に寄与します。</p>
<p><figure class="figure-image figure-image-fotolife" title="図5. 順番をランダムにすることでパイプラインごとに同じTaskへの到達タイミングをずらした"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mski_iksm/20231221/20231221034652.png" width="1200" height="455" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>図5. 順番をランダムにすることでパイプラインごとに同じTaskへの到達タイミングをずらした</figcaption></figure></p>
<p>実行順のランダム化は、gokartの<code>priority</code>というプロパティを使って実現できます。
これは依存関係順位が同じ場合に、実行順を決めるためのプロパティです。
この値をランダムにすることで、依存関係を破壊せずに実行順をランダム化できます。</p>
<pre class="code lang-python" data-lang="python" data-unlink><span class="synStatement">class</span> <span class="synIdentifier">OrderRandomTask</span>(gokart.TaskOnKart):
<span class="synPreProc">@</span><span class="synIdentifier">property</span>
<span class="synStatement">def</span> <span class="synIdentifier">priority</span>(self):
<span class="synStatement">return</span> random.Random().random()
</pre>
<h2 id="重複Taskを省略する方法に関しての他案との比較">重複Taskを省略する方法に関しての他案との比較</h2>
<p>同一Taskを重複して実行させないようにするためにはluigiのcentral schedulerを使う方法もあります。
central schedulerは、どのTaskを順に実行すべきかを中央集権的に管理する機能です。
未実行のTaskを把握しながら次に実行すべきTaskを判断できるので、同一Taskを同時に重複して実行するようなことはありません。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fluigi.readthedocs.io%2Fen%2Fstable%2Fcentral_scheduler.html" title="Using the Central Scheduler — Luigi 2.8.13 documentation" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://luigi.readthedocs.io/en/stable/central_scheduler.html">luigi.readthedocs.io</a></cite></p>
<p>しかし、central schedulerには、単一障害点になりやすいという問題があります。
central schedulerの動作しているnodeが停止すると、すべてのパイプラインが停止してしまいます。
可用性のためには、こうした問題はできるだけ回避したいところです。</p>
<p>また、すべてのパイプラインを同一のTask schedulerに載せづらいという問題もあります。
たとえば、<a href="https://www.m3tech.blog/entry/2023/12/07/113000">エムスリー Advent Calendar 2023 7日目で紹介したkannon</a>では、worker内のlocal schedulerを使うので、luigiのcentral schedulerとの併用ができません。
luigiのcentral schedulerで重複を防ぐには、すべてのパイプラインをcentral schedulerに載せる必要があり、kannonをうまく活用できなくなるという問題があります。</p>
<p>こうした理由から、本稿では重複Taskの重複実行を省略するために、「Taskが完了していたら再度実行しない」という戦略を取りました。</p>
<h2 id="we-are-hiring">we are hiring</h2>
<p>AIチームでは日々大量の機械学習バッチが動作しており、少しでもムダを省くことはインスタンス料金などのコスト削減に大きな影響を与えます。
こうした最適化を機械学習バッチ側、MLOps側から続けております。
ムダを見つけるとすぐ最適化したくなるエンジニアの皆様、AIチームでご応募お待ちしております。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<div class="footnote">
<p class="footnote"><a href="#fn-5980f4bd" id="f-5980f4bd" name="f-5980f4bd" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">あんまりないなぁと思ったのは、エムスリーではそれぞれのメンバーがプロダクト毎の責任を持っているので、タスクの衝突は起きにくいのかもです。</span></p>
</div>
mski_iksm
メール配信システムを SaaS から新規社内システムへ移行した
hatenablog://entry/6801883189067952334
2023-12-20T14:00:00+09:00
2023-12-21T13:05:19+09:00 この記事はエムスリーAdvent Calendar 2023の20日目の記事です。 エムスリーエンジニアリングG コンシューマチームの松原(@ma2ge)です。 今回はコンシューマチームで利用していたSaaSのメール配信システムを、新規に開発した社内システムに移行した経緯や設計時に意識したことなどについて紹介します。 最近使っているキーボードの様子
<p>この記事は<a href="https://qiita.com/advent-calendar/2023/m3">エムスリーAdvent Calendar 2023</a>の20日目の記事です。</p>
<p>エムスリーエンジニアリングG コンシューマチームの松原(<a href="https://twitter.com/ma2ge">@ma2ge</a>)です。
今回はコンシューマチームで利用していたSaaSのメール配信システムを、新規に開発した社内システムに移行した経緯や設計時に意識したことなどについて紹介します。</p>
<p><figure class="figure-image figure-image-fotolife" title="最近使っているキーボードの様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231221/20231221130513.jpg" alt="最近使っているキーボードの様子" width="800" height="600" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>最近使っているキーボードの様子</figcaption></figure></p>
<h2 id="背景">背景</h2>
<p>今回移行する契機となったのはメールの配信数増加に伴うSaaSの利用料金増です。
特に定期的に送るメルマガ配信については、配信量も多く利用コストを押し上げる要因となっていました。
そのためメルマガ配信で大量に使用する部分についてのシステム移行検討が始まりました。</p>
<h2 id="移行検討">移行検討</h2>
<p>SaaSから移行後のシステムについて試算すると、システムの開発や利用料といったコスト面では社内で構築したシステムの方が大幅にコストが下がることがわかりました。
しかしながらメールサーバーを含めて丸ごとメール配信システムを作るのはチームにとってはそれなりに負荷があります。
特に今のチームは主にサービスを開発しているチームであるためメール周りの知見はほとんどありません。
例えばIPのレピュテーションを高めることにどれくらいの力を入れるのか、バウンスの対応はどのように行っていくのかなど、考えるべきことはいくつもあり開発、運用ともに時間も手間も取られそうなことが予想できました。
利用料を削減したいとはいえ、肝心のサービス開発に使う時間が減少してしまっては、削減で得られる利益よりも機会損失による利益の減少の方が大きくなり本末転倒となる可能性もあります。</p>
<p>そのため今回は丸ごと全てを作るのではなく、メールサーバー部分については社内のものを利用することにしました。
エムスリーも長いことサービスを運営しており、社内横断のSREチームが運用しているメール配信用の社内メールサーバーがあります。
そちらを利用することで、メールサーバー周りの運用をSREチームに見てもらうことで負担を一部軽減し、今回開発するメール配信システム部分をチーム内で見るようにすることで運用負荷も現実的な範囲にできそうと判断しました。
移行対象のメルマガはそれなりに配信量も多く社内メールサーバーの負荷も心配でしたが、幸いメールサーバーを強化することで配信量はカバーできそうとの見込みを立ててもらうことができ、この案で開発を進めることになりました。</p>
<h2 id="設計時に意識したこと">設計時に意識したこと</h2>
<p>ということで社内メールサーバーを利用することで方針は決まったので、次は設計の方針です。
要件としてはSaaSで利用していた下記機能をまずは必要な分満たすことです。</p>
<ul>
<li>メール配信速度を段階的にあげていきサービスに必要な配信速度を目指す</li>
<li>メール配信に伴う配信状況、クリック数等の計測</li>
</ul>
<p>メールの配信速度については、専門的なサービスとして提供しているSaaSに追いつくことは難しいです。そのため自サービスで求めている配信速度を目指すことにし、配信速度も段階的に高めていくことで調整がつきました。
移行後は送信量も多くなるのでIPレピュテーションのリスクも高まります。その意味でもここは一気に速度をあげていくということはせずに徐々に進めていく必要があります。</p>
<p>開発周りについては先日の記事「<a href="https://www.m3tech.blog/entry/rails-web-development-on-aws-lambda">AWS Lambda でも Rails で Web 開発</a>」をご覧いただいて、今回は運用と独立性について記します。</p>
<h3 id="運用楽に">運用楽に</h3>
<p>設計で意識したことの1点目は運用が楽になるようにしたことです。移行検討にも記載しましたが、サービス開発に集中できるよう運用は楽であればあるほど良いです。</p>
<p>メール配信については社内メールサーバーの強化やメルマガ配信の量によって、ある程度柔軟に配信速度を調節できると運用コストが下がることが見込めたので、スケーラビリティを高めるためにSQSとAWS Lambdaを組み合わせて採用することにしました。
この連携ではSQSをイベントソースとしたときの最大同時実行数も制御できるので、この数値を変動させるだけで簡易的に処理速度をスケールさせることができます。
また最大で1000まで同時実行数をスケールさせることもできるため、1関数あたりでそれなりの量を処理できれば、今後メール配信が増えたとしても十分に処理できるだけの能力が見込めます。</p>
<p>メールの配信状況やクリック数の測定に関してはできるだけサーバー管理不要なAWSサービスを組み合わせることにしました。
AWS LambdaからCloudWatch Logs経由でイベントを出力しておいて、Firehoseを経由しS3へログファイルを出力し、その後AWS Glueを使用して社内の共通DWH基盤であるBigQueryへとデータ連携するような仕組みを構築しました。
AWS Glueは初めて使用するAWSのサービスではあったのですが、サーバーレスで運用周りを任せられそうだったことから選定することにしました。社内的にはEmbulkを使っているものも多いのですが、サーバーを共用していたりQA用のリソースがそれほど多くないことが懸念となり今回のプロジェクトでは見送ることにしました。</p>
<h3 id="独立性">独立性</h3>
<p>2点目は配信システムの独立性で、マイクロサービスとして切り出して開発するようにしたことです。
メール周りに関してこのシステムに切り出すことで、サービス部分は自分たちのビジネスロジックに集中できます。
また今はまだ開発できていないのですが将来的に予約送信等の機能も入れば、サービス部分で予約にまつわる余計な複雑さを抱える必要もなくなります。</p>
<p>ということで今回はこのような構成でシステムを構築しました(メール送信から配信状況の計測まで)</p>
<p><figure class="figure-image figure-image-fotolife" title="システムへのメール送信リクエストから配送状況をデータとして格納する部分までの構成"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231221/20231221130510.png" alt="システムへのメール送信リクエストから配送状況をデータとして格納する部分までの構成" width="733" height="404" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>システムへのメール送信リクエストから配送状況をデータとして格納する部分までの構成</figcaption></figure></p>
<p>本記事では紹介できませんでしたが、メールサーバー周りの運用についてはSREチームの記事がありますので興味のある方はぜひご覧になってください。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2F2023%2F06%2F06%2F110000" title="大量メール送信のための予備知識 - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/2023/06/06/110000">www.m3tech.blog</a></cite></p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2F2023%2F06%2F07%2F110000" title="postfixによる大量メール送信にまつわる問題と対処 - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/2023/06/07/110000">www.m3tech.blog</a></cite></p>
<h2 id="結果">結果</h2>
<p>このような構成、意図で設計した結果ですが、本システムの運用で逼迫することもなくサービス開発も引き続き実施できています。</p>
<p>SQSとAWS Lambdaの組み合わせは見込み通り、同時実行数の制御を使うことで柔軟な運用ができてとても便利でした。特にメールサーバーの配信処理性能が向上することに合わせてこちらの処理速度も追従させる必要があったため、設定値の変更のみで追従できるのは非常に楽でした。
AWS Glueは初めて触るサービスでしたが運用してみると管理するサーバーも必要なく、スケジュール実行できたり、ログも最初から吐いてくれたりと運用目線で楽ができてよかったです。一方でAWS Glueは開発で結構ハマってしまったり、Sparkになれていなかったりで手間取った部分もありました。</p>
<p>独立性については元々SaaSを使っていたこともあり、比較的分けやすくAPIを呼び分けるだけで移行もしやすい状態にできたかと思います。メール配信に特化させたためもしかしたら将来的には別のサービスで同じシステムを使い回すということもできるかもしれません。</p>
<p>当初目的であるコストダウンについても月間で約100万円ほど削減できました。</p>
<h2 id="We-are-hiring">We are hiring!</h2>
<p>今回は直近で担当したメール配信システムの移行について振り返りつつ記録してみました。
開発の意思決定について日常の様子が少しでも伝わればと思います。</p>
<p>エムスリーでは開発から運用まで一気通貫で担当したい技術好きなエンジニアを募集しております。ご興味がありましたら、お気軽にお問い合わせください。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
ma2gedev
超一流のプロダクトマネージャーへの道:世界観編
hatenablog://entry/6801883189067666066
2023-12-19T17:39:46+09:00
2023-12-19T17:40:01+09:00 この記事はエムスリーAdvent Calendar 2023の19日目の記事です。 こんにちは。エンジニアリンググループ プロダクト支援チームでプロダクトマネージャーをしている中村です。日々、取締役CTO/VPoPの山崎から学び、超一流のプロダクトマネージャーを目指しています。 先日、「超一流のプロダクトマネージャーへの道」というタイトルで、超一流のプロダクトマネージャーを目指す中での学びや体験を書きました。 www.m3tech.blog 今回のブログでは、超一流のプロダクトマネージャーを目指すうえで不可欠な世界観について、現時点での私の理解を整理していきたいと思います。 エムスリーではプロ…
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231219/20231219173949.png" width="800" height="420" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>この記事は<a href="https://qiita.com/advent-calendar/2023/m3">エムスリーAdvent Calendar 2023</a>の19日目の記事です。</p>
<p>こんにちは。エンジニアリンググループ プロダクト支援チームでプロダクトマネージャーをしている中村です。日々、取締役CTO/VPoPの山崎から学び、超一流のプロダクトマネージャーを目指しています。</p>
<p>先日、「超一流のプロダクトマネージャーへの道」というタイトルで、超一流のプロダクトマネージャーを目指す中での学びや体験を書きました。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2F2023%2F10%2F03%2F110000" title="超一流のプロダクトマネージャーへの道 - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/2023/10/03/110000">www.m3tech.blog</a></cite></p>
<p>今回のブログでは、超一流のプロダクトマネージャーを目指すうえで不可欠な世界観について、現時点での私の理解を整理していきたいと思います。</p>
<ul class="table-of-contents">
<li><a href="#エムスリーではプロダクトの世界観が重要視されている">エムスリーではプロダクトの世界観が重要視されている</a></li>
<li><a href="#本ブログで扱う世界観とは">本ブログで扱う世界観とは</a></li>
<li><a href="#世界観を活用するか否かによるプロダクトマネジメントへの影響">世界観を活用するか否かによる、プロダクトマネジメントへの影響</a></li>
<li><a href="#世界観の整理">世界観の整理</a></li>
<li><a href="#世界観を整理したことで起きたこと">世界観を整理したことで起きたこと</a></li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#We-are-hiring">We are hiring!!</a></li>
</ul>
<h2 id="エムスリーではプロダクトの世界観が重要視されている">エムスリーではプロダクトの世界観が重要視されている</h2>
<p>先日、プロダクトマネジメントにおける国内最大級のカンファレンス<a href="https://2023.pmconf.jp/">pmconf2023</a>が開催され、エムスリーからは山崎さんが登壇しました。発表資料、アーカイブ動画は以下からご覧いただけます。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2F2023.pmconf.jp%2Fsession%2FzBfEEEcp" title="プロダクトと事業を無限にスケールするための最強のロードマップの作り方丨SESSION 講演内容丨プロダクトマネージャーカンファレンス 2023" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://2023.pmconf.jp/session/zBfEEEcp">2023.pmconf.jp</a></cite></p>
<p>また、山崎さんとVPoEの河合さんによるpmconf23の振り返り動画も公開されています。pmconf23の見どころなどが語られていますので、アーカイブ動画と共にぜひご覧ください。</p>
<p><iframe width="560" height="315" src="https://www.youtube.com/embed/AqxD2sDoflA?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen title="第2回 エムスリーエンジニアリンググループ CTO&VPoEに聞いてみた - #pmconf2023 振り返り編 (1/3)"></iframe><cite class="hatena-citation"><a href="https://www.youtube.com/watch?v=AqxD2sDoflA">www.youtube.com</a></cite></p>
<p>山崎さんの発表の中で、「プロダクトと事業を無限にスケールするための最強のロードマップとは拡張可能な世界観から動的に生成される」として、「拡張可能な世界観」の重要性が語られました。</p>
<p>エムスリーでは、日々の業務の中でそもそも世界観を活用することが求められています。プロダクトマネージャーが集まる勉強会<a href="#f-0f8ffa86" id="fn-0f8ffa86" name="fn-0f8ffa86" title="この会については「エムスリーが誇る最強のプロダクトマネージャー育成環境:プロダクトマネージャー定例」として以前のテックブログで紹介されています。">*1</a>や1on1の中では、その活用の仕方についてしばしば話題にあがります。
発表の中でも語られている通り、超一流のプロダクトマネージャーを目指すにあたって、この世界観の活用が不可欠なスキルとして位置付けられているからです。</p>
<h2 id="本ブログで扱う世界観とは">本ブログで扱う世界観とは</h2>
<p>本ブログでは、そのプロダクトを一言を表すとどういうことか、そのプロダクトの特徴を表すシンプルな表現のことを世界観と表現します。たとえば弊社Web講演会アプリ<a href="#f-2cf29253" id="fn-2cf29253" name="fn-2cf29253" title="Web講演会とは、日本最大級の医療従事者向けWebセミナープラットフォームです。エンジニアリンググループの紹介資料でもご紹介しています。">*2</a>の世界観は「フェスアプリ」です。医師の講演会を音楽フェスティバルのライブと見立てて、世界観を表現しています。</p>
<h2 id="世界観を活用するか否かによるプロダクトマネジメントへの影響">世界観を活用するか否かによる、プロダクトマネジメントへの影響</h2>
<p>A/Bテストの結果の捉え方や改善策の打ち方、また、ユーザーインタビューで得たインプットをプロダクトに反映させる際に、世界観を使いこなせるPdMかどうかで、そのムーブに雲泥の差があります。</p>
<p>具体例として、A/Bテストで伸ばしたかった指標とは別の指標に、想定外のネガティブ影響が生じた事例(実際の事例です)を取り上げます。</p>
<ul>
<li><p>世界観を活用しない場合:集めたファクトをメタ認知せずに、改善策を打とうとします。つまり対症療法です。</p></li>
<li><p>世界観を活用する場合:世界観に立ち戻り、問題の根本を改善しようとします。</p></li>
</ul>
<p>対症療法が必要な場合も当然ありますが、この事例では、世界観に立ち戻ることで世界観とプロダクトが合致していないことに気が付きました。さらに言うと、A/Bテストが失敗しただけでなく、プロダクトとして生み出す利益の最大化が出来ていないという状況だったのです。そこで、世界観を整理するための改善を実施しました。</p>
<h2 id="世界観の整理">世界観の整理</h2>
<p>まず、そのプロダクトの世界観は何か、「一言で表す」ことから始めました。</p>
<p>世界観を一言で表せたら、世界観とプロダクトの構造において大きなズレが生じていることが明らかになりました。すなわち、世界観がプロダクトに反映されていれば本来そのような仕様になっているはずはない、といったものです。そこで、そのズレを無くし世界観が反映されたプロダクトにすべく、仕様を変更しました。</p>
<h2 id="世界観を整理したことで起きたこと">世界観を整理したことで起きたこと</h2>
<p>世界観の整理後、再度A/Bテストを行ったところ、想定通りの結果を得ることができました。さらに、以前行ってあまり良い結果が出なかった施策を、世界観を整理した後に再テストしたところ、当初期待していた成果を得られました。</p>
<p>このことから、以下のことを学びました。</p>
<ul>
<li>世界観を使いこなすことがアウトカムに直結する</li>
<li>世界観は細部の仕様決定にまで影響する</li>
</ul>
<p>「神は細部に宿る」という言葉が好きで、以前から細部の仕様検討まで妥協せずにやってきた自負はありました。しかし、細部になればなるほど個別最適になっており、全体の世界観の中でどうあるべきかを考えられていなかったということに気が付きました。つまり、ただ闇雲に細部にまでこだわれば良いということではなく、細部にまで世界観が行き渡っているかにこだわる必要があるのです。そうすることでちぐはぐに見えない筋の通ったプロダクトとなり、ユーザーから愛されるのだと思います。そしてその結果がアウトカムとなり、事業の成長につながるのだと思います。</p>
<h2 id="まとめ">まとめ</h2>
<p>本ブログでは、プロダクトマネジメントにおいて世界観を活用することの効果をご紹介しました。具体的には、世界観を活用することが対症療法や部分最適に陥ることを防ぎ、プロダクトの成長につながるということです。</p>
<p>以前から世界観の重要性は理解しているつもりでしたが、プロダクトマネジメントの中で活用しきれていませんでした。本ブログの中でご紹介したA/Bテストの事例での経験により、世界観を活用することでこれほどアウトカムに違いが出るのかと、世界観の持つ力を改めて実感しました。</p>
<p>引き続き超一流のプロダクトマネージャーを目指し、世界観を使いこなすだけでなく、「拡張可能な世界観」をも使いこなせるようになっていきたいです。</p>
<h2 id="We-are-hiring">We are hiring!!</h2>
<p>エムスリーエンジニアリンググループでは、一緒に働く仲間を募集しています! 一緒に超一流のプロダクトマネージャーを目指す仲間が増えたらとても嬉しいです。まずはカジュアル面談から、以下URLよりご応募をお待ちしています。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<div class="footnote">
<p class="footnote"><a href="#fn-0f8ffa86" id="f-0f8ffa86" name="f-0f8ffa86" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">この会については「<a href="https://www.m3tech.blog/entry/2022/12/25/203858">エムスリーが誇る最強のプロダクトマネージャー育成環境:プロダクトマネージャー定例</a>」として以前のテックブログで紹介されています。</span></p>
<p class="footnote"><a href="#fn-2cf29253" id="f-2cf29253" name="f-2cf29253" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">Web講演会とは、日本最大級の医療従事者向けWebセミナープラットフォームです。<a href="https://speakerdeck.com/m3_engineering/introduction-of-m3-engineering-group-2022?slide=21">エンジニアリンググループの紹介資料</a>でもご紹介しています。</span></p>
</div>
kananakamu
pandasのSeriesとMagicMockの併用でハマった件
hatenablog://entry/6801883189066532632
2023-12-18T11:00:00+09:00
2023-12-18T11:00:07+09:00 この記事はエムスリーAdvent Calendar 2023の18日目の記事です。 AI・機械学習チームの高田です。 AI・機械学習チームではデータパイプラインを構築する機会が多く、パイプラインの中でpandasを活用しています。 今回はpandasのSeries型を扱う関数の単体テストにMagicMockを使った際にハマったポイントを紹介したいと思います。
<p>この記事は<a href="https://qiita.com/advent-calendar/2023/m3">エムスリーAdvent Calendar 2023</a>の18日目の記事です。
AI・機械学習チームの高田です。</p>
<p>AI・機械学習チームではデータパイプラインを構築する機会が多く、パイプラインの中でpandasを活用しています。
今回はpandasのSeries型を扱う関数の単体テストにMagicMockを使った際にハマったポイントを紹介したいと思います。</p>
<ul class="table-of-contents">
<li><a href="#Seriesmapの活用例">Series.mapの活用例</a></li>
<li><a href="#単体テスト">単体テスト</a></li>
<li><a href="#何が起きているのか">何が起きているのか</a></li>
<li><a href="#修正結果">修正結果</a></li>
<li><a href="#おわりに">おわりに</a></li>
<li><a href="#We-are-hiring">We are hiring!!</a></li>
</ul>
<h2 id="Seriesmapの活用例">Series.mapの活用例</h2>
<p>データの前処理工程ではDataFrameやSeriesの値を利用して別のSeriesを作成することが多くあります。
例えばアンケートの自由記述回答のうち、一定の文字数以上のデータのみ扱うために、テキストデータのSeriesから文字列長のSeriesを作成する場合が考えられます。
SeriesからSeriesの作成にはmapやapplyをよく使いますが、今回はmapを使用してみます。</p>
<p>サンプル実装です。</p>
<pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">import</span> pandas <span class="synStatement">as</span> pd
<span class="synPreProc">from</span> typing <span class="synPreProc">import</span> Callable
<span class="synStatement">def</span> <span class="synIdentifier">add_column_from_text</span>(df: pd.DataFrame, column_name: <span class="synIdentifier">str</span>, mapper: Callable):
df[column_name] = df[<span class="synConstant">'text'</span>].map(mapper)
<span class="synStatement">return</span> df
<span class="synStatement">def</span> <span class="synIdentifier">calc_text_length</span>(x):
<span class="synStatement">return</span> <span class="synIdentifier">len</span>(x)
df = pd.DataFrame([[<span class="synConstant">'おはようございます'</span>], [<span class="synConstant">'こんにちは'</span>]], columns=[<span class="synConstant">'text'</span>])
new_df = add_column_from_text(df, <span class="synConstant">'length'</span>, calc_text_length)
sufficient_length_df = new_df[new_df[<span class="synConstant">'length'</span>] > <span class="synConstant">5</span>]
<span class="synIdentifier">print</span>(sufficient_length_df)
</pre>
<p><code>length</code>カラムに文字列長を格納できています。</p>
<table>
<thead>
<tr>
<th style="text-align:left;"> text </th>
<th style="text-align:right;"> length </th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left;"> おはようございます </td>
<td style="text-align:right;"> 9 </td>
</tr>
</tbody>
</table>
<h2 id="単体テスト">単体テスト</h2>
<p>では、<code>add_column_from_text</code>の単体テストを書きます。</p>
<pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">import</span> unittest
<span class="synStatement">class</span> <span class="synIdentifier">TestAddColumnFromText</span>(unittest.TestCase):
<span class="synStatement">def</span> <span class="synIdentifier">test_add_column_from_text</span>(self):
<span class="synConstant">"""</span>
<span class="synConstant"> - 返り値が期待通りである</span>
<span class="synConstant"> - mapperが意図通りの引数で呼ばれている</span>
<span class="synConstant"> </span>
<span class="synConstant"> ことを保証する</span>
<span class="synConstant"> """</span>
expected = pd.DataFrame([
<span class="synIdentifier">dict</span>(text=<span class="synConstant">'おはようございます'</span>, length=<span class="synConstant">9</span>),
<span class="synIdentifier">dict</span>(text=<span class="synConstant">'こんにちは'</span>, length=<span class="synConstant">5</span>),
])
mock_func = MagicMock()
mock_func.side_effect = calc_text_length
actual = add_column_from_text(df, <span class="synConstant">'length'</span>, mock_func)
pd.testing.assert_frame_equal(expected, actual)
mock_func.assert_any_call(<span class="synConstant">'おはようございます'</span>)
mock_func.assert_any_call(<span class="synConstant">'こんにちは'</span>)
</pre>
<p>実行結果</p>
<pre class="code" data-lang="" data-unlink>=====================================================================
FAIL: test_add_column_from_text (__main__.TestAddColumnFromText)
mapperが意図通りの引数で呼ばれていることを保証する
----------------------------------------------------------------------
Traceback (most recent call last):
... 略 ...
AssertionError: Attributes of DataFrame.iloc[:, 1] (column name="length") are different
Attribute "dtype" are different
[left]: int64
[right]: float64
----------------------------------------------------------------------
Ran 1 test in 0.023s
FAILED (failures=1)</pre>
<p><code>actual</code>を表示してみると <code>length</code>が全てNaNになっています。</p>
<table>
<thead>
<tr>
<th style="text-align:left;"> text </th>
<th style="text-align:right;"> length </th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left;"> おはようございます </td>
<td style="text-align:right;"> NaN </td>
</tr>
<tr>
<td style="text-align:left;"> こんにちは </td>
<td style="text-align:right;"> NaN </td>
</tr>
</tbody>
</table>
<h2 id="何が起きているのか">何が起きているのか</h2>
<p>mock自体はMagicMockで正しく定義できているようです。</p>
<pre class="code lang-python" data-lang="python" data-unlink>>>> mock_func = MagicMock()
>>> mock_func.side_effect = calc_text_length
>>> mock_func(<span class="synConstant">'こんにちは'</span>)
<span class="synConstant">5</span>
</pre>
<p>となると、pandas.Series.mapの内部で意図しない動作になっていそうです。
<a href="https://pandas.pydata.org/docs/reference/api/pandas.Series.map.html#pandas-series-map">pandas.Series.mapのAPI reference</a>を確認すると</p>
<blockquote><p>arg: function, collections.abc.Mapping subclass or Series</p></blockquote>
<p>mapの第一引数は関数、 dictのサブクラス、 Series型を受け付けます。</p>
<blockquote><p>When arg is a dictionary, values in Series that are not in the dictionary (as keys) are converted to NaN. However, if the dictionary is a dict subclass that defines <strong>missing</strong> (i.e. provides a method for default values), then this default is used rather than NaN.</p></blockquote>
<p>その第一引数がdict型で、defaultdictのようにkeyが存在しないときのデフォルト値を定義していない場合に<code>NaN</code>で埋める挙動をするという注意書きがあります。</p>
<p>どうやらmock_funcがdict型として認識されて関数として実行されていないのではないか?という仮説を立てました。</p>
<p>そこでpandas.Series.mapの内部実装を追っていきます。
関数コールを追っていくと<a href="https://github.com/pandas-dev/pandas/blob/a671b5a8bf5dd13fb19f0e88edc679bc9e15c673/pandas/core/algorithms.py#L1738-L1818">map_array</a>の中に</p>
<pre class="code py" data-lang="py" data-unlink> if is_dict_like(mapper):</pre>
<p>という記述があります。mapperはmock_funcに相当します。ここでdictライクかそれ以外かで処理が分岐していました。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fpandas-dev%2Fpandas%2Fblob%2Fv2.1.4%2Fpandas%2Fcore%2Fdtypes%2Finference.py%23L273-L303" title="pandas/pandas/core/dtypes/inference.py at v2.1.4 · pandas-dev/pandas" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/pandas-dev/pandas/blob/v2.1.4/pandas/core/dtypes/inference.py#L273-L303">github.com</a></cite></p>
<p><code>is_dict_like</code>関数の実装を確認すると以下3つのattributeを持つオブジェクトをdictライクと判定しています。</p>
<ul>
<li><code>__getitem__</code></li>
<li><code>keys</code></li>
<li><code>__contains__</code></li>
</ul>
<pre class="code lang-python" data-lang="python" data-unlink>>>> [<span class="synIdentifier">hasattr</span>(mock_func, attr) <span class="synStatement">for</span> attr <span class="synStatement">in</span> (<span class="synConstant">"__getitem__"</span>, <span class="synConstant">"keys"</span>, <span class="synConstant">"__contains__"</span>)]
[<span class="synIdentifier">True</span>, <span class="synIdentifier">True</span>, <span class="synIdentifier">True</span>]
</pre>
<p>mock_funcはこれら3つのattributeを持っていたためにdicライクとして判定され、関数として実行されていないことが原因だということが判明しました。</p>
<p>MagicMockは通常のMockの機能に加え、<code>__getitem__</code>や<code>__contains__</code>も含む多くのmagic methodがサポートされています。
今回はmagic methodのサポートが仇となり、関数として判定される前にdict型として判定されてしまったことがバグの原因でした。</p>
<h2 id="修正結果">修正結果</h2>
<p>そこで、単体テストを以下のように修正しました。</p>
<pre class="code lang-diff" data-lang="diff" data-unlink>import unittest
class TestAddColumnFromText(unittest.TestCase):
def test_add_column_from_text(self):
"""
- 返り値が期待通りである
- mapperが意図通りの引数で呼ばれている
ことを保証する
"""
expected = pd.DataFrame([
dict(text='おはようございます', length=9),
dict(text='こんにちは', length=5),
])
<span class="synSpecial">- mock_func = MagicMock()</span>
<span class="synIdentifier">+ mock_func = Mock()</span>
mock_func.side_effect = calc_text_length
actual = add_column_from_text(df, 'length', mock_func)
pd.testing.assert_frame_equal(expected, actual)
mock_func.assert_any_call('おはようございます')
mock_func.assert_any_call('こんにちは')
</pre>
<p>実行結果</p>
<pre class="code" data-lang="" data-unlink>----------------------------------------------------------------------
Ran 1 test in 0.008s
OK</pre>
<p>テストが通りました。</p>
<h2 id="おわりに">おわりに</h2>
<p>今までは便利なMockだなという軽い気持ちでMagicMockを使っていましたが、MagicMockを使用するべきではない事例に初めて遭遇しました。</p>
<p>個人的にはOSSの内部実装を地道に読み解くよい機会になり、勉強になりました。</p>
<h2 id="We-are-hiring">We are hiring!!</h2>
<p>AI・機械学習チームでは絶賛エンジニアを募集中です!
作って終わりじゃないテスタブルな実装にこだわりを持ってプロダクト開発に取り組みたい方は是非カジュアル面談等ご応募ください!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
tkda-h3
世にも奇妙なQUERY関数 ~冬のスプレッドシート特別編~
hatenablog://entry/6801883189066974486
2023-12-17T12:00:00+09:00
2023-12-26T11:59:30+09:00 この記事はエムスリーAdvent Calendar 2023の17日目の記事です。 こんにちは、エムスリーエンジニアリンググループ/ BIR(Business Intelligence and Research) チーム の遠藤(@en_ken)です。 皆さんGoogleスプレッドシート使っていますか? エンジニアだとあまり使わない方もいるかも知れませんが、BigQueryとの連携が非常に容易なため、 データをビジネスサイドに見せたり分析したりする際には便利なツールです。 BigQueryをバックエンドと考えると、スプレッドシートは簡易なフロントエンドのフレームワークとも捉えられます。 ちょっ…
<p>この記事は<a href="https://qiita.com/advent-calendar/2023/m3">エムスリーAdvent Calendar 2023</a>の17日目の記事です。</p>
<p>こんにちは、エムスリーエンジニアリンググループ/ BIR(Business Intelligence and Research) チーム の遠藤(<a href="https://twitter.com/en_ken">@en_ken</a>)です。</p>
<p>皆さんGoogleスプレッドシート使っていますか?</p>
<p>エンジニアだとあまり使わない方もいるかも知れませんが、BigQueryとの連携が非常に容易なため、
データをビジネスサイドに見せたり分析したりする際には便利なツールです。</p>
<p>BigQueryをバックエンドと考えると、スプレッドシートは簡易なフロントエンドのフレームワークとも捉えられます。
ちょっとしたビューの変更はセルで行えるため、
BIRでは業務を整理する際のプロトタイピングとして、
あるいはビジネスサイドの要件でExcel的なインタフェースが好ましいときのアプリケーションの作成方法として
スプレッドシートを利用するケースがあります。</p>
<ul class="table-of-contents">
<li><a href="#関数を組み合わせるのはシンドイ">関数を組み合わせるのはシンドイ</a></li>
<li><a href="#QUERY関数の誘惑">QUERY関数の誘惑</a></li>
<li><a href="#QUERY関数の奇妙な仕様">QUERY関数の奇妙な仕様</a><ul>
<li><a href="#select句に文字列を書くとヘッダ行が勝手に入る">select句に文字列を書くとヘッダ行が勝手に入る</a></li>
<li><a href="#select句に空文字は記述できない">select句に空文字は記述できない</a></li>
<li><a href="#select句に同じ文字列を記述できない">select句に同じ文字列を記述できない</a></li>
<li><a href="#select句に同じ列は2回記述できない">select句に同じ列は2回記述できない</a></li>
<li><a href="#空行を出力できない">空行を出力できない</a></li>
<li><a href="#空文字だけの行は消える">空文字だけの行は消える</a></li>
<li><a href="#文字列数値混在時文字列が少数派だと空文字に変換される">文字列・数値混在時、文字列が少数派だと空文字に変換される</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#We-are-hiring">We are hiring!!</a></li>
</ul>
<h2 id="関数を組み合わせるのはシンドイ">関数を組み合わせるのはシンドイ</h2>
<p>BigQueryのデータを表示したいだけであれば、BigQuery側のクエリでデータを整形してビューとしてそのまま表示すればよいですが、
「BigQueryのデータ」+「スプレッドシート上の入力項目」を組み合わせてビューとして整形する場合にはスプレッドシート上にロジックが必要になります。
ロジック実装の選択肢はスプレッドシート関数 or Goolgle App Script(GAS)が選択肢になると思います。</p>
<p>GASの方がJavaScript準拠で書くことができ、<a href="https://github.com/google/clasp">clasp</a>を使えばローカル実行やバージョン管理も可能なのでエンジニアフレンドリーではある一方、
実行にはトリガー操作が必要なので、入力項目を即時反映できるリアクティブなビューを作りたい場合は関数を選択することになります。</p>
<p>しかしながら、そこそこ複雑なロジックを組んでデータ整形する場合、いわゆるExcel準拠の関数を組み合わせて実現するのは結構大変そうです。スプレッドシートの関数は仕様上、複数を組み合わせようとすると入れ子になっていくので可読性が破綻しがちです。</p>
<h2 id="QUERY関数の誘惑">QUERY関数の誘惑</h2>
<p>Googleのスプレッドシートには、独自拡張の関数として<a href="https://support.google.com/docs/answer/3093343"><code>QUERY</code></a>があります。</p>
<p>QUERY関数は<a href="https://developers.google.com/chart/interactive/docs/querylanguage?sjid=15567082792386236106-AP&hl=ja#language-clauses">独自の魔改造された、でもなんだかSQLっぽいクエリ言語</a>でクエリを書くことができる関数です。</p>
<p>「第1引数で範囲を指定するからfrom句がない」</p>
<p>「列名はasとかでは書けなくてlabel句での記載が必要」</p>
<p>など奇妙な点はあるもののなんかSQLっぽい、関数組み合わせて頑張るよりはだいぶエンジニアに優しそうに見えます。
やってみると確かに簡易なクエリは見知った感じで書けますし、これひとつあればデータをほしい形に整形できるので可読性も良さそう。</p>
<p>そんな一見良い落としどころ感に誘われて、私はQUERYを使って実装を進めていってしまいました。
ところがQUERYの森深くに誘われると、大きな困難に直面します。</p>
<h2 id="QUERY関数の奇妙な仕様">QUERY関数の奇妙な仕様</h2>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120107.png" width="800" height="267" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h3 id="select句に文字列を書くとヘッダ行が勝手に入る">select句に文字列を書くとヘッダ行が勝手に入る</h3>
<p>QUERY関数は第3引数はヘッダ行の認識させるためのパラメータになっており、
参照範囲からヘッダ部分を抜いた上でQUERYの第3引数を0にすれば、
参照範囲の内容からselectして出力するだけであれば、ヘッダは出力されません。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120014.png" width="800" height="184" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>ところが、select句に文字列を記載すると、自動的に以下のようなヘッダ行が現れます。
なにか制約によるものなのだと思いますが、よくわかりません。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120017.png" width="800" height="183" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>ただ、一応これには回避方法があり、label句でヘッダに表示されている名前を空文字で置き換えると自動で出力されるヘッダを消すことができます。</p>
<p><figure class="figure-image figure-image-fotolife" title="回避策"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120020.png" width="800" height="177" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>回避策</figcaption></figure></p>
<p>でもなんか気持ち悪いですね。
複数ある場合は、ひとつひとつ置き換えの記述を書く必要があります😇</p>
<h3 id="select句に空文字は記述できない">select句に空文字は記述できない</h3>
<p>select句に空文字を記載するとエラー(N/A)になります。</p>
<p><figure class="figure-image figure-image-fotolife" title="空文字NG"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120023.png" width="800" height="133" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>空文字NG</figcaption></figure></p>
<p>エラーメッセージも出ないため、最初なんだかわかりません😇</p>
<p>おそらく空文字を許容すると前述の自動で生成されるヘッダ名が空文字になってしまうため許容されないのだと推察されますが、結構困ります。</p>
<h3 id="select句に同じ文字列を記述できない">select句に同じ文字列を記述できない</h3>
<p>空行以外なら問題ないかというとそんなことはなく、
select句に同じ文字列を複数書いた場合もエラーになります。</p>
<p><figure class="figure-image figure-image-fotolife" title="同じ文字列NG"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120026.png" width="800" height="260" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>同じ文字列NG</figcaption></figure></p>
<p>同じ文字列を許容すると、前述の自動で生成されるヘッダの列名が被ってしまうことを避けるための仕様のようです。</p>
<p>全部別の文字列にすればいけます😇</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120059.png" width="800" height="127" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h3 id="select句に同じ列は2回記述できない">select句に同じ列は2回記述できない</h3>
<p>上記諸々の問題を回避する方法は、select句には文字列は書かず、出力したい文字列を参照範囲に含めておくことです。
空文字を出力したい場合は、参照範囲に空文字を含めておけば、ヘッダの出力は回避しつつ空文字を出力できます。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120029.png" width="800" height="159" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>ところが、空文字を複数列で出したい場合に、同じ列をselect句に記載するとエラーになります。</p>
<p><figure class="figure-image figure-image-fotolife" title="同じ列NG"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120032.png" width="800" height="251" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>同じ列NG</figcaption></figure></p>
<p>同じ文字列を複数出したかったら出す数だけの列を作って参照範囲に含め、別々の列を使いましょう😇</p>
<p><figure class="figure-image figure-image-fotolife" title="回避策"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120035.png" width="800" height="150" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>回避策</figcaption></figure></p>
<h3 id="空行を出力できない">空行を出力できない</h3>
<p>これはスプレッドシート(というかExcel)関数の思想に準拠したものなのだと思いますが、クエリの結果が0件のとき、関数はN/Aになります。そのため、参照先のデータが変わると、さっきまで動いていたクエリがN/Aになる現象が発生します。</p>
<p><figure class="figure-image figure-image-fotolife" title="空行NG"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120038.png" width="800" height="185" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>空行NG</figcaption></figure></p>
<p>IFERRORで囲むことでエラーは処理できますが、何らか出力自体は必要なため、完全に空行を返す(何も返さない)ということができません。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120041.png" width="800" height="124" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h3 id="空文字だけの行は消える">空文字だけの行は消える</h3>
<p>with句のようにクエリ結果にさらにクエリしたい場合はQUERYを入れ子にすることで実現できます。(あれ? 入れ子にしたくなくてQUERYを選んだ気がしましたね😇)</p>
<p><figure class="figure-image figure-image-fotolife" title="with句っぽいやつ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120044.png" width="800" height="227" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>with句っぽいやつ</figcaption></figure></p>
<p>union句のように複数のSQL結果を結合したい場合も同様に入れ子で実現できます。</p>
<p><figure class="figure-image figure-image-fotolife" title="unionっぽいやつ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120046.png" width="800" height="202" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>unionっぽいやつ</figcaption></figure></p>
<p>しかし、このクエリで前述のように片方の内側のクエリ結果が空行を返しN/Aになると、当然全体も失敗してしまいます。
ちなみに、入れ子にしてエラーになった場合、全く何のエラーかわかりません。メンテナンスする上では最悪ですね😇</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120050.png" width="800" height="374" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>そこで個別にIFERRORでラップするわけですが、前述の通り、エラー時に何も返さないことができないため、空文字のみのゴミ行が結果に入ってきそうです。</p>
<p><figure class="figure-image figure-image-fotolife" title="エラー処理で空文字返してカウント"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120053.png" width="800" height="253" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>エラー処理で空文字返してカウント</figcaption></figure></p>
<p>とやってみるとcount=2で後者のQUERYの結果のみなので、空文字のみを返している行は結果からは消えています。つまり、空文字のみの行を返せば実質何も返さないのと同じ動作になるようです。
labelで空文字に書き換えるとヘッダが消える挙動と言われてみれば一貫性は取れてますが、なんか気持ち悪いですね😇</p>
<h3 id="文字列数値混在時文字列が少数派だと空文字に変換される">文字列・数値混在時、文字列が少数派だと空文字に変換される</h3>
<p>少し毛色が違いますが、<a href="https://qiita.com/Shinoda_Naoki/items/d73e28a8a99fdc5ec1f5">こちらの記事</a>にあるとおり、文字列と数値が混じった列があった場合、クエリ結果の文字列が少ないと勝手に空行になります。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120056.png" width="800" height="182" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>カウント1(C列)は文字列が少ないので勝手に空行になっていいますが、カウント2(D列)は文字列のほうが多いので空行にはなりません。混乱する挙動ですね😇</p>
<p>普通のSQLではデータ型が混在している列を扱うことはないと思うので、異常なケースではありますが、であればクエリ結果にデータ型が混在していたらエラーにして振ってくれた方がまだ親切な気がします。</p>
<p>あまり困るユースケースがなさそうとはいえ、私のユースケースではこれも引っかかりました😇</p>
<h2 id="まとめ">まとめ</h2>
<p>ということで、QUERY関数の言語仕様はかなりとっつきにくく、それなりのビューを表現するために大量のワークアラウンドを求められます。また、スプレッドシートのエラー出力はとっつきにくい言語仕様と戦うにはあまりに貧弱です。
ハマりどころが多すぎるので、使用するならシンプルなクエリが限度だと思います。</p>
<p>個人的な結論として、QUERY関数は業務で利用するようなスプレッドシートに組み込むものではないと思います。一見便利だなと思って使いだしても、かなり早い段階でメンテナンスコストとの損益分岐点がやってきます。</p>
<p>QUERYが入れ子しはじめたら勇気を持って撤退しましょう。
せっかく作ったのに? 典型的なコンコルド効果です。
GASで実装するほうが確実に良い選択です。リアクティブなビューは諦めて作り直しましょう。</p>
<p>正しい撤退判断ができないでいると、次にこんなスプレッドシートをメンテナンスする扉を開けてしまうのは、あなたかも知れません……🕶</p>
<p><figure class="figure-image figure-image-fotolife" title="EDテーマ ガラモン・ソング"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231217/20231217120102.png" width="800" height="785" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>EDテーマ : ガラモン・ソング</figcaption></figure></p>
<h2 id="We-are-hiring">We are hiring!!</h2>
<p>上記のスプレッドシートを改善してくれるエンジニアを募集してます😇<br>嘘です。これは私が責任を持ってどうにかします。</p>
<p>BIRでは今後アンケートシステムの大規模なリニューアル・リアーキテクチャを行っていく予定になっています。興味がある方は、以下のURLからカジュアル面談にぜひご応募ください!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fproduct%2F" title="M3, Inc. The Power of Medical Innovation Better Life, Better Together|エムスリー株式会社" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/product/">jobs.m3.com</a></cite></p>
enkn
アラート対応に追われる日々にサヨナラ!効率化できる仕組み作りのポイント
hatenablog://entry/6801883189065106137
2023-12-16T12:00:00+09:00
2023-12-16T12:00:14+09:00 アラート対応に追われる日々にサヨナラ!効率化できる仕組み作りのポイント。アラート対応をより効率的に行えるアラート設計のポイントを紹介します。
<p>目撃したアラートメッセージ、全部対応できていますか?<br/>
自分の担当ではないアラートや発生しても問題ないアラートもあるかもしれません。今回はアラート対応をより効率的に行えるアラート設計のポイントを紹介します。</p>
<p>この記事は <a href="https://qiita.com/advent-calendar/2023/m3">エムスリー Advent Calendar 2023</a> の 16 日目の記事です。</p>
<p>Unit1(製薬企業向けプラットフォームチーム)の佐野がまとめさせていただきます。</p>
<p><figure class="figure-image figure-image-fotolife" title="イメージ画像です(実際はこんなに禍々しくないですが)"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231216/20231216120008.png" width="400" height="400" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>イメージ画像です(実際はこんなに禍々しくないですが)</figcaption></figure></p>
<ul class="table-of-contents">
<li><a href="#アラート対応の価値と向き合い方">アラート対応の価値と向き合い方</a><ul>
<li><a href="#この章のまとめ">この章のまとめ</a></li>
</ul>
</li>
<li><a href="#本当に必要なアラートに絞り込む">本当に必要なアラートに絞り込む</a><ul>
<li><a href="#この章のまとめ-1">この章のまとめ</a></li>
</ul>
</li>
<li><a href="#アラート内容に対応しやすくなる情報を盛り込む">アラート内容に対応しやすくなる情報を盛り込む</a><ul>
<li><a href="#この章のまとめ-2">この章のまとめ</a></li>
</ul>
</li>
<li><a href="#アラートが発出される前にアラートを予見する">アラートが発出される前にアラートを予見する</a><ul>
<li><a href="#この章のまとめ-3">この章のまとめ</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#We-are-Hiring">We are Hiring!</a></li>
</ul>
<h2 id="アラート対応の価値と向き合い方">アラート対応の価値と向き合い方</h2>
<p>まずはアラート対応について考えを整理しましょう。</p>
<p>アラート対応は非常に重要です。システム障害をいち早く検知しビジネスやユーザーへの影響を最小限にできます。 障害が発生してしまうと障害対応工数や遺失利益、顧客対応、提案機会の損失など、影響は多岐にわたります。</p>
<p>また、実際にアプリケーションを利用されるユーザーにもご不便をおかけしてしまいます。いち早く復旧することで気持ちよくサービスをご利用いただけるようになります。</p>
<p>アラートはシステムの異常を検知するために非常に有効ですが、多く出すぎているとどれが本当に対応すべきアラートなのか分かりづらくなってしまいます。オオカミ少年になってしまわないようアラートの削減も重要です。</p>
<p>そのため、アラートに対する姿勢としては</p>
<ul>
<li>本当に必要なアラートに絞り込む</li>
<li>アラート内容に対応しやすくなる情報を盛り込む</li>
<li>アラートが発出される前にアラートを予見する</li>
</ul>
<p>という対応が必要だと考えます。</p>
<h3 id="この章のまとめ">この章のまとめ</h3>
<ul>
<li>アラート対応はユーザ体験、利益を守る大切な行動である</li>
<li>アラート内容やルールを整備し日々の運用コストを下げる事も同時に重要となる</li>
</ul>
<h2 id="本当に必要なアラートに絞り込む">本当に必要なアラートに絞り込む</h2>
<p>本当に必要なアラートとは何かを考えるために、実は不要なアラートを洗い出します。</p>
<ul>
<li>単発では問題ないが、頻出したり連続で出ると問題となるもの(しきい値チェックなど)</li>
<li>ビジネス上の理由で「今回は」問題がないようなもの</li>
</ul>
<p>アラートを絞り込むために削減すべきものの多くは基本的に1点目の理由か思います。使っているアプリケーションの種類によりますが、これはSentryのAlertRuleで制御可能です。</p>
<p><a href="https://33fa1ur95-dked9075u.sentry.dev/static/5ad23c7ea69216e69f1988a60c816145/3a1b1/new-alert-rule-any.png" class="http-image"><img src="https://33fa1ur95-dked9075u.sentry.dev/static/5ad23c7ea69216e69f1988a60c816145/3a1b1/new-alert-rule-any.png" class="http-image" alt="https://33fa1ur95-dked9075u.sentry.dev/static/5ad23c7ea69216e69f1988a60c816145/3a1b1/new-alert-rule-any.png"></a></p>
<p><a href="https://sentry.io/resources/alert-rules/">https://sentry.io/resources/alert-rules/</a></p>
<p>こちらの公式サイトのマニュアルにある通り、一定期間のうちに何回以上アラートが検知されたら通知するという設定が可能です。Webアプリケーションやバッチのエラー出力ロジックで制御するよりもSentryで一括対応したほうが集約されるので良いかと思います。Web UI上で柔軟に変更可能なのでリリースせずに対応できる点もスピード感があって良いと思います。</p>
<h3 id="この章のまとめ-1">この章のまとめ</h3>
<ul>
<li>SentryのRuleでアラートの発出数を制御し、本当に問題があるときのみアラートが出るようにする</li>
</ul>
<h2 id="アラート内容に対応しやすくなる情報を盛り込む">アラート内容に対応しやすくなる情報を盛り込む</h2>
<p>アラート対応しやすくなる情報とは何かを考えます。この時に非常に重要なのが、実際に対応する時に必要な情報は何かということです。</p>
<ul>
<li>対応が遅れたときのビジネス、ユーザーへ影響</li>
<li>デッドライン</li>
<li>アラート対応すべきか否かを判断できる責任者(部署)</li>
<li>前回対応時の記録(チケットやドキュメントなど)を即参照できるURLなど</li>
</ul>
<p>原因箇所を特定し、素早く対応することも非常に重要ですが、それよりもまずビジネス、ユーザーに現在どのような影響が及んでいるかを考えるべきです。優先順位や対応方針(根本原因の特定、対応 or 暫定的な対応)を決めることができます。デッドラインも同じように対応方針を決める上で非常に重要な情報です。</p>
<p>実際に対応を進める際、対応する人は前回対応したメンバーとは異なると考えたほうが良いです。</p>
<p>この時にとても助かるのが「前に対応したときの記録」です。まとまってくなくて雑でも良いので、何かしら情報を残しておけば次の世代(未来の自分含む)に繋がります。Slackのスレッドに独り言でも良いので記録を残しておくと良いです。<a href="#f-5117908e" id="fn-5117908e" name="fn-5117908e" title="キレイなドキュメントにするのは非常に難しいため、他のメンバーにも手伝ってもらうのも良いと思います">*1</a></p>
<h3 id="この章のまとめ-2">この章のまとめ</h3>
<ul>
<li>優先順位と対応方針を決めるためにビジネス、ユーザーへの影響を考える</li>
<li>アラートに情報を盛り込むことで対応が高速化される</li>
<li>アラート → 影響判断 → 優先順位決定 → 過去対応に従って対応。という流れを作る</li>
</ul>
<h2 id="アラートが発出される前にアラートを予見する">アラートが発出される前にアラートを予見する</h2>
<p>多くの場合、アラートの予見はある程度可能だと思います。すべてのアラート(エラー)を予期する必要はなく、ビジネスロジックを書いている時に例外パターンを思いついたときがアラートの予見です。</p>
<p>必要に応じてExceptionやErrorを発火させるのも良いですし、catchして情報を付加して投げ直すのも良いと思います。サービスの機能追加やクラウド化等でアプリケーションも成長しているため、日々新しい種類のエラーが発生すると思いますが、草の根活動で1つずつ対応していくことでキレイな世界が待っているはずです。</p>
<p>チームでこの観点を持ちながら対応をしていくと、いつか「ここはこの情報を盛り込んでアラート出しましょう」とか「ビジネス側に影響を確認しておきますね」といったコメントも生まれるかもしれません。</p>
<h3 id="この章のまとめ-3">この章のまとめ</h3>
<ul>
<li>アラート運用をチームで理解する</li>
<li>コードレビューやコーディング時にアラート発出後の運用まで考えておくという文化を作る</li>
</ul>
<h2 id="まとめ">まとめ</h2>
<p>今回はアラート運用についてまとめさせていただきました。日々発出されるアラートについて少し整理してみようという気持ちが湧いていただいたら嬉しいです。</p>
<p>かくゆう私も数年前まではアラートメールや通知にうまく対応できていませんでしたが、その後エラー対応を何度も経験するうちに向き合い方が変わり、アラートが発出される前に事前に手を打っておくようになりました。</p>
<p>エムスリーでは既存のサービスをクラウド化したり、機能開発が活発に行われているため新しいアラートが生まれるスピードも早いですが、先手を打っておくことで日に0〜数件のアラート対応で済んでいます。</p>
<p>これから年末年始という長い休みを迎えると思いますが、みなさんが無事に過ごせるよう祈っています。</p>
<h2 id="We-are-Hiring">We are Hiring!</h2>
<p>エムスリーではエンジニアを募集中です。</p>
<p>運用系のエンジニアも日々工夫をしながら業務に当たっています。業務改善が好きな方、是非一緒に働きましょう。そしてアラートやエラーが0になる世界を目指しましょう!</p>
<p>また、運用系のチームでもPython、Terraform、AWS、Dockerなどの技術を使う機会も多くあります。私でも力になれるのでしょうか、というお気持ちもあるかもしれませんが少しでも興味をお持ちいただけましたらカジュアル面談もありますので一度お話をさせてください。ご応募お待ちしています。</p>
<p><iframe src="https://open.talentio.com/r/1/c/m3-inc/embed/pages/57609" width="100%" height="300" frameborder=0 title="%E3%82%A8%E3%83%A0%E3%82%B9%E3%83%AA%E3%83%BC+%7C+%E3%83%87%E3%83%BC%E3%82%BF%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2"></iframe><cite class="hatena-citation"><a href="https://open.talentio.com/r/1/c/m3-inc/pages/57609">open.talentio.com</a></cite>
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<div class="footnote">
<p class="footnote"><a href="#fn-5117908e" id="f-5117908e" name="f-5117908e" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">キレイなドキュメントにするのは非常に難しいため、他のメンバーにも手伝ってもらうのも良いと思います</span></p>
</div>
gaddict
Idempotency-Keyヘッダによるマイクロサービスの冪等性の担保
hatenablog://entry/6801883189066213841
2023-12-15T11:00:00+09:00
2023-12-15T11:00:04+09:00 こちらはエムスリー Advent Calendar 2023の15日目の記事です。 デジスマチームの田口です。 去年のアドベントカレンダーでイベント駆動アーキテクチャの記事を書きましたが、ありがたいことにデジスマ診療のサービスとしての成長も著しく、開発も一層活発になっています。 去年と比較してどれくらいスケールしたかはCTOでありデジスマPdMでもある山崎さんがpmconf 2023で発表した資料にもあるので、興味がある方は是非ご覧ください。 speakerdeck.com 本記事では去年の記事でも少し触れた「冪等性」について、デジスマチームでの考え方・実装についてまとめます。
<p>こちらは<a href="https://qiita.com/advent-calendar/2023/m3">エムスリー Advent Calendar 2023</a>の15日目の記事です。</p>
<p>デジスマチームの田口です。
去年のアドベントカレンダーで<a href="https://www.m3tech.blog/entry/2022/12/24/110000">イベント駆動アーキテクチャの記事</a>を書きましたが、ありがたいことにデジスマ診療のサービスとしての成長も著しく、開発も一層活発になっています。</p>
<p>去年と比較してどれくらいスケールしたかはCTOでありデジスマPdMでもある山崎さんが<a href="https://2023.pmconf.jp/">pmconf 2023</a>で発表した資料にもあるので、興味がある方は是非ご覧ください。</p>
<p><iframe id="talk_frame_1113296" class="speakerdeck-iframe" src="//speakerdeck.com/player/9c89346212874c4d92072bebf1550af0" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/yamamuteki/the-greatest-roadmap-for-unlimited-scaling-your-business-and-products/">speakerdeck.com</a></cite></p>
<p>本記事では去年の記事でも少し触れた「冪等性」について、デジスマチームでの考え方・実装についてまとめます。</p>
<ul class="table-of-contents">
<li><a href="#なぜ冪等性が重要か">なぜ冪等性が重要か</a></li>
<li><a href="#Idempotency-Key-ヘッダ">Idempotency-Key ヘッダ</a></li>
<li><a href="#デジスマでの実装">デジスマでの実装</a></li>
<li><a href="#おわりに">おわりに</a></li>
<li><a href="#We-are-hiring">We are hiring!!</a></li>
</ul>
<h2 id="なぜ冪等性が重要か">なぜ冪等性が重要か</h2>
<p><a href="https://digikar-smart.jp/">デジスマ診療</a>(以降デジスマ)はQRコードによるチェックインや自動後払い、オンライン診療など新しい医療体験を提供するサービスです。</p>
<p><iframe width="560" height="315" src="https://www.youtube.com/embed/3FxYz5H6LBw?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen title="さあ、はじめよう。デジスマ診療"></iframe><cite class="hatena-citation"><a href="https://www.youtube.com/watch?v=3FxYz5H6LBw">www.youtube.com</a></cite></p>
<p>デジスマではマイクロサービスアーキテクチャを採用しており、各業務ドメインを扱うサービスや、フロントエンド向けの所謂BFFに相当するサービスが存在します。
執筆時点で15個ほどのマイクロサービスが稼働しており、サービス間の通信はRESTベースのHTTP通信で行われます。</p>
<p>ネットワーク上での通信が多くなりますが、サービス間の通信は様々な理由で失敗することがあります。
特にネットワークエラーなど、呼び出し側では処理の成否が確認できないケースも多々あります。
また、基本的にトランザクションがサービス毎に閉じるため、複数のドメインを跨ぐようなユースケースでは途中で処理が失敗した場合に中途半端な状態になる可能性もあります。</p>
<p>これらの課題を解決する方法の1つとして、デジスマではどのAPIも気軽にリトライできる状態にしておくことで、何らか通信が失敗してもリトライにより復帰できるようにしています。
API実装時に何度同じリクエストが来ても同一の結果を返す、つまり冪等になるように気を付けることで、一時的なエラーをリトライ<a href="#f-623f7505" id="fn-623f7505" name="fn-623f7505" title="ここではプログラムによる自動リトライや、UIにエラーが表示されてユーザーが再実行することも含めた"広義の"リトライになります。">*1</a>でカバーできるようにしています。</p>
<h2 id="Idempotency-Key-ヘッダ">Idempotency-Key ヘッダ</h2>
<p>HTTP通信で冪等性を担保するために提案された、Idempotency-Keyヘッダという<a href="https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-04">IETF Draft</a>があります。
ここではPOSTやPATCHといった冪等ではない(可能性がある)メソッドに対して、主に次のような仕様を必須としています。</p>
<ul>
<li><code>Idempotency-Key</code> というkeyのヘッダにUUIDなどの文字列をvalueとして指定する。</li>
<li>異なるリクエストに同じIdempotency-Keyヘッダの値を指定してはならない。</li>
<li>リソースの同一性はリソースオーナーが定義し、クライアントがその通りに実装しなければならない。</li>
</ul>
<p>他にも任意の仕様として、次のようなものもあります。</p>
<ul>
<li>idempotency keyに有効期限を設定しても良い</li>
<li>リクエストの同一性検証のため、リクエストペイロードのチェックサムなどのフィンガープリントを組み合わせても良い</li>
</ul>
<p>実際に<a href="https://stripe.com/docs/api/idempotent_requests">Stripe API</a>や<a href="https://developer.amazon.com/docs/amazon-pay-api-v2/idempotency.html">Amazon Pay API</a>など、これらの仕様をベースに実装されているものもあります。
やはり支払い系など多重にリソースが作成されると大きな問題になりやすいAPIほど、積極的に実装されている印象です。
デジスマにも医療機関に自動で支払いをする機能があるため、冪等性は重要な要素になります。</p>
<p>デジスマではこのIdempotency-Keyヘッダをベースに冪等性を担保する仕組みを実装しています。
今の所APIはデジスマサービス内での利用のみのため、次のような方針となっています。</p>
<ul>
<li>Idempotency-Keyヘッダ相当の独自ヘッダに文字列(特に理由がなければUUID)を指定する</li>
<li>リクエストの同一性はクライアントサイドで管理し、サーバーサイドで検証等は特にしない(フィンガープリントを用いた検証等をしない)</li>
<li>サーバーサイドでユースケース毎にidempotency keyの有効期限を設定する</li>
</ul>
<h2 id="デジスマでの実装">デジスマでの実装</h2>
<p>上記冪等性の方針に関して、デジスマでの実装について紹介します。
医療機関に新規予約をする時の疑似コードを例にすると、次のようになります。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synComment">// idempotency keyと1対1となるようなIDを生成</span>
<span class="synType">val</span> idempotencyKey = request.headers[<span class="synConstant">"Idempotency-Key"</span>]
<span class="synType">val</span> reservationId = idempotencyLockRepository.getLockOrSet(
key = <span class="synConstant">"create-reservation-</span><span class="synIdentifier">${</span>authContext.userId<span class="synIdentifier">}</span><span class="synConstant">-</span><span class="synIdentifier">$idempotencyKey</span><span class="synConstant">"</span>,
value = UUID.randomUUID().toString(),
).let { UUID.fromString(it) }
<span class="synComment">// 既に存在する予約IDの場合はクライアントに返却</span>
<span class="synType">val</span> reservation = reservationRepository.find(reservationId)
<span class="synStatement">if</span> (reservation <span class="synStatement">!=</span> <span class="synConstant">null</span>) <span class="synStatement">return</span> reservation
<span class="synComment">// 新規登録処理</span>
...
</pre>
<p>仕組みとしては、ユースケース毎にidempotency keyに対応する何らかの文字列(今回の場合は予約ID)をグローバルに管理しています。
<code>idempotencyLockRepository</code> はAmazon DynamoDBを使って文字列を管理しており、次のようなコードになります。</p>
<pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synType">fun</span> getLockOrSet(key: <span class="synType">String</span>, value: <span class="synType">String</span>, ttl: <span class="synType">Duration</span> = <span class="synType">Duration</span>.ofDays(<span class="synConstant">1</span>)): <span class="synType">String</span> {
<span class="synStatement">return</span> dynamoDbClient.updateItem(
UpdateItemRequest.builder()
.tableName(TABLE_NAME)
.key(
mapOf(
<span class="synConstant">"key"</span> to AttributeValue.builder().s(key).build(),
),
)
.updateExpression(<span class="synConstant">"SET #value = if_not_exists(#value, :value), #ttl = if_not_exists(#ttl, :ttl)"</span>)
.expressionAttributeValues(
mapOf(
<span class="synConstant">":value"</span> to AttributeValue.builder().s(value).build(),
<span class="synConstant">":ttl"</span> to AttributeValue.builder().n(Instant.now().plus(ttl).epochSecond.toString()).build(),
),
)
.expressionAttributeNames(
mapOf(
<span class="synConstant">"#value"</span> to <span class="synConstant">"value"</span>,
<span class="synConstant">"#ttl"</span> to <span class="synConstant">"ttl"</span>,
),
)
.returnValues(ReturnValue.ALL_NEW)
.build(),
).attributes()[<span class="synConstant">"value"</span>]<span class="synSpecial">!!</span>.s()
}
</pre>
<p>DynamoDBのテーブルについて、<code>key</code> というハッシュキーに呼び出し元から渡されたキー文字列と、<code>value</code> というフィールドにidempotency keyに対応する文字列、ttl(有効期限)をセットするよう試みます。
呼び出し元にはハッシュキーに既に <code>value</code> がセットされている場合はその値を、セットされていない場合は渡された値を保存して返却します。
これにより一度リクエストされたidempotency keyに対しては毎回同じ予約IDが発行されます。</p>
<p>ハッシュキーを指定する際にidempotency key以外のものも文字列に含めるようにしています(今回の例では <code>"create-reservation-${authContext.userId}-$idempotencyKey"</code>)。</p>
<p>idempotency keyはクライアントから指定されるので、攻撃者に漏れたり推測しやすい値の場合に悪用されるのを回避するためにこのような形式になっています。
今回は認証情報である(と仮定してください) <code>authContext.userId</code> を含めることで安全にしています。</p>
<p>また、この実装では並列にリクエストが来た場合に完全に冪等にならないケース<a href="#f-5c1b0e47" id="fn-5c1b0e47" name="fn-5c1b0e47" title="先に受付たリクエストの予約作成中に、後のリクエストの予約存在チェックをすり抜けてしまうと、こちらも予約を作成しようとして予約IDの一意制約エラー等になるなどがあり得ます">*2</a>もありますが、大きな問題になることはないため実装しやすさを優先しています。</p>
<h2 id="おわりに">おわりに</h2>
<p>今回はHTTP通信で冪等性を担保する仕組みと、デジスマでの実装について紹介しました。
Idempotency-KeyヘッダもまだDraftではあるので、今回紹介したものは一例として、実装の際は何を実現したいかを最初に検討するのが良さそうに思います。
また、今回の例だとビジネスロジックにidempotency keyが入り込んでしまうため、フレームワークのinterceptorやmiddlewareといった層に実装するのも良さそうです。</p>
<h2 id="We-are-hiring">We are hiring!!</h2>
<p>エムスリーでは絶賛エンジニアを募集中です!
デジスマ診療以外にも様々なプロダクトがありますので、ご興味ある方は是非カジュアル面談等ご応募ください!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<div class="footnote">
<p class="footnote"><a href="#fn-623f7505" id="f-623f7505" name="f-623f7505" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">ここではプログラムによる自動リトライや、UIにエラーが表示されてユーザーが再実行することも含めた"広義の"リトライになります。</span></p>
<p class="footnote"><a href="#fn-5c1b0e47" id="f-5c1b0e47" name="f-5c1b0e47" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">先に受付たリクエストの予約作成中に、後のリクエストの予約存在チェックをすり抜けてしまうと、こちらも予約を作成しようとして予約IDの一意制約エラー等になるなどがあり得ます</span></p>
</div>
ken_tunc
デジカルiOSアプリの歴史
hatenablog://entry/6801883189066576106
2023-12-14T13:19:22+09:00
2023-12-14T13:20:16+09:00 こちらはエムスリー Advent Calendar 2023の14日目の記事です。 こんにちは、デジスマチームの堀田です。普段はデジスマチーム*1でWebフロントとバックエンドの開発を行なっていますが、 サブタスクとしてデジカル*2のiOSアプリの改善も行なっています。 改善には軽微なバグ修正も含まれていましたが、新機能の追加や大幅な変更が行われることもありました。その結果、初期のバージョンからは大きく変貌を遂げています。 良いタイミングなので、このプロジェクトの歴史をまとめようと思いました。 *1:デジスマ診療 https://digikar-smart.jp/ *2:https://dig…
<p>こちらは<a href="https://qiita.com/advent-calendar/2023/m3">エムスリー Advent Calendar 2023</a>の14日目の記事です。</p>
<p>こんにちは、デジスマチームの堀田です。普段はデジスマチーム<a href="#f-9d30e55b" id="fn-9d30e55b" name="fn-9d30e55b" title="デジスマ診療 [https://digikar-smart.jp/] ">*1</a>でWebフロントとバックエンドの開発を行なっていますが、
サブタスクとしてデジカル<a href="#f-7c74a5a8" id="fn-7c74a5a8" name="fn-7c74a5a8" title="[https://digikar.co.jp/]">*2</a>のiOSアプリの改善も行なっています。</p>
<p>改善には軽微なバグ修正も含まれていましたが、新機能の追加や大幅な変更が行われることもありました。その結果、初期のバージョンからは大きく変貌を遂げています。</p>
<p>良いタイミングなので、このプロジェクトの歴史をまとめようと思いました。</p>
<ul class="table-of-contents">
<li><a href="#初めまして手書きアプリ">初めまして、手書きアプリ</a></li>
<li><a href="#認証機能周りの強化">認証機能周りの強化</a></li>
<li><a href="#Mainstoryboardからカルテ画面の切り出し">Main.storyboardからカルテ画面の切り出し</a><ul>
<li><a href="#PencilKitの検討">PencilKitの検討</a></li>
</ul>
</li>
<li><a href="#iPhoneアプリ-カルテビューワー-のリリース">iPhoneアプリ 〜カルテビューワー〜 のリリース</a></li>
<li><a href="#カルテビューワーのデザインリニューアル">カルテビューワーのデザインリニューアル</a></li>
<li><a href="#カルテビューワー-in-手書きアプリ機能-miniPhone機能-">カルテビューワー in 手書きアプリ機能 〜miniPhone機能 〜</a><ul>
<li><a href="#Git-Repositoryのマージ">Git Repositoryのマージ</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#We-are-hiring">We are hiring!!</a></li>
</ul>
<h1 id="初めまして手書きアプリ">初めまして、手書きアプリ</h1>
<p>デジカルでは、Apple Pencilを使ってカルテを書けるiPad向けのアプリ、通称「手書きアプリ」を提供しています。これは紙カルテから電子カルテに切り替えた後も、カルテを手で書きたいユーザー向けのアプリです。</p>
<p>アプリ開発当初、手でカルテを書く以外にも色々な機能をiPadアプリで扱えるようにする案もあったそうです。しかし、現在ではあくまで「本体のWebアプリと併用して使うもの」という位置付けになっています。</p>
<p>リリース時点ではデジカルチームとは別のマルチデバイスチームが開発に関わってくれていたようです。それ以降の改善はデジカルチームが引き受けるという流れになっていました。</p>
<p>手書きアプリがリリースされて数年後に私がエムスリーに入社しました。</p>
<h1 id="認証機能周りの強化">認証機能周りの強化</h1>
<p>デジカルはカルテ情報を扱うというサービスの性質上、セキュリティ対策は重要でした。そのため、ログイン時にはID・パスワードと合わせてクライアント証明書が必要な仕様となっています。これはリリース当初から現在まで変わっていません。</p>
<p>改善したのはクライアント証明書を取り込むまでのフローです。クライアント証明書を取り込むタイミングは</p>
<ol>
<li>デジカル導入時</li>
<li>クライアント証明書の期限切れのタイミングで行う更新時</li>
</ol>
<p>の2つです。頻度自体は少ないものの、取り込みは重たい作業でした。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231214/20231214131952.png" width="673" height="633" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span></p>
<p>これは本体のWebアプリ上のクライアント証明書発行画面です。改善前はこの画面にQRコードは無く、クライアント証明書のダウンロードURLとパスワードが文字列で表示されているだけでした。</p>
<p>ユーザーはこれらの文字列をなんとかして手書きアプリに入力して証明書をインストールしていました。このような状況だったので、発行画面にQRコードを表示し、アプリからはカメラで取り込むように改善しました。</p>
<div class="images-row mceNonEditable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231214/20231214131956.png" width="600" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231214/20231214131952.png" width="673" height="633" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></div>
<p>アプリの証明書取り込み画面は、上の画像のように、カメラプレビューの上に証明書発行画面の簡易イラストを透過して重ねるデザインにしました。このデザインによって、イラストと発行画面を重ねている間にサクッと証明書が取り込まれるという体験を作れました。</p>
<p>クライアント証明書取り込み周りの改善とは別に、FaceId/TouchIdでのログインサポートも対応しています。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231214/20231214132002.png" width="600" height="800" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span></p>
<h1 id="Mainstoryboardからカルテ画面の切り出し">Main.storyboardからカルテ画面の切り出し</h1>
<p>手書きアプリは本体のWebアプリと併用して使う立ち位置のため、最低限の機能だけが実装されています。例えば、ある日付の予約・受付の一覧やカルテ編集画面などです。
そのためアプリ全体で見ると画面数はかなり少ないですが、その全てがMain.storyboardに定義されていました。</p>
<p>今後の開発のため、メイン機能であるカルテ編集画面だけでもと思い、別の定義ファイル (.xib) に切り出しました。</p>
<h2 id="PencilKitの検討">PencilKitの検討</h2>
<p>カルテ編集画面を切り出す過程で、カルテ編集時に実際に線などを描くキャンバスに<a href="https://developer.apple.com/documentation/pencilkit">PencilKit</a>の採用を検討しました。
個人的に、PencilKitで何かを描く体験は最高だと思っています。これを手書きアプリに使えたらユーザー体験も格段に良くなるだろうと考えていました。最終的には採用を見送ったのですが、それには以下のような理由がありました。</p>
<p>カルテを書く際の大きな特徴として、体の部位を絵図で表現したシェーマを使うというものがあります。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231214/20231214132009.png" width="600" height="800" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span></p>
<p>このようにイラストの画像データをキャンバス上に貼り付け、その上に症状などを書き込むというやり方です。
シェーマはカルテ編集では必須の機能ですが、PencilKitでは「画像データをキャンバス上に貼る」ことができませんでした。
そのため、現在でもカルテのキャンバスは自作したものを使っています。</p>
<h1 id="iPhoneアプリ-カルテビューワー-のリリース">iPhoneアプリ 〜カルテビューワー〜 のリリース</h1>
<p>手書きアプリの改善が落ち着くと、訪問診療や休暇中の連絡によって外出先でも簡単にカルテを確認できるアプリの案が上がりました。
iPadも持ち運びは容易なので手書きアプリでそれを実現できそうではありますが、より簡単に見られるようにiPhone向けのカルテ閲覧専用アプリを作ることになりました。通称、カルテビューワーです。</p>
<p>カルテビューワーの開発からリリースは2~3週間程度でした。セキュリティ周りは妥協ができない部分でしたが、手書きアプリで使っているロジックをそのまま使えたので大きな工数削減に繋がりました。
デザインに関しても、本体のWebアプリをベースに最低限のデザインを私が行なったので、こちらも工数削減に繋がっています。</p>
<p><figure class="figure-image figure-image-fotolife" title="初期のカルテビューワー"><div class="images-row mceNonEditable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231214/20231214131923.png" width="323" height="704" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231214/20231214131926.png" width="324" height="707" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231214/20231214131929.png" width="326" height="704" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></div><figcaption>初期のカルテビューワー</figcaption></figure></p>
<p>当時のUIがこちらです。</p>
<h1 id="カルテビューワーのデザインリニューアル">カルテビューワーのデザインリニューアル</h1>
<p>その後、一定のユーザー層には需要があることが分かり、デザインリニューアルを段階的に行いました。
それと同時に見られるデータの種類も増やし、一気にビューワーとしての機能が補充され、シンプルで洗練されたデザインになりました<a href="#f-4ba46977" id="fn-4ba46977" name="fn-4ba46977" title="新しくチームにジョインした、とても頼りになるデザイナーがやってくれました。手書きアプリのクライアント証明書取り込み画面をデザインしてくれたのも同じデザイナーです。">*3</a>。</p>
<p><figure class="figure-image figure-image-fotolife" title="デザインリニューアル後のカルテビューワー"><div class="images-row mceNonEditable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231214/20231214131932.png" width="369" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231214/20231214131936.png" width="369" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231214/20231214131939.png" width="369" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231214/20231214131942.png" width="369" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></div><figcaption>デザインリニューアル後のカルテビューワー</figcaption></figure></p>
<p>上図が示す通り、現時点でカルテタブは8個あります。元々、各データのページをスワイプで切り替えるなど他のデザインを検討していました。しかし、見たいデータのページまで移動するのに2~3回のスワイプを要するより、ワンタップで移動できる方が医師にとって便利では?という考えでアプリのデザインではあまり見ないですが8個のタブをフッターに置くことにしました。</p>
<p>懸念は誤タップで別画面が開いてしまうことですが、実装後にチームメンバーで触ってみたところほとんど誤タップすることはなかったのでこの案で進めることにしました。</p>
<h1 id="カルテビューワー-in-手書きアプリ機能-miniPhone機能-">カルテビューワー in 手書きアプリ機能 〜miniPhone機能 〜</h1>
<p>カルテビューワーのリニューアルが段階的に進んだ後、再度手書きアプリの改善案が上がりました。それは、「カルテビューワーの画面を手書きアプリで見られたら便利では?」という案でした。通称、miniPhone機能です。</p>
<p>確かにカルテを書くときに患者様の診療履歴やデータが見られると便利そうです。本体のWebアプリで確認もできますが、iPad上で完結することに越したことはなさそうです。</p>
<p><figure class="figure-image figure-image-fotolife" title="miniPhone機能">
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231214/20231214131945.png" width="600" height="800" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span>
<figcaption>miniPhone機能</figcaption>
</figure></p>
<p>このように仮想的にカルテビューワーを手書きアプリ上に埋め込んだような機能が仕上がりました。カルテの編集の邪魔にならないように、表示・非表示の切り替え、ドラッグでの移動をサポートしています。</p>
<h2 id="Git-Repositoryのマージ">Git Repositoryのマージ</h2>
<p>miniPhone機能開発の前は、手書きアプリとカルテビューワーは別々のRepositoryで管理されていました。
この機能はつまるところ、手書きアプリRepositoryからカルテビューワーRepositoryの資産を使えるようにしたいというものです。</p>
<p>手書きアプリRepositoryにカルテビューワーRepositoryをsubmoduleで埋め込んだりしてできるかな?などを最初は考えましたが、最終的には2つのRepositoryをマージすることにしました。digikar-ios-apps Repositoryの爆誕です。</p>
<p>digikar-ios-appsでは、1つのworkspaceを作りました。そのworkspaceの中に、</p>
<ul>
<li>手書きアプリ.xcodeproj</li>
<li>カルテビューワー.xcodeproj</li>
<li>Frameworks Package</li>
<li>Pods (手書きアプリだけが使用)</li>
</ul>
<p>が含まれています。</p>
<p>FrameworksはSwift Package Managerで管理するモジュール群で、この中で機能モジュールごとの細分化を行なっています。以下に、その定義の一部を載せています。</p>
<p><figure class="figure-image figure-image-fotolife" title="Frameworks Packageの一部"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231214/20231214131949.png" width="350" height="800" loading="lazy" title="" class="hatena-fotolife" style="width:240px" itemprop="image"></span><figcaption>Frameworks Packageの一部</figcaption></figure></p>
<ul>
<li>AppFeature : 手書きアプリで使う機能への依存をまとめたモジュール</li>
<li>PhoneAppFeature: カルテビューワーで使う機能への依存をまとめたモジュール</li>
<li>MiniKarteFeature: 手書きアプリだけで使う、miniPhone機能のモジュール</li>
<li>KarteFeature: カルテ機能のコアとなるモジュール</li>
</ul>
<p>MiniKarteFeatureへの依存を持っているのはAppFeatureだけです。よって、手書きアプリ側のコードでこれをimportできますが、
カルテビューワー側でimportするとエラーになります。手書きアプリ専用の機能なので、こういう構成が取れるのはありがたいです。</p>
<h1 id="まとめ">まとめ</h1>
<p>サブタスクとしてデジカルのアプリ改善を行なってきましたが、振り返ってみるとストーリーが生まれていました。
そのストーリーを一度まとめておこうと思い、このブログを書きました。きっかけをくれたPdM、磨きをくれたデザイナー、チームメンバーにはとても感謝しています。</p>
<p>技術的な負債があったりと、まだまだ良くできることはあるので、これからも取り組んでいきます。</p>
<blockquote><p>おまけ: 直近で書いた記事</p>
<ul>
<li><p>m3 tech blog: <a href="https://www.m3tech.blog/entry/2022/05/27/110916">TypeScriptの型で麻雀の点数計算をする</a></p></li>
<li><p>個人ブログ <a href="https://hrtyy.dev/web/rsc_memo/">RSC Memo</a></p></li>
<li><p>個人ブログ <a href="https://hrtyy.dev/web/rsc_payload/">RSC Payload</a></p></li>
<li><p>個人ブログ <a href="https://hrtyy.dev/web/rsc_parser/">rscq, CLI Parser for React Server Component Payloads</a></p></li>
</ul>
</blockquote>
<h1 id="We-are-hiring">We are hiring!!</h1>
<p>エムスリーでは絶賛エンジニアを募集中です!
今回紹介した技術スタック・アーキテクチャ以外にも様々な構成のプロダクトがありますので、ご興味ある方は是非こちらからお願いします!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<div class="footnote">
<p class="footnote"><a href="#fn-9d30e55b" id="f-9d30e55b" name="f-9d30e55b" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">デジスマ診療 <a href="https://digikar-smart.jp/">https://digikar-smart.jp/</a> </span></p>
<p class="footnote"><a href="#fn-7c74a5a8" id="f-7c74a5a8" name="f-7c74a5a8" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://digikar.co.jp/">https://digikar.co.jp/</a></span></p>
<p class="footnote"><a href="#fn-4ba46977" id="f-4ba46977" name="f-4ba46977" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">新しくチームにジョインした、とても頼りになるデザイナーがやってくれました。手書きアプリのクライアント証明書取り込み画面をデザインしてくれたのも同じデザイナーです。</span></p>
</div>
m3yuyahorita
Axon Framework で簡単にEventSourcing+CQRSなアプリケーションを作る
hatenablog://entry/6801883189065805221
2023-12-13T14:13:54+09:00
2023-12-13T14:14:15+09:00 この記事はエムスリーAdvent Calendar 2023の13日目の記事です。 こんにちは、製薬企業向けプラットフォームチームエンジニアの桑原です。 前回のJJUG CCC の登壇についてのブログで Axon Framework について軽く触れました。今回はAxon Frameworkがどのようなもので、どういった使い方をするかを紹介したいと思います。 背景:CommandとQueryに最適なモデルが異なる CommandとEvent追記型との相性は良かった QueryがEvent追記型との相性は良くなかった 苦肉の解決策 Axon Framework ざっくりアーキテクチャ Comma…
<p>この記事は<a href="https://qiita.com/advent-calendar/2023/m3">エムスリーAdvent Calendar 2023</a>の13日目の記事です。</p>
<p>こんにちは、製薬企業向けプラットフォームチームエンジニアの桑原です。</p>
<p><a href="http://%E2%80%8Bhatena:http-notation:625bb3b360aa92fa6c5b9535f325e1c272cbecb1%E2%80%8B">前回のJJUG CCC の登壇についてのブログ</a>で Axon Framework について軽く触れました。今回はAxon Frameworkがどのようなもので、どういった使い方をするかを紹介したいと思います。</p>
<ul class="table-of-contents">
<li><a href="#背景CommandとQueryに最適なモデルが異なる">背景:CommandとQueryに最適なモデルが異なる</a><ul>
<li><a href="#CommandとEvent追記型との相性は良かった">CommandとEvent追記型との相性は良かった</a></li>
<li><a href="#QueryがEvent追記型との相性は良くなかった">QueryがEvent追記型との相性は良くなかった</a></li>
<li><a href="#苦肉の解決策">苦肉の解決策</a></li>
</ul>
</li>
<li><a href="#Axon-Framework">Axon Framework</a><ul>
<li><a href="#ざっくりアーキテクチャ">ざっくりアーキテクチャ</a></li>
<li><a href="#Command">Command</a></li>
<li><a href="#EventからReadModelへのマッピング">EventからReadModelへのマッピング</a></li>
<li><a href="#Query">Query</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#参考記事">参考記事</a></li>
<li><a href="#We-are-Hiring">We are Hiring!</a></li>
</ul>
<h2 id="背景CommandとQueryに最適なモデルが異なる">背景:CommandとQueryに最適なモデルが異なる</h2>
<p>上述のリンクで紹介したメッセージ配信システムの設計では、アプリケーション特性の違いからメッセージ配信を担うCommandとメッセージ閲覧を担うQueryに分けました。
結果的にCQRSのようになりましたが、最初からCQRSを目指していたわけではなく、データモデルは1つのものを考えていました。</p>
<h3 id="CommandとEvent追記型との相性は良かった">CommandとEvent追記型との相性は良かった</h3>
<p>データモデリングは<a href="https://scrapbox.io/kawasima/%E3%82%A4%E3%83%9F%E3%83%A5%E3%83%BC%E3%82%BF%E3%83%96%E3%83%AB%E3%83%87%E3%83%BC%E3%82%BF%E3%83%A2%E3%83%87%E3%83%AB">イミュータブルモデル</a>の考えを参考に、リソースとイベントに分けて考えました。イベントは更新や上書きせずに追記する考え方はCommandの実装ととても相性が良かったです。</p>
<h3 id="QueryがEvent追記型との相性は良くなかった">QueryがEvent追記型との相性は良くなかった</h3>
<p>メッセージには 送信,開封,削除 などのイベントが紐付けられ、1つのメッセージに対して複数の開封イベントが発生することもあります。</p>
<p><figure class="figure-image figure-image-fotolife" title="メッセージリソースとイベントの関係"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231213/20231213141356.png" width="308" height="311" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>メッセージリソースとイベントの関係</figcaption></figure></p>
<p>そして以下のようなテーブルの状態になってる場合を想定します。</p>
<table>
<caption>message</caption>
<tr>
<th>message_id</th>
<th>title</th>
<th>body</th>
</tr>
<tr>
<td>1</td>
<td><div>hello axon</div></td>
<td ><div>this is message body</div></td>
</tr>
<tr>
<td>2</td>
<td><div>not expected to be read</div></td>
<td ><div>this message is not expected to be read</div></td>
</tr>
</table>
<table>
<caption>message_read</caption>
<tr>
<th>message_id</th>
<th>read_datetime</th>
</tr>
<tr>
<td>1</td>
<td><div> 2023-12-13T10:00:00+09:00 </div></td>
</tr>
<tr>
<td>2</td>
<td><div> 2023-12-13T11:00:00+09:00 </div></td>
</tr>
<tr>
<td>1</td>
<td><div> 2023-12-13T12:00:00+09:00 </div></td>
</tr>
</table>
<table>
<caption>message_read_canceled</caption>
<tr>
<th>message_id</th>
<th>canceled_datetime</th>
</tr>
<tr>
<td>2</td>
<td><div> 2023-12-13T12:00:00+09:00 </div></td>
</tr>
</table>
<p>message_id:1 に対しては開封レコードが2件入ってます。<br/>
message_id:2 に対しては開封レコードが入っているもののその後開封取り消しされています。</p>
<p>この状態で、メッセージ一覧画面などで現在有効な未読一覧を検索したい場合、一筋縄ではいかなくなります。</p>
<ul>
<li><p>SQLで表現可能?<br/>
<code>message</code> , <code>message_read</code>, <code>message_read_canceled</code> を結合し、<code>message_read</code> にレコードがなければ未読。
または <code>message_read_canceled</code> の最新日が <code>message_read</code> の最新日付よりも未来日であれば未読。<br/>
などをSQLで表現するのはとても骨が折れそうです。</p></li>
<li><p>アプリで現在状態の導出?<br/>
またはアプリでイベントを取得し直して再計算すれば実現はできそうですが性能が満足に出るとは到底思えません。</p></li>
</ul>
<h3 id="苦肉の解決策">苦肉の解決策</h3>
<p>そのままだとクエリに耐えられないことは容易に想像できたので、メッセージ配信システムではイベントを保存すると同時にクエリ用のステータスも更新することにしております。</p>
<table>
<caption>message</caption>
<tr>
<th>message_id</th>
<th>title</th>
<th>body</th>
<th style="background:#b0dfff;">status</th>
</tr>
<tr>
<td>1</td>
<td><div>hello axon</div></td>
<td ><div>this is message body</div></td>
<td ><div>READ</div></td>
</tr>
<tr>
<td>2</td>
<td><div>not expected to be read</div></td>
<td ><div>this message is not expected to be read</div></td>
<td ><div>UNREAD</div></td>
</tr>
</table>
<p>せっかくイベントの追記してるのに、状態の更新もしないといけないのは二度手間感も少々ありますね。</p>
<p>事実を記録するという観点でイベントをそのまま追記していくのはとても有効な設計なのですが、やはりそのままだとクエリで厳しくなり、データモデルにステータスなどをもたせるなどの対応が必要になってしまいそうです。かといってクエリ用のリードモデルの作成となると一気に考えることが増えそうです。</p>
<h2 id="Axon-Framework">Axon Framework</h2>
<p>前回の JJUG CCC 2023 Fall で似た内容でこの悩みを解決されていた事例がありました。<br/>
<a href="https://speakerdeck.com/nrslib/implementation-pub-sub-microservice">実践Pub/Subマイクロサービス――SpringとAxonで作る疎結合でスケーラブルなシステム</a></p>
<p>このセッションでAxon Framework が使用されており、とても面白そうだったので最近私も家で遊び始めてます。</p>
<h3 id="ざっくりアーキテクチャ">ざっくりアーキテクチャ</h3>
<p><a href="https://developer.axoniq.io/axon-framework/overview"><strong>Axon Framework</strong></a> はDDDとCQRSをベースとしたマイクロサービス用のフレームワークです。Axon Frameworkとは別にAxon Serverも必要になり、Axon Framework は Axon Server とのやり取りを隠蔽してくれる立ち位置にいます。ざっくりとしたアーキテクチャは <a href="https://docs.axoniq.io/reference-guide/architecture-overview">Architecture Overview</a> の図が参考になると思います。</p>
<p><figure class="figure-image figure-image-fotolife" title="https://docs.axoniq.io/reference-guide/architecture-overview から"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231213/20231213141402.png" width="766" height="689" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption><a href="https://docs.axoniq.io/reference-guide/architecture-overview">https://docs.axoniq.io/reference-guide/architecture-overview</a> から</figcaption></figure></p>
<p>開発者が意識すべきは主に3点です。</p>
<ul>
<li>Commandを受け取って(生成して)Axon Server に渡す。</li>
<li>Axon Server から通知されるEventを受け取り、read用のモデルReadModelを生成する。</li>
<li>QueryはReadModelから欲しい情報を取得する。</li>
</ul>
<p>イベントの保存と送受信を全部Axon Frameworkがやってくれるのでビジネスロジックの開発に注力して高機能なCQRSを開発することが可能になります。更に、Spring Boot Integration を使用すれば Axon Server へのデータ送信のためのコンポーネントやイベント検知のためのアノテーションが提供されるため、開発者は外部システムの存在を意識することなくメソッドを呼び出すだけで済んでしまいます。</p>
<p>実際に Axon Framework を使って、先程の Command/Query 用のモデルをどのように構築していくかを確かめていきます。</p>
<h3 id="Command">Command</h3>
<p><figure class="figure-image figure-image-fotolife" title="Eventの登録"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231213/20231213141406.png" width="766" height="689" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Eventの登録</figcaption></figure></p>
<ul>
<li>Axon ServerへCommandを渡す</li>
</ul>
<pre class="code lang-java" data-lang="java" data-unlink><span class="synPreProc">@RestController</span>
<span class="synType">public</span> <span class="synType">class</span> MessageRestEndpoint {
<span class="synType">private</span> <span class="synType">final</span> CommandGateway commandGateway;
<span class="synPreProc">@PostMapping</span>(<span class="synConstant">"/create-message"</span>)
<span class="synType">public</span> CompletableFuture<Void> createMessage(<span class="synPreProc">@RequestBody</span> MessageCreate messageCreate) {
String messageId = UUID.randomUUID().toString();
<span class="synStatement">return</span> commandGateway.send(<span class="synStatement">new</span> CreateMessageCommand(messageId, messageCreate.title, messageCreate.body));
}
<span class="synPreProc">@PostMapping</span>(<span class="synConstant">"/{messageId}/read"</span>)
<span class="synType">public</span> CompletableFuture<Void> readMessage(<span class="synPreProc">@PathVariable</span> String messageId) {
<span class="synStatement">return</span> commandGateway.send(<span class="synStatement">new</span> ReadMessageCommand(messageId));
}
}
<span class="synType">public</span> <span class="synType">class</span> CreateMessageCommand {
<span class="synPreProc">@TargetAggregateIdentifier</span>
<span class="synType">private</span> <span class="synType">final</span> String messageId;
<span class="synType">private</span> <span class="synType">final</span> String title;
<span class="synType">private</span> <span class="synType">final</span> String body;
<span class="synComment">// getter,setter,equals,hashCode,toString...</span>
}
</pre>
<p>Commandの発行自体は <code>CommandGateway</code> を経由するだけです。</p>
<ul>
<li>Axon Server から CommandHandlerを呼び出し</li>
</ul>
<p>Commandを発行するとAxon Framework により <code>@CommandHandler</code> 付与されたメソッドを呼び出され、ここでCommandからEventを生成して発火します。</p>
<pre class="code lang-java" data-lang="java" data-unlink><span class="synPreProc">@Aggregate</span>(snapshotTriggerDefinition = <span class="synConstant">"messageAggregateSnapshotTriggerDefinition"</span>)
<span class="synType">public</span> <span class="synType">class</span> MessageAggregate {
<span class="synPreProc">@CommandHandler</span>
<span class="synType">public</span> MessageAggregate(CreateMessageCommand command) {
apply(<span class="synStatement">new</span> MessageCreatedEvent(command.getMessageId(), command.getTitle(), command.getBody()));
}
}
<span class="synType">public</span> <span class="synType">class</span> MessageCreatedEvent {
<span class="synType">private</span> <span class="synType">final</span> String messageId;
<span class="synType">private</span> <span class="synType">final</span> String title;
<span class="synType">private</span> <span class="synType">final</span> String body;
<span class="synComment">// getter,setter,equals,hashCode,toString...</span>
}
</pre>
<p>ここで <code>apply</code> された <code>MessageCreatedEvent</code> はAxon Server へ送信され、イベントジャーナルとして永続化されます。図の右上Event Storeに該当します。</p>
<p><figure class="figure-image figure-image-fotolife" title="永続化されたイベント"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231213/20231213141359.png" width="800" height="332" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>永続化されたイベント</figcaption></figure></p>
<p>Command側のイベントの登録は以上です。Axon Frameworkにより永続化層のことは何も触れることなく、Eventが保存されています。また、永続化先はRDBやKafkaなどに対応しているため、要件に沿った製品の選定も柔軟に選択できます。</p>
<h3 id="EventからReadModelへのマッピング">EventからReadModelへのマッピング</h3>
<p><figure class="figure-image figure-image-fotolife" title="readmodelへのマッピング"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231213/20231213141409.png" width="766" height="689" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>readmodelへのマッピング</figcaption></figure></p>
<ul>
<li>EventHandlerの呼び出し</li>
</ul>
<p>Eventが永続化されると、Axon Framework から <code>@EventHandler</code>付与したメソッドを呼び出されます。ここでEventからQuery側に都合のいいReadModelの作成を行えます。</p>
<pre class="code lang-java" data-lang="java" data-unlink><span class="synPreProc">@Service</span>
<span class="synPreProc">@ProcessingGroup</span>(<span class="synConstant">"messages"</span>)
<span class="synType">public</span> <span class="synType">class</span> InMemoryMessagesEventHandler <span class="synType">implements</span> MessagesEventHandler {
<span class="synType">private</span> <span class="synType">final</span> Map<String, Message> messages = <span class="synStatement">new</span> HashMap<>();
<span class="synType">public</span> InMemoryMessagesEventHandler(QueryUpdateEmitter emitter) {
<span class="synType">this</span>.emitter = emitter;
}
<span class="synPreProc">@EventHandler</span>
<span class="synType">public</span> <span class="synType">void</span> on(MessageCreatedEvent event) {
String messageId = event.getMessageId();
messages.put(messageId, <span class="synStatement">new</span> Message(messageId, event.getTitle(), event.getBody()));
}
<span class="synPreProc">@EventHandler</span>
<span class="synType">public</span> <span class="synType">void</span> on(MessageReadEvent event) {
messages.computeIfPresent(event.getMessageId(), (messageId, message) -> {
message.setMessageStatusRead();
message.setLatestReadDateTime(event.getReadDateTime());
emitUpdate(message);
<span class="synStatement">return</span> message;
});
}
}
</pre>
<p><code>MessageReadEvent</code> のイベントハンドラ内で、 <code>message.setMessageStatusRead()</code> を呼び出しています。このタイミングでイベントを元に、更に必要があれば現在や過去の状態を元にReadModelに対して検索用のステータスを導出、保持させることができます。Eventと検索用のReadModelが分離されてスッキリします。</p>
<p>ここでは簡素化のため <code>private final Map<String, Message> messages = new HashMap<>();</code> に対してReadModelの構築をしていますが、永続化先はRDBやNoSQLも選択可能です。自由に記述できるのでここでも要件に沿った選定が可能です。</p>
<h3 id="Query">Query</h3>
<p><figure class="figure-image figure-image-fotolife" title="ReadModelからの読み込み"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231213/20231213141412.png" width="766" height="689" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ReadModelからの読み込み</figcaption></figure></p>
<p>Queryではすでに特化したReadModelが作成されているので、取得するだけです。</p>
<h2 id="まとめ">まとめ</h2>
<p>とても簡単にEventSourcing + CQRSのアプリケーションが作成できてしまいました!
Axon Frameworkを活用することでビジネスロジックの開発に注力しても高機能なCQRS環境が作成できてしまうので、とても強力なフレームワークだと感じています。ここでは紹介しきれていませんが新たなReadModelを作成したい場合はEventのReplay機能など魅力的な機能がまだまだ他にもあるようですので、どんどん触ってみたいと思います。</p>
<h2 id="参考記事">参考記事</h2>
<ul>
<li><a href="https://www.baeldung.com/axon-cqrs-event-sourcing">A Guide to the Axon Framework:Baeldung</a></li>
<li><a href="https://qiita.com/megasys1968/items/04444d8e70415b5cead4">Axon Frameworkを使ってみる:Qiita</a></li>
<li><a href="https://speakerdeck.com/nrslib/implementation-pub-sub-microservice">実践Pub/Subマイクロサービス――SpringとAxonで作る疎結合でスケーラブルなシステム</a></li>
</ul>
<h2 id="We-are-Hiring">We are Hiring!</h2>
<p>エムスリーはまだまだJavaやKotlinなどのJVM系言語によるシステム開発も行っております!
興味を持たれたらぜひこちらから!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F+" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/ ">jobs.m3.com</a></cite></p>
kuwavar
GKEでMLバッチ運用のコツ
hatenablog://entry/6801883189065580194
2023-12-12T11:00:00+09:00
2023-12-12T11:00:45+09:00 この記事はエムスリーAdvent Calendar 2023とMLOps Advent Calendar 2023の12日目の記事です。 AI・機械学習チームの北川です。 最近は猫のかまってアピールがすごすぎて、よく仕事の邪魔されます。 かまって欲しがる猫 現在AI・機械学習チームではMLのバッチをGoogle Kubernetes Engine(GKE)上で運用しています。 現在数えてみたところ240個以上のバッチがGKE上で動いているようです。 AI・機械学習チームでは2019年頃から約4年ほどGKE上でMLバッチを運用しています。 その間にコストの最適化や安定したバッチの運用などに力を入…
<p>この記事は<a href="https://qiita.com/advent-calendar/2023/m3">エムスリーAdvent Calendar 2023</a>と<a href="https://qiita.com/advent-calendar/2023/mlops">MLOps Advent Calendar 2023</a>の12日目の記事です。</p>
<p>AI・機械学習チームの北川です。
最近は猫のかまってアピールがすごすぎて、よく仕事の邪魔されます。</p>
<p><figure class="figure-image figure-image-fotolife" title="かまって欲しがる猫"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231212/20231212110025.png" width="800" height="601" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>かまって欲しがる猫</figcaption></figure></p>
<p>現在AI・機械学習チームではMLのバッチをGoogle Kubernetes Engine(GKE)上で運用しています。
現在数えてみたところ240個以上のバッチがGKE上で動いているようです。</p>
<p>AI・機械学習チームでは2019年頃から約4年ほどGKE上でMLバッチを運用しています。
その間にコストの最適化や安定したバッチの運用などに力を入れてきました。
この記事では、主にスケールインとコスト最適化について説明しようと思います。</p>
<p>チームのMLについて全体を把握したい場合は以下の記事が詳しいです。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2Fai-mlops-history" title="AI・機械学習チーム流MLOpsの歴史 - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/ai-mlops-history">www.m3tech.blog</a></cite></p>
<ul class="table-of-contents">
<li><a href="#GKEの用語の確認">GKEの用語の確認</a><ul>
<li><a href="#Pod">Pod</a></li>
<li><a href="#Node">Node</a></li>
<li><a href="#Node-Pool">Node Pool</a></li>
</ul>
</li>
<li><a href="#KubernetesのEvictionについて">KubernetesのEvictionについて</a></li>
<li><a href="#Evictionを回避することによる問題">Evictionを回避することによる問題</a></li>
<li><a href="#Autopilotは銀の弾丸になるか">Autopilotは銀の弾丸になるか</a></li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#We-are-hiring">We are hiring!!</a></li>
</ul>
<h2 id="GKEの用語の確認">GKEの用語の確認</h2>
<p>まず最初にGKE(Kubernetes)の用語について確認をしていきます。
Kubernetesがわかる方は飛ばして次に行ってください。</p>
<ul>
<li>Pod</li>
<li>Node</li>
<li>Node Pool</li>
</ul>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231212/20231212110006.png" width="800" height="406" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<h3 id="Pod">Pod</h3>
<p>Podは1つまたは複数のコンテナのグループを表すKuberntesのリソースです。
複数のContainerを起動できるので、docker composeのようなものと認識すると分かりやすいかもしれません。
ストレージやネットワークの共有リソースを持つので、各コンテナ間で通信したり、データを共有したりすることが出来ます。</p>
<h3 id="Node">Node</h3>
<p>Nodeは1つのVMまたは物理的なマシンを表します。
各Nodeには複数のPodが配置されます。
GKEのStandardモードではNodeは一つのCompute Engineを表します。
Amazon Elastic Kubernetes Service(EKS)でもNodeはEC2インスタンスを表すようです。</p>
<p>Podでは各コンテナに対してCPUやメモリの下限や上限を指定することが出来ます。
メモリの下限を設定するとKubernetesのスケジューラは、その情報を利用してどのNodeにどのPodを配置するかを決定します。</p>
<h3 id="Node-Pool">Node Pool</h3>
<p>Node Poolはクラスタ内で同じ構成を持つNodeのグループです。
新たなPodを作成しようとした時にCPUやメモリなどリクエストが足りない場合、Node PoolはNodeをスケールアウトしてくれます。
逆にNodeが余っている場合はNodeをスケールインしてくれます。</p>
<p>GKEのStandardモードやEKSはNode単位で課金されます。
そのため自動でスケールインしてくれる機能はとても嬉しいものです。</p>
<h2 id="KubernetesのEvictionについて">KubernetesのEvictionについて</h2>
<p>KubernetesにはEvictionという機能があります。
これはNodeのリソースが余っていた場合にPodを別のNodeに退避させ、Nodeをスケールインしてくれるという機能です。
これによって料金の課金をある程度抑えてくれます。</p>
<p><figure class="figure-image figure-image-fotolife" title="EvictionによってNodeがスケールインされる"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231212/20231212110010.png" width="765" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>EvictionによってNodeがスケールインされる</figcaption></figure></p>
<p>しかし、この機能はMLのバッチのような長時間計算して、その状態を保持する必要がある場合には致命的となります。
近頃のMLバッチはパイプラインライブラリなどを利用して、バッチ全体を複数のステップに分割して各ステップをキャッシュするというようなものが多いと思います。
AI・機械学習チームでも<a href="https://github.com/m3dev/gokart">gokart</a>というパイプラインライブラリを利用しています。
しかし、実際に計算する部分は数時間かかるものがあり、その実行中にEvictionが走るとそれまでに計算した状態ごと消えてしまいます。</p>
<p><figure class="figure-image figure-image-fotolife" title="Evictionによって学習の途中状態が消される"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231212/20231212110013.png" width="800" height="497" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Evictionによって学習の途中状態が消される</figcaption></figure></p>
<p>Evictionで困るのはバッチだけではありません。
例えば、レプリカ数が1しかないAPIなどは別のNodeで新たに起動している間にはサービスの断続が出てしまいます。
この様にEvictionを回避したい場合は色々あり、Google CloudでもEvictionを回避する手段を用意してくれています。
以下のようなannotationを書くことによって回避することが出来ます。</p>
<p><figure class="figure-image figure-image-fotolife" title="safe-to-evictアノテーションを利用してevictionを拒否する"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231212/20231212110017.png" width="800" height="480" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>safe-to-evictアノテーションを利用してevictionを拒否する</figcaption></figure></p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">metadata</span><span class="synSpecial">:</span>
<span class="synIdentifier">annotations</span><span class="synSpecial">:</span>
<span class="synIdentifier">cluster-autoscaler.kubernetes.io/safe-to-evict</span><span class="synSpecial">:</span> <span class="synConstant">"false"</span>
</pre>
<p>しかし、このevictionを回避すると別の問題が出てきてしまいます。</p>
<h2 id="Evictionを回避することによる問題">Evictionを回避することによる問題</h2>
<p>Evictionを回避することによって実際に発生した問題を見てみましょう。
弊チームにはメモリが膨大に必要なバッチからメモリ利用量は少ないが長時間かかるバッチまで様々なバッチがあります。
そこで以下のようなケースを考えてみます。</p>
<ol>
<li>巨大なNodeに巨大なPodが実行される。</li>
<li>そのNodeにメモリ利用量が少なくて長時間かかるPodが迷い込む</li>
<li>巨大なPodが終了する</li>
<li>メモリは全然必要ないのに長時間かかるPodが残り続ける</li>
<li>evictionが拒否されるため、巨大なNodeはずっと残り続ける</li>
</ol>
<p><figure class="figure-image figure-image-fotolife" title="safe-to-evictによって高価なNodeが残り続ける"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231212/20231212110021.png" width="708" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>safe-to-evictによって高価なNodeが残り続ける</figcaption></figure></p>
<p>この様にevictionを回避した結果、高価なNodeがずっと残り続け課金額が跳ね上がります。</p>
<p>そこで弊チームでの回避方法をどうしたかを見てみましょう。
PodにはnodeSelectorという値を指定できます。
nodeSelectorはPodが載ってもよいNodeの種類を選ぶというものです。
そして、NodePoolにはリソースが少ないNode用のものからリソースが多いNode用のものまで用意します。
これによってある程度料金とNodeのスケールを指定することが出来ました!(手動で)</p>
<p><figure class="figure-image figure-image-fotolife" title="Podが配置されるNodeを指定する"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231212/20231212110033.png" width="800" height="746" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Podが配置されるNodeを指定する</figcaption></figure></p>
<p>Nodeについてはもっとメモリが欲しいという要望があったり、GPUを載せたNodeがほしいという要望があったり様々です。
そして、Spot VMのような料金が安いNodeなども用意してデフォルトではそちらを使うようにするなどしたりしました。
結果NodePoolを管理する数が増えていき、人間が考えなければいけないNodeが増えていきました。。</p>
<p>GKEにはStandardモードとは別にAutopilotモードがあります。
AutopilotモードはNodePoolの管理をユーザーがしなくても良いモードになります。
これを利用すれば、このNode管理地獄から抜け出せそうです!</p>
<h2 id="Autopilotは銀の弾丸になるか">Autopilotは銀の弾丸になるか</h2>
<p>さて、Autopilotがどのようなものかを見てみましょう。
GKEのStandardがNodeのVMの料金課金だったのに対して、Autopilotは各Podのリソース料金課金になります。
つまり、我々が今まで管理していたNodeの空き容量があると、小さなPodが迷い込んでNodeの課金額がとか言っていたのがPodにしか課金されないので気にしなくても良くなります。
さあ、これですべて解決したと思われたその時</p>
<p><strong>Autopilotではsafe-to-evictが利用できません</strong></p>
<p>そう、振り出しに戻ります。
safe-to-evictの裏側を考えると、Google Cloudのリソースの最適化とは方向性が違ってくるでしょうし、Autopilotでの展開の難しさは想像に難くありません。
そのため、現時点のAI・機械学習チームでは、時間のかかるJobのAutopilotモードの利用に踏み切れていませんでした。</p>
<p>このブログをこれで締めようと思いつつ、ファクトチェックのために色々調べていたらこのような記事に出会います。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcloud.google.com%2Fkubernetes-engine%2Fdocs%2Fhow-to%2Fextended-duration-pods" title="Extend the run time of Autopilot Pods | Google Kubernetes Engine (GKE) | Google Cloud" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://cloud.google.com/kubernetes-engine/docs/how-to/extended-duration-pods">cloud.google.com</a></cite></p>
<p>なんとAutopilotでもsafe-to-evict利用できるようになっている!!??
我々的には超重大ニュースが、7月くらいに出てました。気づくのに4ヶ月かかりました。。
注意点としてはevictionを防げるのは7日間だけということです。</p>
<p>自分たちの用途としては7日以上かかるJobは数個しかないので、Autopilot行けるかとなり試して見ようとしました。そして、、、</p>
<p><strong>safe-to-evictはSpot VMでは利用できません</strong></p>
<p>Spot VMというのは可用性が保証されないかわりに通常のVMに比べて非常に安価に利用できるインスタンスです。
Spot VMは最小で60%の割引、最大で91%の割引が適用されます。
可用性が保証されないとはいえ、そこまでスケールインが多発するわけでもないのでStandardモードでは多くのJobがSpot VMでsafe-to-evictを利用していました。
Spot VMがsafe-to-evictで利用できる事を完全に保証をするのは難しそうですし、Google Cloud上でも利用できないのが現状です<a href="#f-ef205147" id="fn-ef205147" name="fn-ef205147" title="記事執筆時点 2023/12/11">*1</a>。
個人的な感覚では、Spot VMのスケールインよりもevictionの方が頻度が多いと思っています。
なので、本音を言えば「safe-to-evictを完全には保証出来ないが内部のevictionを発生しない」といった方向性で良いので利用させて欲しい所ですが、これはこれで保証のラインが難しそうだなと思いながら情報ウォッチしています。</p>
<p>というわけで料金最適化のためにStandardに残り続けるか、NodePoolの開放を求めてAutopilot(非Spot VM)に行くか
我々のGKEでのMLOpsはまだまだこれからだぜという事で締めさせてもらいます。</p>
<h2 id="まとめ">まとめ</h2>
<p>Kubernetes上でのMLはAPIのような利用とは異なることを考える必要があり、なかなか難しいです。
しかし、Kubernetes上に集めることによって監視などを自動化するなど嬉しい点もたくさんあります。
何よりKubernetesをいじれる環境というのはとても楽しいです。
今後もAutopilotの進化を見つつ、いつでも乗り換え出来るようなインフラの作り方を心がけていきたいと思います。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2Fai-slo-monitoring" title="MLのバッチでもSLO監視はじめました。 - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/ai-slo-monitoring">www.m3tech.blog</a></cite></p>
<h2 id="We-are-hiring">We are hiring!!</h2>
<p>AI・機械学習チームでは、自分たちでGKEクラスタを運用しながら積極的にMLOps基盤開発に取り組んでいます。
システム全体を見通して基盤を作っていきたいMLOpsエンジニアを募集しています!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<div class="footnote">
<p class="footnote"><a href="#fn-ef205147" id="f-ef205147" name="f-ef205147" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">記事執筆時点 2023/12/11</span></p>
</div>
kitagry
機械学習モデルのA/BテストをしやすくするGo言語のAPI設計
hatenablog://entry/6801883189063523655
2023-12-11T17:00:00+09:00
2023-12-11T17:00:34+09:00 こちらはエムスリー Advent Calendar 2023 11日目の記事です。 DALL-Eでサムネ作るの楽勝だぜとなりそうでならない Overview A/Bテストをしまくっている、機械学習エンジニアの農見(@rookzeno)です。皆さんA/Bテストをしてますでしょうか。エムスリーでは色々な施策の効果を見るために沢山のA/Bテストをしています。そのためA/Bテストを簡易にできるような設計を作ることも大事なことです。 AI・機械学習チームには、Goで書かれた機械学習関連の機能を各サービスに提供するAPIサーバがあり、こちらのYAMLファイルを設定するだけでA/Bテストが出来るようにしまし…
<p>こちらは<a href="https://qiita.com/advent-calendar/2023/m3">エムスリー Advent Calendar 2023</a> 11日目の記事です。</p>
<p><figure class="figure-image figure-image-fotolife" title="DALL-Eでサムネ作るの楽勝だぜとなりそうでならない"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231211/20231211170029.jpg" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>DALL-Eでサムネ作るの楽勝だぜとなりそうでならない</figcaption></figure></p>
<h1 id="Overview">Overview</h1>
<p>A/Bテストをしまくっている、機械学習エンジニアの農見(<a href="https://twitter.com/rookzeno">@rookzeno</a>)です。皆さんA/Bテストをしてますでしょうか。エムスリーでは色々な施策の効果を見るために沢山のA/Bテストをしています。そのためA/Bテストを簡易にできるような設計を作ることも大事なことです。</p>
<p>AI・機械学習チームには、Goで書かれた機械学習関連の機能を各サービスに提供するAPIサーバがあり、こちらのYAMLファイルを設定するだけでA/Bテストが出来るようにしました。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">rules</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> modelA
<span class="synIdentifier">random_seed</span><span class="synSpecial">:</span> <span class="synConstant">42</span>
<span class="synIdentifier">threshold</span><span class="synSpecial">:</span> <span class="synConstant">50</span>
<span class="synIdentifier">ctrl</span><span class="synSpecial">:</span>
<span class="synIdentifier">weight</span><span class="synSpecial">:</span> <span class="synConstant">0</span>
<span class="synIdentifier">test</span><span class="synSpecial">:</span>
<span class="synIdentifier">weight</span><span class="synSpecial">:</span> <span class="synConstant">1</span>
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> modelB
<span class="synIdentifier">weight</span><span class="synSpecial">:</span> <span class="synConstant">1</span>
</pre>
<p>このYAMLファイルをどのようにGoのAPIで使ってるかを今回は解説します。</p>
<ul class="table-of-contents">
<li><a href="#Overview">Overview</a></li>
<li><a href="#はじめに--全体の構成について">はじめに : 全体の構成について</a></li>
<li><a href="#0-YAMLファイルの解説">0. YAMLファイルの解説</a></li>
<li><a href="#1-YAMLファイルを読み込む">1. YAMLファイルを読み込む</a></li>
<li><a href="#2-DBからデータを取り出す">2. DBからデータを取り出す</a></li>
<li><a href="#3-この方法の良い所と悪い所">3. この方法の良い所と悪い所</a><ul>
<li><a href="#We-are-hiring">We are hiring!</a></li>
</ul>
</li>
</ul>
<h1 id="はじめに--全体の構成について">はじめに : 全体の構成について</h1>
<p>このYAMLファイルをGoのAPIでどう扱っているかという話の前に、AIチームのMLプロダクトの全体構成について説明します。</p>
<p>AIチームでは「バッチで学習・推論して結果をDBに保存しておき、APIはDBの参照のみ行う(リクエスト時に推論をしない)」という構成をよく採用しています。今回はこの構成であることを前提としたコードになりますが、リクエスト時に推論する場合でも同じやり方はできると思います。</p>
<p><figure class="figure-image figure-image-fotolife" title="AIチームあるある構成(現代)"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231211/20231211170025.png" width="800" height="449" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>AIチームあるある構成(現代)</figcaption></figure></p>
<p><a href="https://www.m3tech.blog/entry/ai-mlops-history">AI・機械学習チーム流MLOpsの歴史 - エムスリーテックブログ</a>より</p>
<h1 id="0-YAMLファイルの解説">0. YAMLファイルの解説</h1>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">rules</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> modelA
<span class="synIdentifier">randomseed</span><span class="synSpecial">:</span> <span class="synConstant">42</span>
<span class="synIdentifier">threshold</span><span class="synSpecial">:</span> <span class="synConstant">50</span>
<span class="synIdentifier">ctrl</span><span class="synSpecial">:</span>
<span class="synIdentifier">weight</span><span class="synSpecial">:</span> <span class="synConstant">0</span>
<span class="synIdentifier">test</span><span class="synSpecial">:</span>
<span class="synIdentifier">weight</span><span class="synSpecial">:</span> <span class="synConstant">1</span>
<span class="synStatement">- </span><span class="synIdentifier">name</span><span class="synSpecial">:</span> modelB
<span class="synIdentifier">weight</span><span class="synSpecial">:</span> <span class="synConstant">1</span>
</pre>
<p>まずこのYAMLファイルが何を示しているのかを説明します。ctrl50%ではmodelA × 0 + modelB × 1、test50%ではmodelA × 1 + modelB × 1のアンサンブルを行うという設定です。randomseedという設定があると思いますが、これはユーザーを分ける関数のseedになります。ctrlとtestには有意差がないように分ける必要があるので、適切なrandomseedを毎回選ぶ必要があります。エムスリーでは毎回そのA/Bテストにとって最適な任意の関数とrandomseedを選んでA/Bテストをしています。</p>
<p>このテーブルの例で具体的に説明します。</p>
<table>
<thead>
<tr>
<th>id</th>
<th> modelA </th>
<th> modelB</th>
<th>ctrl</th>
<th>test</th>
</tr>
</thead>
<tbody>
<tr>
<td> 1 </td>
<td> 100 </td>
<td>10 </td>
<td>10 </td>
<td>110 </td>
</tr>
<tr>
<td> 2 </td>
<td> 0 </td>
<td>15 </td>
<td>15 </td>
<td>15 </td>
</tr>
</tbody>
</table>
<p>modelAはid1に100点、id2に0点をつけています。modelBはid1に10点、id2に15点つけてます。この時ctrlではmodelBのみなのでid1に10点、id2に15点となりid2>id1なのでid2,id1という順番で表示します。一方でtestではmodelAとmodelBの足し算なのでid1に110点、id2に15点となりid1,id2という順番で出すことになります。このように新たなモデルを追加するとレコメンド結果が変わりその効果を見るのがA/Bテストです。</p>
<h1 id="1-YAMLファイルを読み込む">1. YAMLファイルを読み込む</h1>
<p>ここからGoのコードでどのように処理してるかを見ていきます。まずはYAMLファイルを読み込むところからです。</p>
<p>YAMLファイルをGoで読み込みには以下のように書けばいいです。</p>
<pre class="code lang-go" data-lang="go" data-unlink>
<span class="synStatement">import</span> (
<span class="synConstant">"context"</span>
<span class="synConstant">"fmt"</span>
<span class="synConstant">"io"</span>
<span class="synConstant">"log/slog"</span>
<span class="synConstant">"os"</span>
<span class="synConstant">"gopkg.in/yaml.v3"</span>
)
<span class="synStatement">type</span> Config <span class="synStatement">struct</span> {
Rules []ruleConfig <span class="synConstant">`yaml:"rules"`</span>
}
<span class="synStatement">type</span> ruleConfig <span class="synStatement">struct</span> {
WeightValue <span class="synConstant">`yaml:",inline"`</span>
Name <span class="synType">string</span> <span class="synConstant">`yaml:"name"`</span>
RandomSeed *<span class="synType">int</span> <span class="synConstant">`yaml:"randomseed"`</span>
Threshold *<span class="synType">int</span> <span class="synConstant">`yaml:"threshold"`</span>
Ctrl WeightValue <span class="synConstant">`yaml:"ctrl"`</span>
Test WeightValue <span class="synConstant">`yaml:"test"`</span>
}
<span class="synStatement">type</span> WeightValue <span class="synStatement">struct</span> {
Weight *<span class="synType">float64</span> <span class="synConstant">`yaml:"weight"`</span>
}
<span class="synStatement">func</span> ReadYaml(ctx context.Context, r <span class="synType">io.Reader</span>) (Config, <span class="synType">error</span>) {
<span class="synStatement">var</span> config Config
err := yaml.NewDecoder(r).Decode(&config)
<span class="synStatement">if</span> err != <span class="synStatement">nil</span> {
<span class="synStatement">return</span> config, fmt.Errorf(<span class="synConstant">"failed to decode yaml: %v"</span>, err)
}
<span class="synStatement">return</span> config, <span class="synStatement">nil</span>
}
<span class="synStatement">func</span> main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, <span class="synStatement">nil</span>))
configFile, err := os.Open(<span class="synConstant">"config.yaml"</span>)
<span class="synStatement">if</span> err != <span class="synStatement">nil</span> {
logger.Error(<span class="synConstant">"Cannot open config file"</span>, err)
}
config, err := ReadYaml(context.Background(), configFile)
configFile.Close()
<span class="synStatement">if</span> err != <span class="synStatement">nil</span> {
logger.Error(<span class="synConstant">"Cannot create config.Config"</span>, err)
}
fmt.Println(config)
}
</pre>
<p>ReadYamlという関数でYAMLファイルを読み込んでます。Configという構造体を作ってタグをつけると、<code>yaml.NewDecoder(r).Decode(&config)</code>で自動的にパースして構造体に入れてくれるので便利です。</p>
<p>これでYAMLファイルをconfigという構造体に入れることができました。</p>
<p>このconfigを使ってDBに入ったデータを取ってきます。</p>
<h1 id="2-DBからデータを取り出す">2. DBからデータを取り出す</h1>
<pre class="code lang-go" data-lang="go" data-unlink><span class="synStatement">import</span> (
<span class="synConstant">"database/sql"</span>
<span class="synConstant">"fmt"</span>
<span class="synConstant">"strings"</span>
<span class="synConstant">"github.com/jmoiron/sqlx"</span>
)
<span class="synStatement">type</span> Content <span class="synStatement">struct</span> {
ID <span class="synType">string</span> <span class="synConstant">`db:"id"`</span>
Score sql.NullFloat64 <span class="synConstant">`db:"score"`</span>
}
<span class="synStatement">type</span> Rule <span class="synStatement">struct</span> {
Name <span class="synType">string</span>
Weight <span class="synType">float64</span>
}
<span class="synStatement">type</span> DB <span class="synStatement">struct</span> {
pool *sqlx.DB
}
<span class="synComment">// テスト判定 簡易化のためuserIDにrandomSeed値を掛ける方法でやってますが、好きな方法でやってください</span>
<span class="synStatement">func</span> isTest(userID <span class="synType">int</span>, randomSeed <span class="synType">int</span>, threshold <span class="synType">int</span>) <span class="synType">bool</span> {
<span class="synStatement">return</span> userID*randomSeed%<span class="synConstant">100</span> < threshold
}
<span class="synComment">// user_idが属するruleのみを取得する</span>
<span class="synStatement">func</span> (c Config) GetRules(userID <span class="synType">int</span>) []Rule {
result := <span class="synStatement">make</span>([]Rule, <span class="synConstant">0</span>, <span class="synStatement">len</span>(c.Rules))
<span class="synStatement">for</span> _, r := <span class="synStatement">range</span> c.Rules {
r, ok := r.GetRule(userID)
<span class="synStatement">if</span> !ok {
<span class="synStatement">continue</span>
}
result = <span class="synStatement">append</span>(result, r)
}
<span class="synStatement">return</span> result
}
<span class="synStatement">func</span> (r ruleConfig) GetRule(userID <span class="synType">int</span>) (Rule, <span class="synType">bool</span>) {
rule := Rule{
Name: r.Name,
}
<span class="synStatement">if</span> r.Threshold == <span class="synStatement">nil</span> {
rule.Weight = *r.Weight
<span class="synStatement">return</span> rule, <span class="synStatement">true</span>
}
target := r.Ctrl
<span class="synStatement">if</span> isTest(userID, *r.RandomSeed , *r.Threshold) {
target = r.Test
}
<span class="synStatement">if</span> target.Weight == <span class="synStatement">nil</span> {
<span class="synStatement">return</span> Rule{}, <span class="synStatement">false</span>
}
rule.Weight = *target.Weight
<span class="synStatement">return</span> rule, <span class="synStatement">true</span>
}
<span class="synStatement">func</span> (d *DB) LoadScores(userID <span class="synType">int</span>, config Config) ([]Content, <span class="synType">error</span>) {
rules := config.GetRules(userID)
sqls := <span class="synStatement">make</span>([]<span class="synType">string</span>, <span class="synConstant">0</span>, <span class="synStatement">len</span>(rules))
args := <span class="synStatement">make</span>([]any, <span class="synConstant">0</span>)
<span class="synComment">// rulesに書かれているscoreをUNION ALLで全て出す</span>
<span class="synStatement">for</span> _, c := <span class="synStatement">range</span> rules {
sqls = <span class="synStatement">append</span>(sqls, fmt.Sprintf(<span class="synConstant">`SELECT id, score * ? as score FROM %s_score WHERE user_id = ?`</span>, c.Name))
args = <span class="synStatement">append</span>(args, c.Weight, userID)
}
sql := strings.Join(sqls, <span class="synConstant">" UNION ALL "</span>)
<span class="synComment">// scoreを足し算する</span>
sql = fmt.Sprintf(<span class="synConstant">"SELECT id, SUM(score) AS score FROM (%s) GROUP BY id"</span>, sql)
<span class="synStatement">var</span> contents []Content
err := d.pool.Select(&contents, sql, args...)
<span class="synStatement">if</span> err != <span class="synStatement">nil</span> {
<span class="synStatement">return</span> <span class="synStatement">nil</span>, err
}
<span class="synStatement">return</span> contents, <span class="synStatement">nil</span>
}
</pre>
<p>こちらは大きく分けて2段階に分かれています。最初がconfig構造体からuserに対するRuleのスライス(rules)を取得する部分。次がSQLにする部分です。</p>
<p>configにはtestやctrl等書いてありますが、ユーザー単位に落とすときにはその情報は必要ないです。なのでGetRulesでユーザーがtestかctrlどっちになるかを見て、NameとWeightのみをもつrulesにします。</p>
<p>rulesが出来たら後はSQLにするだけです。Goで見ると少し複雑ですが、SQLで書くとこんな感じです。</p>
<pre class="code lang-sql" data-lang="sql" data-unlink><span class="synStatement">SELECT</span> id, <span class="synIdentifier">SUM</span>(score) <span class="synSpecial">AS</span> score
<span class="synSpecial">FROM</span> (
<span class="synStatement">SELECT</span> id, score * weight
<span class="synSpecial">FROM</span> modelA_score <span class="synSpecial">WHERE</span> user_id = ?
<span class="synStatement">UNION</span> <span class="synStatement">ALL</span>
<span class="synStatement">SELECT</span> id, score * weight
<span class="synSpecial">FROM</span> modelB_score <span class="synSpecial">WHERE</span> user_id = ?
)
<span class="synSpecial">GROUP</span> <span class="synSpecial">BY</span> id
</pre>
<p>rulesに入っているモデルのscoreを全部出してgroupbyでsumしてるだけです。</p>
<p>これでYAMLファイルでA/Bテストができました。めでたしめでたし。</p>
<h1 id="3-この方法の良い所と悪い所">3. この方法の良い所と悪い所</h1>
<ul>
<li><p>良い所</p>
<ul>
<li>YAMLファイルを見るだけでテスト内容がわかる</li>
<li>バッチ側が独立しているので好き勝手にモデルを作成して試すことができる</li>
</ul>
</li>
<li><p>悪い所</p>
<ul>
<li>テーブル数が増えるにつれてDBの負荷が上がる</li>
</ul>
</li>
</ul>
<p>この方法の悪い所としてはバッチ側で1つのテーブルを作成してABする場合よりもDBの負荷が上がってしまうことですが、API側にロジックを持つことで、バッチ側の複雑性を下げることが出来ます。更に、今なんのモデルを試しているかをYAMLファイルを見るだけでわかるので良いかなと思ってます。</p>
<h2 id="We-are-hiring">We are hiring!</h2>
<p>AI・機械学習チームでは、A/Bテストしやすい環境を整える事も大事にしています。
環境を整えるのが好きな人はもちろん、A/Bテストするための高精度なモデルを作る人も募集しています!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
rookzeno
Looker APIを活用して確実なデプロイを実現させる
hatenablog://entry/6801883189064474417
2023-12-10T06:30:00+09:00
2023-12-10T06:30:18+09:00 これはエムスリーAdvent Calendar 2023 の10日目の記事です。 こんにちは、エンジニアリンググループの石塚です。最近は年明けに控えている結婚式という大イベントに向けてダイエット中でスポーツジムへ通い、有酸素運動するのと並行して食事制限をして追い込んでいる毎日です。2ヶ月ほどで6kg弱の減量を目標に地道に日々目標をスプレッドシートにまとめながら追い込んでます。(今の所良いペースです。) 今回は、弊社で利用しているLookerというBIツールを利用しているなかで発生したつらみの共有と対策について共有します。少しニッチな内容ですが、自分自身が調べているときに同事象で苦しんでいるよう…
<p>これは<a href="https://qiita.com/advent-calendar/2023/m3">エムスリーAdvent Calendar 2023</a> の10日目の記事です。</p>
<p>こんにちは、エンジニアリンググループの石塚です。最近は年明けに控えている結婚式という大イベントに向けてダイエット中でスポーツジムへ通い、有酸素運動するのと並行して食事制限をして追い込んでいる毎日です。2ヶ月ほどで6kg弱の減量を目標に地道に日々目標をスプレッドシートにまとめながら追い込んでます。(今の所良いペースです。)</p>
<p>今回は、弊社で利用しているLookerというBIツールを利用しているなかで発生したつらみの共有と対策について共有します。少しニッチな内容ですが、自分自身が調べているときに同事象で苦しんでいるようなブログ記事が見当たらなかったこともあり、ニッチな人を対象に有益な内容になれば幸いです。<figure class="figure-image figure-image-fotolife" title="11/6からの体重減少とランニング累計の記録をグラフにしました。"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231210/20231210063006.png" width="800" height="496" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>11/6からの体重減少とランニング累計の記録をグラフにしました。</figcaption></figure></p>
<ul class="table-of-contents">
<li><a href="#Lookerとは">Lookerとは?</a></li>
<li><a href="#デプロイに成功したのに想定のバージョンがデプロイされていない">デプロイに成功したのに想定のバージョンがデプロイされていない?</a><ul>
<li><a href="#確認したこと">確認したこと</a></li>
</ul>
</li>
<li><a href="#防止策">防止策</a><ul>
<li><a href="#Get-Project-Workspace">Get Project Workspace</a></li>
</ul>
</li>
<li><a href="#その他Looker-APIの紹介">その他Looker APIの紹介</a><ul>
<li><a href="#Run-Git-Connection-Test">Run Git Connection Test</a></li>
<li><a href="#terraform-provider-looker">terraform-provider-looker</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#We-are-Hiring">We are Hiring</a></li>
</ul>
<h2 id="Lookerとは">Lookerとは?</h2>
<p>LookerはGoogle Cloudが提供するBIツールになります。エムスリーで活用しているBIツールのひとつとしてLookerを採用し顧客向けにダッシュボードを提供しております。LookerではLookML(Looker Modeling Language)という独自の言語を用いてデータモデルやダッシュボードの定義をコードで管理ができます。エムスリーではGitLabと連携してバージョン管理を実現しております。より詳しいアーキテクチャについては以の 「LookerによるダッシュボードのアーキテクチャとTerraform providerの実装と運用」 のブログを参照ください。
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2Flooker-dashboard-architecture" title="LookerによるダッシュボードのアーキテクチャとTerraform providerの実装と運用 - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/looker-dashboard-architecture">www.m3tech.blog</a></cite></p>
<h2 id="デプロイに成功したのに想定のバージョンがデプロイされていない">デプロイに成功したのに想定のバージョンがデプロイされていない?</h2>
<p>前述の通りLookMLをGitLabでバージョン管理をしており、指定ブランチへのマージをトリガーにWebhookでデプロイされる仕組みになっておりました。普段と変わらず追加機能をリリースしWebhookでのデプロイも異常なく完了したためリリース周知をして関係者に対象機能の確認を依頼したところ、「機能追加が反映されていないようです。ご確認をお願いできますでしょうか?」とslackでメンションが飛んできました。</p>
<h3 id="確認したこと">確認したこと</h3>
<p>◯BigQuery上のデータの確認</p>
<p>LookerではBigQuery上のデータを参照しているのでデータ自体に問題があるかをまずは確認しました。ただしこれは問題なし。Looker側からの参照がうまくできていないことが判明。</p>
<p>◯Webhookログの確認</p>
<p>GitLabのWebhookのメニューから指定ブランチをトリガーに実行されたWebhookの実行ログを確認しました。ログ上は正しく実行されており、before/afterのgit commit hashがログから確認できました。この段階ではWebhookが正常しているので何故状態。</p>
<p>◯Deployment Managerを確認</p>
<p>Lookerの管理画面にあるDeployment Managerを確認しました。ここではDeployされているgit commit hashの先頭7桁が確認することでき、これがWebhookログに記載のcommit hashと一致しておりませんでした。Webhookを手動で再実行しても状況は変わらず。。。</p>
<p>◯Test Git Connectionを確認</p>
<p>Lookerの管理画面からTest Git Connectionを実行してGit接続を確認しました。下記キャプチャが当時のGit接続テストの実行結果になります。なんか赤い。。。</p>
<p><figure class="figure-image figure-image-fotolife" title="当時のGit接続テストの結果"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231210/20231210063012.png" width="637" height="441" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>当時のGit接続テストの結果</figcaption></figure></p>
<p>Git連携で問題があることが判明したので、keyを再発行してGit接続を再設定しました。その後Webhookを再送してDeployment Managerを確認するとWebhookログにあるafterのgit commit hashでデプロイされるようになりめでたしめでたしとなりました。</p>
<h2 id="防止策">防止策</h2>
<p>めでたしめでたしで終わらせずに対策を考えましょう。LookerはAPIを提供しており様々な操作や情報の取得をAPIを利用して実現可能となっております。その中で使えそうなAPIを探していると良さげなAPIが存在しました。</p>
<h3 id="Get-Project-Workspace">Get Project Workspace</h3>
<p><a href="https://developers.looker.com/api/explorer/4.0/methods/Project/project_workspace?sdk=py&s=ProjectWorkspace">Looker API Interactive Reference</a></p>
<p>このAPIでは引数で指定したプロジェクトに関する情報が取得できます。その中に「git_head」 というフィールドがあり、これが現在デプロイされている最新のgit commit hashになります。以下のようなプログラムでGitLab側のgit commit hashと比較し、異なる場合はraise Exceptionをするというシンプルなプログラムを作成しました。</p>
<pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">import</span> looker_sdk
<span class="synPreProc">import</span> os
<span class="synStatement">def</span> <span class="synIdentifier">main</span>():
sdk = looker_sdk.init40()
commit_sha = os.environ.get(<span class="synConstant">'CI_COMMIT_SHA'</span>)
<span class="synComment"># ref.https://developers.looker.com/api/explorer/4.0/types/Project/ProjectWorkspace?sdk=py</span>
project_info = sdk.project_workspace(project_id=<span class="synConstant">'hoge-bi'</span>, fields=<span class="synConstant">'git_head'</span>)
looker_git_head = project_info.get(<span class="synConstant">'git_head'</span>)
<span class="synIdentifier">print</span>(f<span class="synConstant">'commit_sha : {commit_sha}'</span>)
<span class="synIdentifier">print</span>(f<span class="synConstant">'looker_git_head : {looker_git_head}'</span>)
<span class="synStatement">if</span> commit_sha != looker_git_head:
<span class="synStatement">raise</span> <span class="synType">Exception</span>(f<span class="synConstant">'There are differences between the commit sha and the looker git head.'</span>
f<span class="synConstant">' commit sha : {commit_sha}, looker git head : {looker_git_head}'</span>)
<span class="synIdentifier">print</span>(<span class="synConstant">'There are no differences between the commit sha and the looker git head.'</span>)
<span class="synStatement">if</span> __name__ == <span class="synConstant">'__main__'</span>:
main()
</pre>
<p>元々はGitLabのWebhookの設定からLooker Webhook Deployの設定をしていたのですが、Deploy後に上のプログラムを実行させるという依存関係を作る必要があるのでgitlab-ciのstageを分けることで依存関係を実現させました。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synIdentifier">stages</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>apply
<span class="synStatement">- </span>postprocess
<span class="synIdentifier">.master-branch-apply-rules</span><span class="synSpecial">:</span>
<span class="synIdentifier">rules</span><span class="synSpecial">:</span>
<span class="synStatement">- </span><span class="synIdentifier">if</span><span class="synSpecial">:</span> <span class="synConstant">'$CI_COMMIT_REF_NAME == "master" && $CI_PIPELINE_SOURCE != "schedule"'</span>
<span class="synIdentifier">changes</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>$RULES_CHANGES_PATH
<span class="synIdentifier">deploy_looker_qa</span><span class="synSpecial">:</span>
<span class="synIdentifier">extends</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>.master-branch-apply-rules
<span class="synIdentifier">variables</span><span class="synSpecial">:</span>
<span class="synIdentifier">RULES_CHANGES_PATH</span><span class="synSpecial">:</span> hoge_bi/**/*
<span class="synIdentifier">stage</span><span class="synSpecial">:</span> apply
<span class="synIdentifier">image</span><span class="synSpecial">:</span> alpine
<span class="synIdentifier">before_script</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>apk add --upgrade curl
<span class="synStatement">- </span>export LOOKER_PROJECT=hoge-bi
<span class="synIdentifier">script</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>set -eu
<span class="synStatement">- </span>curl -fsSL -H <span class="synConstant">"X-Gitlab-Token:${LOOKER_QA_WEBHOOK_SECRET}"</span> <span class="synConstant">"https://hogehoge.cloud.looker.com/webhooks/projects/${LOOKER_PROJECT}/deploy/ref/${CI_COMMIT_SHA}"</span>
<span class="synIdentifier">check_looker_git_head_qa</span><span class="synSpecial">:</span>
<span class="synIdentifier">extends</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>.master-branch-apply-rules
<span class="synIdentifier">variables</span><span class="synSpecial">:</span>
<span class="synIdentifier">RULES_CHANGES_PATH</span><span class="synSpecial">:</span> hoge_bi/**/*
<span class="synIdentifier">stage</span><span class="synSpecial">:</span> postprocess
<span class="synIdentifier">image</span><span class="synSpecial">:</span> python:3.11
<span class="synIdentifier">before_script</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>export LOOKERSDK_BASE_URL="https://hogehoge.cloud.looker.com"
<span class="synStatement">- </span>export LOOKERSDK_CLIENT_ID=${LOOKER_QA_CLIENT_ID}
<span class="synStatement">- </span>export LOOKERSDK_CLIENT_SECRET=${LOOKER_QA_CLIENT_SECRET}
<span class="synStatement">- </span>cd hoge_bi/scripts/check_looker_git_head
<span class="synStatement">- </span>pip install poetry <span class="synType">&&</span> poetry install
<span class="synIdentifier">script</span><span class="synSpecial">:</span>
<span class="synStatement">- </span>poetry run python check_looker_git_head.py
</pre>
<p>これでデプロイエラーが発生した場合は検知ができるようになりました。めでたしめでたし。</p>
<p><figure class="figure-image figure-image-fotolife" title="幸せなCI Success"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231210/20231210063010.png" width="610" height="152" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>幸せなCI Success</figcaption></figure></p>
<h2 id="その他Looker-APIの紹介">その他Looker APIの紹介</h2>
<p>その他のLooker APIに関する紹介になります。</p>
<h3 id="Run-Git-Connection-Test">Run Git Connection Test</h3>
<p><a href="https://developers.looker.com/api/explorer/4.0/methods/Project/run_git_connection_test?sdk=py&s=git">Looker API Interactive Reference</a></p>
<p>デプロイエラーが発生した際の確認でLooker管理画面から実行していたTest Git Connectionの実行ができるAPIになります。これも防止策で使うAPIの案には上がったのですが、git commit hashを見るのが確実だとなったので不採用になりました。</p>
<h3 id="terraform-provider-looker">terraform-provider-looker</h3>
<p><a href="https://github.com/hirosassa/terraform-provider-looker/tree/master">GitHub - hirosassa/terraform-provider-looker: Terraform Provider for managing Looker</a></p>
<p>これはAPIではなくlookerを操作するためののterraform providerプラグインになります。便利に利用しているterraformの裏ではLooker APIがたくさん使われております。弊社ではこのプラグインを利用してLookerのセットアップを管理しております。</p>
<h2 id="まとめ">まとめ</h2>
<p>以上、LookerのDeploy時に発生した想定外のエラー内容とその防止策についてでした。エラーが起きても一つずつ調査をして再発防止を進める、この繰り返しがサービスをブラッシュアップさせていくなと改めて感じました。</p>
<h2 id="We-are-Hiring">We are Hiring</h2>
<p>エムスリーではエンジニアを募集中です。ハードスキル、ソフトスキル共にハイレベルなメンバーに囲まれた環境で日々成長を実感しております。
少しでもご興味を持った方は、以下ページよりカジュアル面談等に申し込み頂ければと思います!</p>
<p><iframe src="https://open.talentio.com/r/1/c/m3-inc/embed/pages/57609" width="100%" height="300" frameborder=0 title="%E3%82%A8%E3%83%A0%E3%82%B9%E3%83%AA%E3%83%BC+%7C+%E3%83%87%E3%83%BC%E3%82%BF%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2"></iframe><cite class="hatena-citation"><a href="https://open.talentio.com/r/1/c/m3-inc/pages/57609">open.talentio.com</a></cite></p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
kozuke1210
年末だしRedashのクエリ棚卸しでもしましょう (調査用サンプルクエリ付き)
hatenablog://entry/6801883189065108172
2023-12-09T11:00:00+09:00
2023-12-09T11:00:11+09:00 これはエムスリー Advent Calendar 2023の9日目の記事です。 前日の記事はSREチーム後藤の「共有会をより効果的にするために考えたあれやこれ」でした。 エムスリーエンジニアリンググループ データ基盤チームの木田です。 師走です。12月といえば年末に向けて大掃除の季節です。 古来日本では12月に宮中で「煤払い」という行事を行う風習があったことが、今日年末の時期に大掃除をする由来とされているそうです。 私の自宅はまだ何も手をつけられていませんが、せめてBIツールの中だけでもということでRedashのクエリ棚卸しをした話*1をご紹介します。 *1:この記事で詳しく書いた事情で年末よ…
<p>これは<a href="https://qiita.com/advent-calendar/2023/m3">エムスリー Advent Calendar 2023</a>の9日目の記事です。
前日の記事はSREチーム後藤の「<a href="https://www.m3tech.blog/entry/2023/12/08/110000">共有会をより効果的にするために考えたあれやこれ</a>」でした。</p>
<p>エムスリーエンジニアリンググループ データ基盤チームの木田です。</p>
<p>師走です。12月といえば年末に向けて大掃除の季節です。
古来日本では12月に宮中で「煤払い」という行事を行う風習があったことが、今日年末の時期に大掃除をする由来とされているそうです。
私の自宅はまだ何も手をつけられていませんが、せめてBIツールの中だけでもということでRedashのクエリ棚卸しをした話<a href="#f-7942b24a" id="fn-7942b24a" name="fn-7942b24a" title="この記事で詳しく書いた事情で年末よりも少し前にやりました">*1</a>をご紹介します。</p>
<ul class="table-of-contents">
<li><a href="#エムスリーにおけるRedashの活用状況">エムスリーにおけるRedashの活用状況</a></li>
<li><a href="#長年の運用の結果起きた性能問題">長年の運用の結果起きた性能問題</a><ul>
<li><a href="#問題の特定と発生原因">問題の特定と発生原因</a></li>
<li><a href="#Workerの増設">Workerの増設</a></li>
</ul>
</li>
<li><a href="#クエリの棚卸しと改善">クエリの棚卸しと改善</a><ul>
<li><a href="#使われていない定期実行クエリの停止">使われていない定期実行クエリの停止</a><ul>
<li><a href="#サンプル1-直近参照なしかつ定期実行クエリ">サンプル1 直近参照なしかつ定期実行クエリ</a></li>
</ul>
</li>
<li><a href="#クエリの実行時間改善">クエリの実行時間改善</a><ul>
<li><a href="#サンプル2-実行時間の長いクエリTop100">サンプル2 実行時間の長いクエリTop100</a></li>
<li><a href="#サンプル3-平均取得件数の多いクエリTop100">サンプル3 平均取得件数の多いクエリTop100</a></li>
</ul>
</li>
<li><a href="#影響範囲の洗い出しクエリの修正">影響範囲の洗い出し、クエリの修正</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#We-are-hiring">We are hiring!</a></li>
</ul>
<h2 id="エムスリーにおけるRedashの活用状況">エムスリーにおけるRedashの活用状況</h2>
<p><a href="https://redash.io/">Redash</a>はオープンソースのBIツールで、データの分析や可視化を簡単に行うことができるツールです。</p>
<p>BigQueryやRDBはもちろん、Athenaなど幅広いデータソースへの接続に対応しています。また、クエリエディタが付属しており、SQLを記述・実行してその結果を表やグラフで可視化が可能です(商用BIツールでよくある視覚的にクエリを構築するようなUIは提供されていません)。良くも悪くもシンプルなツールですが、SQLの基礎知識さえあれば無償で利用できて、主要機能が揃っているところが個人的には好みです。</p>
<p>エムスリーには、プロダクトマネージャーやディレクターなどエンジニア以外の職種でもSQLを読み書きできる人が多数在籍おり、クエリ共有+簡易的可視化ツールとして、アクセスログや記事のPV情報の集計・各種KPIの共有などの幅広いユースケースで有効活用されていると感じます。</p>
<p>なお、主に社内向けの分析ツールということもあり、システム基盤はEC2インスタンス1台+クエリ実行のWorker (ECS Fargate spotインスタンス) という必要最低限の構成で運用しています。</p>
<h2 id="長年の運用の結果起きた性能問題">長年の運用の結果起きた性能問題</h2>
<p>そんなRedashですがかれこれ5年以上運用を続けてきた結果、性能の問題が頻発するようになりました。
具体的にはピーク時にダッシュボードのRefreshやクエリの実行が長時間待たされるような状況です。(弊社の場合だと平日の午前中がピークタイムでした)</p>
<p><figure class="figure-image figure-image-fotolife" title="Redashがぐるぐるして返ってこない問題"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231209/20231209110005.png" width="800" height="127" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Redashがぐるぐるして返ってこない問題</figcaption></figure></p>
<p>これまでインスタンスの再起動などでだましだまし対応してきたものの、発生頻度が多くなり本格的な調査とクエリの棚卸しをしました。</p>
<h3 id="問題の特定と発生原因">問題の特定と発生原因</h3>
<p>Redash管理者が、Redashの状況 (例えばクエリキューの実装であるRedisのメモリ使用量やQueueに積まれたジョブ数など)が確認できるSystem Statusという画面が (URLは <code>/admin/status</code>) 提供されており、 まずはそこを確認することになります。</p>
<p>今回の性能問題の原因は、クエリ実行待ちのQueueに実行待ちクエリが大量に積まれていて、その結果利用者から見るといつまでもRefreshが終わらないという状態でした。Redashではユーザーが実行したクエリは一度<code>queries</code>, <code>scheduled_queries</code>, <code>periodic</code>等のQueueに登録されて、Workerがそれを逐次消化するという処理になっています。</p>
<h3 id="Workerの増設">Workerの増設</h3>
<p>即効性のある対応かつ正攻法として、Worker数を増やすという対応が考えられます。まずは滞留解消のためにWorkerの増設をしました。一方で、問題発生の度に野放図に増やしてしまうとコスト増になるので、処理の滞留原因となったクエリについても見直しすることにしました。</p>
<h2 id="クエリの棚卸しと改善">クエリの棚卸しと改善</h2>
<p>Redashでは、クエリの定義、クエリの実行履歴、ユーザー一覧のようなメタデータが「re:dash metadata」というデータソースでデフォルトで利用できます。今回はこのメタデータを検索することで後述のようなクエリの棚卸しを実施しました。</p>
<p><figure class="figure-image figure-image-fotolife" title="デフォルトで利用できるredashのメタデータ検索用データソース"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231209/20231209110008.png" width="754" height="458" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>デフォルトで利用できるredashのメタデータ検索用データソース</figcaption></figure></p>
<h3 id="使われていない定期実行クエリの停止">使われていない定期実行クエリの停止</h3>
<p>たくさんありました。Redashには作成したクエリを日次等で定期実行する機能があり、定期実行で登録されたまま放置されたクエリが多々ありました。特に平日の午前中にスケジュールされたものが多く、上記のピーク時に重なってクエリの渋滞を起こしていました。</p>
<p>クエリやダッシュボードからの参照が直近無く、かつ定期実行されているクエリを下記のようなSQLでリストアップして定期実行を停止しました。</p>
<h4 id="サンプル1-直近参照なしかつ定期実行クエリ">サンプル1 直近参照なしかつ定期実行クエリ</h4>
<pre class="code lang-sql" data-lang="sql" data-unlink><span class="synStatement">select</span>
q.id,
q.name,
u.name <span class="synSpecial">as</span> owner,
u.email <span class="synSpecial">as</span> owner_email,
qe.last_access_time,
qe.year_uu, <span class="synComment">-- 直近1年間の利用者数 (クエリ)</span>
de.year_uu <span class="synSpecial">as</span> dashboard_year_uu, <span class="synComment">-- 直近1年間の利用者数 (ダッシュボード経由)</span>
de.related_dashboards, <span class="synComment">-- クエリを組み込んでいるダッシュボードのリスト</span>
q.schedule
<span class="synSpecial">from</span> queries q
<span class="synSpecial">join</span> users u <span class="synSpecial">on</span> q.user_id = u.id
<span class="synSpecial">left</span> <span class="synSpecial">join</span> (
<span class="synStatement">select</span>
<span class="synIdentifier">cast</span>(object_id <span class="synSpecial">as</span> <span class="synType">integer</span>) <span class="synSpecial">as</span> query_id,
<span class="synIdentifier">max</span>(<span class="synSpecial">case</span> <span class="synSpecial">when</span> action <span class="synStatement">in</span> (<span class="synSpecial">'</span><span class="synConstant">create</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">view</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">view_source</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">execute</span><span class="synSpecial">'</span>) <span class="synSpecial">then</span> events.created_at <span class="synSpecial">else</span> <span class="synSpecial">null</span> <span class="synSpecial">end</span>) <span class="synSpecial">as</span> last_access_time,
<span class="synIdentifier">count</span>(<span class="synStatement">distinct</span> <span class="synSpecial">case</span> <span class="synSpecial">when</span> events.created_at > now() - interval <span class="synSpecial">'</span><span class="synConstant">1 year</span><span class="synSpecial">'</span> <span class="synSpecial">then</span> events.user_id <span class="synSpecial">else</span> <span class="synSpecial">null</span> <span class="synSpecial">end</span>) <span class="synSpecial">as</span> year_uu
<span class="synSpecial">from</span> events
<span class="synSpecial">where</span>
object_type = <span class="synSpecial">'</span><span class="synConstant">query</span><span class="synSpecial">'</span> <span class="synStatement">and</span> <span class="synIdentifier">coalesce</span>(events.object_id, <span class="synSpecial">''</span>) <> <span class="synSpecial">''</span>
<span class="synSpecial">group</span> <span class="synSpecial">by</span> object_id
) qe <span class="synSpecial">on</span> q.id = qe.query_id
<span class="synSpecial">left</span> <span class="synSpecial">join</span> (
<span class="synStatement">select</span>
visualizations.query_id <span class="synSpecial">as</span> query_id,
string_agg(<span class="synStatement">distinct</span> dashboards.id || <span class="synSpecial">'</span><span class="synConstant">_</span><span class="synSpecial">'</span> || dashboards.name, <span class="synSpecial">'</span><span class="synConstant">\n</span><span class="synSpecial">'</span>) <span class="synSpecial">as</span> related_dashboards,
<span class="synIdentifier">max</span>(<span class="synSpecial">case</span> <span class="synSpecial">when</span> action <span class="synStatement">in</span> (<span class="synSpecial">'</span><span class="synConstant">create</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">view</span><span class="synSpecial">'</span>, <span class="synSpecial">'</span><span class="synConstant">edit</span><span class="synSpecial">'</span>) <span class="synSpecial">then</span> events.created_at <span class="synSpecial">else</span> <span class="synSpecial">null</span> <span class="synSpecial">end</span>) <span class="synSpecial">as</span> last_access_time,
<span class="synIdentifier">count</span>(<span class="synStatement">distinct</span> <span class="synSpecial">case</span> <span class="synSpecial">when</span> events.created_at > now() - interval <span class="synSpecial">'</span><span class="synConstant">1 year</span><span class="synSpecial">'</span> <span class="synSpecial">then</span> events.user_id <span class="synSpecial">else</span> <span class="synSpecial">null</span> <span class="synSpecial">end</span>) <span class="synSpecial">as</span> year_uu
<span class="synSpecial">from</span> events
<span class="synSpecial">join</span> dashboards <span class="synSpecial">on</span> <span class="synIdentifier">cast</span>(events.object_id <span class="synSpecial">as</span> <span class="synType">integer</span>) = dashboards.id <span class="synStatement">and</span> <span class="synStatement">not</span> dashboards.is_archived
<span class="synSpecial">join</span> widgets <span class="synSpecial">on</span> dashboards.id = widgets.dashboard_id
<span class="synSpecial">join</span> visualizations <span class="synSpecial">on</span> widgets.visualization_id = visualizations.id
<span class="synSpecial">where</span>
events.object_type = <span class="synSpecial">'</span><span class="synConstant">dashboard</span><span class="synSpecial">'</span> <span class="synStatement">and</span> <span class="synIdentifier">coalesce</span>(events.object_id, <span class="synSpecial">''</span>) <> <span class="synSpecial">''</span>
<span class="synSpecial">group</span> <span class="synSpecial">by</span> visualizations.query_id
) de <span class="synSpecial">on</span> q.id = de.query_id
<span class="synSpecial">where</span>
<span class="synStatement">not</span> q.is_archived
<span class="synStatement">and</span> q.schedule <span class="synSpecial">is</span> <span class="synStatement">not</span> <span class="synSpecial">null</span>
<span class="synStatement">and</span> qe.last_access_time < <span class="synSpecial">'</span><span class="synConstant">yyyy-mm-dd</span><span class="synSpecial">'</span> <span class="synComment">-- 最終アクセスが古い (日付部分は適宜変更してください)</span>
<span class="synSpecial">order</span> <span class="synSpecial">by</span> last_access_time
</pre>
<p>棚卸し前はおよそ400件のクエリが定期実行されていましたが、うち120件余り(およそ3, 4件に1件)の定期実行を止めました。</p>
<h3 id="クエリの実行時間改善">クエリの実行時間改善</h3>
<p>workerの数が少ない状況下では特定のクエリの実行に時間がかかると、その間後続のクエリ実行が滞留してしまいます。
まずは実行時間が長いクエリを洗い出します。</p>
<h4 id="サンプル2-実行時間の長いクエリTop100">サンプル2 実行時間の長いクエリTop100</h4>
<pre class="code lang-sql" data-lang="sql" data-unlink><span class="synSpecial">with</span>
query_stats <span class="synSpecial">as</span> (
<span class="synStatement">select</span>
query_hash,
<span class="synIdentifier">round</span>(<span class="synIdentifier">cast</span>(<span class="synIdentifier">avg</span>(runtime) <span class="synSpecial">as</span> <span class="synType">numeric</span>), <span class="synConstant">2</span>) <span class="synSpecial">as</span> avg_runtime <span class="synComment">-- 平均実行時間</span>
<span class="synSpecial">from</span>
query_results
<span class="synSpecial">where</span>
retrieved_at <span class="synStatement">between</span> <span class="synSpecial">'</span><span class="synConstant">yyyy-mm-dd</span><span class="synSpecial">'</span> <span class="synStatement">and</span> <span class="synSpecial">'</span><span class="synConstant">yyyy-mm-dd</span><span class="synSpecial">'</span> <span class="synComment">-- 期間指定</span>
<span class="synSpecial">group</span> <span class="synSpecial">by</span>
query_hash
)
<span class="synStatement">select</span>
q.id,
q.name,
st.avg_runtime
<span class="synSpecial">from</span> queries q
<span class="synSpecial">join</span> query_stats st <span class="synSpecial">using</span> (query_hash)
<span class="synSpecial">where</span>
<span class="synStatement">not</span> q.is_archived
<span class="synSpecial">order</span> <span class="synSpecial">by</span>
avg_runtime <span class="synSpecial">desc</span>
limit <span class="synConstant">100</span>;
</pre>
<p>実行時間がかかるクエリは大きく分けると以下の2パターンで発生します。</p>
<ul>
<li>DBやDWH上での実行に時間がかかるクエリ</li>
<li>必要以上に大量のデータを取得しているクエリ</li>
</ul>
<p>前者ついてはSQLのチューニング等を個別に検討することになります。個別対応ゆえ、今回は割愛します。</p>
<p>後者について、RedashのWorkerはクエリ実行の結果をJSON形式でDBに保存するため、単純なクエリであっても件数が多いとWorkerがデータを取得してRedashのDBに書き込む部分がボトルネックとなり実行時間がかかります。</p>
<p>Redashで表やグラフ形式でクエリ結果を可視化することを考えたときに、人間が一目で処理できる情報量には限度があります。必要以上にデータを表示したり、グラフ上にプロットするのはブラウザの負荷増にもなるため避けたほうが良いでしょう。適宜集計処理やLimit句で制限をかけることで取得する件数を抑られることが期待されます。</p>
<p>例えば新着数百件のデータを表で参照するユースケースであるにもかかわらず全件(数十万件)のデータを取得・保存しているケースなどがありました。こうしたクエリは実行時間がかかるだけではなく、画面の表示やDBのストレージにも優しくないので件数制限を個別に行いました。</p>
<p>上記長時間実行クエリの発展形で、取得件数の多いクエリをリストアップするサンプルです。クエリ実行結果のJSONから件数を抽出して集計しているのでかなり重たいクエリになります。利用のピーク時を外した時間帯で実行するようにご留意ください。</p>
<h4 id="サンプル3-平均取得件数の多いクエリTop100">サンプル3 平均取得件数の多いクエリTop100</h4>
<pre class="code lang-sql" data-lang="sql" data-unlink><span class="synSpecial">with</span> query_dashboard <span class="synSpecial">as</span> (
<span class="synStatement">select</span>
q.id,
string_agg(d.id || <span class="synSpecial">'</span><span class="synConstant">_</span><span class="synSpecial">'</span> || d.name, <span class="synIdentifier">chr</span>(<span class="synConstant">10</span>)) <span class="synSpecial">as</span> related_dashboards
<span class="synSpecial">from</span>
queries q
<span class="synSpecial">left</span> <span class="synSpecial">join</span> visualizations viz <span class="synSpecial">on</span> viz.query_id = q.id
<span class="synSpecial">left</span> <span class="synSpecial">join</span> widgets w <span class="synSpecial">on</span> viz.id = w.visualization_id
<span class="synSpecial">left</span> <span class="synSpecial">join</span> dashboards d <span class="synSpecial">on</span> w.dashboard_id = d.id
<span class="synStatement">and</span> <span class="synStatement">not</span> d.is_archived
<span class="synSpecial">group</span> <span class="synSpecial">by</span>
q.id
),
query_stats <span class="synSpecial">as</span> (
<span class="synStatement">select</span>
query_hash,
<span class="synIdentifier">count</span>(<span class="synStatement">distinct</span> id) <span class="synSpecial">as</span> cnt,
<span class="synIdentifier">round</span>(<span class="synIdentifier">cast</span>(<span class="synIdentifier">avg</span>(runtime) <span class="synSpecial">as</span> <span class="synType">numeric</span>), <span class="synConstant">2</span>) <span class="synSpecial">as</span> avg_runtime,
<span class="synIdentifier">round</span>(<span class="synIdentifier">cast</span>(<span class="synIdentifier">avg</span>(json_array_length(data :: json -> <span class="synSpecial">'</span><span class="synConstant">rows</span><span class="synSpecial">'</span>)) <span class="synSpecial">as</span> <span class="synType">numeric</span>),<span class="synConstant">2</span>) <span class="synSpecial">as</span> avg_rows
<span class="synSpecial">from</span>
query_results
<span class="synSpecial">where</span>
retrieved_at <span class="synStatement">between</span> <span class="synSpecial">'</span><span class="synConstant">yyyy-mm-dd</span><span class="synSpecial">'</span> <span class="synStatement">and</span> <span class="synSpecial">'</span><span class="synConstant">yyyy-mm-dd</span><span class="synSpecial">'</span> <span class="synComment">-- 期間指定</span>
<span class="synSpecial">group</span> <span class="synSpecial">by</span>
query_hash
)
<span class="synStatement">select</span>
q.id,
q.name,
u.name <span class="synSpecial">as</span> owner,
st.cnt,
st.avg_runtime,
st.avg_rows,
qd.related_dashboards,
q.schedule :: json -> <span class="synSpecial">'</span><span class="synConstant">interval</span><span class="synSpecial">'</span> <span class="synSpecial">as</span> interval,
q.schedule :: json -> <span class="synSpecial">'</span><span class="synConstant">time</span><span class="synSpecial">'</span> <span class="synSpecial">as</span> time
<span class="synSpecial">from</span>
queries q
<span class="synSpecial">join</span> query_stats st <span class="synSpecial">using</span> (query_hash)
<span class="synSpecial">join</span> query_dashboard qd <span class="synSpecial">on</span> q.id = qd.id
<span class="synSpecial">join</span> users u <span class="synSpecial">on</span> q.user_id = u.id
<span class="synSpecial">where</span>
<span class="synStatement">not</span> q.is_archived
<span class="synSpecial">order</span> <span class="synSpecial">by</span>
avg_rows <span class="synSpecial">desc</span>
limit
<span class="synConstant">100</span>;
</pre>
<p>今回の棚卸では30秒以上実行に時間がかかっているクエリや、 50,000件以上取得しているクエリを対象にlimit句を追加したり、可視化の変更を作成者と相談しながら20~30件のクエリを最適化しました。 <s>過去の自分が書いたクエリもありました。</s></p>
<h3 id="影響範囲の洗い出しクエリの修正">影響範囲の洗い出し、クエリの修正</h3>
<p>結局のところ各クエリの作成者や管理者に個別にコンタクトして修正をお願いしたり、こちらでクエリを編集させていただいたり、未使用のものはアーカイブしたりというアナログなやり方で棚卸しをしました。関係者とコミュニケーションする上では以下のような点を伝えるとスムーズに受け入れてもらえました。</p>
<ul>
<li>協力いただけることに感謝し、クエリの調整によって性能が改善して使いやすくなることを伝える</li>
<li>不可逆な変更ではないことを伝える</li>
<li>RedashのクエリIDやクエリ名だけではなく、関連しているダッシュボード名も伝える</li>
</ul>
<p>以上のような取り組みの結果、Redashぐるぐる問題は無事解決したのでした。めでたしめでたし。</p>
<h2 id="まとめ">まとめ</h2>
<p>本稿ではRedashパフォーマンス問題への対応とシステムリソースの節約のためのクエリ棚卸しについて、サンプルクエリ<a href="#f-8c7b0af4" id="fn-8c7b0af4" name="fn-8c7b0af4" title="本記事執筆時点で前提としているRedashのバージョンは10.1.0 です">*2</a>を交えて紹介しました。
掃除と同じく常日頃から定期的なチェックや整理整頓をするのに越したことはありませんが、年末や期末などをきっかけに取り組んでみてはいかがでしょうか。</p>
<h2 id="We-are-hiring">We are hiring!</h2>
<p>エムスリーではデータを活用して様々なサービスを開発するエンジニアを募集中です。
少しでもご興味を持った方は、以下ページよりカジュアル面談等に申し込み頂ければと思います!</p>
<p><iframe src="https://open.talentio.com/r/1/c/m3-inc/embed/pages/57609" width="100%" height="300" frameborder=0 title="%E3%82%A8%E3%83%A0%E3%82%B9%E3%83%AA%E3%83%BC+%7C+%E3%83%87%E3%83%BC%E3%82%BF%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2"></iframe><cite class="hatena-citation"><a href="https://open.talentio.com/r/1/c/m3-inc/pages/57609">open.talentio.com</a></cite></p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<div class="footnote">
<p class="footnote"><a href="#fn-7942b24a" id="f-7942b24a" name="f-7942b24a" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">この記事で詳しく書いた事情で年末よりも少し前にやりました</span></p>
<p class="footnote"><a href="#fn-8c7b0af4" id="f-8c7b0af4" name="f-8c7b0af4" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">本記事執筆時点で前提としているRedashのバージョンは<a href="https://github.com/getredash/redash/releases/tag/v10.1.0">10.1.0</a> です</span></p>
</div>
takumikid2
共有会をより効果的にするために考えたあれやこれ
hatenablog://entry/6801883189064565223
2023-12-08T11:00:00+09:00
2023-12-08T11:22:05+09:00 この記事は エムスリー Advent Calendar 2023 の 8 日目の記事です。 前日は小栗さんによる kannonを実プロダクトに組み込んで3倍高速化を達成した話 でした こんにちは、SREチームの後藤です。 エムスリーでは各チームのSREが集まって情報共有をする SRE Meetup という共有会を7月から新たに始めました。 このように情報共有のために集まるということは一般的に良くあることかと思いますが、効果的に運営していくには実は考えることが多いです。 今回はSRE Meetupの企画から実施までに意識して取り組んだ工夫を紹介していきたいと思います。
<p>この記事は <a href="https://qiita.com/advent-calendar/2023/m3">エムスリー Advent Calendar 2023</a> の 8 日目の記事です。<br/>
前日は小栗さんによる <a href="https://www.m3tech.blog/entry/2023/12/07/113000">kannonを実プロダクトに組み込んで3倍高速化を達成した話</a> でした</p>
<p>こんにちは、SREチームの後藤です。<br/>
エムスリーでは各チームのSREが集まって情報共有をする SRE Meetup という共有会を7月から新たに始めました。
このように情報共有のために集まるということは一般的に良くあることかと思いますが、効果的に運営していくには実は考えることが多いです。
今回はSRE Meetupの企画から実施までに意識して取り組んだ工夫を紹介していきたいと思います。</p>
<ul class="table-of-contents">
<li><a href="#背景エムスリーのSREを取り巻く組織事情">背景:エムスリーのSREを取り巻く組織事情</a></li>
<li><a href="#SRE-Meetupの開催へ向けて">SRE Meetupの開催へ向けて</a><ul>
<li><a href="#1-目的意識をしっかりと持って集まる">1. 目的意識をしっかりと持って集まる。</a></li>
<li><a href="#2-双方向のコミュニケーションを重視する">2. 双方向のコミュニケーションを重視する</a></li>
<li><a href="#3-主催者が情熱をもって取り組む">3. 主催者が情熱をもって取り組む</a></li>
</ul>
</li>
<li><a href="#実際にやってみてどうだったか">実際にやってみてどうだったか</a></li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#We-are-Hiring">We are Hiring!</a></li>
</ul>
<h2 id="背景エムスリーのSREを取り巻く組織事情">背景:エムスリーのSREを取り巻く組織事情</h2>
<p>エムスリーにおけるSREは、コアSREとチームSREの2種類が存在します。</p>
<ul>
<li>コアSRE
<ul>
<li>SREチームに所属。全社横断的なリソースの管理や施策の推進を担当。</li>
</ul>
</li>
<li>チームSRE
<ul>
<li>各チームに所属。チーム内で担当サービスのSRE活動を推進。</li>
</ul>
</li>
</ul>
<p>チームSREが生まれた理由には当時の組織の状況やクラウド移行への取り組みなど様々な要因があるのですが、詳しくは下記の記事をご覧ください。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2F2021%2F01%2F12%2F113000" title="SREの民主化とクラウド移行 - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/2021/01/12/113000">www.m3tech.blog</a></cite></p>
<p>エムスリーは各チームが自立しており独立性が高いのが特徴なのですが、そこに合わせてチームSREが発展していったことで、SRE同士の横の繋がりというのは少ない状態でした。
チームごとの独立性が高いとはいえ、クラウドはAWSが多い、DBがPostgreSQLが多いといった共通の部分はありますし、技術によらない開発スタイルや工夫などチーム内で留めておくにはもったいないです。</p>
<p>このような課題感から、SRE同士の横の繋がりを強化して情報を共有する場としてSRE Meetupの企画が立ち上がりました。</p>
<h2 id="SRE-Meetupの開催へ向けて">SRE Meetupの開催へ向けて</h2>
<p>情報共有のために集まるというのは、いわゆる共有会や連絡会と呼ばれるようなもので良くあるものかと思います。
ただし、実際に効果的に運営していくのは難しいと感じている方も多いのではないでしょうか。
我々としても、ただ時間を作って集まるだけではうまくいかないだろうという懸念があり、どのように工夫して運営していくか議論を重ねました。
その結果、以下のようなポイントを意識して企画を進めていくことになりました。</p>
<ol>
<li>目的意識をしっかりと持って集まる。</li>
<li>双方向のコミュニケーションを重視する。</li>
<li>主催者が情熱を持って取り組む。</li>
</ol>
<h3 id="1-目的意識をしっかりと持って集まる">1. 目的意識をしっかりと持って集まる。</h3>
<p>人が定期的に集まって情報を共有をするという場において、最も危惧すべきことは形骸化やマンネリ化です。
目的意識が希薄なまま集まっているだけでは、情報共有が進まないどころかただ時間を浪費しているだけになってしまいます。
ここに関しては、事前アンケートで共有会のそもそもの必要性を確認することと、共有会のコンセプトを明文化して伝えるということを行いました。</p>
<p>まずは参加者向けの事前アンケートです。
いくら主催側が推し進めて共有会を開いたとして、参加者側の課題認識が異なっていたり共有会の価値を感じてもらえなかったら、目的意識も何もありません。
そのため企画の方向がある程度固まった時点で参加者向けに事前アンケートをとってみました。
事前アンケートでは、所属チームでの課題や他チームの活動を知りたいかなどいくつかの質問をしたのですが、参加者側も同じように課題認識があり自分の業務に役立つような話を聞きたいという意見が多く上がりました。
その他にも共有会の企画に役立つ意見も多数得られたので、事前アンケートは良いやり方であったと思います。</p>
<p>次に、共有会のコンセプトの明文化です。
コンセプトについては事前にしっかりと作り込んで資料として明文化しました。
内容は共有会の目的や参加者にお願いしたいことなどまとめて、実際に初回の冒頭に時間をとって説明をしました。
この狙いとしては、目的や会の在り方をしっかりと伝えることと、後から立ち返られる共有会の軸を定めることです。
資料は口頭で説明するだけでなく、いつでも見られるように掲載してあります。</p>
<p><figure class="figure-image figure-image-fotolife" title="コンセプト説明資料の抜粋。良いことが書いてある。"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231208/20231208110004.png" width="800" height="459" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>コンセプト説明資料の抜粋。良いことが書いてある。</figcaption></figure></p>
<h3 id="2-双方向のコミュニケーションを重視する">2. 双方向のコミュニケーションを重視する</h3>
<p>共有会の中でのコミュニケーションはなるべく双方向のものにしたいと考えました。
すなわち、特定の人物ばかりが喋るのではなく、多くの参加者が発言することや参加者同士の議論が活発になることを目指しました。</p>
<p>ここに関してはシンプルに共有会では全チーム順番に話してもらうようにしました。
元々各チームの状況を知りたいという要望もあったのでこの形が目的に合致していました。
ただしフリースタイルなのも話しづらいので、話す内容は「直近チームで取り組んだ業務」「相談したいこと」「今月のテーマ」の3つと定めて、話しやすいようにする工夫はしました。
テーマというのはポストモーテム共有とか使ってよかったクラウドサービスとかその時々でトークテーマを決めて話すというものなのですが、このテーマも参加者の要望に基づいて決めるようにしており、双方向のコミュニケーションを意識しています。</p>
<p>共有会の司会進行では、各チームの話が終わったタイミングで他のチームから質問や意見を募る時間をとるようにしました。
これは最後にまとめて質問の時間を取るよりも都度質問できたほうが発言がしやすいと考えてのことで、私が司会の時には自分の意見も織り交ぜるようにして議論が活発になるように努めています。</p>
<h3 id="3-主催者が情熱をもって取り組む">3. 主催者が情熱をもって取り組む</h3>
<p>最後は精神論のようになってしまいましたが非常に重要な点だと考えています。
何を目指しこの共有会を立ち上げたのか、どのようにしていきたいのか、当初の情熱を変わらず持ち続けることが共有会の発展と継続には不可欠です。
主催者は共有会の準備や司会進行など多くの責務を担うので、そこにどのような気持ちで取り組んでいるか大事にすべきだと思っています。
また、どんなものでも最初から完璧ではなくやりながら改善していくものですが、改善へのモチベーションというのもこの情熱をもとに湧き上がってくるものだと思います。</p>
<h2 id="実際にやってみてどうだったか">実際にやってみてどうだったか</h2>
<p>共有会であるSRE Meetupは、7月に初めて開催してから月次でこれまで5回開催してきました。
参加者からも好評で良いスタートを切ることができたと思います。
実際に議論が盛り上がった内容を一部ご紹介すると、下記のようなものがありました。</p>
<ul>
<li>負債を溜め込まないために負債返却デーを作って集中的に改善タスクを実施しているチームの取り組み</li>
<li>QA環境で想定外にコストが膨らんでいたクラウドサービスのコスト削減方法</li>
<li>DBのEOL時期の共有と各チームのバージョンアップ方法の議論</li>
</ul>
<p>また、共有会をやり始めて良い意味で想定外だったのが、ビデオ会議での発言だけではなくチャットが非常に盛り上がるという点です。
発言よりハードルも低く話も遮らないので、質問や意見、関連するURLの共有など多くのチャットが飛び交っています。
これは非常に嬉しいことで、司会をしている私としてもチャットも適宜拾うとともに、チャットのログは毎回全て議事録に合わせてまとめています。</p>
<p>一方で、しょうがない部分はありますが発言者がある程度固定化されてしまう点や、集まった知見を活用しやすい形式でまとめていきたいといった点が今後改善していきたい部分です。
まだ始まったばかりの取組なので今後も継続的に良くしていきたいと考えています。</p>
<h2 id="まとめ">まとめ</h2>
<p>本記事ではいわゆる共有会をやっていく上で工夫したポイントについて紹介しました。
その時々の状況でベストな手段は変わるので、しっかりと考え抜いて企画をしつつ、反応を見ながら改善していくことが大事だと思います。
今回の記事が皆様の参考になれば幸いです。</p>
<h2 id="We-are-Hiring">We are Hiring!</h2>
<p>エムスリーではエンジニアを募集中です。
ハイレベルなエンジニアがたくさんいる環境で知見の共有も加速していき、より高め合える仲間がたくさん増えると嬉しいです。
ご応募お待ちしています。</p>
<p><iframe src="https://open.talentio.com/r/1/c/m3-inc/embed/pages/57755" width="100%" height="300" frameborder=0 title="%E3%82%A8%E3%83%A0%E3%82%B9%E3%83%AA%E3%83%BC+%7C+SRE"></iframe><cite class="hatena-citation"><a href="https://open.talentio.com/1/c/m3-inc/requisitions/detail/10947">open.talentio.com</a></cite>
<iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
AkiraGoto
kannonを実プロダクトに組み込んで3倍高速化を達成した話
hatenablog://entry/6801883189063313647
2023-12-07T11:30:00+09:00
2023-12-13T14:44:27+09:00 こんにちは!エムスリー Advent Calendar 2023 7日目担当の小栗 (@irungo_ic)です。私は東京大学 電子情報工学科で学生(B4)をしており、エムスリーには業務委託で参画しています。 今回はgokartの分散並列化ライブラリkannonをエムスリーの実プロダクトに組み込んだ取り組みについて紹介します。結果として、社内のとあるプロダクトのIntegration TestのJobを7時間30分から2時間20分に短縮するという、約3.2倍の高速化を達成できました!それまでに生じた苦労、kannonへの追加機能などについてお話しします。 kannonとは? kannonとは、…
<p>こんにちは!<a href="https://qiita.com/advent-calendar/2023/m3">エムスリー Advent Calendar 2023</a> 7日目担当の小栗 (<a href="https://twitter.com/irungo_ic">@irungo_ic</a>)です。私は東京大学 電子情報工学科で学生(B4)をしており、エムスリーには業務委託で参画しています。</p>
<p>今回は<a href="https://github.com/m3dev/gokart">gokart</a>の分散並列化ライブラリ<a href="https://github.com/m3dev/kannon">kannon</a>をエムスリーの実プロダクトに組み込んだ取り組みについて紹介します。結果として、社内のとあるプロダクトのIntegration TestのJobを7時間30分から2時間20分に短縮するという、<strong>約3.2倍の高速化</strong>を達成できました!それまでに生じた苦労、kannonへの追加機能などについてお話しします。</p>
<h2 id="kannonとは">kannonとは?</h2>
<p><a href="https://github.com/m3dev/kannon">kannon</a>とは、M3が主体となって開発しているOSSのデータパイプラインライブラリ<a href="https://github.com/m3dev/gokart">gokart</a>をk8s上で分散並列実行するためのライブラリです。私が今年3月に参加したAIチームでのインターンで開発したライブラリで、その後も機能改善を進めています。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fm3dev%2Fkannon" title="GitHub - m3dev/kannon: Kannon is a wrapper for the gokart library that allows gokart tasks to be easily executed in a distributed and parallel manner on multiple kubernetes jobs." class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/m3dev/kannon">github.com</a></cite></p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231207/20231207113010.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>エムスリーでは多くのプロダクトでgokartで実装したパイプラインをKubernetes (k8s)上で運用しています。特に機械学習モデルの改善を重ねていく作業では、性能検証のためのjobを何度も実行することになります。規模の大きいものになると、一回のイテレーションに要する時間が非常に大きくなることが課題となっていました。このイテレーションを短縮することは、MLエンジニアの試行サイクルを高速化することに直結するため、重要な課題と考えられます。</p>
<p>エムスリーで扱っている機械学習パイプラインではk-fold CVなどに代表される、データ依存性の無い並列実行可能な箇所がしばしば現れます。この中には実行時間の長いTaskも含まれます。しかし、gokartはシングルスレッド実行であるため、これらの部分を逐次的に実行します。</p>
<p>そこで、kannonはこのような部分を複数のk8s jobに分散し、並列に実行することを可能にします。既に逐次実行が前提となっているgokartを分散並列化するにはいくつか方針が考えられますが、パイプラインがk8s (GKE)上で運用されていることから、k8sのjobを用いて分散させることにしました。また、gokartのキャッシュやAWS, GCPなどのクラウドストレージとの連携といった便利機能は機械学習パイプラインにおいて重要であり、これらを引き継ぐためにkannonはgokartのwrapperとして実装されています。</p>
<h3 id="kannonの使い方">kannonの使い方</h3>
<p>kannonの技術的詳細は「gokartで爆速開発!MLOps勉強会」で発表した内容をもとにした以下の記事にまとまっています。是非こちらもご覧ください。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.m3tech.blog%2Fentry%2F2023%2F07%2F27%2F170000" title="gokartの分散並列化ライブラリkannonの中身 - エムスリーテックブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.m3tech.blog/entry/2023/07/27/170000">www.m3tech.blog</a></cite></p>
<p>ここではkannonの使い方と、"master"と"child"のコンセプトだけ確認しておきましょう!</p>
<p>kannonを使うには、gokartのTask定義とTask実行の2ヶ所を次のように書き換えるだけです。
<span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231207/20231207113017.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231207/20231207113020.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>分散したいTaskの継承クラスを<code>gokart.TaskOnKart</code>から<code>kannon.TaskOnBullet</code>に変更します。そして<code>gokart.build()</code>と同様に<code>Kannon(**).build()</code>を実行します。この引数には環境変数関連やk8s関連のものが含まれます。</p>
<p>kannonにはmaster jobとchild jobという概念があります。master jobは1つ存在し、Taskの実行の制御を行います。実行が始まると、masterは依存関係を解決してTask Queueを構築し、所定の手順でTaskを処理していきます。分散するべきTaskに出会うと、
masterはTaskや実行に必要なすべての情報を引き継いだchild jobを生成し、後は全てをchildに任せて次のステップに進みます。その裏でchild jobは与えられたTaskを実行します。このようにしてkannonは分散並列実行を実現しています。</p>
<h2 id="何がどのくらい速くなったか">何がどのくらい速くなったか?</h2>
<p>kannonを組み込んだプロダクトは、行動ログとユーザに対するアンケートを用いて解釈しやすいユーザーの嗜好分類を行うものです (詳しくは<a href="https://www.m3tech.blog/entry/2019/12/10/000000">こちらの記事</a>)。開発において、MLモデルを改善し検証するサイクルを回すために、Integration Testというものを実行します。これはデータのダウンロードから整形、モデルの学習、評価までを一貫で行うパイプラインとして、gokartで記述されています。社内ではこれをk8sのbatch jobとして運用しています。</p>
<p>このIntegration Testのうち規模の大きいものは従来7時間半ほどかかっていました。これをkannon導入により2時間20分ほどに短縮することができました!およそ<strong>3.2倍の高速化</strong>を達成できました。この高速化はMLエンジニアの試行サイクルを高速化することに直結するため非常に意義のあることです。</p>
<h2 id="実プロダクトで運用するまで">実プロダクトで運用するまで</h2>
<p>インターンからJoinしたため初めはプロダクトのデプロイ周りの知識などがなく、そのキャッチアップから行いました。ライブラリ側・プロダクト側で足りていない機能を洗い出し、実装を行いました。本記事ではライブラリ側の主な修正や追加機能を紹介します。</p>
<h3 id="MasterからChildへのTaskの受け渡し">MasterからChildへのTaskの受け渡し</h3>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fm3dev%2Fkannon%2Fpull%2F29" title="Use pickle instead of serialize by maronuu · Pull Request #29 · m3dev/kannon" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/m3dev/kannon/pull/29">github.com</a></cite></p>
<p>これまではTaskの受け渡しをserializeした文字列の受け渡しにより実現していました。しかしserializeした文字列をchildがdeserializeするときに、パラメータが全て<code>str</code>型になってしまうという<a href="https://github.com/m3dev/kannon/issues/8">Issue</a>が生じました。他にも、child側がTask定義を読み込むためにTaskクラスをimportする必要があるなど、色々と不便なことがありました。</p>
<p>そこで、この修正ではTaskオブジェクトをpickle化して全jobからアクセスできる共有ストレージに配置し、それを経由してTaskを受け渡すことにしました。これにより以上の問題は全て解決しました。</p>
<h3 id="Child-Taskが失敗した場合の挙動">Child Taskが失敗した場合の挙動</h3>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fm3dev%2Fkannon%2Fpull%2F37%2Ffiles" title="Additional job check by maronuu · Pull Request #37 · m3dev/kannon" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/m3dev/kannon/pull/37/files">github.com</a></cite></p>
<p>このPRによる変更では、Child Taskの実行順がやってきたときに、もしそのTaskが既に失敗してk8s jobが落ちていた場合、その時点でパイプライン全体の失敗とみなしてmasterも例外を出して落ちるようにしました。</p>
<p>これに加えて、以下のPRの変更では、k8sの機能である<a href="https://kubernetes.io/ja/docs/concepts/overview/working-with-objects/owners-dependents/">Owner Reference</a>を用いて、master jobが落ちた場合、k8s側が自動で紐づいているchild jobをkillするようにしました。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fm3dev%2Fkannon%2Fpull%2F38" title="Add owner reference from child job to master pod by maronuu · Pull Request #38 · m3dev/kannon" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/m3dev/kannon/pull/38">github.com</a></cite></p>
<p>これらを合わせると、現在のポリシーは「masterはchildのいずれかが失敗したら自分も落ちる」というものになります。
一方で、キャッシュの都合などから、生き残っているchildは最後まで実行を続けてほしいという場合もあり得るかと思います。このような場合に対応するためのモードの切り替えを今後実装予定です。</p>
<h3 id="動的に生成したconfigファイルのサポート">動的に生成したconfigファイルのサポート</h3>
<p>この変更が最も苦労したもので、かつマニアックなものです。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fm3dev%2Fkannon%2Fpull%2F39" title="Feature/inherit config support by maronuu · Pull Request #39 · m3dev/kannon" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/m3dev/kannon/pull/39">github.com</a></cite></p>
<p>gokartではタスクを定義するときに <code>@inherits_config_params</code>というデコレータを用いて、configクラスからパラメータを継承することができます。</p>
<pre class="code lang-python" data-lang="python" data-unlink><span class="synStatement">class</span> <span class="synIdentifier">MasterConfig</span>(luigi.Config):
param: <span class="synIdentifier">str</span> = luigi.Parameter()
param2: <span class="synIdentifier">str</span> = luigi.Parameter()
<span class="synPreProc">@</span><span class="synIdentifier">inherits_config_params</span>(MasterConfig)
<span class="synStatement">class</span> <span class="synIdentifier">SomeTask</span>(gokart.TaskOnKart):
param: <span class="synIdentifier">str</span> = luigi.Parameter()
</pre>
<p>このconfigに含まれるパラメータは、以下のようなconfigファイル(<code>ini</code>形式)で指定します。</p>
<pre class="code lang-yaml" data-lang="yaml" data-unlink><span class="synSpecial">[</span>sample.SomeTask<span class="synSpecial">]</span>
param = Hello
</pre>
<p>これを</p>
<pre class="code lang-python" data-lang="python" data-unlink><span class="synStatement">assert</span> luigi.configuration.add_config_path(<span class="synConstant">"./conf/base.ini"</span>)
</pre>
<p>のようにして読み込みます。これでconfigファイルに含まれるパラメータをTaskに注入できます。</p>
<p>さて、configファイルはさまざまな形で運用することができますが、imageをbuildする以前に用意するものとimageをbuildした後のdeploy時に生成するものとがあります。従来であればgokartを1台のnodeで実行するだけでした。この場合、どちらの場合でもpodのlocalには必要なconfigファイルが全てあり、それを登録できます。</p>
<p>一方kannonでは、以下のようにmaster jobは自分自身と同一のimageからchild jobを生成します。</p>
<p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/m3tech/20231207/20231207113014.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p>
<p>つまり、imageのbuild以前に用意されたconfigファイルはchildにも引き継がれますが、imageのbuild以後に動的に生成されたconfigファイルはmasterのpod localにしか存在しないため、childの担当するTaskがconfigを必要とするものであった場合に失敗してしまいます(この事実に気づくまでが大変でした...)。</p>
<p>そこで色々な手法を試した結果、</p>
<ul>
<li>masterが動的生成configファイルを全jobからアクセス可能なストレージ(実際にはGCS bucket)に配置する</li>
<li>ユーザが定義した動的生成ファイルのpathをmasterからchildに渡す</li>
<li>childはそのpathを参照して動的生成configファイルを読み込み、利用する</li>
</ul>
<p>という方法が最もシンプルで実現しやすかったため、これを採用しました。ユーザは以下のような指定を行う必要があります。</p>
<pre class="code" data-lang="" data-unlink>Kannon(
api_instance=client.BatchV1Api(),
...
dynamic_config_path=dynamic_config_path,
).build(RunIntegrationTest())</pre>
<p>将来的には複数のconfigファイルに対応することや、指定不要の方法をサポートすることを検討しています。</p>
<h2 id="どのTaskを分散実行するべきか">どのTaskを分散実行するべきか</h2>
<p>現状のkannonは、どのTaskをchild jobで実行するかをユーザが指定する必要があります。そのため、逐次実行におけるTaskの実行時間のプロファイルを取ることが必要です。</p>
<p>gokartの機能として、Taskを実行後に<code>TASK_WORKSPACE_DIRECTORY/task_infos</code>以下にTaskの実行情報が含まれたpickleファイルを出力できます。Task名と実行時間が対応した<code>pd.DataFrame</code>を得ることができるため、それを用いて実行時間が長くなっているTaskを特定することができます。</p>
<p>今回のプロダクトではモデルを学習するTaskが最も実行時間が長く、なおかつ5-fold CVのために5並列で実行可能なものだったため、これを分散並列実行の対象にしました。</p>
<p>今後はこの指定部分を自動化・簡易化することも目指していく予定です。</p>
<h2 id="まとめ">まとめ</h2>
<p>kannonは私がインターンに参加した時点ではふわっとしたテーマしか決まっておらず、期間中に仕様の検討からリリースまでを行った思い出深いライブラリです。しかしインターンの時点ではToyデータでのみ検証していたため、いざ今回実プロダクトに組み込むとなると、多くの修正や不足している機能が見つかりました。地道に修正を重ねて行ってIntegration Testがkannonで動いたときはとても嬉しかったです。時間短縮の数値的な成果としても満足のいくものが得られてよかったです。同時に、デバッグの大変さを痛感しました。k8sを前提とした機能である以上デバッグが大変になりがちなため、もっと容易に開発ができるような方法を確立していくことも必要だなと感じました。不足している機能、ほしい機能など、今後の方針も見えてきたので、引き続き開発を進めていきます!</p>
<p>また、規模の大きい事業・プロダクトを支える基盤という、実応用と基礎の中間のあたりに携われているのは、学生的にはなかなか無い貴重な経験だなと感じています。基盤となるライブラリを作るのはとてもやりがいがあって楽しいです。</p>
<h2 id="Were-hiring">We're hiring!</h2>
<p>AI・機械学習チームでは、インパクトの大きいプロダクトに関わりつつも、kannonのような基盤的な新しいライブラリを作ったり導入したりということもやっています。私自身、インターンに参加して以降非常に貴重な経験ができていて楽しいです。
中途の方はもちろんですが、ML・MLOpsやOSS開発に興味のある学生の方も是非新卒採用・インターンに応募してみてください!お待ちしております!</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<p><a href="https://open.talentio.com/r/1/c/m3/pages/78950">【新卒】MLOpsエンジニア(インターン) / エムスリー株式会社</a></p>
<p><a href="https://open.talentio.com/r/1/c/m3/pages/78949">【新卒】機械学習エンジニア(インターン) / エムスリー株式会社</a></p>
irungo
Enumとてもつらい、でも負けない
hatenablog://entry/6801883189062170683
2023-12-06T00:00:00+09:00
2023-12-06T00:05:34+09:00 列挙型、JavaでいうならEnum型、使っていますか。使わないわけにいきませんよね。 でも、Enumを使っていたせいで辛い目にあったことありませんか。ないですか。それならきっともうすぐに辛い目にあうと思います。 Enumはすべてのプログラマに等しく辛みを与えてくれるからです。そんな辛みについて、ちょっと一緒に直視してみましょう。 エムスリーエンジニアリンググループ、Unit1(製薬企業向けプラットフォームチーム)三浦(@yuba@reax.work) [記事一覧 ]がお送りいたします、エムスリー Advent Calendar 2023の6日目です。
<p>列挙型、JavaでいうならEnum型、使っていますか。使わないわけにいきませんよね。<br/>
でも、Enumを使っていたせいで辛い目にあったことありませんか。ないですか。それならきっともうすぐに辛い目にあうと思います。<br/>
Enumはすべてのプログラマに等しく辛みを与えてくれるからです。そんな辛みについて、ちょっと一緒に直視してみましょう。<br/>
エムスリーエンジニアリンググループ、Unit1(製薬企業向けプラットフォームチーム)三浦(<a href="https://reax.work/@yuba">@yuba@reax.work</a>) [<a href="https://www.m3tech.blog/archive/author/Sampo" target="_blank">記事一覧 <span style="font-family:blogicon"></span></a>]がお送りいたします、<a href="https://qiita.com/advent-calendar/2023/m3">エムスリー Advent Calendar 2023</a>の6日目です。</p>
<ul class="table-of-contents">
<li><a href="#アプリケーションプログラミング上の辛み">アプリケーションプログラミング上の辛み</a><ul>
<li><a href="#1-既存のif文が偶発的に意図しない方に倒れる">1. 既存のif文が偶発的に意図しない方に倒れる</a></li>
<li><a href="#2-switch文に至ってはどちらでもないで処理不発に">2. switch文に至っては「どちらでもない」で処理不発に</a></li>
</ul>
</li>
<li><a href="#アプリケーションプログラミング上の対策">アプリケーションプログラミング上の対策</a><ul>
<li><a href="#1-分岐条件をEnumに持たせる">1. 分岐条件をEnumに持たせる</a></li>
<li><a href="#2-switch文でなくswitch式を使う">2. switch文でなくswitch式を使う</a></li>
</ul>
</li>
<li><a href="#データ分析の辛み">データ分析の辛み</a></li>
<li><a href="#データ分析のことまで考慮した対策">データ分析のことまで考慮した対策</a><ul>
<li><a href="#1-データ分析エンジニアとのコミュニケーションを密にする">1. データ分析エンジニアとのコミュニケーションを密にする</a></li>
<li><a href="#2-Enum要素は増やさない">2. Enum要素は増やさない</a></li>
</ul>
</li>
<li><a href="#まとめ">まとめ</a></li>
<li><a href="#We-are-hiring">We are hiring</a></li>
</ul>
<h1 id="アプリケーションプログラミング上の辛み">アプリケーションプログラミング上の辛み</h1>
<p>リストの要素になにかしらの種別がある場合、その種別を表現する必要がどうしても出てきます。そうした場合にEnumの出番があります。<br/>
種別が2種類だけなら<code>isなんとか</code>的なフラグを持つだけでも十分かもしれませんが、3種類以上ある場合や将来的に3種類以上になる可能性が見えるならばEnumを使うことになりますね。</p>
<p>今、「将来的に」と書きました。将来的に増えること、これがEnumの辛みの根源です。</p>
<p>例を書いてみましょう。本記事ではJavaで書いていきますがほとんどの言語について通じる内容になると思います。<br/>
今回書く例は「従業員」、その種別の表現です。</p>
<pre class="code lang-java" data-lang="java" data-unlink><span class="synType">public</span> <span class="synType">enum</span> EmployeeType {
FULL_TIME,
PART_TIME,
}
</pre>
<p>正社員、パートタイム社員の2種類の従業員を扱えるようになっています。<br/>
さてここに変更。会社の方針の変更により人材派遣を受けることになり派遣社員という種別もシステムで扱う必要が生まれました。</p>
<pre class="code lang-diff" data-lang="diff" data-unlink> public enum EmployeeType {
FULL_TIME,
PART_TIME,
<span class="synIdentifier">+ DISPATCH,</span>
}
</pre>
<p>このときにどのような問題が起きるか。</p>
<h2 id="1-既存のif文が偶発的に意図しない方に倒れる">1. 既存のif文が偶発的に意図しない方に倒れる</h2>
<p>コードには、</p>
<pre class="code lang-java" data-lang="java" data-unlink><span class="synStatement">if</span> (employee.type == EmployeeType.FULL_TIME) {
</pre>
<p>と書いてある場合と</p>
<pre class="code lang-java" data-lang="java" data-unlink><span class="synStatement">if</span> (employee.type == EmployeeType.PART_TIME) {
</pre>
<p>と書いてある場合があります。前者の場合、修正なしだと派遣社員はelse側、パートタイムと同じ扱いになります。後者の場合は正社員扱いですね。<br/>
もちろん、これらのif文を書いた時点では派遣社員の存在を意識してはいませんから、新規追加した種別がどちら側に倒れるか意識してif文を書いていたわけがありません。すべてのif文が棚卸し対象であり、全分岐がどちらに倒れるべきであるかを検討する必要があります。</p>
<p>そして、大変な思いをしてそれをやったとしても本当に漏れなく対応できたかどうか、コードレビューで確認することが非常に難しいです<a href="#f-499025e3" id="fn-499025e3" name="fn-499025e3" title="コードレビューで確認困難というのは、改修している本人にとっても漏れなく作業するのが難しいということでもあります">*1</a>。<br/>
すべてのif文に「派遣社員はこういう理由でここでは正社員扱い」みたいにコメントを書きます? はい、大変ですし泥臭いですがそのくらいしか方法がありません。</p>
<h2 id="2-switch文に至ってはどちらでもないで処理不発に">2. switch文に至っては「どちらでもない」で処理不発に</h2>
<p>switch文の場合は、</p>
<pre class="code lang-java" data-lang="java" data-unlink><span class="synStatement">switch</span> (employee.type) {
<span class="synStatement">case</span> FULL_TIME:
<span class="synComment">// 正社員の処理</span>
<span class="synStatement">break</span>;
<span class="synStatement">case</span> PART_TIME:
<span class="synComment">// パートタイムの処理</span>
<span class="synStatement">break</span>;
}
</pre>
<p>となっていますから、派遣社員の場合はどちらの処理も実行されません。二者択一で何かしらやっておかないといけなかった処理がどちらも実行されずデータの整合性が無残にも破綻といったことが起こります。</p>
<p>switch文の場合はdefaultを書いてランタイムにエラー検知することができるのですがね、ユニットテストもステージング環境QAもカバレッジ100%はあり得ない以上、本番でいつか火を噴く地雷であることは変わりません。</p>
<h1 id="アプリケーションプログラミング上の対策">アプリケーションプログラミング上の対策</h1>
<h2 id="1-分岐条件をEnumに持たせる">1. 分岐条件をEnumに持たせる</h2>
<p>ifやswitchで「どの種別か」を判定させない、種別オブジェクトの方に「どの処理をすべきか」を持たせるという、OOPとしてはまっとうな考え方です。</p>
<pre class="code lang-java" data-lang="java" data-unlink><span class="synType">public</span> <span class="synType">enum</span> EmployeeType {
FULL_TIME(<span class="synComment">/* useMonthlySalary =*/</span><span class="synConstant">true</span>),
PART_TIME(<span class="synComment">/* useMonthlySalary =*/</span><span class="synConstant">false</span>),
DISPATCH(<span class="synComment">/* useMonthlySalary =*/</span><span class="synConstant">true</span>),
;
<span class="synType">public</span> <span class="synType">final</span> <span class="synType">boolean</span> useMonthlySalary;
EmployeeType(<span class="synType">boolean</span> useMonthlySalary) {
<span class="synType">this</span>.useMonthlySalary = useMonthlySalary;
}
}
</pre>
<p>こんな風に給与計算のところでの分岐の判定結果をEnumに持たせてしまうと、</p>
<pre class="code lang-java" data-lang="java" data-unlink><span class="synStatement">if</span> (employee.type == EmployeeType.FULL_TIME || employee.type == EmployeeType.DISPATCH) {
<span class="synComment">// 月給制の処理</span>
} <span class="synStatement">else</span> {
<span class="synComment">// 時給制の処理</span>
}
</pre>
<p>ではなく</p>
<pre class="code lang-java" data-lang="java" data-unlink><span class="synStatement">if</span> (employee.type.useMonthlySalary) {
<span class="synComment">// 月給制の処理</span>
} <span class="synStatement">else</span> {
<span class="synComment">// 時給制の処理</span>
}
</pre>
<p>と書けるようになり、Enum要素追加時の要件棚卸しはEnumの方に集約されます。めでたしめでたし。</p>
<p>ただしこの方法、この説明だけなら一見美しいのですが実際の開発になると「ほんとにやるかあ⋯⋯?」ということになってきます。<br/>
なにしろ、判定箇所はどんどん増えていきます。Enumはあっという間に大量のフラグを装備した巨大なロジック塊となっていきます。コンストラクタが<code>(true, true, false, 2, null, false, ...)</code>のような地獄になっていきます。<br/>
上記サンプルでは少しでもわかりやすいようにとパラメータに<code>/* useMonthlySalary =*/</code>みたいなコメントを書いていますが、こういう努力をしてもフラグが増えてきたらまず扱いきれなくなってくることは必定、このEnumに持たせる手法だけだと限界がすぐなのは明らかでしょう。</p>
<h2 id="2-switch文でなくswitch式を使う">2. switch文でなくswitch式を使う</h2>
<p>switch式はJava 12から登場の比較的新しい構文なので、まだなじみのない方もいるかもしれませんね。一言で説明してしまうと、switch式とはswitch文をベースにしつついくつかの点で強化された複数分岐構文なのですが、中でもswitch文と違って網羅性チェックをしてくれるという大きな特徴があります。<br/>
すなわち、以下の式はコンパイルエラーになるのです。</p>
<pre class="code lang-java" data-lang="java" data-unlink><span class="synStatement">var</span> result = <span class="synStatement">switch</span> (employee.type) {
<span class="synStatement">case</span> FULL_TIME -> /* 正社員の処理 */ ;
case PART_TIME -> /* パートタイムの処理 */ ;
// DISPATCHのケースがないのでコンパイルエラーになる: switch式がすべての可能な入力値をカバーしていません
}
</pre>
<p>既存のif文、switch文をすべてswitch式に書き換えれば、派遣社員対応の差分としても漏れがないことをレビュー確認可能になります。</p>
<p>実際にはこの2.と上記の1.の組み合わせで、つまりEnumにとって本質的である条件はEnumに持たせつつそれ以外の多くの判定はswitch式で書くという分業で漏れのないEnum要素追加を実現していくことになるでしょう。<br/>
Java以外の言語であっても、網羅性のある複数分岐構文を持つコンパイル言語ならまったく同じ考え方で静的に解決できます。</p>
<p>⋯⋯アプリケーション本体のプログラミングに限っては。</p>
<h1 id="データ分析の辛み">データ分析の辛み</h1>
<p>ある程度以上のユーザーを獲得しているシステムならば、何らかの形で実績のデータ分析という活動が行われていることでしょう。<br/>
データ分析をするのは通常、システムアプリケーション本体ではなく、分析用DBに転送してデータマートやデータウェアハウスといった形でになりますね。リードレプリカに分析用SQLを投げるだけの形かもしれませんが、どちらにしろアプリ本体の外側で行種別判定のSQLが書かれていることになります。</p>
<pre class="code lang-sql" data-lang="sql" data-unlink><span class="synSpecial">CASE</span> e.<span class="synSpecial">type</span> <span class="synSpecial">WHEN</span> <span class="synSpecial">'</span><span class="synConstant">FULL_TIME</span><span class="synSpecial">'</span> <span class="synSpecial">THEN</span> ... <span class="synSpecial">ELSE</span> ... <span class="synSpecial">END</span>
</pre>
<p>こんなSQLが気付かず放置されていれば、派遣社員はパートタイマー扱いとなりますね。逆パターンのCASE-WHEN式であれば正社員扱いになります。if文と同じ現象がここに。<br/>
もちろん、抽出条件の方にも同じことが起こります。</p>
<pre class="code lang-sql" data-lang="sql" data-unlink><span class="synSpecial">WHERE</span> e.<span class="synSpecial">type</span> = <span class="synSpecial">'</span><span class="synConstant">FULL_TIME</span><span class="synSpecial">'</span>
</pre>
<p>こう書いた抽出式で派遣社員が含まれないのは意図通りなのか問題。</p>
<p>ここでSQLの辛みは、アプリケーションプログラミング本体とはまた違った味わいがあります。</p>
<ul>
<li>アプリケーションプログラムほど強力な参照箇所検索は使えないことが多い。<br/>
Enum定義を自分で持っているわけではないですからね。さらに、特に今回の例のような「type」という特異性の低いカラム名だと検索ノイズが多すぎて全文検索さえ役に立ちません。</li>
<li>多くの場合、アプリケーション本体の開発エンジニアとデータ分析エンジニアは別チームである。<br/>
その結果、Enumの要素を追加したという事実自体がデータ分析エンジニアに伝達し漏れてしまいがち。</li>
</ul>
<p>前者は技術的、後者は人間系の問題ですが、これ結局は人間系の問題に行き着きます。<br/>
だって、前者の問題があるからデータエンジニアは聞いてさえいれば絶対に止めるのです。Enumの追加はちょっと待ってくれと。<br/>
使用箇所を棚卸しして対策入れるまでに急いでも○週間かかるから、それまで本体改修のリリースは待ってくれと。<br/>
ええ、時間はかかりますとも、それこそ前者の理由で。かつ、データ連携先がチーム外や社外にも及んでいれば気が遠くなります。見込みを○週間と即答することさえ難しいですね。</p>
<h1 id="データ分析のことまで考慮した対策">データ分析のことまで考慮した対策</h1>
<h2 id="1-データ分析エンジニアとのコミュニケーションを密にする">1. データ分析エンジニアとのコミュニケーションを密にする</h2>
<p>当たり前すぎてつまらない話ですが、避けられない話です。どんな方法ができるかご考慮ください。定期ミーティングを持つ、兼任メンバーを置く、設計レビューのチェック項目にデータチーム承認というのも設けておくなどです。<br/>
ちなみに私のチームでは全部やっていますし、なんでしたら私がその兼任メンバーです。</p>
<h2 id="2-Enum要素は増やさない">2. Enum要素は増やさない</h2>
<p>ちょっと待ってそれはどうなんだ、Enum要素を増やさないなんてできるのか、実際いま派遣社員を追加しないといけないではないか。</p>
<p>これはこう考えます。<br/>
設計的には、「派遣社員は正社員の一種の新しい内部分類である」などとまず位置づけるのです。<br/>
これは設計の中だけで完結する納得ではなく、利用者サイドとも共有しておくべき認識ですね。<br/>
そして、データ定義としては<code>type == FULL_TIME</code>なる従業員をさらに細かく分類する種別として</p>
<pre class="code lang-java" data-lang="java" data-unlink><span class="synType">public</span> <span class="synType">enum</span> FullTimeEmployeeType {
REGULAR,
DISPATCH,
}
</pre>
<p>を作り、そういうフィールドを従業員クラスに新設するのです。DB的には従業員テーブルにカラムを追加と。</p>
<p>派遣社員は「正社員の一種である」以上、アプリロジックでも分析ロジックでも既存のコードの分岐は基本的に間違いではありません。<br/>
その上で、派遣社員は正社員は処理ロジック違えないといけないところあるんだっけと考えてその違うところだけを修正対象に挙げていけばよいとなります。</p>
<p>もっとも、この手法も問題点が多いことは一目見てよくわかると思います。<br/>
1カラムで十分な種別わけのためにカラム数が増えていくのはもちろん設計として美しくないわけですが、デメリットは単に美感だけの問題ではありません。コードやスキーマの学習コストが上がるわけですから、人の育てにくさというジャブが開発にじわじわ打ち込まれます。<br/>
switch式によって考慮漏れを確実に捕まえる作用も期待できなくなります。要素が増えないんですから。</p>
<p>ここから先は決まった答えはなく、議論しながらチームとして納得できる解決方法を毎回導き出していくものになってきます。<br/>
データエンジニアチームがEnum追加を許容できる場合もあるでしょう。<br/>
それが無理で「内部分類」カラムを増やすしかなくなったときも、アプリ上では単一の分類軸に見えるようなメソッドを設けることもできるかもしれません(これは私はやったことありませんが。やったことある方いらしたら、この方法で新たな辛みが生まれないかなどの知見をいただけたらうれしいです)。</p>
<pre class="code lang-java" data-lang="java" data-unlink><span class="synType">public</span> IntegratedEmployeeType getIntegratedType() {
<span class="synStatement">return</span> <span class="synStatement">switch</span> (<span class="synType">this</span>.type) {
<span class="synStatement">case</span> FULL_TIME -> switch (this.fullTimeEmployeeType) {
case REGULAR -> IntegratedEmployeeType.REGULAR;
case DISPATCH -> IntegratedEmployeeType.DISPATCH;
};
case PART_TIME -> IntegratedEmployeeType.PART_TIME;
};
}
public void setIntegratedType(IntegratedEmployeeType type) {
switch (type) {
case IntegratedEmployeeType.REGULAR -> {
this.type = FULL_TIME;
this.fullTimeEmployeeType = REGULAR;
}
case IntegratedEmployeeType.DISPATCH -> {
this.type = FULL_TIME;
this.fullTimeEmployeeType = DISPATCH;
}
case IntegratedEmployeeType.PART_TIME -> {
this.type = PART_TIME;
this.fullTimeEmployeeType = null;
}
};
}
</pre>
<h1 id="まとめ">まとめ</h1>
<p>結局最後は議論と納得だという話に落ち着いてしまいましたが、設計はいつもそうです。<br/>
ひとりが導き出した最適なる設計——そんなものはありもしないのですが——よりは、チームで問題点を共通認識として持ち、各案のメリットデメリットを共有し、最終的に何を捨てて何を取ったかをみんなが知っている、そうやって作った設計からの方がより良いコードが生まれるのはごく自然なことです。</p>
<h1 id="We-are-hiring">We are hiring</h1>
<p>アプリ開発エンジニアがデータエンジニアの方も向いている、そういう開発環境はエムスリーの自慢ですが、最初からそうであったわけではありません。いくつもの苦い経験を積みながら反省の末に作り上げてきたものです。</p>
<p>アプリエンジニアの顔が見える(リモートですが!)データ分析のお仕事にご興味のあるデータエンジニア、機械学習エンジニアの皆様、そういう開発環境にちょっとでもご興味ありましたらこちらのページからどうぞ。応募を前提にしないカジュアル面談もやっています。</p>
<p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fjobs.m3.com%2Fengineer%2F" title="エンジニア採用サイト|採用情報 | 医療×テクノロジーの可能性は無限大" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://jobs.m3.com/engineer/">jobs.m3.com</a></cite></p>
<div class="footnote">
<p class="footnote"><a href="#fn-499025e3" id="f-499025e3" name="f-499025e3" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">コードレビューで確認困難というのは、改修している本人にとっても漏れなく作業するのが難しいということでもあります</span></p>
</div>
Sampo