ウェブアクセシビリティ 再入門 − 2021年版

この記事は 個人Qiita と同じ内容です

qiita.com/sekiyaeiji

ウェブアクセシビリティ 再入門

※ 本稿は過去の登壇用のスライドの箇条書きのテキストを文章に起こし直し、更新されているデータを最新のものに置き換え、いくつかコメントを加筆して再編しました。

アクセシビリティ

"アクセシビリティ" とは、「accessibility」と書きヌメロニム表記でしばしば「a11y」と記載されます。

単語の意味としては以下の通り「アクセス可能性、アクセスのしやすさ」となります。

  1. 入手[利用]可能性、可触性、近接性、手の届きやすさ
  2. 近づきやすさ、アクセス可能性
  3. アクセス可能性、アクセスのしやすさ

accessibilityとは − 英辞郎 on the WEB

Web アクセシビリティとは

"ウェブアクセシビリティ" とは、現在市場において以下のように理解されているようです。

  • 高齢者、障害者を含むあらゆるユーザーを意識したユニバーサルで高品質なウェブUXとその提供

また、Web の創始者、ティム・バーナーズ・リー がWeb アクセシビリティについて2009年に触れた一節があるそうです。

The power of the Web is in its universality. Access by everyone regardless of disability is an essential aspect.
Web のパワーは、その普遍性にある。障害の有無に関係なく、誰もが使えることが、その本質である。

加えて、W3C は accessibility についての見解を現在このように記しています。

Accessibility is essential for developers and organizations that want to create high quality websites and web tools, and not exclude people from using their products and services.
サービスを高品質に提供したり、プロダクトを利用してくれるあらゆるユーザーを排除したくない開発者にとって、アクセシビリティは不可欠なものである。

引用 : Accessibility - W3C

障害の種別

障害には以下のようなカテゴリーがあるのと、それぞれ定義がなされています。

障害種別ごとの定義

「障害者」の定義って?障害者手帳や障害年金などのサービス、障害者雇用の対象、支援の根拠となる障害者総合支援法について説明します。 − LITALICO仕事ナビ

障害者のネット利用状況

障害を持つ方々は、インターネットにどのように接し、どのくらいの割合が利用し、どのような課題感を持っているのでしょうか。

障害者総数

ネット利用率

日本人全体のネット利用率

ちなみに国民全体のネット利用率はどの程度でしょうか。

2010年代は80%あたりでほぼ横ばいの推移をしていましたが、時世の影響でしょうか、2019年に突然約10%上昇しました。

ネット利用障害者数

  • 510万人
    • 人口比 4.0%
    • 障害者比 53.0% が利用
    • 増加傾向

ネット利用目的

  • 趣味、娯楽
  • 調べる
  • 情報発信
  • 勉強、仕事
  • 交流、コミュニケーション
  • 障害ハンディキャップの補完
    • 文字情報、テロップの充実
    • 文字メディアの音声化
    • 意思伝達の文字化・音声化ツール
    • 肢体不自由の補完

インターネット利用に際して困ること

  • コンピューターウイルスや不正アクセス
  • 障害に配慮したホームページの少なさ
  • 障害を補う機器、ソフトが少ない
  • 障害者向きの情報不足
  • 画面がごちゃごちゃして見にくい
  • 欲しい情報を見つけるのが困難
  • 通信費用が高い
  • わからないときに相談する人がいない
  • 利用者同士のトラブルが怖い

以上の情報をまとめると、Web プロダクトがこれら感情をユーザーに感じさせる状態がつまり、「Web アクセシビリティが悪い」状態ということになります。

極端に質の低いサイトやサービスでは、健常者も感じたことがある内容ではないでしょうか。

共感できる項目も多そうです。

・・・

さて、ここまで現状とユーザー課題についてまとめました。この現状を理解した上で、Web アクセシビリティにおいて実際に発生している課題は何か、について掘り下げて行きましょう。

モダンウェブアクセシビリティ

英国内務省が提唱し取りまとめる「Dos and don'ts」という accessibility に関する記事は、Web アクセシビリティ観点において、してほしいことしてほしくないことについて詳細をよくまとめられています。

Dos and don'ts on designing for accessibility」

001.png
@onouchidebe

英国内務省 (UK Home Office) Accessibility
Dos and don'ts on designing for accessibility
Dos and don'ts on designing for accessibility(pdf)
英国内務省 (UK Home Office) によるウェブアクセシビリティの「べき/べからず」ポスター
ウェブアクセシビリティの「べき/べからず」ポスター (UK Home Office) 日本語版

対象分類

Web アクセシビリティ において以下のカテゴライズごとに対策をまとめてくれています。

自閉症スペクトラムディスレクシア、不安状態 というカテゴリーに言及できているところがこれまであまりなかったポイントであり有用です。

詳細

詳細はそれぞれ以下のとおりです。ひじょうに役立てやすい情報にまとまっています。

自閉症スペクトラム

すること しないこと
単純な色を使う 鮮やかでまぶしい色を使う
優しい言葉で書く 比喩表現や慣用句を使う
簡単な文章と箇条書きを使う 区切りのない長文で文字の壁をつくる
説明的なボタンにする 曖昧で予測不能なボタンにする
簡潔で一貫したレイアウトを構築する 複雑でごちゃごちゃしたレイアウトを構築する

スクリーンリーダー

すること しないこと
画面の説明、動画の書き起こしを提供する 画像や動画だけで情報を表示する
順序立てた論理的なレイアウトにする ページ全体にコンテンツをバラバラに配置する
HTML5を使ってコンテンツを構造化する 文字サイズや配置に頼って構造化する
キーボードだけで使えるように構築する マウスや画面の使用を強制する
リンクや見出しは説明的に書く リンクや見出しを役立たずにする

ロービジョン

すること しないこと
良いコントラストと読みやすい文字サイズを使う 低いコントラストと小さい文字サイズを使う
すべての情報をウェブページ(HTML)で公開する ダウンロードの中に情報を埋没させる
色、形、文字の組み合わせで意味を伝える 色だけで意味を伝える
順序立てた論理的なレイアウトにする
(拡大表示したとき文章は折り返して表示される)
ページ全体にコンテンツを広げる
拡大表示したとき横スクロールが必要(※))
ボタンと通知は文脈にそって配置する 文脈と分離した操作をさせる

ディスレクシア

すること しないこと
理解を助けるために画像や図を使う 長い文章で大きな文字のブロックをつくる
文字揃えは左揃えで一貫したレイアウトを保つ 下線を引く、斜体を使う、大文字で書く
他のフォーマットでの情報提供を検討する
(例:音声や動画)
前のページを覚えておく必要がある
(リマインドやヒントを出しましょう)
コンテンツを短く、明確に、簡潔にする 正確なことばで入力する必要がある
(予測入力や自動補正の機能を使いましょう)
背景と文字のコントラストを利用者が変更できる(※) ひとつの場所に情報をつめこむ

身体障害・運動障害

すること しないこと
クリック可能な範囲を大きくする 精密さを要求する
操作対象のあいだを空ける 操作対象を近づけすぎる
キーボードや音声だけで使えるように設計する マウスをたくさん動かす必要がある
携帯電話やタッチスクリーンを想定して設計する(※) 短い時間制限をもうける
ショートカットを提供する タイピングやスクロールで利用者を疲れさせる

聴覚障害・難聴

すること しないこと
やさしい言葉で書く 難しい言葉や比喩表現を使う
字幕を使うか。動画の書き起こし文を提供する 音声や動画のみで情報提供する
順序立てた論理的なレイアウトにする 複雑なレイアウトやメニューをつくる
小見出し、画像、動画でコンテンツを分割する 長いかたまりのコンテンツを読ませる
予約や手続きの際に利用者が希望するコミュニケーション支援を利用できる 電話を唯一の連絡手段にする

不安状態

すること しないこと
操作を終えるのに十分な時間がある ユーザーを急がせたり、必要のない時間制限を設ける
これから何が起こるかを説明する 次にすることや時間制限で利用者を混乱させる
重要な情報は明確に 操作の結果がはっきりわからない
操作を完了するために必要なサポートを提供する サポートやヘルプにアクセスしづらい
ユーザーが送信前に入力内容を確認できる 質問に回答したユーザーを放置しない

詳細補足

以上詳細について少し補足します。

拡大表示したとき横スクロールが必要

  • レスポンシブレイアウトで解決できます

背景と文字のコントラストを利用者が変更できる

  • 現在では多くのOSにおいて設定が対応できています
    • OSの設定、操作を妨げない対応が重要です

携帯電話やタッチスクリーンを想定して設計する

  • レスポンシブレイアウトが重要です

実際に利用している様子がわかる動画から学ぶ

開発者が Web アクセシビリティ に取り組む際に、以下の動画は必ず確認しておく必要があります。

説明テキストで知ったつもりになるだけではなく、ユーザーストーリーを見て実感した上で開発、デザインに取り組むことが肝要です。

障害者のウェブページ利用方法の紹介ビデオ

視覚障害者(全盲)のウェブページ利用方法 (YouTube再生)

この動画でわかることは以下のとおりです。

まとめ

まずは JIS X 8341-3:2016 達成基準 早見表(レベルA & AA)適合レベル A から実装してみたいと実感することができました。

視覚障害者(弱視)のウェブページ利用方法 (YouTube再生)

この動画でわかることは以下のとおりです。

  • 弱視
  • 先天的、右:0.02(矯正)、左:光を感じる
  • グラフィック利用
  • Win拡大鏡による画面拡大
  • 支援技術なしでテキストを200%までサイズ変更できるようにする
  • 白背景がまぶしいため明暗反転する
  • 自動再生されるカルーセル等画像スライドは一時停止機能が必要
  • スマホタブレットも反転表示利用

まとめ

OSやブラウザの設定、操作を妨げない対応、確認が重要だと実感として知ることができます。

肢体不自由者のウェブページ利用方法 (WMVダウンロード)

この動画でわかることは以下のとおりです。

  • 手指に力がなく書籍のページをめくるのも困難
    • 活字メディアが利用できない
  • トラックボールを腕全体の動きで操作
  • オンスクリーンキーボードを利用
  • IE利用
  • 文字が小さく読みづらい
  • 横スクロールは発生してほしくない
  • 情報発信に挑戦中

まとめ

むしろデジタル機器やウェブコンテンツは より必要とされている と実感できます。

今日からできること

ここまで、アクセシビリティ をとりまく具体的な課題を知ることができました。

ではまず、われわれが実際に行動を起こす際に、まず何から着手ができそうでしょうか。

・・・

最初に対応できることとしてたとえば、Google DevTool の Lighthouse で計測できる Accessibility のスコアアップから目指してみるのはいかがでしょう。

何も行動しないよりは、すぐできることからまず始めてみましょう。

Lighthouse 頻出メッセージと対策例

Lighthouse で Accessibility 対策に取り組むとよく出てくる結果メッセージついて、以下に対策例をまとめてみました。

Background and foreground colors do not have a sufficient contrast ratio

  • 背景色と前景色は十分なコントラスト比を持っていません。

対策例

  • UIデザイナーと相談して色設定の変更を検討しましょう
  • Color Contrast Analyzer
    • レベルAA、レベルAAA への対応色(コントラスト値)を導き出せる

Heading elements are not in a sequentially-descending order

  • 見出し要素は順番に降順ではありません

対策例

  • 見出しの階層構造を適正化しましょう
before
        h3
    h1
          h4
              h6
      h2
            h5
after
    h1
      h2
        h3
          h4
      h2
        h3

Image elements do not have [alt] attributes

  • img要素 には alt属性 がありません

対策例

推奨
  • alt属性値に写真画像の内容を表現するテキストを設定しましょう
  • alt属性値にテキスト入り画像のテキストを設定しましょう
ミニマム対応
  • 空値のalt属性を設定しましょう
    • 読み上げ対象から外すことができます

Form elements do not have associated labels

  • フォーム要素 に ラベル要素 が関連付けられていません

対策例

  • 入力系に label要素 を設定するか、title属性 を設置しましょう
before
    <input type="checkbox"> メールニュースを希望する

    <select> 〜 </select>
after
    <input type="checkbox" for="form-mailnews">
    <label id="form-mailnews"> メールニュースを希望する</label>

    <select title="都道府県"> 〜 </select>

Links do not have a discernible name

  • リンクには識別可能な名前がありません

対策例

  • a要素 内にアンカーテキストを付与するか、alt属性、title属性、aria-label属性 などのいずれかにテキストを設定しましょう
before
    <a href="hoge"></a>
after
    <a href="hoge">記事ページへ</a>

    <a href="hoge"><img alt="記事ページへ"></a>

    <a href="hoge"><span title="記事ページへ"></span></a>

    <a href="hoge" aria-label="記事ページへ"></a>

frame or iframe elements do not have a title

  • frame要素 または iframe要素 にタイトルがありません

対策例

  • frame要素、iframe要素 に title属性 を設定しましょう
before
    <iframe>></iframe>
after
    <iframe title="YouTube動画"></iframe>

ARIA IDs are not unique

  • ARIAIDは一意ではありません

対策例

  • id属性値 を1ドキュメント内で重複させないようにしましょう

さらに深堀りする

Google DevTool の Lighthouse の対応が一通りパスできたら、つぎにやるべきことは以下のとおりです。

レベルAA、レベルAAA 実装を目指す

【参考】JIS X 8341-3: 2016の適合レベルA、AA、AAA 全リスト

WAI-ARIA を利用する

  • role属性
  • aria属性

【参考】WAI-ARIAを活用したフロントエンド実装 | CodeGrid

まとめ − "ウェブアクセシビリティ 再入門 202112 記事バージョン" について総括

以上、重要なポイントを再確認しましょう

  • ウェブアクセシビリティとは、ユニバーサルで高品質なウェブUX のことです
  • 国内ユーザー数は現在 約510万人 で、さらに増え続けています
  • ウェブ は障害のある方々に必要とされています
  • 容易なことから着手しましょう
  • BE、FE、Des が協力して調査、改修しましょう

最後に

最後に、世界で最も有名な障害者と称された指導者、著作家ヘレン・ケラー(Helen Adams Keller − 1880年 - 1968年) のフレーズに触れて本稿を締めさせていただきます。

So long as you can sweeten another’s pain, life is not in vain.
人の苦しみをやわらげてあげられる限り、生きている意味はある

今回取り上げた ウェブアクセシビリティ の件に限らず企業活動とは、ヒトの幸福悩みの解決に根ざしてこそ継続でき得る活動である、と私は理解しています。

その延長線上に Webアクセシビリティ が存在し、国内だけでも 500万以上のユーザー がコンテンツの提供を望んでいるということを定期的に再認識し、プロダクト提供における自身の視野を広い状態に保っておくことは重要そうです。

TypeScript でオブジェクトのプロパティの型推論しても、親オブジェクト自体には型推論は適用されない

この記事は個人ブログと同じ内容です

TypeScript でオブジェクトのプロパティの型推論しても、親オブジェクト自体には型推論は適用されない


前置き

こんにちは、 back check 開発エンジニアの @sota_yamaguchi です。

今回は、直近の開発のなかで、 TypeScript の型推論の挙動に対してなぜか思ったように型が適用されないんだが、、、となったことがあったので、調査した結果知見を得られたので記事にしてみました。

結論

先に結論を述べておきます。 TypeScript の仕様として、オブジェクトのプロパティに対して型ガードを行って型を推論しても、親となるオブジェクト自体には型推論の結果が適用されません。

やろうとしたこと

A 型の demoData オブジェクトのプロパティからそれぞれ undefined を省くように絞り込んで、関数 testFunc の props として渡そうとしました。 if を通過したことによって、型推論で B 型の条件を満たせているように見えますが、 if 通過後も demoData の型が A 型として扱われていることによって、 testFunc の引数として渡そうとするとエラーがでてしまいました。

type A = {
  name: string | undefined
  email: string | undefined
}

type B = {
  name: string
  email: string
}

const testFunc = (props: B): void => {
  console.log(props)
}

const demoData: A = {
  name: 'hoge',
  email: 'hoge'
}

// A 型の demoData のプロパティの型 name, email から、それぞれ undefined を省くように絞り込む
if (!demoData.name || !demoData.email) {
  throw 'error'
}

// error: Argument of type 'A' is not assignable to parameter of type 'B'.
testFunc(demoData)

TypeScript の仕様

まず前提として TypeScript は構造的部分型の言語です。つまり、 A 型と B 型のシグネチャが等しければ、 A 型の代わりに B 型を渡しても怒られない言語です。 今回のケースでは、型ガードによって A 型のプロパティの型から undefined を除外したことで、型推論によって A 型は B 型と同等のインターフェースを提供すると推論してくれることを想定していたのですが、試したところエラーになってしまったので理由がわからずに詰まったというケースでした。

これについて調べた結果、 TypeScript の issue で以下のコメントを見つけました。

Type guards do not propagate type narrowings to parent objects. The narrowing is only applied upon access of the narrowed property which is why the destructing function works, but the reference function does not. Narrowing the parent would involve synthesizing new types which would be expensive. タイプガードは、タイプナローイングを親オブジェクトに伝播しません。ナローイングは、ナローされたプロパティにアクセスしたときにのみ適用されます。そのため、破棄関数は機能しますが、参照関数は機能しません。親を絞り込むには、コストのかかる新しいタイプを合成する必要があります。  by google 翻訳

TypeScript の仕様として、型ガードを行っても、型推論の結果は推論を行なったオブジェクトのプロパティにのみ適用され、親のオブジェクトには適用しないことがわかりました。

TypeScript がこれをサポートしていない理由としては、それっぽい回答として以下を見つけたので貼っておきます。(2021/12/30 時点では、この仕様について明言しているドキュメントはないようです)

  • パフォーマンスについて

    In other words, in order to know the type of x we'd have to look at all type guards for properties of x.That has the potential to generate a lot of work. X 型の型情報を知るためには、 X 型が持つすべてのプロパティに対して型ガードを実施して調べる必要があります。それは大量の処理を生み出してしまう可能性があります。 by オレオレ翻訳

回避策

では、 A 型のオブジェクトを B 型として扱うにはどうしたらいいのか。ということで今回の例に対しては以下の方法で回避することが可能です。

  1. 型ガードによって推論が効いているプロパティのみを引数として扱う方法
if (!demoData.name || !demoData.email) {
  throw 'error'
}

testFunc({name: demoData.name, email: demoData.email})

  1. 型ガードによって親オブジェクトにユーザー定義で型をアサインする方法
const isB = (x: A | B): x is B => !!x.name && !!x.email;

if (isB(demoData)) {
  testFunc(demoData);
}

1 の例はプロパティを推論して、そのまま利用しているのに対して、 2 の例はプロパティの検証をしたらおやオブジェクト自体に型のアサインをしています。 推論後の利用したいプロパティが限られているのであれば安全性を重視して 1 を。拡張性や可読性を上げたいなら 2 を。など、場面によってこれらを使い分けていけるとよさそうです。

おわりに

日頃から TypeScript は触っているので自分は慣れている方だと思い込んでいたのですが、今回の調査で何も分かっていなかったことを実感させられました。 いい振り返りにもなるので、また新しい発見があったら随時発信していきたいと思います。

非エンジニアが知らない、スクラム開発で行われていること

この記事は 個人Qiita と同じ内容です

qiita.com/sekiyaeiji

非エンジニアが知らない(であろう)、スクラム開発において行われていること

スクラム開発」とは、非エンジニアの方々の目にはどう映っているのでしょうか。

エンジニア組織が選択する開発手法のひとつ、ぐらいに見えているかもしれないですね。

実はスクラム開発は、開発手法のみならず、エンジニアにとって働き方改革らしきものまで提供してくれています。

非エンジニアの方々は知らないかもしれない、スクラム開発の根底にあるエンジニアの昨今の"働き方"についてご紹介したいと思います。

本稿ではスクラム開発の開発手法自体にはあまり触れず、スクラム開発がもたらすエンジニアの働き方の変遷にフォーカスをあてて議論を進めます。

スクラム開発自体の説明については日本語版公式ガイドを参照してください

スクラムガイド − スクラム公式ガイド:ゲームのルール / Ken Schwaber & Jeff Sutherland

スクラム用語解説

...と言いつつも、このあとの説明に必要になるスクラム開発に関する最低限の予備知識を、ここで解説しておきます。

スプリント

スクラム開発では短く区切られた期間の最小単位をスプリントと呼び、1スプリントは一般的に1週間や2週間に設定されます。

ポイント

スクラム開発では1つのチケットの作業量見積りを"ポイント"などの相対的な単位によって表現します。

たとえば3ポイントの基準チケットを用意しておき、あるタスクは基準チケットと比較して少し作業量が少ないので「2ポイント」とか、基準チケットの2倍弱ぐらいのボリュームだから「5ポイント」と決めよう、というような、見積りを行う際の単位として利用します。

ベロシティ

スクラム開発ではチームの1スプリントの処理ポイント数をベロシティと呼びます。

直近の3スプリントのベロシティの平均を、次のスプリントの目標ベロシティにする、というように利用します。

003.png

スクラム開発は"働き方改革"!?

スクラム開発の働き方とは

先述の通り、スクラム開発はエンジニアにとって、"働き方改革"かもしれません。

スクラム開発の業務スタイルのポイントは、行動量を一定に維持することです。

そのため、目標ドリブンとか、ウォーターフォール開発のような締切を設定する業務スタイルとは対照的な業務フローになります。

フェーズや状況によらず、常に行動量を一定にすることが重要になります。

細部を吸収しチーム全体のスループットのみを扱う

チームメンバーの日常においては、有休を取ることもありますし体調が悪い時もあるでしょう。 一時的なミーティングに時間を取られることもありますし、プロダクト開発以外の業務もあるでしょう。 突発的な障害対応や顧客サポート補助のような、予定している以外の開発タスクが発生することもあります。

スクラムチームで、直近数回の実績の平均値を次の目標ポイントにしているのはなぜかというと、上記のようなメンバーごとの都合やイレギュラーなイベントによるマイナスも包含して平均化することにより、それらの個々の事象を気にかける必要性を可能な限り削減するためです。 個々の事象を見積りに反映するコストを削減している、とも言えます。

そうして、コンスタントに一定の行動量を継続的に維持できることを重視します。

スクラム開発における働き方のポイント

スクラム開発では、あるスプリントだけ一時的にベロシティを上げてしまうと、目標ベロシティの現在の適正量が測れなくなったり、目標ベロシティが適正量を超えて増大することで徐々にチームを追い込んでしまうという課題が発生します。

ですのでチームの個々のメンバーが、継続的ではない、たまたま1スプリント内だけで一時的にベロシティを上げる行為は、慎むべきということになります。

それはベロシティの読みを狂わせ、チームを追い込んだりメンバーの疲弊の原因になるなど、チーム全体とってマイナスインパクトを発生させます。

また、Webプロダクトが24時間365日稼働であることが当たり前になっている現状において、さまざまな突発的な事態に素早く対応できるためにも、日常的な業務においては変動要素が少なく、稼働や思考サイクルが安定した状態であることが望まれます。

チームの行動量という、太さが常に一定のベルト状の帯を維持しておき、アウトプットはその帯の処理量に応じて一定の頻度で得られる状態をイメージすると理解しやすいかもしれません。

004.png

この帯の太さを一定に保つことへの努力を惜しまないことが、スクラムチームの重要な責務です。

スクラムチームは成長しないのか?

では、スクラムチームのパフォーマンスやアウトプット量には改善の余地はないのか、とか、まったくもって成長しなくてよいのか、というと、そんなことはありません。

チームのベロシティの向上自体を目指してはいけないという訳ではありません。

ベロシティを上げるために、スプリントイベントの改修や効率化、無駄の排除のような業務フローの改善を試みることによって、チームのベロシティの向上を目指すことは何ら問題はありません。

極端な改善例で言いますと、"ショートスリーパーのメンバーばかり集めてベロシティを上げる"とか...を、目指してはいけない、ということはありません。 ・・・なにか別の問題が起きそうな嫌な予感がするので私は実践しません w

スクラム開発のような働き方が実践できる条件

スクラム開発のような働き方はなぜ可能なのでしょうか。

このスタイルが可能になるためには、以下の2つの条件が必要と考えます。

  1. 一定期間中の予定タスクをチケット化して準備できる
  2. 不確定要素の介在が比較的少なく行動量を一定にできる業務内容である

上記は主務やすべての業務がこれに該当する場合だけでなく、一部の業務が継続的にこの条件を満たす場合にも運用できると思います。

1. 一定期間中の予定タスクをチケット化して準備できる

back check 開発チームでは1スプリントを2週間に設定しています。

スクラム開発では毎スプリント、スプリントが始まる前日までに、1スプリントの目標ベロシティ分のタスクを用意しておく必要があります。

優先順に並んだタスクチケットを上から順に取り、次のスプリントで対応するチケットを追加して行き、タスクの合計ポイント数が1スプリントの目標ベロシティのポイント数に達するところまでタスクチケットを追加します。

ある期間の単位でこれが毎回実行できる業務であれば、エンジニアリング業務以外でもスクラム開発と同じ手法で業務対応ができそうです。

2. 不確定要素の介在が比較的少なく行動量を一定にできる業務内容である

開発チームのエンジニアは、新規開発のコードだけをずっと書いていられるわけではなく、障害対応やバグ修正、顧客サポート補助、全社施策などのさまざまの差し込み業務にも対応していますが、それでも6〜7割以上はメインストーリーや改善施策などの主務に集中させてもらえている職能なのかもしれません。

差し込み業務はスプリントポイントに計上しませんので、スクラムチームのベロシティとは、主務(新規、改善タスク)におけるそのチームの価値提供の基準と言えます。

ですので、ある業務に対する単位期間中の一定の行動量を継続的に維持したい場合、維持できる場合に、スクラム開発の働き方が有用になります。

スクラム開発とその他の業務スタイル

スクラム開発とその他の業務スタイルをまとめると以下のようになると思います。

  • スクラム開発
    • チームの行動量をフィックスさせ、1スプリントごとにタスクを消化する
  • ウォーターフォール開発
    • 全体計画をフィックスさせ、複数の開発締め切りを経て計画の完了まで走り切る
  • 目標ドリブンの業務スタイル
    • 一定期間の目標をフィックスさせ、必要な行動量を割り出し目標達成にコミットする

それぞれ、何を軸として固定して、それに対して何を積み上げていくかの対象が異なります。

ウォーターフォール開発の課題

ウォーターフォール開発が昨今のWebプロダクト開発で活用されなくなっている理由を業務スタイル観点で考察しますと、計画が初期にフィックスされ途中の変更コストが考慮されていないために、短期の締切ごとに稼働を上げて変更分を吸収する傾向があり、チームメンバーを追い込むループが発生しチームが疲弊することが主な理由でしょう。

疲弊しても計画を達成すれば問題ないかと言えば、低下したチームパフォーマンスは元の状態に回復するまでインターバルが必要になり、インターバルのロスと追い込みのゲインが相殺するのと、スループットの増減が計画の読みを狂わせる点が課題になり、結論として使いにくいフレームワークとして利用されなくなっているようです。

目標ドリブンの課題

目標を定め必要な行動量を割り出して走り出す業務スタイルは、開発以外の職種で多く見られます。スクラム開発の働き方の実態はこのスタイルとほぼ違いはありませんが、決定的な差は、"目標"を目指さない点です。

平均ベロシティが安定して継続的に高い状態、というのが暗黙の"目標"になっていることもあるかもしれませんが、そういう"目標"を"目指す"という建付けは行いません。なぜでしょうか。

まず第一に、アジャイル開発において「"顧客との協調"を価値」としていることと、スクラム開発においてチームは常に"スプリントゴール(スプリントが提供する価値)"に集中しているため、他に目指すものが不要であるためです。

業務スタイル観点で言うと、"目標"の扱いの難しさがありそうです。

事業面においては目標値を設定できる職種もあると思いますが、開発では目標値の裏付けが求めにくいのです。

KPIの達成という観点が存在すると思われがちですが、それは要件定義側のPMとかデザインチームの目標に設定することは可能ですが、エンジニアリングの目標ではありません。

もっと具体的な、達成可能な定量目標を設定した場合も、目標には課題が多いです。

まず設定した目標値にロジックがないとチームメンバーに納得感が生まれにくいことや、早期に容易に達成できてしまう目標であれば、達成後のモチベーションが低下して残り期間の生産性が低下したり、その反対に難易度が高い目標だとそもそも達成のモチベーションが湧かず大きく不達に終わってしまいます。

目標という指標には元来、人間の主観が介在しやすかったり、人間の精神力を試すような性質がある点が、エンジニアリングとの相性がよくない本質的な原因のような気がします。

また目標という、根拠を立てづらい不確定要素によってパフォーマンスが左右されるというのも、計画の読みを狂わせる原因になりそうです。

まとめ ー スクラム開発の働き方が求められる理由

スクラム開発は開発スタイルとして価値がある手法ですが、定着する理由は業務スタイルとしても以上の通り、もろもろの要因があるということです。

  • 予定タスクを細分化したチケットを準備できる
  • 行動量を一定にできる

この条件にかなうすべての業務にこの手法が利用できます。

この業務スタイルを採用すると以下のメリットが得られます。

  • 確定したタスクの反復のため確実に実践できる
  • サイクルごとに業務のコンディションがモニタリングできる
  • いつもと違う問題が発生したら課題発見と改善のチャンスになる

ネクストアクション

私自身がPOとして要件定義や検証、インプット、アウトプットの反復など、エンジニアリング以外のタスクを生業としているため、月次とかで反復実行しているタスクをチケット化してスクラムのように進行できるかトライしてみたいと思います。

スクラム開発自体も各社試行錯誤しながら継続的なブラッシュアップを繰り返していると思われ、まだまだ進化の余地がある開発フレームワークですので、本稿のようなアウトプットによって活発に情報交換される状況を促進したいと思います。

参考図書

本稿では、スクラムの原則について確認するために、以下の書籍を参考にさせていただきました

SCRUM BOOT CAMP THE BOOK【増補改訂版】 スクラムチームではじめるアジャイル開発

日常から学ぶ 気づきの法則

この記事は 個人Qiita と同じ内容です

qiita.com/sekiyaeiji

Jira のバックログにおいて、
もう見返さないであろうストーリーとかタスクチケットを、ときどき却下しまくって、
小さな業務改善気分を味わっています

今回、少し思い切った断捨離にトライしました

最近追加したストーリーとタスク以外の過去数年分のチケット 135個 を、
チームメンバー全員に1週間ほど目を通し確認してもらった後に、
135チケットすべてを却下してバックログ断捨離を実行しようと思い立ちました

ふと思いついたこと

ところで削除対象の 135 チケットっていまのバックログのリスト全体のどのくらいの割合だ?

ぜんぶで 462 チケットあって 135 チケット削除したら 327 チケットになる、
ということは、135 チケットは現在のチケット全体の 29.2 %を占める...
リスト画面の項目が 3割 も減ったら、画面のパフォーマンス向上するかもな...

よし、測ってみよう ♪

パフォーマンス計測

Chrome > Dev Tool > Lighthouse > Performance にて、
チケット3割削除の前後の、5回の計速値の平均で、数値が向上するかどうか確認しました

001.png

計測結果値を眺めてみて、
メンバーにとって業務影響が最もイメージしやすい項目として、

  • Time to Interactive : UI全体が操作可能になるまでの時間

による比較をしてみようと思いました

削除前と後のTTI(Time to Interactive)の比較

Jira のバックログ一覧画面をリロードして、
Lighthouse の Performance を実行する作業を前後とも5回ずつ繰り返しました

1回目 2回目 3回目 4回目 5回目 平均 改善
before 7.4 12.8 9.7 9.6 9.6 9.82
after 7.4 7.4 7.4 7.9 8.2 7.66 2.16 s

ページのロード時に、画面が使えるようになる時間が、
9秒から7秒になり、2秒改善 という結果になりました

002.png

得られた効果

チームメンバーにアンケートを取って平均を出したところ、
この Jira の バックログ 画面の1日のロード回数は 4.85 回となりました
シンプルに 1日 4回ロードしてると仮定しますと...

時間

年間 200営業日 で
15人 のメンバー が
1日 平均4回 画面ロード する場合に
表示速度が 毎回 2秒短くなると
チーム全体で年間 6.7時間

つまり、Jira 操作に費やしている時間を 6.7時間 削減できた、ということです

チーム全員で、いままで捨てていた年間 6.7時間 を取り返せたことになります

効率

上述の計算の通り、チケットは 29.2 %削減できました

画面スクロールや目視での検索において、
無駄が3割ほど削減し、作業効率が向上できました
集中を阻害する要因も削減できたと言ってよいでしょう

所感

何気ない日常の作業について、
面白がって進められる方法を模索したり、
自身がほぼ事務的に行っているような業務の価値を再確認し、
効果があれば可視化して、
こうして自慢気にアウトプットして w より楽しんでみたり

こんな風に仕事ができている自分っていま、
大丈夫そうだな w って実感しました

考察

ともあれ、
実は以上のような取り組みを実践することは、
自身の目標評価サイクルを回す時や、
プロダクトの分析やデータサイエンスの解法を導き出す時など、
いろいろな場面でプロセスを生み出す時の作業と似ているところがあります

埋もれている法則性を見つけ出すことや、
まだ調べられてないことを調査し可視化しようとする動機と好奇心を持つことの価値、は
とくにデータサイエンスを進める際にも大切なポイントになります

ですので、事業プロダクトについても、
常日頃から上記のようなスタンスで、
没頭し面白がりながら好奇心と行動量を高い水準で維持しておくことで、
新しい指標や効果、価値を発見する確率を上げることができそうだ、
と感じた取り組みでした

まとめ

  • 何気ない日常を面白がる素材はすぐ手元に転がっている
  • 日常を面白がれる活力が湧く
  • 埋もれた価値再発見しわかりやすい指標で可視化するスキルは有用である
  • 没頭面白がりながら好奇心行動量を維持することで、気づきの確率は上げられる

WINDOW 関数を一通り試してみよう

この記事は個人ブログと同じ内容です

www.ritolab.com


MySQL も 8 から WINDOW 関数が使えるようになり更に利便性が向上していますが、SQL の WINDOW 関数にはどんな関数があるのか?ということで、WINDOW 関数を一通り試してみます。

WINDOW 関数

WINDOW 関数は、結果行の集約を行うことなく集計・分析のための計算を行う事のできる関数。

ウィンドウ関数は、一連のクエリー行に対して集計のような操作を実行します。ただし、集計操作ではクエリー行が単一の結果行にグループ化されますが、ウィンドウ関数ではクエリー行ごとに結果が生成されます。

引用元:MySQL :: MySQL 8.0 リファレンスマニュアル :: 12.21.2 Window 関数の概念と構文

例えば集計関数を用いる際に GROUP BY すると結果行がまとめられますが、WINDOW 関数を用いる(もしくは集約関数を WINDOW 関数として処理する)場合は結果行はまとめられず、各行に結果が付与されるようになります。

GROUP BY で集計

SELECT
    user_id,
    AVG(score) AS average
FROM scores
GROUP BY user_id;

+---------+---------+
| user_id | average |
+---------+---------+
|       1 | 76.7204 |  <-- 行が user_id でまとめられる
|       2 | 73.7097 |
|       3 | 74.1505 |
|       4 | 74.5591 |
|       5 | 75.2688 |
+---------+---------+

WINDOW 関数で集計

SELECT
    user_id,
    score,
    AVG(score) OVER (PARTITION BY user_id) AS average
FROM scores;

+---------+-------+---------+
| user_id | score | average |
+---------+-------+---------+
|       1 |    85 | 76.7204 |  <-- それぞれのレコードに average が付与される
|       1 |    83 | 76.7204 |
|       1 |    88 | 76.7204 |
|       1 |    90 | 76.7204 |
|       1 |    58 | 76.7204 |
+---------+-------+---------+

AVG() は集約関数ですが上記のように集約関数を WINDOW 関数として処理する事も可能)

基本形

分析関数 OVER句([PARTITION BY 句] [ORDER BY 句] [frame 句])

FUNCTION_NAME(expr) OVER ([PARTITION BY expr, expr,..] [ORDER BY expr, expr,..] [ROWS|RANGE ...])

  • PARTITION BY 句:どのカラムをグループとするか
  • ORDER BY 句:ソート順の指定
  • frame 句:対象行範囲の指定

これらを必要に応じて指定しつつ、振りたい値を行に付与していく。

名前付き Window

ウィンドウを定義する事で同じものをまとめられてクエリを簡単にできる。
MySQL :: MySQL 8.0 リファレンスマニュアル :: 12.21.4 名前付きウィンドウ

SELECT
    subject,
    user_id,
    average,
    MIN(average) OVER w1 as min_avg,
    MAX(average) OVER w1 as max_avg
FROM user_avg_scores_by_subject
WINDOW w1 AS (PARTITION BY subject) -- <- ウィンドウを定義
;

サンプルテーブル

サンプルで score テーブルを作成し、ここに対してクエリを投げていきます。

create table scores
(
    id                  bigint unsigned auto_increment primary key,
    user_id             int unsigned not null,
    subject             varchar(255) not null,
    score               int unsigned not null,
    implementation_date date         not null
)

5 名のユーザーが 1 日に 3 科目のテストを 1 ヶ月間受けた結果を格納したテーブルになっています。

+-----+---------+---------+-------+---------------------+
| id  | user_id | subject | score | implementation_date |
+-----+---------+---------+-------+---------------------+
|   1 |       1 | sub01   |    85 | 2021-12-01          |
|   2 |       1 | sub02   |    83 | 2021-12-01          |
|   3 |       1 | sub03   |    88 | 2021-12-01          |
|   4 |       1 | sub01   |    90 | 2021-12-02          |
|   5 |       1 | sub02   |    58 | 2021-12-02          |
|   6 |       1 | sub03   |    84 | 2021-12-02          |
|   7 |       1 | sub01   |    76 | 2021-12-03          |
|   8 |       1 | sub02   |    72 | 2021-12-03          |
(略)

次項からクエリを書いていきますが、COUNT や SUM などの集約関数も WINDOW 関数として処理できるのでそちらも含めて試していきます。

COUNT

レコード数をカウントします。

SELECT
    implementation_date,
    subject,
    user_id,
    score,
    COUNT(user_id) OVER (PARTITION BY user_id) as total_tests
FROM scores
ORDER BY implementation_date, subject, user_id;

PARTITION BY 句に user_id を指定して、ユーザーごとの総レコード数(=総テスト実施数)を付与します。

+---------------------+---------+---------+-------+-------------+
| implementation_date | subject | user_id | score | total_tests |
+---------------------+---------+---------+-------+-------------+
| 2021-12-01          | sub01   |       1 |    85 |          93 |
| 2021-12-01          | sub01   |       2 |    85 |          93 |
| 2021-12-01          | sub01   |       3 |    78 |          93 |
| 2021-12-01          | sub01   |       4 |    93 |          93 |
| 2021-12-01          | sub01   |       5 |    78 |          93 |
(略)
| 2021-12-31          | sub03   |       5 |    61 |          93 |
+---------------------+---------+---------+-------+-------------+
465 rows in set (0.00 sec)

1 日 3 テスト × 1 ヶ月(31 日)のため 1 人あたりの総テスト実施数は 93 回ですが、それらが各レコードに追加されていることがわかります。(このテーブルの総レコード数は 93 × 5 人分 = 465 レコード)

SUM

合計値を算出します。

SELECT
    user_id,
    subject,
    score,
    implementation_date,
    SUM(score) OVER (PARTITION BY user_id, subject) as total_by_subject
FROM scores
ORDER BY user_id, implementation_date, subject;

user_id と subject でグルーピングして、各ユーザーの科目ごとの合計得点を付与します。

+---------+---------+-------+---------------------+------------------+
| user_id | subject | score | implementation_date | total_by_subject |
+---------+---------+-------+---------------------+------------------+
|       1 | sub01   |    85 | 2021-12-01          |             2417 |
|       1 | sub02   |    83 | 2021-12-01          |             2405 |
|       1 | sub03   |    88 | 2021-12-01          |             2313 |
(略)
|       5 | sub03   |    61 | 2021-12-31          |             2438 |
+---------+---------+-------+---------------------+------------------+
465 rows in set (0.00 sec)

AVG

平均値を算出します。

SELECT
    user_id,
    implementation_date,
    subject,
    score,
    AVG(score) OVER (PARTITION BY user_id, subject) AS average
FROM scores
ORDER BY user_id, implementation_date, subject;

ユーザごと各科目の平均点を付与します。

+---------+---------------------+---------+-------+---------+
| user_id | implementation_date | subject | score | average |
+---------+---------------------+---------+-------+---------+
|       1 | 2021-12-01          | sub01   |    85 | 77.9677 |
|       1 | 2021-12-01          | sub02   |    83 | 77.5806 |
|       1 | 2021-12-01          | sub03   |    88 | 74.6129 |
(略)
|       5 | 2021-12-31          | sub03   |    61 | 78.6452 |
+---------+---------------------+---------+-------+---------+
465 rows in set (0.01 sec)

また、 frame 句で範囲を指定すればその区間での平均値も算出できるので、移動平均を求めたい時にも使えて便利です。

SELECT
    user_id,
    implementation_date,
    subject,
    score,
    AVG(score) OVER (PARTITION BY user_id, subject) AS average,
    AVG(score) OVER (PARTITION BY user_id, subject ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS moving_average_3,
    AVG(score) OVER (PARTITION BY user_id, subject ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING) AS moving_average_5
FROM scores
ORDER BY user_id, implementation_date, subject;

3 区間と 5 区間での平均値を付与します。

+---------+---------------------+---------+-------+---------+------------------+------------------+
| user_id | implementation_date | subject | score | average | moving_average_3 | moving_average_5 |
+---------+---------------------+---------+-------+---------+------------------+------------------+
|       1 | 2021-12-01          | sub01   |    85 | 77.9677 |          87.5000 |          83.6667 |
|       1 | 2021-12-01          | sub02   |    83 | 77.5806 |          70.5000 |          71.0000 |
|       1 | 2021-12-01          | sub03   |    88 | 74.6129 |          86.0000 |          80.3333 |
|       1 | 2021-12-02          | sub01   |    90 | 77.9677 |          83.6667 |          79.5000 |
|       1 | 2021-12-02          | sub02   |    58 | 77.5806 |          71.0000 |          71.2500 |
|       1 | 2021-12-02          | sub03   |    84 | 74.6129 |          80.3333 |          75.7500 |
|       1 | 2021-12-03          | sub01   |    76 | 77.9677 |          77.6667 |          81.8000 |
|       1 | 2021-12-03          | sub02   |    72 | 77.5806 |          67.3333 |          72.6000 |
|       1 | 2021-12-03          | sub03   |    69 | 74.6129 |          71.6667 |          72.8000 |
(略)
|       5 | 2021-12-31          | sub03   |    61 | 78.6452 |          63.5000 |          60.3333 |
+---------+---------------------+---------+-------+---------+------------------+------------------+
465 rows in set (0.00 sec)

LAG / LEAD

指定行分、前の値(LAG)・後ろの値(LEAD)を付与します。

SELECT
    user_id,
    implementation_date,
    subject,
    score,
    LAG(score, 1) over (PARTITION BY user_id, subject) as last_score,
    LEAD(score, 1) over (PARTITION BY user_id, subject) as next_score
FROM scores
ORDER BY user_id, subject, implementation_date;

user_id と subject でグルーピングした上で、各行に前後行の score を付与します。

+---------+---------------------+---------+-------+------------+------------+
| user_id | implementation_date | subject | score | last_score | next_score |
+---------+---------------------+---------+-------+------------+------------+
|       1 | 2021-12-01          | sub01   |    85 |       NULL |         90 |
|       1 | 2021-12-02          | sub01   |    90 |         85 |         76 |
|       1 | 2021-12-03          | sub01   |    76 |         90 |         67 |
|       1 | 2021-12-04          | sub01   |    67 |         76 |         91 |
|       1 | 2021-12-05          | sub01   |    91 |         67 |         98 |
|       1 | 2021-12-06          | sub01   |    98 |         91 |         58 |
|       1 | 2021-12-07          | sub01   |    58 |         98 |         83 |
(略)
|       1 | 2021-12-30          | sub01   |    93 |         56 |         76 |
|       1 | 2021-12-31          | sub01   |    76 |         93 |       NULL |
|       1 | 2021-12-01          | sub02   |    83 |       NULL |         58 |
|       1 | 2021-12-02          | sub02   |    58 |         83 |         72 |
|       1 | 2021-12-03          | sub02   |    72 |         58 |         72 |
(略)
|       5 | 2021-12-31          | sub03   |    61 |         66 |       NULL |
+---------+---------------------+---------+-------+------------+------------+
465 rows in set (0.00 sec)

グルーピングされた行の先頭と後尾についてはそれぞれ last と next が存在しないので null になっています。

ROW_NUMBER

行グループ内で序数を振る。

SELECT
    implementation_date,
    user_id,
    ROW_NUMBER() over (PARTITION BY user_id, subject) as times,
    subject,
    score
FROM scores;

ユーザーと科目でグルーピングし、科目毎の実施回(times)を振ってみます。

+---------------------+---------+-------+---------+-------+
| implementation_date | user_id | times | subject | score |
+---------------------+---------+-------+---------+-------+
| 2021-12-01          |       1 |     1 | sub01   |    85 |
| 2021-12-02          |       1 |     2 | sub01   |    90 |
| 2021-12-03          |       1 |     3 | sub01   |    76 |
| 2021-12-04          |       1 |     4 | sub01   |    67 |
| 2021-12-05          |       1 |     5 | sub01   |    91 |
| 2021-12-06          |       1 |     6 | sub01   |    98 |
(略)
| 2021-12-29          |       1 |    29 | sub01   |    56 |
| 2021-12-30          |       1 |    30 | sub01   |    93 |
| 2021-12-31          |       1 |    31 | sub01   |    76 |
| 2021-12-01          |       1 |     1 | sub02   |    83 |
| 2021-12-02          |       1 |     2 | sub02   |    58 |
| 2021-12-03          |       1 |     3 | sub02   |    72 |
(略)
| 2021-12-29          |       5 |    29 | sub03   |    54 |
| 2021-12-30          |       5 |    30 | sub03   |    66 |
| 2021-12-31          |       5 |    31 | sub03   |    61 |
+---------------------+---------+-------+---------+-------+
465 rows in set (0.01 sec)

RANK

順位付けを行う。

SELECT
    implementation_date,
    subject,
    user_id,
    score,
    RANK() OVER (PARTITION BY subject, implementation_date ORDER BY score DESC) as ranking
FROM scores
ORDER BY implementation_date, subject, ranking;

subject と implementation_date でグルーピングを行い、score 降順でランク付けする事で、テスト日ごと各科目別の順位を付与します。

+---------------------+---------+---------+-------+---------+
| implementation_date | subject | user_id | score | ranking |
+---------------------+---------+---------+-------+---------+
| 2021-12-01          | sub01   |       4 |    93 |       1 |
| 2021-12-01          | sub01   |       1 |    85 |       2 |
| 2021-12-01          | sub01   |       2 |    85 |       2 |
| 2021-12-01          | sub01   |       3 |    78 |       4 |
| 2021-12-01          | sub01   |       5 |    78 |       4 |
| 2021-12-01          | sub02   |       1 |    83 |       1 |
| 2021-12-01          | sub02   |       2 |    81 |       2 |
| 2021-12-01          | sub02   |       5 |    76 |       3 |
| 2021-12-01          | sub02   |       3 |    70 |       4 |
| 2021-12-01          | sub02   |       4 |    50 |       5 |
| 2021-12-01          | sub03   |       4 |    97 |       1 |
(略)
| 2021-12-31          | sub03   |       5 |    61 |       5 |
+---------------------+---------+---------+-------+---------+
465 rows in set (0.01 sec)

NTILE

指定したグループ数で分類してランク付けを行う。分類は近しい値(=できるだけ同じサイズ)でグルーピングされる。

SELECT
    user_id,
    total,
    NTILE(3) OVER (ORDER BY total desc) as g_rank
FROM user_total_scores;

5 人の合計得点を 3 つのグループに分類してランク付けを行います。

+---------+-------+--------+
| user_id | total | g_rank |
+---------+-------+--------+
|       1 |  7135 |      1 |
|       5 |  7000 |      1 |
|       4 |  6934 |      2 |
|       3 |  6896 |      2 |
|       2 |  6855 |      3 |
+---------+-------+--------+
5 rows in set (0.00 sec)

3 つのグループに分類されてランク付けされているのが確認できます。

DENSE_RANK

グループのランクを振る。

同率順位があっても順位が繰り下がらないところが rank() との違い。

SELECT
    subject,
    user_id,
    average,
    DENSE_RANK() OVER (PARTITION BY subject ORDER BY average desc) AS d_rnk,
    RANK() OVER (PARTITION BY subject ORDER BY average desc) as rnk
FROM user_avg_scores_by_subject;

各科目別でのユーザーの平均点に対してランク付けを行います。

+---------+---------+---------+-------+-----+
| subject | user_id | average | d_rnk | rnk |
+---------+---------+---------+-------+-----+
| sub01   |       1 |      78 |     1 |   1 |
| sub01   |       4 |      76 |     2 |   2 |
| sub01   |       3 |      75 |     3 |   3 |
| sub01   |       5 |      74 |     4 |   4 |
| sub01   |       2 |      73 |     5 |   5 |
| sub02   |       1 |      78 |     1 |   1 |
| sub02   |       4 |      76 |     2 |   2 |
| sub02   |       2 |      73 |     3 |   3 |
| sub02   |       3 |      73 |     3 |   3 |
| sub02   |       5 |      73 |     3 |   3 |
| sub03   |       5 |      79 |     1 |   1 |
| sub03   |       1 |      75 |     2 |   2 |
| sub03   |       2 |      75 |     2 |   2 |
| sub03   |       3 |      74 |     3 |   4 |
| sub03   |       4 |      71 |     4 |   5 |
+---------+---------+---------+-------+-----+

sub03 の順位を見てみると、同率順位があった際にその後の順位が繰り下がっていないことがわかります。

PERCENT_RANK

パーセントランクを算出する。

  • ランク最上位を 0 としてランクをパーセントで振る。
  • ランクの範囲は 0 〜 1
  • パーセントランクの計算式は(rank - 1)/(ウィンドウまたはパーティションの行数 - 1)
SELECT
    subject,
    user_id,
    average,
    RANK() OVER (PARTITION BY subject ORDER BY average desc) as rnk,
    PERCENT_RANK() OVER (PARTITION BY subject ORDER BY average desc) as p_rnk
FROM user_avg_scores_by_subject;

各位ユーザー科目毎の平均点に対してそれぞれパーセントランクを振ってみます。

+---------+---------+---------+-----+-------+
| subject | user_id | average | rnk | p_rnk |
+---------+---------+---------+-----+-------+
| sub01   |       1 |      78 |   1 |     0 |
| sub01   |       4 |      76 |   2 |  0.25 |
| sub01   |       3 |      75 |   3 |   0.5 |
| sub01   |       5 |      74 |   4 |  0.75 |
| sub01   |       2 |      73 |   5 |     1 |
| sub02   |       1 |      78 |   1 |     0 |
| sub02   |       4 |      76 |   2 |  0.25 |
| sub02   |       2 |      73 |   3 |   0.5 |
| sub02   |       3 |      73 |   3 |   0.5 |
| sub02   |       5 |      73 |   3 |   0.5 |
| sub03   |       5 |      79 |   1 |     0 |
| sub03   |       1 |      75 |   2 |  0.25 |
| sub03   |       2 |      75 |   2 |  0.25 |
| sub03   |       3 |      74 |   4 |  0.75 |
| sub03   |       4 |      71 |   5 |     1 |
+---------+---------+---------+-----+-------+

CUME_DIST

行グループ内で累積分布を振る。

指定したグループの(orderによる)最終行を 1 として、そこに向けて 0 から積み上がっていくイメージ。相対的な位置がわかる。

SELECT
    user_id,
    subject,
    average,
    CUME_DIST() OVER (PARTITION BY subject ORDER BY average) as cume_dist_by_avg
FROM user_avg_scores_by_subject
ORDER BY user_id, subject;

ユーザーの科目別平均から、科目をグループとして累積分布を振ってみます。

+---------+---------+---------+------------------+
| user_id | subject | average | cume_dist_by_avg |
+---------+---------+---------+------------------+
|       1 | sub01   |      78 |                1 |
|       1 | sub02   |      78 |                1 |
|       1 | sub03   |      75 |              0.8 |
|       2 | sub01   |      73 |              0.2 |
|       2 | sub02   |      73 |              0.6 |
|       2 | sub03   |      75 |              0.8 |
|       3 | sub01   |      75 |              0.6 |
|       3 | sub02   |      73 |              0.6 |
|       3 | sub03   |      74 |              0.4 |
|       4 | sub01   |      76 |              0.8 |
|       4 | sub02   |      76 |              0.8 |
|       4 | sub03   |      71 |              0.2 |
|       5 | sub01   |      74 |              0.4 |
|       5 | sub02   |      73 |              0.6 |
|       5 | sub03   |      79 |                1 |
+---------+---------+---------+------------------+

結果を見ると、ユーザー 1 は科目 sub03 の成績に関しては 80% の位置にいる事がわかります。

FIRST_VALUE / LAST_VALUE / NTH_VALUE

グループにおいてそれぞれ最初・最後・指定行の値を振る。

SELECT
    subject,
    user_id,
    average,
    FIRST_VALUE(average) OVER (PARTITION BY subject ORDER BY average) as first,
    LAST_VALUE(average) OVER (PARTITION BY subject ORDER BY average ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) as last,
    NTH_VALUE(average ,3) OVER (PARTITION BY subject ORDER BY average DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) as third_value
FROM user_avg_scores_by_subject;

科目別ユーザー平均点から、点数順においてそれぞれ最初・最後・3 番目行の値を振ってみます。

+---------+---------+---------+-------+------+-------------+
| subject | user_id | average | first | last | third_value |
+---------+---------+---------+-------+------+-------------+
| sub01   |       1 |      78 |    73 |   78 |          75 |
| sub01   |       4 |      76 |    73 |   78 |          75 |
| sub01   |       3 |      75 |    73 |   78 |          75 |
| sub01   |       5 |      74 |    73 |   78 |          75 |
| sub01   |       2 |      73 |    73 |   78 |          75 |
| sub02   |       1 |      78 |    73 |   78 |          73 |
| sub02   |       4 |      76 |    73 |   78 |          73 |
| sub02   |       2 |      73 |    73 |   78 |          73 |
| sub02   |       3 |      73 |    73 |   78 |          73 |
| sub02   |       5 |      73 |    73 |   78 |          73 |
| sub03   |       5 |      79 |    71 |   79 |          75 |
| sub03   |       1 |      75 |    71 |   79 |          75 |
| sub03   |       2 |      75 |    71 |   79 |          75 |
| sub03   |       3 |      74 |    71 |   79 |          75 |
| sub03   |       4 |      71 |    71 |   79 |          75 |
+---------+---------+---------+-------+------+-------------+

LAST_VALUE() で全体の last を取る場合はデフォルトは自分行までしか読まないため上記クエリのように frame 句の指定が必要です。

その場合は FIRST_VALUE() を使って last も取得するとシンプルだし事故らなくて良いと思いました。

SELECT
    subject,
    user_id,
    average,
    FIRST_VALUE(average) OVER (PARTITION BY subject ORDER BY average) as first,
    LAST_VALUE(average) OVER (PARTITION BY subject ORDER BY average ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) as last,
    FIRST_VALUE(average) OVER (PARTITION BY subject ORDER BY average DESC) as last_by_first_value, -- <- average の降順の最初行をとる
  NTH_VALUE(average ,3) OVER (PARTITION BY subject ORDER BY average DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) as third_value
FROM user_avg_scores_by_subject;

結果は LAST_VALUE() と同じになります。

+---------+---------+---------+-------+------+---------------------+-------------+
| subject | user_id | average | first | last | last_by_first_value | third_value |
+---------+---------+---------+-------+------+---------------------+-------------+
| sub01   |       1 |      78 |    73 |   78 |                  78 |          75 |
| sub01   |       4 |      76 |    73 |   78 |                  78 |          75 |
| sub01   |       3 |      75 |    73 |   78 |                  78 |          75 |
| sub01   |       5 |      74 |    73 |   78 |                  78 |          75 |
| sub01   |       2 |      73 |    73 |   78 |                  78 |          75 |
| sub02   |       1 |      78 |    73 |   78 |                  78 |          73 |
| sub02   |       4 |      76 |    73 |   78 |                  78 |          73 |
| sub02   |       2 |      73 |    73 |   78 |                  78 |          73 |
| sub02   |       3 |      73 |    73 |   78 |                  78 |          73 |
| sub02   |       5 |      73 |    73 |   78 |                  78 |          73 |
| sub03   |       5 |      79 |    71 |   79 |                  79 |          75 |
| sub03   |       1 |      75 |    71 |   79 |                  79 |          75 |
| sub03   |       2 |      75 |    71 |   79 |                  79 |          75 |
| sub03   |       3 |      74 |    71 |   79 |                  79 |          75 |
| sub03   |       4 |      71 |    71 |   79 |                  79 |          75 |
+---------+---------+---------+-------+------+---------------------+-------------+

JSON_ARRAYAGG

グループの値を JSON にまとめる。

SELECT
    user_id,
    subject,
    implementation_date,
    score,
    JSON_ARRAYAGG(score) OVER (PARTITION BY user_id, subject ORDER BY user_id, subject) as all_scores_for_subject
FROM scores
ORDER BY  user_id, subject;

ユーザーの科目別の得点をまとめてみます。

+---------+---------+---------------------+-------+--------------------------------------------------------------------------------------------------------------------------------+
| user_id | subject | implementation_date | score | all_scores_for_subject                                                                                                         |
+---------+---------+---------------------+-------+--------------------------------------------------------------------------------------------------------------------------------+
|       1 | sub01   | 2021-12-01          |    85 | [85, 90, 76, 67, 91, 98, 58, 83, 69, 87, 98, 91, 70, 91, 60, 55, 95, 92, 93, 78, 66, 94, 50, 84, 63, 56, 69, 83, 56, 93, 76]   |
|       1 | sub01   | 2021-12-02          |    90 | [85, 90, 76, 67, 91, 98, 58, 83, 69, 87, 98, 91, 70, 91, 60, 55, 95, 92, 93, 78, 66, 94, 50, 84, 63, 56, 69, 83, 56, 93, 76]   |
|       1 | sub01   | 2021-12-03          |    76 | [85, 90, 76, 67, 91, 98, 58, 83, 69, 87, 98, 91, 70, 91, 60, 55, 95, 92, 93, 78, 66, 94, 50, 84, 63, 56, 69, 83, 56, 93, 76]   |
(略)

そのユーザーの科目別の得点がまとめられていることが確認できました。

JSON_OBJECTAGG

グループの値を JSON にまとめる。

こちらは key:value の形式で出力できる。

SELECT
    implementation_date,
    subject,
    user_id,
    score,
    JSON_OBJECTAGG(user_id, score) OVER (PARTITION BY implementation_date, subject ORDER BY user_id ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) as scores_for_everyone
FROM scores
ORDER BY  implementation_date, subject, user_id;

結果行に、その回・科目の全員の得点を付与してみます。

+---------------------+---------+---------+-------+------------------------------------------------+
| implementation_date | subject | user_id | score | scores_for_everyone                            |
+---------------------+---------+---------+-------+------------------------------------------------+
| 2021-12-01          | sub01   |       1 |    85 | {"1": 85, "2": 85, "3": 78, "4": 93, "5": 78}  |
| 2021-12-01          | sub01   |       2 |    85 | {"1": 85, "2": 85, "3": 78, "4": 93, "5": 78}  |
| 2021-12-01          | sub01   |       3 |    78 | {"1": 85, "2": 85, "3": 78, "4": 93, "5": 78}  |
| 2021-12-01          | sub01   |       4 |    93 | {"1": 85, "2": 85, "3": 78, "4": 93, "5": 78}  |
| 2021-12-01          | sub01   |       5 |    78 | {"1": 85, "2": 85, "3": 78, "4": 93, "5": 78}  |
| 2021-12-01          | sub02   |       1 |    83 | {"1": 83, "2": 81, "3": 70, "4": 50, "5": 76}  |
| 2021-12-01          | sub02   |       2 |    81 | {"1": 83, "2": 81, "3": 70, "4": 50, "5": 76}  |
(略)
| 2021-12-31          | sub03   |       5 |    61 | {"1": 79, "2": 96, "3": 67, "4": 100, "5": 61} |
+---------------------+---------+---------+-------+------------------------------------------------+

user_id : score でまとめられているのが確認できました。

まとめ

WINDOW 関数は「分析関数」と呼ばれているだけあって分析を行う上で知っておいて損はない便利な関数が多く、一度試しておくと選択肢の幅が広がるのでオススメです。

WINDOW 関数自体も、知ってると知らないとでは記述するクエリ量が地味に違ってくる(=計算量も変わってくる)ので、集計関数と併せて上手く使いこなしていきたいところです。

Laravel APISpec Generatorの使い方

Laravelのカレンダー | Advent Calendar 2021 - Qiita の3日目の記事。

前回Laravel APISpec Generatorを作った記事を公開した。

https://zenn.dev/kotamat/articles/2a63e9958e0905

ただ、この内容だけだと具体的にどういう効果がありそうかが見えないので、具体的な使用方法を元に紹介してみる。

ちなみにNuxt3とのつなぎ込みの紹介はこちらのプレゼン資料を見てもらえると良いかもしれない

https://slides.com/kotamat/nuxt3-laravel-apispec-generator

使用方法1: サーバーで使ってるマスターデータをフロントでも使う

例えば下記の様に、configの中にmasterデータを保管しているとする。

config/master
├── aa.php
└── bb.php

aa.php

<?php
return [
    'hoge' => 'hogehoge',
    'fuga' => 'fugafuga'
];

bb.php

<?php
return [
    'cat' => '猫',
    'dog' => '犬'
];

そして、MasterControllerを下記のように設定しておき

<?php

namespace App\Http\Controllers;

class MasterController extends Controller
{
    public function __invoke()
    {
        return config('master');
    }
}

下記のテストケースを書いておく。

<?php

namespace Tests\Feature\Http\Controllers;

use Tests\TestCase;

class MasterControllerTest extends TestCase
{

    public function testInvoke()
    {
        $res = $this->getJson(route('master'));
        $res->assertOk();
    }
}

実際にOASを吐き出してみる

テストを実行すると下記のようなjsonが吐き出され

jsonを開く

{
    "openapi": "3.0.0",
    "info": {
        "title": "auto generated spec",
        "version": "0.0.0"
    },
    "paths": {
        "\/api\/master": {
            "get": {
                "summary": "\/api\/master",
                "description": "\/api\/master",
                "operationId": "\/api\/master:GET",
                "security": [],
                "responses": {
                    "200": {
                        "description": "",
                        "content": {
                            "application\/json": {
                                "schema": {
                                    "type": "object",
                                    "properties": {
                                        "aa": {
                                            "type": "object",
                                            "properties": {
                                                "hoge": {
                                                    "type": "string",
                                                    "example": "hogehoge"
                                                },
                                                "fuga": {
                                                    "type": "string",
                                                    "example": "fugafuga"
                                                }
                                            },
                                            "required": [
                                                "hoge",
                                                "fuga"
                                            ]
                                        },
                                        "bb": {
                                            "type": "object",
                                            "properties": {
                                                "cat": {
                                                    "type": "string",
                                                    "example": "\u732b"
                                                },
                                                "dog": {
                                                    "type": "string",
                                                    "example": "\u72ac"
                                                }
                                            },
                                            "required": [
                                                "cat",
                                                "dog"
                                            ]
                                        }
                                    },
                                    "required": [
                                        "aa",
                                        "bb"
                                    ],
                                    "title": "\/api\/master_GET_response_200"
                                }
                            }
                        }
                    }
                },
                "parameters": [
                    {
                        "in": "header",
                        "name": "Content-Type",
                        "schema": {
                            "type": "string"
                        },
                        "description": "application\/json"
                    },
                    {
                        "in": "header",
                        "name": "Accept",
                        "schema": {
                            "type": "string"
                        },
                        "description": "application\/json"
                    }
                ]
            }
        }
    }
}

例えばtypescript-axiosで吐き出したコードを使うと

import { DefaultApi } from "~/spec";

const api = new DefaultApi()
const { data } = await api.apiMasterGET()
data.aa.fuga // :string

というような形で参照することができる。

masterデータはプロダクトが大きくなるにつれて追加されていくものであり、個別で型を使いまわしたいケースもあると思うが、下記のようにすれば小さい単位で型を取り回すことができる。

import { ApiMasterGETResponse200 } from "~/spec";
type aa = ApiMasterGETResponse200['aa']
// こんなかんじで具体型も吐き出されているのでこれを使っても良い
import { ApiMasterGETResponse200Aa } from "~/spec";
type aa2 = ApiMasterGETResponse200Aa

レスポンスステータスごとに型を使い分ける

例えば200が返るパターンと、422(バリデーション)が返ってくるパターンでは当然返却されるdataの型は変わってくる。

例えば下記の様なAPIをかんがえてみる

Controller

<?php

class JobController extends Controller
{
    public function update(UpdateRequest $request, Job $job)
    {
        $job->fill($request->safe()->all());
        return $job;
    }
}

UpdateRequest

<?php

class UpdateRequest extends FormRequest
{
    public function authorize()
    {
        return $this->user() instanceof User;
    }

    public function rules()
    {
        return [
            'name' => "required|string",
            'user_id' => "exists:" . User::class . ",id"
        ];
    }
}

今回は下記のような200, 403, 422がそれぞれ返ってくるようなテストケースをかんがえてみる

<?php

class JobControllerTest extends TestCase
{
    /**
     * @dataProvider provideUpdateParams
     */
    public function testUpdate(bool $auth, bool $hasName, int $statusCode)
    {
        /** @var Job $job */
        $job = Job::factory()->create();
        if ($auth) {
            $this->actingAs($job->user);
        }
        $params = Job::factory()->make()->toArray();
        if (!$hasName) {
            unset($params['name']);
        }
        $res = $this->putJson(route('job.update', ['job' => $job->id]), $params);
        $res->assertStatus($statusCode);
    }

    public function provideUpdateParams(): array
    {
        return [
            [
                "auth" => true,
                "name" => true,
                "expect" => 200
            ],
            [
                "auth" => false,
                "name" => true,
                "expect" => 403
            ],
            [
                "auth" => true,
                "name" => false,
                "expect" => 422
            ],
        ];

    }
}

実際にOASを吐き出してみる

今回はステータスコードごとにjsonファイルが吐き出される

storage/app/api/
└── job
    └── {job}
        ├── PUT.200.json
        ├── PUT.403.json
        └── PUT.422.json

全ファイルを php artisan apispec:aggregate マージしたものが下記

jsonを開く

{
    "openapi": "3.0.0",
    "info": {
        "title": "auto generated spec",
        "version": "0.0.0"
    },
    "paths": {
        "\/api\/job\/{job}": {
            "put": {
                "summary": "\/api\/job\/{job}",
                "description": "\/api\/job\/{job}",
                "operationId": "\/api\/job\/{job}:PUT",
                "security": [
                    {
                        "bearerAuth": []
                    }
                ],
                "responses": {
                    "200": {
                        "description": "",
                        "content": {
                            "application\/json": {
                                "schema": {
                                    "type": "object",
                                    "properties": {
                                        "id": {
                                            "type": "integer",
                                            "example": 22
                                        },
                                        "name": {
                                            "type": "string",
                                            "example": "Ms. Ana Rosenbaum"
                                        },
                                        "created_at": {
                                            "type": "string",
                                            "example": "2021-11-29T11:19:09.000000Z"
                                        },
                                        "updated_at": {
                                            "type": "string",
                                            "example": "2021-11-29T11:19:09.000000Z"
                                        },
                                        "user_id": {
                                            "type": "integer",
                                            "example": 47
                                        }
                                    },
                                    "required": [
                                        "id",
                                        "name",
                                        "created_at",
                                        "updated_at",
                                        "user_id"
                                    ],
                                    "title": "\/api\/job\/{job}_PUT_response_200"
                                }
                            }
                        }
                    },
                    "403": {
                        "description": "",
                        "content": {
                            "application\/json": {
                                "schema": {
                                    "type": "object",
                                    "properties": {
                                        "message": {
                                            "type": "string",
                                            "example": "This action is unauthorized."
                                        },
                                        "exception": {
                                            "type": "string",
                                            "example": "Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException"
                                        },
                                        "file": {
                                            "type": "string",
                                            "example": "\/var\/www\/html\/vendor\/laravel\/framework\/src\/Illuminate\/Foundation\/Exceptions\/Handler.php"
                                        },
                                        "line": {
                                            "type": "integer",
                                            "example": 387
                                        },
                                        "trace": {
                                            "type": "array",
                                            "items": {
                                                "type": "object",
                                                "properties": {
                                                    "file": {
                                                        "type": "string",
                                                        "example": "\/var\/www\/html\/vendor\/laravel\/framework\/src\/Illuminate\/Foundation\/Exceptions\/Handler.php"
                                                    },
                                                    "line": {
                                                        "type": "integer",
                                                        "example": 332
                                                    },
                                                    "function": {
                                                        "type": "string",
                                                        "example": "prepareException"
                                                    },
                                                    "class": {
                                                        "type": "string",
                                                        "example": "Illuminate\\Foundation\\Exceptions\\Handler"
                                                    },
                                                    "type": {
                                                        "type": "string",
                                                        "example": "->"
                                                    }
                                                },
                                                "required": [
                                                    "file",
                                                    "line",
                                                    "function",
                                                    "class",
                                                    "type"
                                                ]
                                            }
                                        }
                                    },
                                    "required": [
                                        "message",
                                        "exception",
                                        "file",
                                        "line",
                                        "trace"
                                    ],
                                    "title": "\/api\/job\/{job}_PUT_response_403"
                                }
                            }
                        }
                    },
                    "422": {
                        "description": "",
                        "content": {
                            "application\/json": {
                                "schema": {
                                    "type": "object",
                                    "properties": {
                                        "message": {
                                            "type": "string",
                                            "example": "The given data was invalid."
                                        },
                                        "errors": {
                                            "type": "object",
                                            "properties": {
                                                "name": {
                                                    "type": "array",
                                                    "items": {
                                                        "type": "string",
                                                        "example": "The name field is required."
                                                    }
                                                }
                                            },
                                            "required": [
                                                "name"
                                            ]
                                        }
                                    },
                                    "required": [
                                        "message",
                                        "errors"
                                    ],
                                    "title": "\/api\/job\/{job}_PUT_response_422"
                                }
                            }
                        }
                    }
                },
                "parameters": [
                    {
                        "in": "header",
                        "name": "Content-Type",
                        "schema": {
                            "type": "string"
                        },
                        "description": "application\/json"
                    },
                    {
                        "in": "header",
                        "name": "Accept",
                        "schema": {
                            "type": "string"
                        },
                        "description": "application\/json"
                    },
                    {
                        "in": "path",
                        "name": "job",
                        "required": true,
                        "schema": {
                            "type": "integer"
                        },
                        "description": "22"
                    }
                ],
                "requestBody": {
                    "content": {
                        "application\/json": {
                            "schema": {
                                "type": "object",
                                "properties": {
                                    "name": {
                                        "type": "string",
                                        "example": "Ms. Ana Rosenbaum"
                                    },
                                    "user_id": {
                                        "type": "integer",
                                        "example": 47
                                    }
                                },
                                "required": [
                                    "name",
                                    "user_id"
                                ],
                                "title": "\/api\/job\/{job}_PUT_request"
                            }
                        }
                    }
                }
            }
        }
    },
    "components": {
        "securitySchemes": {
            "bearerAuth": {
                "type": "http",
                "scheme": "bearer",
                "bearerFormat": "JWT"
            }
        }
    }
}

で、これをどう使うのかというと

import { ApiJobJobPUTRequest, ApiJobJobPUTResponse403, ApiJobJobPUTResponse422, DefaultApi } from "~~/spec"

export default async () => {
    const api = new DefaultApi
    const param: ApiJobJobPUTRequest = {
        name: "user name",
        user_id: 1
    }
    const { data, status } = await api.apiJobJobPUT({
        job: 1,
        apiJobJobPUTRequest: param
    })
    switch (status) {
        case 403:
            return { data: data as any as ApiJobJobPUTResponse403, status }
        case 422:
            return { data: data as any as ApiJobJobPUTResponse422, status }
        default:
            return { data: data, status: status as 200 }
    }
}

上記のように、リクエストパラメータに ApiJobJobPUTRequest 型を付けて送るのは通常パターンではあるが、その返却されたステータスコードを元に switch 文で型を詰め直して返却している。 この関数の返り値は下記のようになり、ステータスコードと中のデータが合わさったunion型のPromiseが返る

() => Promise<{
    data: ApiJobJobPUTResponse403;
    status: 403;
} | {
    data: ApiJobJobPUTResponse422;
    status: 422;
} | {
    data: ApiJobJobPUTResponse200;
    status: 200;
}>

この返り値は下記のようにif文で分岐させることによってほしいデータの型を得ることができる

const res = await fn()

if (res.status === 200) {
    // ApiJobJobPUTResponse200
    res.data.name
}
if (res.status === 422) {
    // ApiJobJobPUTResponse422
    res.data.message
}
if (res.status === 403) {
    // ApiJobJobPUTResponse403
    res.data.trace
}

ちなみにVue3 script setupでは下記のように呼び出すと v-if での分岐で参照データの切り分けができるようになる。(useFetchはNuxt3の関数)

<template>
    <div>
        <div v-if="data.status === 200">{{ data.data.name }}</div>
        <div v-if="data.status === 422">{{ data.data.message }}</div>
        <div v-if="data.status === 403">{{ data.data.trace }}</div>
    </div>
</template>
<script setup lang="ts">
const { data } = await useFetch("/api/job/update")

</script>

こうすることでフロントエンドでステータスコードごとに型安全な表示の切り替えを行うことができるようになる

まとめ

今回は利用ケースとして有り得そうな2つのケースをベースに紹介させてもらった。 もしかしたらこういう使い方もできるかも?というのがあればぜひコメントとかいただけると嬉しいです。

backcheck を TypeScript に移行するまでの流れ

はじめに

皆さん、こんにちは。株式会社ROXX、backcheck開発チームの山口と申します。 backcheckフロントエンドのTypeScript移行がある程度軌道にのってきたので、ここまでの過程を文書化することにしました。

この記事ではTSの導入までの過程についてかいつまんでお話ししようと思います。

やっていること

Nuxt2系(JavaScript)で書かれたbackcheckのフロントエンドをTypeScriptへマイグレーションしています(2021/11時点で現在進行中)。 IEのサポートの終了+Nuxt3がStableになるタイミングで、Nuxt3+Composition APIへの乗り換えを予定していることから、vue-class-componentやvue-property-decoratorは導入せず、Options APIを使用したまま、TypeScriptのみを導入することとしました。

TypeScript 移行で目指すゴール

「完全 TypeScript 化ではなく、最速で8割 TypeScript 化を目指す」

これをスローガンとして、まずは、普段の開発作業で触る箇所に対して TypeScript でかける状態をなるべく早く用意することをゴールとして考えました。

なぜ TypeScript に書き換えるのか

主には以下を目的としてTypeScriptを導入することにしました。

  • 静的解析により、型安全に開発できる
    • 型がドキュメントがわりになる
    • 早い段階でエラーに気づける

TS移行完了までに相応のコストがかかりますが、移行が完了していなくても部分的に恩恵を受けられることや、長期的にみてコードの品質や開発速度が向上することが移行への後押しになりました。

メンバーのTSキャッチアップ

弊チームでは、実務でTSを書いたことがあるメンバーが12人中7人でした。 このままだと残った5人がフロントのコードが書けなくなる + 実務で使ったことのあるメンバーでも理解度がまちまちであったため、慣れるまでは移行作業を4人参加のモブプロで進めることでキャッチアップすることとしました。

また、モブプロ以外の施策として、TS移行のキックオフ前に、TS未経験者に向けたTypeScriptワークショップを行いました。これにより、モブプロ開始のタイミングで最低限の基本知識は全員が聞いたことがある状態とすることができました。 (TypeScript説明会ではTypeScript Deep Diveをベースに、概要や基本的な機能などについて解説を行いました)

TS環境構築

TypeScript、eslintの設定

キックオフの時点から"strict": trueの状態としています。 vueファイルでmixinsを呼び出している箇所など、型の適用が難しい箇所については、将来的にリファクタリングすることとして、マイグレーションのタイミングではts-ignoreすることでエラーを回避するようにしました。

また、TSにマイグレーションする上で、リファクタリングしたくなるコードは、影響範囲が大きくなってしまうので別でタスクを用意し、このプロジェクトではなるべくリファクタリングをしないように決めました。

eslintの設定は、TSマイグレーション未対応のjsやvueファイルでもエラーが出てしまうような項目については、overrideして設定をOFFにしています。

サンプル実装の作成

移行作業の着手前に、1ファイルだけTS化したファイルを用意することで、他ファイルを移行する際の判例としました。実装の例を用意したことで、実装イメージがチームの共通認識としてもてたので、よかったと思います。

TSに移行したファイルのリグレッションテスト

該当画面の挙動にデグレがないかを確認するための、ブラックボックスなテストをQAとして行うこととしました。 その他に、utilsの関数や共通コンポーネントなど、全体影響があるものに関しては、正常系フローの動作確認を行うテストを別途実施することで、デグレが起きていないことを確認することにしました。

移行計画の作成

内容としては開発フローへの乗せ方、作業の進め方の2つを事前に決めました。

開発フローへの乗せ方

backcheck のフロントエンドは JS, vue ファイルあわせて約550ファイル・5万行のコードがあります。 これを80%TSに移行するための超概算で以下の数字がでました。

たとえば... 1スプリントあたりのベロシティの20%を移行作業にわりあてたとすると → 33週かかる 1年は約52週 つまり... 完了までに7ヶ月くらいかかります。

なかなかかかりますね...

TSの移行作業の割合を、1スプリントあたりのベロシティの20%以下に落としてしまうと完了までに年単位でかかってしまうため、プランニングする量としては1スプリントあたりのベロシティの20%を固定枠で設けて、メインのストーリータスクと並行して進めていくというやり方にしました。

移行作業の進め方

当初の計画では、こちらのポッドキャストで説明していた進め方を参考に、影響範囲の少ないところから着手していく計画でした。

api→utils→middleware→vueファイルの順に、細かくマイグレーションしていくことで、依存ファイルの多いvueファイルに着手するタイミングには、依存ファイルが全てTSに置き換わっているイメージです。

しかし、普段の開発の中で触る箇所はある程度絞られています。そのため、TypeScriptマイグレーション専任の担当者を設けずに進めている弊チームでは、影響範囲の少ない箇所からマイグレーションをしても日頃の開発フローの中で恩恵を受けられるまでに相応の時間がかかることに気がつきました。

そのため、普段の開発で触る頻度の高い箇所で、よりはやく恩恵をうけられるように、画面(pagesディレクトリ)単位でチケットを立て、画面に依存しているファイルは全てそのチケットの中でマイグレーションする方針に変更しました。 また、constantsで定義していた定数に関しては事前に一括でTS化を行ってしまいました。

おわりに

実際に移行作業をスタートすると、キックオフ時点に考慮が漏れていて後から決定した内容などもあったりしました。導入準備だけでも3ヶ月ほどかかったので、TS移行の計画を立てる場合はある程度長い目で見ながら進めるのがいいかと思います。

その他、ここはどう進めたの?この設定はどうした?こうした方がよさそう。などご意見、ご質問がありましたらぜひお声がけください。

また、現時点でチーム全員がTSでの実装イメージが持てている状態までTSのキャッチアップが進んだので、今後はモブプロをやめて移行作業の速度アップを考えています。 その辺についてもお時間のある時に記事にしようと思うのでお楽しみに。