1年半取り組んだプロジェクトのPMを語る�
Vueを用いた開発プロジェクト用にカスタムジェネレーターを作ってみる
はじめに
こんにちは、モチベーションクラウドの開発にフリーのエンジニアとして参画している@HayatoKamonoです。
この記事は、「モチベーションクラウド Advent Calendar 2018」2日目の記事となります。
先に成果物のイメージ
$ yo mcs
_-----_
| | ╭──────────────────────────╮
|--(o)--| │ Welcome to MCS App │
`---------´ │ Generator! │
( _´U`_ ) ╰──────────────────────────╯
/___A___\ /
| ~ |
__'.___.'__
´ ` |° ´ Y `
? What do you want to generate? (Use arrow keys)
❯ Vue Component
Vuex Store Module
? What's the component name? SampleComponent
create SampleComponent/SampleComponent.index.js
create SampleComponent/SampleComponent.vue
create SampleComponent/SampleComponent.stories.js
create SampleComponent/SampleComponent.specs.js
↑ この記事では、このようなものを作っていきます。
概要
モチベーションクラウドの開発チームでは2018年10月から改善期間と称して、開発に関するガイドラインやルール作りをはじめとする、様々な改善活動に取り組んでいます。
私が所属するフロントエンド開発チームでも、改善活動の一環として複数のガイドラインを作成しているのですが、その作成したガイドラインの1つに、コンポーネントを配置するディレクトリ構成・ファイル構成に関するガイドラインがあります。
.
├── atoms
├── molecules
├── organisms
│ └── SampleComponent
│ ├── index.js
│ ├── SampleComponent.specs.js
│ ├── SampleComponent.stories.js
│ └── SampleComponent.vue
├── templates
├── pages
└── decorators
簡単に説明をすると、components
ディレクトリ配下のディレクトリを、Atomic Designのatoms
、molecules
、organisms
、templates
、pages
で切り、そして、開発するコンポーネント毎にそのコンポーネント名でディレクトリを切るというディレクトリ構成で行くという方針で固まりました。
また、コンポーネント単位のディレクトリ配下には、「index.js」、単一ファイルコンポーネントの「ComponentName.vue」、Storybook用の「ComponentName.stories.js」、テスト用の「ComponentName.specs.js」を作成するというファイル構成で行くことに決まりました。
しかしながら、コンポーネント単位のディレクトリ以下に、毎回、4つのファイルを手動で決まった命名規則に合わせて作成するのは何気に面倒ですし、また、コード内に登場するコンポーネント名や、import
するファイル名などを毎回、手動で書いたりするのも面倒です。
そこで今回はこれらのファイル一式を雛形ファイルをもとに自動生成するジェネレーターを作ってみたいと思います。
Yeoman事始め
今回、ジェネレーターを作成するに当たっては、Yeomanを利用します。
What's Yeoman?
Yeoman helps you to kickstart new projects, prescribing best practices and tools to help you stay productive.
To do so, we provide a generator ecosystem. A generator is basically a plugin that can be run with theyo
command to scaffold complete projects or useful parts.
$ npm install -g yo
まずは、Yeomanをインストール。
$ mkdir generator-mcs
早速、ジェネレーター用のフォルダをYeomanの命名規則に従って作成します。
ジェネレーター用のフォルダ名は「generator-name」のように、「generator」をフォルダ名のprefixとし、ハイフンで次に続くジェネレーター名を区切る必要があります。
$ cd generator-mcs
$ npm init -y
$ npm install --save yeoman-generator yosay
次に、package.json
を作成し、今回の記事で作成するジェネレーターで使う依存モジュールを先にインストールしておきます。
※ 今回は、npm init -y
でpackage.json
の細かな設定はスキップしましたが、作成したジェネレーターをYeomanのGeneratorsページにインデックスさせたい場合は、package.json
のkeyword
に["yeoman-generator"]
を指定、また、description
に任意の説明文を入れる必要があります。
$ mkdir -p generators/app generators/component
$ touch generators/app/index.js generators/component/index.js
今度は作業ディレクトリにgenerators
フォルダーを作成し、その中にapp
フォルダーとcomponent
フォルダーを作成します。
.
├── generators
│ ├── app
│ │ └── index.js
│ └── component
│ └── index.js
└── package.json
Yeomanにはapp generator
というメインのジェレネーターと、sub generator
というサブのジェネレーターの2種類のジェネレーターが作成可能です。
両者の使い分けは、app generator
は主にアプリケーション全体の雛形を作る為に利用し、sub generator
はより限定的な用途に利用するといったイメージです。
デフォルトの設定では、app
ディレクトリ以下のindex.js
がapp generator
に対応します。
そして、その他の任意の名前をつけるディレクトリ以下のindex.js
がそれぞれsub generator
に対応し、今回の例でいうと、component
ディレクトリ下のindex.js
がsub generator
に該当します。
$ yo mcs
例えば、この記事で作成しているジェネレーターの名前はgenertor-mcs
なので、app
ディレクトリ以下のapp generator
は上記のコマンドで後ほど実行出来ることになります。
$ yo mcs:component
また、先ほどgenerator
ディレクトリ以下に作成したcomponent
ディレクトリの名前は、そのまま、サブジェネレーターの名前となるので、上記のコマンドでこちらのサブジェネレーターを後ほど実行出来るようになります。
const Generator = require('yeoman-generator');
const yosay = require('yosay');
module.exports = class extends Generator {
constructor(args, opts) {
super(args, opts);
this.log(yosay('Welcome to MCS App Generator!'));
}
};
とりあえず、app generator
に対応するapp/index.js
を上記のように編集して、Hello World的なものを作り動作確認をしてみます。
$ npm link
動作確認をする為にも、上記のコマンドを実行して、現在作成しているジェネレーターをグローバールインストールしておきます。
$ yo mcs
_-----_
| | ╭──────────────────────────╮
|--(o)--| │ Welcome to MCS App │
`---------´ │ Generator! │
( _´U`_ ) ╰──────────────────────────╯
/___A___\ /
| ~ |
__'.___.'__
´ ` |° ´ Y `
yo generator-name
を実行すると、無事にapp generator
の実行が確認出来るはずです。
This Generator is empty. Add at least one method for it to run.
というエラーが出るかもしれませんが、後で他にメソッドを追加すれば解消されるエラーなので、今は特に気にせず、次に進みます。
ちなみに、最初にインストールしたyosay
ライブラリは、上記の実行結果を見て分かる通り、Yeomanのイメージキャラクターのアスキーアートを出力する為のライブラリです。(見た目大事、楽しさ大事!)
今回は特にアプリケーション全体の雛形ファイルを作ることはしないので、一旦、app generator
はここまでとします。
雛形ファイルをもとにファイルを自動生成する
.
├── atoms
├── molecules
├── organisms
│ └── SampleComponent
│ ├── index.js
│ ├── SampleComponent.specs.js
│ ├── SampleComponent.stories.js
│ └── SampleComponent.vue
├── templates
├── pages
└── decorators
ここからは、この記事の概要で説明した通り、上記のようにcomponent単位のディレクトリ以下にindex.js
、componentName.vue
, componentName.stories.js
、componentName.specs.js
の4ファイルを雛形ファイルをもとに生成するsub generator
を作って行きます。
.
├── generators
│ ├── app
│ │ └── index.js
│ └── component
│ └── index.js
└── package.json
それでは、既に作成しておいたcomponent
ディレクトリ以下のindex.js
を編集して、yo mcs:component
で実行出来るジェネレーターを作成して行きます。
$ mkdir generators/component/templates
先に、component
ディレクトリ以下にtemplates
という名前で雛形ファイルを配置する為のディレクトリを作成しておきます。
Yeomanのデフォルトの設定では、templates
と名前の付いたディレクトリのパスが、後述するthis.templatePath()
で取得可能なパスになります。
$ cd generators/component/templates/
$ touch _index.js _ComponentName.vue _ComponentName.specs.js _ComponentName.stories.js
$ tree .
.
├── _ComponentName.specs.js
├── _ComponentName.stories.js
├── _ComponentName.vue
└── _index.js
続けて、templates
ディレクトリ以下に雛形となる4つのファイルを任意の名前で作成します。
現時点では単に空ファイルを作成しているだけですが、後ほど、これらの雛形ファイルにそれらしいベースとなるコードを追記したいと思います。
※ ファイル名は任意の名前で大丈夫です。_
をファイル名の先頭に付けることは必須ではありません。
const Generator = require('yeoman-generator');
module.exports = class extends Generator {
constructor(args, opts) {
super(args, opts);
}
writing () {
this.fs.copyTpl(
this.templatePath('_index.js'),
this.destinationPath('ComponentName/ComponentName.index.js')
);
this.fs.copyTpl(
this.templatePath('_ComponentName.vue'),
this.destinationPath('ComponentName/ComponentName.vue')
);
this.fs.copyTpl(
this.templatePath('_ComponentName.stories.js'),
this.destinationPath('ComponentName/ComponentName.stories.js')
);
this.fs.copyTpl(
this.templatePath('_ComponentName.specs.js'),
this.destinationPath('ComponentName/ComponentName.specs.js')
);
}
};
早速、component/index.js
を上記のように編集し、雛形ファイルをもとに新規ファイルを生成してみます。
ファイルの書き込み処理は、Generator
クラスに定義されているwriting
メソッドの中に記述するようにします。
現段階では、コンポーネント名を直書きしていますが、後ほど、ユーザーからコンポーネント名の入力を受け付けるようにして、動的にコンポーネント名をファイル名に適用して行きます。
this.fs.copyTpl(
this.templatePath('_index.js'),
this.destinationPath('ComponentName/ComponentName.index.js')
);
ここでやっていることは、templates
ディレクトリ以下の雛形ファイルをコピー元として指定し、yo
コマンドが実際に実行されるカレントディレクトリ以下のComponentName/ComponentName.index.js
にファイルをコピーするという処理です。
$ mkdir ~/demo-project
$ cd ~/demo-project
$ yo mcs:component
ジェネレーター開発用の作業ディレクトリで、yo
コマンドでジェネレーターを実行してしまうと、作業ディレクト内にファイルが生成されてしまうので、ここからは、yo
コマンドを実行する時には、別に作成したプロジェクトディレクトリ内で実行して行きます。
.
└── ComponentName
├── ComponentName.index.js
├── ComponentName.specs.js
├── ComponentName.stories.js
└── ComponentName.vue
yo
コマンドを実行すると、コマンドを実行したカレントディレクトリ上に、ComponentName
というディクレトリが作られ、その中に4つのファイルが生成されていることが確認出来ました。
今度は動的にコンポーネント名を生成されるファイルに反映出来るようにして行きます。
const Generator = require('yeoman-generator');
module.exports = class extends Generator {
constructor(args, opts) {
super(args, opts);
}
async prompting() {
// ユーザーの入力に関する情報をインスタンス変数に入れておく
this.answers = await this.prompt([
{
type: 'input',
name: 'componentName',
message: `What's the component name?`,
validate (input) {
if (input.length > 0) {
return true;
} else {
// 文字列を返すと検証エラー時にそのメッセージが出力表示される
return "You need to provide the component name.";
}
}
}
]);
}
writing () {
// ユーザーが入力したコンポーネント名をインスタンス変数を参照して渡す
this.fs.copyTpl(
this.templatePath('_index.js'),
this.destinationPath(`${this.answers.componentName}/${this.answers.componentName}.index.js`)
);
this.fs.copyTpl(
this.templatePath('_ComponentName.vue'),
this.destinationPath(`${this.answers.componentName}/${this.answers.componentName}.vue`)
);
this.fs.copyTpl(
this.templatePath('_ComponentName.stories.js'),
this.destinationPath(`${this.answers.componentName}/${this.answers.componentName}.stories.js`)
);
this.fs.copyTpl(
this.templatePath('_ComponentName.specs.js'),
this.destinationPath(`${this.answers.componentName}/${this.answers.componentName}.specs.js`)
);
}
};
ユーザーからの入力を受け付ける処理は、Generator
クラスに定義されているprompting()
の中に記述するようにします。
prompting
メソッドの中で実行しているprompt
メソッドは非同期実行され、promise
を返します。
そのため、async
, await
をprompting
メソッドのところでは使っています。
$ yo mcs:component
? What's the component name? SampleComponent
create SampleComponent/SampleComponent.index.js
create SampleComponent/SampleComponent.vue
create SampleComponent/SampleComponent.stories.js
create SampleComponent/SampleComponent.specs.js
yo
コマンドを実行すると、コンポーネント名を入力するプロンプトが表示され、入力したコンポーネント名が反映されたディレクトリとファイルが生成されることが確認出来ました。
<template>
</template>
<script>
export default {
name: "<%= componentName %>"
}
</script>
<style lang="scss" scoped>
</style>
ここまでは、空の雛形ファイルをもとに動作確認を進めていたので、templates/_ComponentName.vue
に簡易的なコードを加えて保存しておきます。
既に雛形ファイルをコピーする際に使用してきたfs.copyTpl
メソッドはejs template syntax
を使用しているので、雛形ファイルの方では、<%= componentName %>
のようなejs
の記法を使用することが可能です。
this.fs.copyTpl(
this.templatePath('_ComponentName.vue'),
this.destinationPath(`${this.answers.componentName}/${this.answers.componentName}.vue`),
{ componentName: this.answers.componentName }
);
_ComponentName.vue
内の<%= componentName %>
の部分に、ユーザーから入力されたコンポーネント名を反映出来るようにしてみます。
その為には、上記のコードのように、fs.copyTpl
メソッドの第3引数に対象の雛形ファイルに渡したい変数をkey
, value
ペアのオブジェクトで渡してあげます。
$ yo mcs:component
? What's the component name? SampleComponent
identical SampleComponent/SampleComponent.index.js
conflict SampleComponent/SampleComponent.vue
? Overwrite SampleComponent/SampleComponent.vue? (ynaxdH)
y) overwrite
n) do not overwrite
a) overwrite this and all others
x) abort
d) show the differences between the old and the new
h) Help, list all options
これで、再度、yo
コマンドを実行し、コンポーネント名を入力してあげれば、入力したコンポーネント名が生成される.vue
ファイルの該当箇所に反映されます。
尚、ファイル生成時に既に同じファイルが存在していて、既存のファイルと新規に生成されるファイルの内容が異なる場合は、上記のようにconflict
が発生します。
その際は、H
を押せば選択可能なオプションが表示されるので、その中から任意のオプションを選択すると良いです。
ちなみに、y
を押せば上書き保存してくれます。
app generatorからsub generatorに処理を委譲する
最後に、ジェネレーターから別のジェネレーターに処理を委譲する方法を紹介して、今回の記事を終わりにしたいと思います。
ここでは例として、app generator
からsub generator
に処理を委譲してみたいと思います。
const Generator = require('yeoman-generator');
const yosay = require('yosay');
module.exports = class extends Generator {
constructor(args, opts) {
super(args, opts);
this.log(yosay('Welcome to MCS App Generator!'));
}
// ユーザーにサブジェネレーターを選択してもらう
async prompting() {
const answers = await this.prompt([
{
type: 'list',
name: 'generatorName',
message: 'What do you want to generate?',
choices: [
{
name: 'Vue Component',
value: 'component'
},
{
name: 'Vuex Store Module',
value: 'module'
}
]
}
]);
// ユーザーが選択したサブジェネレーターに処理を委譲する
this.composeWith(
require.resolve(`../${answers.generatorName}`)
);
}
};
app generator
に対応するapp/index.js
を上記のように編集します。
これで、yo mcs
とコマンド実行した時に、先ほど作成したcomponent
サブジェネレーターを実行するのか、module
サブジェネレーターを実行するのかを、ユーザーに選択してもらえるようになり、選択されたサブジェネレーターに処理が委譲され実行されることになります。
※ 尚、2つ目の選択肢に対応するサブジェネレーターは未作成の為、選択するとファイルが見つからずエラーになります。上記の例はmodule
サブジェネレーターが仮に既に作成済みであったという想定の例になります。
$ yo mcs
_-----_
| | ╭──────────────────────────╮
|--(o)--| │ Welcome to MCS App │
`---------´ │ Generator! │
( _´U`_ ) ╰──────────────────────────╯
/___A___\ /
| ~ |
__'.___.'__
´ ` |° ´ Y `
? What do you want to generate? (Use arrow keys)
❯ Vue Component
Vuex Store Module
? What's the component name? SampleComponent
create SampleComponent/SampleComponent.index.js
create SampleComponent/SampleComponent.vue
create SampleComponent/SampleComponent.stories.js
create SampleComponent/SampleComponent.specs.js
今後やりたいこと
- yosayライブラリで出力されるYeomanのイメージキャラクターの代わりに、モチベーションクラウドの広告に起用されている役所広司さんのアスキーアートをウェルカムメッセージに表示出来るようにしたい
- 実際のプロジェクトで使われているコードを雛形ファイルにし、今回作成したジェネレーターを実用的なものにしたい
- 単一ファイルコンポーネントの
<script>
部分をASTにparseしてprops
情報を抽出し、そのprops
情報をもとにStorybook addonのknobs
に対応したStorybookファイルを自動生成出来るようにしたい
参考
関連記事
こちらの記事はモチベーションクラウド Advent Calendar 2018に投稿した記事です。
他にも、以下の記事をモチベーションクラウド Advent Calendar 2018に投稿しています。
- Vueを用いた開発プロジェクト用にカスタムジェネレーターを作ってみる
- Vueを用いた開発プロジェクト用に「コンポーネント設計・実装ガイドライン」を作った話
- Vuexを用いた開発プロジェクト用にガイドラインを作成した話
身の丈にあったWebAPI設計ガイドラインを作った話
こんにちは。フリーランスエンジニアの@dayoshixです。
現在、リンクアンドモチベーションのモチベーションクラウドの開発に、主にフロントエンドエンジニアとしてお手伝いさせて頂いております。
そのようなご縁もありモチベーションクラウドのアドベントカレンダー(3日目)に参加させて頂くことになりましたので宜しくお願いします!!
トップバッターの@ishigeさん、2日目の@HayatoKamonoさんお疲れ様でした!!
お二人の記事はこちら。どちらも力作なので宜しくお願いします。
ということで始めたいと思います。
概要
最近モチベーションクラウドのWebAPI設計ガイドラインが作成されたのですが、それはどのような方針で作成されたのか、その結果どのようなガイドラインが出来上がったのかを紹介します。
背景
これまでの状況
これまで、開発チームではWebAPIの設計に関する明文化されたルールがありませんでした。
WebAPI設計に関して専任の担当者がいるわけではなく、最近では状況にもよりますがフロントエンドエンジニアが設計を行う場面も増えてきました。
また、新たな開発メンバーも徐々に増えていき、迷いが生ずる場面が多くなり小さなストレスになっていました。
その結果かどうかわかりませんが、APIとしての一貫性が徐々に失われているように感じていた人も少なくなかったと思います。
改善しよう!!
開発チームとして技術的負債返済のための改善活動の一環としてWebAPI設計ガイドラインを作ろうという話になりました。
結果、私がガイドライン作成の音頭取りとベース作成を担当させて頂くことになり、開発メンバー全員にレビューして貰いブラッシュアップするようなフローで進めることになりました。
大切にしたこと
ガイドラインを作成するに当たって下記の点を大切にしました。
身の丈に合わせる
ネット上に公開されているガイドラインをそのまま模倣するのではなく、それらを参考にしつつも自分達が開発を通じて実際に迷ったことをもとに、今の自分達に必要な身の丈にあった最小限のルールだけを定義しようと考えました。
また、システムの文脈に依存する一般化できないようなルールがこぼれ落ちないことに注意しました。
基本を意識する
基本の理解を疎かにした状態で応用から入らないよう、基本を崩す場合の具体的な判断例を示すことを意識しました。
例えば、最初から特段の根拠もなく基本を崩しパフォーマンス最適化した設計にしないことを促したいと考えました。
べき論よりやり易さを優先する
セオリーや思想的にこうあるべき、という事が自分達にとってやりにくければ執着せずに無視することを意識しました。
システム概要
システムの特徴によってルールとして考慮する点が異なってくる事から、ガイドラインを紹介する前にモチベーションクラウドのシステム概要をざっくりと説明します。
- SaaS型のBtoBサービス
- バックエンドに
Ruby on Rails
、フロントエンドにVue.js
を使用したいわゆるシングルページアプリケーション - 2018年11月時点でクライアントのプラットフォームはWebのみ
- WebAPIはシステム内で閉じた使われ方をしており、ユーザーに公開するAPIはない
WebAPI設計ガイドライン
それでは実際に作られたWebAPI設計ガイドラインを紹介します。
ガイドライン中に時折なぜこのような選択をしたのかをコメントしています。
概要
本文書はMCSバックエンド〜フロントエンド間のWebAPI設計のガイドラインです。
MCSはモチベーションクラウドの社内での総称になります。
設計指針
RESTful like
基本的にはRESTに従い設計して下さい。
但し、RESTを原則的に従うことでアプリケーションが複雑になる、パフォーマンスに問題が生ずるなどの理由がある場合はRESTの原則に逸れても問題ありません。
RESTful like
な設計を目指して下さい。
RESTの原則に外れる設計パターンについてはこちらを参照して下さい。
画面に最適化されたWebAPIを作るよりもREST APIを作ることを優先する
画面が必要とする情報を取得するための汎用性の無いWebAPIを設計することよりも、その画面に必要なリソースを取得するためのWebAPIを設計することを優先して下さい。
その画面に必要なリソースの種別が多く、API呼び出しのラウンドトリップ過多でパフォーマンスに影響が出た場合に初めて画面向けのAPIを設計することを検討して下さい。
なぜ?
画面向けのWebAPIは画面の表示要素が変更されるたびにバックエンド側にも改修が発生します。
RESTベースでWebAPIを作っている場合、変更対象の表示要素の情報を取得するためのエンドポイントが既に作られている可能性がありますし、新規に作った場合でも将来的に他の用途で再利用できる可能性があります。
画面の文言をバックエンドで管理しない
特別な理由が無い限り、画面に表示する文言リソースをバックエンドで管理しWebAPI経由でフロントエンドに提供するような設計にせず、フロントエンドで持つようにして下さい。
🙅 Bad
GET /menu_items
{
"meta": null,
"data": [{
"id": 1000,
"title": "属性検索",
"description": "登録済みの属性の一覧を検索することができます。"
},{
"meta": null,
"data": [{
"id": 1001,
"title": "ユーザー検索",
"description": "登録済みのユーザーの一覧を検索することができます。"
}]
}
🙆 good
GET /menu_items
{
"meta": null,
"data": [{
"id": 1000,
"type": 1
},{
"meta": null,
"data": [{
"id": 1001,
"type": 2
}]
}
共通ルール
命名
endpoint(URL)
path
snake caseであること
🙅 Bad
/surveySettings/1
/survey-settings/1
🙆 good
/survey_settings/1
railsとの親和性を考慮しました
query paramter
lower camel caseであること
🙅 Bad
/survey_settings?suvey_id=1000
🙆 good
/survey_settings?suveyId=1000
クライアントサイドJSで触るデータに関してはクライアントサイドJSの命名規則と同じlower camel caseに統一しました。
response body
property名
lower camel caseであること
🙅 Bad
{
"meta": null,
"data": {
"id": 1000,
"first_name": "foo"
}
}
🙆 good
{
"meta": null,
"data": {
"id": 1000,
"firstName": "foo"
}
}
ページング
ページング操作に対応したエンドポイントにおいてはページング情報は下記の命名に従って下さい。
request:
フィールド名 | 意味 | 制約 | 備考 |
---|---|---|---|
page | ページ番号。開始番号は1 です。 |
必須指定 | |
limit | 1ページあたりの件数。 | 任意指定 | 1度に全件取得されると問題がある場合は必ずサーバーサイドで制限チェックを行うこと |
sort | ソート対象のフィール名です。 | 任意指定 | |
direction | ソート順です。asc の場合は昇順、desc の場合は降順。 |
任意指定 |
response:
フィールド名 | 意味 | 制約 | 備考 |
---|---|---|---|
total | 検索結果の総件数です。 | 必須指定 | リソースとしてではなくmeta情報として設定して下さい。 |
既存に暗黙の命名ルールがありましたがたまに外れた命名もあったため明文化しました。
HTTP method
POST
、GET
、PUT
、DELETE
のみ使って下さい。
各methodはCRUD
のそれぞれに対応します。
responseのsatus code
正常系
一律 200
を設定して下さい。
異常系
エラーの表現を参照のこと。
response bodyのフォーマット
基本
リソースとリソース以外の情報(リストデータのページング情報、セキュリティトークン等)を分けるために、リソースをdata
プロパティに、リソース以外の情報をmeta
プロパティに分ける下記を基本のフォーマットとする。
{
"meta": { // リソース以外の情報をここに定義
"totalCount": 5000,
"securityToken" "trHqrxxwK8mJhY"
},
"data": [
// リソースはここに定義
{
"id": 100,
"name": "foo"
}
]
}
このルールは意見の別れるところかと思います。
response bodyはあくまでも純粋なリソースとし、meta情報はHTTP Responseの拡張ヘッダーに設定すべきという考え方もあります。
RESTを前提に扱ったツールと相性が悪いなどデメリットもありますが、情報がJSONに集約されていることによる開発・デバッグ時の分かりやすさを優先しました。
HTTP method: GET
フォーマットはendpointごとの任意の形式にして下さい。
HTTP method: POST
フォーマットはリクエストされたデータにID、生成日が設定されているデータにして下さい。
例)ユーザー情報の新規登録が正常に行われた場合のresponse
- 新規登録のrequest情報:
- endpoint:
/users
- requst body:
json { "name": "foo", "gender": 1 }
🙅 Bad
status code: 200
のみを返却し、response bodyを返却しない。
🙆 good
リソースのIDと生成日が含まれる下記のresponse bodyを返却する。
{
"id": 1001,
"name": "foo",
"gender": 1,
"createdAt": "2018-11-12T06:41:58.898+0900"
}
HTTP method: PUT
フォーマットはリクエストされたデータに更新日が更新されているデータにして下さい。
例)ユーザー情報の更新が正常に行われた場合のresponse
- 更新のrequest情報:
- endpoint:
/users/1001
- requst body:
json { "id": 1001, "name": "bar", "gender": 0 }
🙅 Bad
status code: 200
のみを返却し、response bodyを返却しない。
🙆 good
リソースのIDと更新日が含まれる下記のresponse bodyを返却する。
{
"id": 1001,
"name": "bar",
"gender": 0,
"updatedAt": "2018-11-12T06:44:34.894+0900"
}
HTTP method: DELETE
bodyを設定せず、status codeのみ返却して下さい。
非同期操作(※)
フォーマットはendpointごとの任意の形式にして下さい。
※ 非同期操作とは、バッチ処理を起動する
などの最終的な結果が即時に出ない操作を意味します。
エラーの表現
種類
エラーはシステム仕様上起こり得る業務エラーと、システム仕様上想定していないシステムエラーでエラーを区別し、適したエラーを定義して下さい。
業務エラー・システムエラーの具体例
業務エラーの例
- ユーザーの入力内容に誤りがあった。
- 所謂バリデーションエラー
- 他のユーザーが削除したデータを参照した。
- 閲覧権限の無いリソースへのアクスセスを検出した。
システムエラーの例
- バグが原因でサーバーサイドで例外が発生した。
- 外部連携システムがダウンした。
responseのstatus code
業務エラー
エラーの種別により下記のstatus codeを設定して下さい。
status code | 意味 | 発生ケース |
---|---|---|
400 | リクエストの内容に誤りがある | 入力内容のバリデーションに引っかかった。 パラメータ改竄されている、等 |
401 | 認証がなされていない | 認証が必要なendpointに認証情報を付加せずにアクセスした。 |
403 | リソースに対してアクセス権がない | 閲覧権限のない情報にアクセスした、等 |
上記は一例になります
システムエラー
エラーの種別により下記のstatus codeを設定して下さい。
status code | 意味 | 発生ケース |
---|---|---|
500 | システム内で想定外のエラーが発生した | システムがダウンしている、 サーバーサイドアプリケーションにバグがある、等 |
status codeの追加について
扱うエラーに対して本ガイドラインで定めているstatus codeに適したものが無い場合、適したstatus codeをガイドラインに追加して下さい。
また、扱わないエラーのstatus codeをあらかじめガイドラインに追加しないようにして下さい。
参考:HTTPステータスコード
response bodyのフォーマット
エラーを表現する場合、基本的にはreponseのstatus codeで表しますが、クライアント側でより詳細なレベルのエラー情報が必要な場合にのみ、reponse bodyで詳細なエラー情報を表現して下さい。
基本
エラーの詳細情報を表す場合、下記の基本フォーマットに従って下さい。
{
"meta": null,
"data": [
{
"code": 400000, // ※ 詳細エラーコード。必須。
"message": "error!!" // 任意
},
// ...
]
}
※ 詳細エラーコードの採番についてはこちらで説明します。
基本フォーマットで表現できない詳細情報を付加したい場合は基本フォーマットを変更しないことを前提に拡張して構いません。
{
"meta": null,
"data": [
{
"code": 400002,
"message": null,
"rowNo": 1, // 拡張部分
"columnNo": 10 // 拡張部分
},
{
"code": 400003,
"message": null,
"rowNo" 1,
"columnNo": 15
}
]
}
詳細エラーコードの採番規則
詳細エラーコードは数値型で、HTTPレスポンスのstatus codeと連番を組み合わせて採番して下さい。
フォーマットは6桁の数値で先頭3桁はstatus code、後続の3桁はエラー種別を表す0
から始まる連番になります。
例)status codeが400
のBad request
で、エラー種別が 3
の場合
{
"meta": null,
"data": [
{
"code": 400003
}
]
}
詳細エラーコード
詳細エラーコードは下記を使用して下さい。
エラーコード | status code | 意味 | 備考 |
---|---|---|---|
400000 | 400 | 既に指定されたemailアドレスがデータベースに存在する |
適したエラーコードが無い場合、上記のテーブルに追加して下さい。
Formのバリデーションエラー
Formのバリデーションエラーにおいて、エラー発生元フィールドとエラー情報を関連付ける必要がある場合は下記のルールに従って下さい。
- エラーレスポンスの詳細エラー情報ごとに
fieldName
という名前のフィールドを追加し、リクエストに含まれているバリデーション対象のフィールド名を設定する
例)メールアドレスに重複エラーとクレジットカード番号に与信エラーのバリデーションエラーが発生した場合
- メールアドレスのフィールド名は
email
、クレジットカードの番号のフィールド名はcardNo
とします。 - メールアドレスの重複エラーの詳細エラーコードは
400001
、クレジットカードの番号の与信エラーの詳細エラーコードを400002
とします。
request:
{
"meta": null,
"data": {
"email": "osumi_kumamon@ggmail.com",
"cardNo": "378282246310005"
}
}
response:
(status codeは400
:bad request)
{
"meta": null,
"data": [
{
"code": 400001,
"fieldName": "email"
},
{
"code": 400002,
"fieldName": "cardNo"
}
]
}
データが無い場合の表現
リソース自体が無い
リソースのデータ形式が配列の場合は空配列([]
)を設定して下さい。
{
"meta": null,
"data": []
}
リソースのデータ形式がObjectの場合はnull
を設定して下さい。
{
"meta": null,
"data": null
}
リソース内の一部のpropertyが無い
リソース自体が無い場合のルールと同じです。
リソースのデータ形式が配列の場合は空配列([]
)を設定して下さい。
{
"meta": null,
"data": {
"id": 1,
"children": []
}
}
リソースのデータ形式がObjectの場合はnull
を設定して下さい。
{
"meta": null,
"data": {
"id": 1,
"firstName": "Thet Win Aung",
"lastName": null
}
}
データのフォーマット
データ型
基本的にデータに適したデータ型で設定して下さい。
🙅 Bad
{
"meta": null,
"data": {
"id": 1,
"score": "60.5" ‼️
}
}
🙆 Good
{
"meta": null,
"data": {
"id": 1,
"score": 60.5 👍
}
}
日時型
ISO8601拡張形式のタイムゾーンJST
で表現して下さい。
🙅 Bad
{
"meta": null,
"data": {
"id": 1,
"createdAt": "2018/10/29 05:38:24"
}
}
🙆 Good
{
"meta": null,
"data": {
"id": 1,
"createdAt": "2018-10-29T05:38:24.486+09:00"
}
}
区分値
区分を表すコード値は数値で指定して下さい。
区分値の例
🙅 Bad
{
"meta": null,
"data": {
"id": 1,
"status": "in_progress"
}
}
🙆 Good
{
"meta": null,
"data": {
"id": 1,
"status": 1
}
}
真偽値
真偽を表すフラグ値はBoolean
で指定して下さい。
フラグ値の例
🙅 Bad
{
"meta": null,
"data": {
"id": 1,
"published": 1
}
}
🙆 Good
{
"meta": null,
"data": {
"id": 1,
"published": true
}
}
サニタイズ
responce bodyをXSS対策のためにサニタイズせず、rawデータで指定して下さい。
🙅 Bad
{
"meta": null,
"data": {
"id": 1,
"name": "<script>alert("foo")</script>"
}
}
🙆 Good
{
"meta": null,
"data": {
"id": 1,
"published": "<script>alert(\"foo\")</script>"
}
}
サニタイズはクライアントサイドのテンプレートシステムで行なっているためです。
query parameter
配列
配列データは<フィールド名>[]
で表現して下さい。
🙅 Bad
GET /suveys?id=1,2,3,4,5
🙆 Good
GET /suveys?id[]=1&id[]=2&id[]=3&id[]=4&id[]=5
railsとの親和性を考慮しました。
構造を持つデータ表現
複数の異なるリソースを含むリソースはリソース単位でネスト構造を持たせて表現して下さい。
🙅 bad
{
"meta": null,
"data": {
"id": 1,
"name": "foo"
"barId": 1,
"barName": "foo"
}
}
🙆 good
{
"meta": null,
"data": {
"id": 1,
"name": "foo"
"bar": {
"id": 1,
"name": "foo"
}
}
}
エンドポイントのネスト
エンドポイントのネストは基本的にせず、path parameter
よりquery parameter
で表現することを優先して下さい。
🙅 bad
/companies/:company_id/survys/:survey_id/foos/:foo_id
🙆 good
/foos/:foo_id?companyId=:company_id&surveyId=:survey_id
REST原則に外れる設計パターン
REST原則に従うことによってアプリケーションとして問題が生ずる場合があります。
下記に示すパターンに沿って設計を行って下さい。
WebAPIコールのN+1問題
問題
複数のリソースで構成されたリソースの一覧を取得する場合、単純にRESTらしい設計を行うとN+1問題
が発生します。
下記に例を示します。
前提
- 画面表示対象の回答者は100人いる
- 回答者として表示する情報は回答数、ユーザー名、性別、生年月日
- 用意されているリソースは下記の2つ
- 回答者リソース
- Endpoint:
/answers
- 回答数とユーザーIDを保持する
- Endpoint:
- ユーザーリソース
- Endpoint:
/users
- ユーザー名、性別、生年月日を保持する
- Endpoint:
- 回答者リソース
制御フロー
- 回答者リソースを全件(100件)取得する
-
/answers
のAPIコール数:1回
-
- 回答者に紐づくユーザーリソースを取得する
-
/users/:id
のAPIコール数:100回
-
😱回答者の数だけAPIコールが発生しクライアント側のパフォーマンスやサーバー負荷の問題が発生しうる!!
設計パターン
本問題に対する設計パターンは2つあり、それぞれの状況によって使い分けて下さい。
パターンA: リソースの検索条件として複数のIDを指定できるようにする
対象のリソースのEndpointのフィルタ条件として複数のIDを指定できるように設計します。
これにより複数のリソースで構成された情報を取得する際のAPIコール回数がリソースの種別数分に抑えることができます。
先の例ではユーザーリソースのフィルタ条件に配列型のIDを定義できるようにします。
- 回答者リソースを全件(100件)取得する
-
/answers
のAPIコール数:1回
-
- 回答者に紐づくユーザーリソースを取得する
-
/users?id[]=1&id[]=2&id[]=3....
のAPIコール数:1回
-
😀合計2回のAPIコールで済みました。
基本的にはパターンBよりパターンAを優先します。
パターンB: リソースに異なるリソースを含める
REST原則に違反する形にはなりますが、リソースに異なるリソースを含めます。
これにより複数のリソースで構成された情報を1度のAPIコールで取得することができます。
先の例では回答者のリソースにユーザーのリソースを含めるように拡張します。
{
"meta": null
"data": [
{
"id": 1,
"answerCount": 50,
"user": {
"id": 1,
"name": "foo",
"gender": 1,
"birthDate": "1994-07-30T00:00:00.000+0900"
}
},
{
"id": 2,
"answerCount": 45,
"user": {
"id": 2,
"name": "bar",
"gender": 0,
"birthDate": "1974-10-02T00:00:00.000+0900"
}
},
...
]
}
パターンAとの使い分けの観点としては、このリソースが拡張した異なるリソースとともに複数個所でよく使われるのかどうかになります。
よく使われる場合はパターンAよりパターンBを採用した方が良いでしょう。
そうでなければ、拡張したリソースは多くのケースで余計な情報になるためパターンAを採用すべきでしょう。
また、本パターンを採用する場合、拡張するリソースが3種類以上、及び3階層以上にならないようにして下さい。
🙅 Bad
{
"meta": null
"data": [
{
"id": 1,
"answerCount": 50,
"user": {
"id": 1,
"name": "foo",
"gender": 1,
"birthDate": "1994-07-30T00:00:00.000+0900",
"company": { // 🙆 2階層目のリソース
"id": 100,
"name": "bar",
"address": { // 🙅 3階層目のリソース
"id": 30,
"location": "東京都中央区銀座6丁目10-1"
}
}
},
"answerdQuestion": [{ // 🙅 3種類以上のリソース
"no": 1,
"title": "第一問 あなたの性別は?",
"answer": 1
}]
},
...
]
}
🙆 good
パターンAを使用し複数のリソースを複数回のAPIコールで取得する、もしくは画面に最適化した専用のリソースとして新たに定義しましょう。
最後に
紹介したガイドラインを適用した本格運用をまだ始めたばかりで、今後足り無い部分や余計な部分など色々な問題が出て来ると思います。
しかしこの点に関しては心配はしていません。
ガイドラインはシステムの改変、チームの成長、文化の変化などにより成長していく生き物のようなものです。
チームでこのガイドラインを放置せず大切に育て行くことにこそ意義があります。
ということでレビューをしてくれた全ての開発メンバーの皆様ありがとうございました!!
一緒にガイドラインを大切に育てて行きましょう!!😀
最後の最後に
組織改善にご興味のある方は全ての組織がこれで変わるモチベーションクラウドを是非ご検討して頂ければと思います!!
�フロントエンドAPIモック�導入でビルド時間が爆速になった
フロントエンドAPIモック導入したことでビルド時間が爆速になった
おはようございます、モチベーションクラウドの開発に参画している@sinpaoutです。
TL; DR
Docker + Rails + Mysql + webpackerで起動するのが時間かかりすぎるので
全てを捨ててnodeのみ(webpack-dev-server)で生きていくことに。。。
環境
Rails + Mysql + webpacker(vue.jsビルド)がDockerイメージとして管理され、
コマンド一発で開発環境を起動できる。
問題
起動にdb:setup
や db:migrate
などDBの初期化が走り大体3〜5分前後かかる。
更に画面が起動後にWebpackerが走り、フロントのビルドは1分ちょい。
DBの処理化なしでマイグレーションのみでもRailsが立上がるまで2,3分かかってしまう。
普段はJSのビルド時間も入れると5分はかかってしまう。
立上がったあとは毎回ログインして目的の画面に進むが途中で何かしらエラーに直面して進めなくなることはよくある。
また、 Seeds
データが全てのパターンきちんと用意さていない事が多く
目的の画面まで到達するのにかなりの時間と労力がかかってしまう。
ときにはDockerが壊れて丸一日をクジラのお世話に費やされてしまうエンジニアもいた。
Seedsデータを用意できても更新系やデータのありなしなどの
パターンはDBを直接触る必要が出てき来たりするので手間がかかるので
APIモックシステムの構築を検討することになった。
APIモックとは
APIをJSONファイルとしてwebpack-dev-serverなど簡易Webサーバで提供する仕組み
Railsなどのバックエンドを起動しないため高速に開発環境を起動可能
バックエンドの関係者たちを退場させる:
Nodeは今どきnvmなど入れとけばバージョン管理も楽なのでDockerも退場。
(みんな今まで頑張ってくれてありがとう。。。)
アプリのAPIパスとJSONファイルのマッピングはyamlファイルで定義し
axiosのintercepeterでアドレスを変換する
使ってみる
マッピングの設定:
# js/mocks/apiMapper.yml
default:
desc: デフォルトのモック
api:
/users: mocks/users.json
/users/1/: mocks/users/detail.json
users.json
の中身
{
"users": [{
"id": "",
"name": "",
}]
}
上記の例は
/users
のAPIをmocks/users.json
に
/users/1/
のAPIを mocks/users/detail.json
に置き変える。
※ パスのidの部分は全て1として解釈するようにする。
APIをJSONファイルと関連付けてくれる人
API Mockerの詳細
// js/mocks/apiMocker.js
import urlParse from 'url-parse'
import apiMapper from './apiMapper.yml'
const defaultApi = apiMappers.default.api
global.apiMockIntercepter = (config) => {
const originalUrl = config.url
const parsedUrl = urlParse(config.url)
let apiPath = parsedUrl.pathname.replace(new RegExp(`^${config.baseURL}`), '')
// idをすべて1に置き換える
apiPath = apiPath.replace(/\/([0-9]+)\//ig, '/1/')
const mockApiPath = defaultApi[apiPath]
if (mockApiPath) {
// 強制的にGETに
config.method = 'get'
config.url = config.baseURL + mockApiPath
// 元情報を書き出す
console.info('api mocked', originalUrl, mockApiPath)
}
return config
}
※ 環境に合わせてパスを調整する必要がある。
Webpackの設定
普段は index
のみバンドルするが、実行環境がlocal
の時のみ apiMocker
を挿入する。
apiMocker
が index
より前に挿入する必要がある。
CopyWebpackPlugin:
モックのJSONファイルをoutputパスにコピーさせる
// webpack.local.js
if (process.env.DEV_ENV === 'local') {
...
// Inject api mocker
webpackConfig.entry.index = [
`${dir.mocks}/apiMocker.js`,
`${dir.js}/index.js`
]
webpackConfig.plugins.push(new CopyWebpackPlugin([{
from: `${dir.mocks}/api/`,
to: `${output.path}/api/`
}]))
...
}
axiosの設定
実行環境がlocal
の時かつ apiMockIntercepter
が存在したら使うようにする
axios.interceptors.request.use((config) => {
if (process.env.DEV_ENV === 'local' && global.apiMockIntercepter) {
return global.apiMockInterceptor(config)
}
return config
})
モックマッピングの拡張
パターンごとに切り替えられるようにする。
# js/mocks/apiMapper.yml
default: &default
desc:
api: &api
/users: /users.json
/users/1/: /users/detail.json
...
noData:
<<: *default
api:
<<: *api
/users: /users_no_data.json
noData
のパターンでは /users
をデータなしに置き換える
users_no_data.json
{
"users": []
}
一捻り
Devtoolのネットワークでデバッグ
元のAPIやPOST場合は中身がリクエストの中身がわからなくなるので
console
で元の情報を表示するように apiMocker
を更新
// js/mocks/apiMocker.js
global.apiMockIntercepter = (config) => {
const originalUrl = config.url
...
if (mockApiPath) {
...
// 元情報を書き出す
console.info('api mocked', originalUrl, mockApiPath)
}
return config
}
UIからの置き換え
開発時に直接モックを切替変えられると、より効率上がるので
画面の右上あたりにパターン一覧を置き換えるポップアップ的なものを実装。
ブラウザーのリロード後もモックの設定を有効にしたいのでsessionStorageに突っ込む。
cookieを使わない理由はhttpOnlyなどを考慮したため。
E2E用
Cypressなどからパターンを変えられるように apiMocker
を更新
// js/mocks/apiMocker.js
const apiMocker = {
currentPattern: sessionStorage.getItem('apiMockerPattern') || 'default'
}
// E2Eようの外部モジュールから参照できるようにグローバル変数にしておく
window.apiMocker = apiMocker
...
// モックパターンをセットする関数を用意
apiMocker.setCurrentMock = function (patternName) {
apiMocker.currentPattern = patternName
sessionStorage.setItem('apiMockerPattern', apiMocker.currentPattern)
}
global.apiMockIntercepter = (config) => {
...
const apiMap = apiMappers[apiMocker.currentPattern].api
const mockApiPath = apiMap[apiPath]
...
}
Cypressから使う
cy.window().then((win) => {
win.apiMocker.setCurrentMock('noData')
cy.visit('localhost:8081/company/1/users')
})
結果
- 開発環境の立ち上げ5分 → 1分ちょい
- パターンごとにのデータの用意がjsonファイルのみで完結(ストレス激減)
- Dockerの死亡やSeed不足の悩みから開放
- E2Eからのパターンが切替えられるようになるためUIテストが書きやすい
- DirやPOなどのエンジニア以外への画面共有が楽
今後の追加機能
- Webpackerのビルド廃止(Railsと完全に縁を切る)
- Railsは嫌いではない(むしろ好き)がやりすぎると制御しづらくなるの要注意。
- POSTなどの更新系API対応
- 4xx、5xx系のエラー対応
- webpackのバージョンアップやチューニング
- プルリクエスト単位でのレビュー環境の用意
- 静的なファイルで再現できるためS3にビルド結果を展開が可能
Cypressを使ったインテグレーションテストの導入
Cypressの導入
おはようございます、モチベーションクラウドの開発に参画している@sinpaoutです。
TL; DR
フロントエンドの機能動作を担保するためCypressを導入しインテグレーションテスト機構を構築した。
Cypressの導入、使い方、工夫、運用方法について記述する。
経緯
新機能を開発していると、ある日「手を止めて負債を解消しようぜ」という天命が下って希望の日差しが見えた。
みんなで負債リストを書き出し、優先順位づけしてリファクタリングを行う準備をした。
そこでリファクタリングの動作確認に自動テストの導入が必須であることに気づきCypressを使ったインテグレーションを導入することに至った。
インテグレーションテストとは
ユニットテストを増やすと勝手に品質が上がるという話をよく聞くが
書くことが多く短時間で品質を担保するためには不向き。
しかし、E2E全機能を全ブラウザーでテストもできないので
Chromeのみで基本動線を担保するレベルのものにすることになった。
環境
Rails、Vue+Vuex、SPA。
一部coffee+erbも残っているが今回はSPAだけを対象にする。
Cypress
先ず動かして感覚をつかめる
https://docs.cypress.io/guides/getting-started/writing-your-first-test.html#Add-a-test-file
実際のテスト
シナリオ1
- データが存在する場合ユーザー一覧が表示されること
- データなしのメッセージが表示されないこと
cy.get('.users tbody tr').should('to.have.length.gt', 0)
cy.get('.empty-msg').should('not.exist')
基本的にDOMの特定はcy.getを使いjqueryのセレクターがサポートされている。
アサーションはshould関数をしよう。
シナリオ2
- 一覧のデータをクリックすることで詳細ページへ遷移できること
- 表示されたページが詳細ページであること
- パンくずの一つ前をクリックで元の一覧画面に戻ること
import url from 'url'
cy.get('.users tbody tr').eq(0).click()
cy.url().should(pageUrl => {
const parsedUrl = url.parse(pageUrl)
// urlが正しいこと
expect(parsedUrl.pathname.match(/\/user\/[0-9]+/)).to.ok
})
※ SPAのURLの検証はパターンのみ検証するため正規表現を使う
Page Object Pattern
各テストでDOMを直接参照しているとDOMの変更があった場合の修正範囲が広がる。
別の画面の機能を呼び出したい時にページのDOM構成を調べる必要がある。
上記の問題を解決するためPage Object Patternを導入する。
先ず共通的なPage Objectを追加。
- ページを意識しない共通的な機能を実装
import url from 'url'
const PAGE_URLS = {
companies: '/companies/?',
users: '/companies/[0-9]+/users/?',
users: '/companies/[0-9]+/users/[0-9]+/?'
}
export default class PageObject {
reload () {
cy.reload()
}
breadcrumb () {
return cy.get('.breadcrumbs')
}
breadcrumbs () {
return cy.get('.breadcrumbs-list > li')
}
// 現在のページのURLをチェック
isPageOf (pageName) {
cy.url().should(pageUrl => {
if (!PAGE_URLS[pageName]) {
throw new Error(`Page url regex not found for pageName: ${pageName}`)
}
const pathReg = new RegExp(PAGE_URLS[pageName])
const parsedUrl = url.parse(pageUrl)
expect(parsedUrl.pathname.match(pathReg)).to.ok
})
}
}
各ページのPage Objectが共通を継承し、該当ページの機能を実装。
import CommonPageObject from '../../common/PageObject'
const URL = '/users'
export default class UsersPageObject extends CommonPageObject {
visit () {
cy.visit(URL)
}
// 検索エリアを開く
expandSearchArea () {
cy.get('.users .fieldset').then($elements => {
if ($elements.length <= 2) {
cy.get('.users .expand-button').click()
}
})
}
// 検索ボタンの押下
search () {
cy.get('.users .actions button').click()
}
getUserRows () {
return cy.get('.user-list tbody tr')
}
emptyMessage () {
return cy.get('.empty-data')
}
}
シナリオ3
- リクエストの中身を検証する
共通PageObjectに関数を追加。
// PageObject.js
spyApiModule () {
cy.window().its('axios').then((module) => {
cy.spy(module, 'get')
cy.spy(module, 'post')
cy.spy(module, 'put')
cy.spy(module, 'delete')
})
}
getSpiedApiModule () {
return cy.window().its('axios')
}
※ windowオブジェクトからaxiosを取得しているので
axiosがグローバル変数としてセットされている必要がある。
if (process.env.NODE_ENV === 'test') {
window.axios = axios
}
使い方
import UsersPageObject from './UsersPageObject'
const userPage = new UsersPageObject()
// 予めスパイさせる
userPage.spyApiModule()
userPage.search()
userPage.getSpiedApiModule().then((module) => {
cy.wrap(module.get).should('be.called')
const firstArgs = module.get.firstCall.args[0]
const parsedQuery = queryString.parse(url.parse(firstArgs).query)
expect(parsedQuery).eqls({
page: '1',
limit: '25',
sort: 'email',
direction: 'asc'
})
})
※ SpyモジュールはSinonが使われている。
運用
テストの記述は2段階に分けて行う。
1. 先ずテストケースだけ(describeとit)のみを記述しレビューを通す
2. それからテストの中身(DOM操作やアサーション)を記述する
テストケースとテストの中身を分けることによってレビューコストを削減ができる。
また、作業分担できるので担当者の得意な部分を依頼ができ、作業効率化が図れる。
Config
cypress.json
{
"baseUrl": "http://localhost:8081/",
"ignoreTestFiles": [
"!/**/*.spec.js"
],
"viewportWidth": 1300,
"viewportHeight": 800,
"video": false
}
integrationフォルダー配下は全てテストファイル扱いになる。
PageObejctのファイルを対象外にしたいので *.spec.js
のみが対象となるようにする。
CI連携
モックサーバーが立ち上がってからCypressが走る必要あるのでCI上では工夫が必要。
先ず公式のGuideを参照し、npm scriptを工夫する。
モック
モックサーバーは基本的にDockerなどを使わずローカルでwebpack-dev-serverで起動する。
APIのモックはJSON静的なJSONファイルで提供している。
APIモックの詳細はこちらの記事 を参照。
シェルスクリプトを用意して環境変数のセットとwebpack-dev-serverの起動を行っている。
./bin/local-front-dev
運用コマンド
# モックサーバーを起動
yarn serve:local
# Cypressのtest runnerを起動(モックサーバーを立上げておく)
yarn cy:open
# ヘッドレスでテストを実行(モックサーバーを立上げておく)
yarn cy:run
# ローカルでテストを記述
yarn e2e:local
# CI上でテストを実行
yarn e2e:ci
タスクの詳細
Cypressの起動&テストタスク
"cy:open": "cypress open",
"cy:run": "cypress run",
モックサーバー起動・終了タスク
"serve:local": "./bin/local-front-dev",
"serve:local:kill": "kill $(lsof -i :8081 | grep node | awk '{print $2}')",
モックが立ち上がるのをwaitするタスク
"serve:local:wait": "wait-on http-get://localhost:8081/assets/packs/index.js",
ローカル用タスク
"e2e:local": "run-p serve:local e2e:test",
"e2e:test": "run-s serve:local:wait cy:open",
CIで用タスク(終了後プロセスを殺す)
"e2e:ci": "run-p serve:local e2e:ci:test",
"e2e:ci:test": "run-s serve:local:wait cy:run serve:local:kill",
wait-on モジュールのスターも忘れずに。
問題
ヘッドレスの場合 Electron
ブラウザーしか選択肢がない。
テストケースによってヘッドレスで固まってしまうことがあるので
CIの安定した運用はまだ先になりそう。
Chromeのヘッドレスもサポートするそうで期待したい。
https://github.com/cypress-io/cypress/issues/832
感想
E2Eに特化しているサービスだけあってデバッグ機能など豊かで使いやすい。
OSSで始められて将来テストが遅くなったらお金払って並列実行などで運用してもらうことも可能。
Test runner:
Test runner のUI上でselectorやデータ型の相違などの問題が解決するので
になれることをおすすめ。
https://docs.cypress.io/guides/core-concepts/test-runner.html#Overview
おまけ
主に使われるコマンド一覧。
visit:
指定のURLへ遷移
cy.visit('http://localhost:8081/')
url:
現在のURLを取得
cy.url().should('include', '/operations/companies/1/users')
get:
DOMの取得
cy.get('.breadcrumbs-menulist > li').eq(0).click()
contains:
指定の文字列が含まれている要素を取得
cy.contains('メンバー設定').click()
eq:
複数要素取れた場合指定インデックスで取得
cy.get('.users-actions button').eq(1).should('be.visible')
find:
小要素から検索
cy.get('.users-conditions .users-field')
.find('input[type=text]')
.should('have.value', '田中')
children:
小要素一覧を取得
cy.get('.users-conditions .users-field')
.eq(fieldIndex)
.find('select.form-select-target')
.should('be.visible')
.children()
.should('have.length', options.length)
click:
クリックイベントを発火
cy.get('.breadcrumbs .breadcrumbs-menulist').eq(0).find('a').click()
select:
セレクトボックスを指定した値で選択
cy.get('.users-conditions .users-field')
.find('select')
.select('3')
each:
小要素一覧を回す
wrap:
取得した要素をラップしてCypressコマンド使えるようにする
cy.get('.users-conditions .users-field')
.find('select.form-select-target')
.children()
.each(($el, index) => {
cy.wrap($el)
.should('have.value', options[index].value)
.and('have.text', options[index].text)
})
アサーション
should:
基本的なアサーションの作成機能である。
文字列として指定できるものはchainersと予備chaiはsinonの関数が使える。
https://docs.cypress.io/api/commands/should.html#Syntax
// テキストフィールドが表示されていること
cy.get('.users-conditions .users-field')
.should('be.visible')
// クラスが指定されていないこと
cy.get('.list-pager .list-pager-next')
.should('not.have.class', 'is-disabled')
// href属性が存在すること
cy.get('.users-conditions .users-link')
.should('have.attr', 'href')
// 指定数のDOMの数がヒットすること
cy.get('.users-conditions .users-item')
.should('have.length', 5)
// 指定した値が選択されていること
cy.get('.users-conditions .users-item')
.should('have.value', '3')
// 指定した文字列が入力されていること
cy.get('.users-conditions .users-item')
.should('have.text', 'user01@mail.com')
and:
shouldと合わせてAND条件を指定可能にする
cy.get('.users-conditions .users-link')
.should('have.attr', 'href')
.and('contain', '/operations/companies/1/magellan_operation_menu')
expect:
ChaiのBDDアサーション
https://docs.cypress.io/guides/references/assertions.html#Chai
// trim後の文字列の比較
expect($btn.text().trim()).eq('検索')
// checkboxが中間(未選択)状態であること
expect($el[0].indeterminate).eq(true)
リクエストの中身
server:
router関数が使用可能にするためにサーバーを起動
route:
ネットワークリクエストの振る舞いを管理
as:
ルートのリクエスト情報のエリアス、DOMでも使用可能
it('Getting users api status should be 200', () => {
cy.server()
// companies/1/users で始まるリクエストを監視
cy.route('GET', '/companies/1/users*').as('getUsers')
cy.get('.users-conditions .users-form-actions button').click()
cy.url().should('include', '?page=1&sort=email&direction=asc')
// getUsersエリアスのxhrオブジェクトからstatusを取得
cy.wait('@getUsers').its('status').should('eq', 200)
cy.visit('/operations/companies/1/users')
})
ApiモジュールのSpy
it('リクエストパラメーターにpage、sort、directionのみが含まれること', () => {
userPage.spyApiModule()
userPage.search()
userPage.getSpiedApiModule().then((module) => {
cy.wrap(module.get).should('be.called')
const firstArgs = module.get.firstCall.args[0]
const parsedQuery = queryString.parse(url.parse(firstArgs).query)
expect(parsedQuery).eqls({
page: '1',
limit: '25',
sort: 'email',
direction: 'asc'
})
})
})
小技
例:windowオブジェクトからaxiosを取得してaxios.getをスパイする
cy
.window()
.its('axios')
.as('axios')
.then((axios) => {
cy.spy(axios, 'get')
})
...
cy.get('@axios').then((axios) => {
expect(axios.get).to.be.calledOnce
})
デザイナーとStorybookをS3上で共有
デザイナーとStorybookをS3上で共有
おはようございます、モチベーションクラウドの開発に参画している@sinpaoutです。
開発の流れ
UI部品をStorybookで管理しデザイナーが部品の組合せで画面デザインを行う。
Storybook化されている部品で完結するデザインはワイヤーレベルのもので実装する。
デザインの差異などが発生したらStorybookが正解とする。
問題
- デザイナーが簡単にStorybookを閲覧できる環境がない
- 開発現場にいる場合はエンジニアのPCを覗いて閲覧するしかない
- 見ているブランチが古かったりする
- リモートの場合は見れないがどうしているのだろうか!!
解決策
- Storybookを静的にビルドしS3で展開てい共有
- 成果物はブランチ単位でビルドし、誰でも簡単に見れるようにする
S3の展開への流れ
手動で実行するshellを用意し、エンジニアが実行してアップロードする。
将来はCIと連携してPushのたびにビルドされるようにすることも可能。
ブランチ一覧のページを用意し、見ているブランチを認識できるようにする。
展開コマンド
./bin/publish-storybook.sh
詳細
aws s3 sync
などのコマンドが使えるようにする。
現場では awsアカウントは開発でログインしているので aws-cli
は普通に使用可能
npm-scriptsの用意
"storybook:build": "rm -rf tmp/storybook && build-storybook -c ./js/.storybook -o tmp/storybook",
バケットと同期
# bin/publish-storybook.sh
branch=$(git symbolic-ref --short HEAD)
# "/" は問題ありそうなので一旦 "_" に置き換えておく
folder=$(echo $branch | sed -e "s/[/]/_/g")
yarn storybook:build
# コンテンツはbranchesディレクトリ配下におく
aws s3 sync $STORYBOOK_PATH "s3://$S3_FOLDER/branches/$folder" \
--delete \
--acl public-read
※ acl が public-read だが後述するセキュリティーで制限している
ブランチのデータ
S3上のフォルダー一覧をbranches.txtとして書き出す。
不要な文字列を切り取る。
aws s3 ls "s3://$S3_FOLDER/branches/" | \
sed -e "s/ PRE //g" | \
sed -e 's/[ /]//g' > tmp/branches.txt
ブランチ一覧の表示用ページ
ブランチ一覧を表示するhtmlファイルもアップロード。
htmlファイルはS3にあれば動作するがgitで管理したいので毎回アップロードしておく。
htmlの中身は同じディレクトリのbranches.txt をajaxで読み込んで
一覧のリンクを表示するのみ。
aws s3 sync js/.storybook/s3 \
"s3://$S3_FOLDER" \
--exclude "*" \
--include "index.html" \
--acl public-read
Storybookの一覧
各リンクからStorybookが見れる。
セキュリティー
ACLをpublicにしているがバケットポリシーで社内からしか見れないようにIP制限する。
バケットポリシージェネレーター:
https://awspolicygen.s3.amazonaws.com/policygen.html
後始末
ビルドしたコンテンツの削除処理はS3に任せる。
バケット
→ 管理
→ ライフサイクルルールの追加
名前とスコープ:
ライフサイクル名は分かりやすいのを入力。
prefixはバケットを除いたディレクトリのパス
my-bucket/storybook/branches
の場合はstorybook/branches
にする。
移行:
何もしない
有効期限:
現行バージョン
をチェック
オブジェクトの現行バージョンを失効する
をチェック
日数を90にする。
これでコンテンツが作成されてから90日後に消去される。
はまったポイント
S3の静的サイトホスティングのパスが分かりづらい。
htmlファイルなどのコンテンツの詳細にあるリンクは遷移できない。
バケット名
→ プロパティ
→ Static website hosting
→ エンドポイント
を使う必要がる。
先ず始めてみるならこれだけで十分だがSSLやドメインの設定はCloud Frontで行う。
vscodeでVue.jsを書くときに使っているプラグインとか
お疲れ様です。@dayoshixです。
ここ1年ほど仕事でvscodeを使ってVue.jsアプリを書いています。
今、vscodeに入れているプラグインの中からVue.js開発に関連していて便利だと思ったプラグインとかツールをまとめてみました。
ESLint
eslintを使用する場合は入れておきましょう。
後述するVetur
を使用し単一ファイルコンポーネント(.vue
)内のJSにeslintを効かせたい場合にも必要となります。
Vetur
単一ファイルコンポーネントでVue.jsを書いている場合はほぼ必須のpluginです。
これがないとシンタックスハイライトや、HTMLやJSなど文脈に合わせたコード補間などが効かないので黙って入れることになります。
色々な事ができるのでドキュメントを一通り参照することをお勧めします。
中でもSetupページにある単一コンポーネントファイル内のJSにESLintを効かせるvscodeの設定は先に紹介したESLintプラグインを入れている場合は設定しておくとeslint-plugin-vueのEssentialなルールセットでチェックしてくれるので便利です。
また、ES Moduleのパス指定でWebpackのaliasを使用している場合、通常設定ではコードジャンプが効かなくなってしまうのですが、ここで紹介されているPath mappingをやっておくとコードジャンプが効くようになるので参考にすると良いでしょう。
Bookmarks
単一ファイルコンポーネントを編集する際、コードが長くなればなるほど<template>
、<script>
、<style>
の行ききが煩わしくなる事があります。
そういう場合に行単位でブックマークが出来てショートカットキーで移動できるこのプラグインが便利です。
Vue Peek
vscodeの標準機能である参照先のコードをその場でのぞき見できるpeekを単一ファイルコンポーネントで行えるようになります。
peekをよく使う人は入れておくと便利かもしれません。
その他
vscodeとは関係ありませんがVue.js開発に便利な物も紹介します。
Vue.js devtools
Vue.js開発を便利にするWebブラウザエクステンションで、Vue.js開発をする場合に最初に入れるべき物と言えます。
Vueコンポーネントのinspectionや状態、Vuexストアのmutationごとの状態などを参照する事が出来ます。
Vue CLI 3
Vue.jsアプリのベストプラクティスを適用したっぽいプロジェクト雛形を作成出来たり、Vue.jsのpluginやライブラリなどをアプリに組み込んで来れたり、色々できる開発補助ツールです。
これで作ったプロジェクト雛形と同等のレベルのものを一人で作るには膨大な時間と労力が必要なので特別な理由がない限り利用したいところ。
総括
まとめてみるとVue CLI 3
でいい感じのプロジェクトの雛形が出来て、vscode
にVetur
を入れて、ChromeにVue.js devtools
を入れるだけで快適な開発環境を手に入れる事が出来て今Vue.jsでWebフロントエンドを始める人は恵まれているなぁと思った。😌
Vueを用いた開発プロジェクト用に「コンポーネント設計・実装ガイドライン」を作った話
はじめに
こんにちは、モチベーションクラウドの開発にフリーのエンジニアとして参画している@HayatoKamonoです。
この記事は、「モチベーションクラウド Advent Calendar 2018」8日目の記事となります。
概要
モチベーションクラウドの開発チームでは2018年10月から改善期間と称して、開発に関するガイドラインやルール作りをはじめとする、様々な改善活動に取り組んでいます。
その改善活動の一環として作成した「コンポーネント設計・実装ガイドライン」を今回は説明を交えながらご紹介して行きたいと思います。
目次
- Componentの粒度に関して
- Container ComponentとPresentational Component
- コンポーネントの共通化に関して
- コンポーネント実装時の細かな決め事
- CSSに関して
- まとめ
Componentの粒度に関して
これまではフロントエンド開発チームの中で「どの粒度でコンポーネントを切るか?」に関して共通の方針が存在していなかったため、人によって実装するコンポーネントの粒度がバラバラでした。
ただでさえ、Vueの場合、単一ファイルコンポーネントでコンポーネント実装を行うと、どうしても1ファイル内のコードが長くなりがちなので、色々な責務を一つのコンポーネントに押し込んでしまうと、何スクロールもしないと1ファイル内のコード全体を読み通すことが出来ないようなコンポーネントが簡単に出来上がってしまいます。
実際に、そのような産物もチラホラ・・・
そこで、フロントエンド開発チームでは、「コンポーネントの粒度」に関しての方針を決め、ガイドラインに入れることにしました。
Atomic Design
コンポーネントはAtomic Designを参考にし、以下の粒度を意識して実装するという方針に決めました。
- atoms
- molecules
- organisms
- templates
- pages
とはいえ、方針だけ決めても実際に運用に乗らなければ意味がありません。
Atomic Designは「実際に運用してみると難しい」というような話は度々、ネット上の記事で見かけたり、実際に聴いたりもします。
私自身、Atomic Designを参考にしてコンポーネント実装を行なった経験が以前ありましたが、自分の経験としても、「このコンポーネントはatoms
なのか、molecules
なのか?」等と判断に迷うことが度々ありました。
また、チームの中にはAtomic Designでのコンポーネント実装を経験している人もいれば、そうでない人もいたので、チームのみんなで「何がどの粒度に該当するのか?」について、サンプルコードや既存のコンポーネントをベースに認識合わせを行なうことにしました。
大事にしたこと
Atomic Designの解釈について大事にしたことは「このチームにおいて、どう解釈するか?」です。なので、チーム内のみんなが納得感を持てれば、それが他のAtomic Designを取り入れている現場の解釈と異なっていてもOKということです。
atoms
- これ以上分割出来ない最小単位の機能を持つ
- (例)formのlabel、button、inputなど
- atomは他のatomを自身の範囲内に含むこともある
molecules
- 意味のある単位でatomを組み合わせて作られた集合体
- (例) フォームのラベル、テキストボックス、ボタンを組み合わせた検索ボックス
- moleculeは他のmoleculeを自身の範囲内に含むこともある
organisms
- 意味のある単位でatom、moleculeを組み合わせて作られた集合体
- (例) ロゴ、検索ボックス、ナビゲーションリンクを組み合わせたヘッダー
- organismsは他のorganismsを自身の範囲内に含むこともある
- 必要に応じてContainer Componentを通じて、VuexのstoreやVue Routerのrouteにアクセスする
templates
- レイアウトに責務を持つ
- 特定のレイアウトを適用したいコンポーネントの中で使われる(pagesレベルのコンポーネントに限定しない)
- 多くの場合、slotsを含む
pages
- atom、molecules, organisms, templatesの集合体
- 単一のURLに対応する
- 必要に応じてContainer Componentを通じて、VuexのstoreやVue Routerのrouteにアクセスする
共通方針
また、全てのレベルのコンポーネントに共通する方針として、コンポーネント自身にCSSのpositionやコンポーネントの外側へのmarginを持たさず、コンポーネントを使う側でそれらは指定するということも共通の認識として持つようにしました。
Container ComponentとPresentational Component
Reactコミュニティーでは、Dan Abramovの「Presentational and Container Components」で有名になった「Container Component」と「Presentational Component」を分けて実装するパターンがお馴染みです。
モチベーションクラウドのフロントエンド開発チームには、私自身も含め、React経験者がチラホラおり、2018年の8月、9月頃から、Container ComponentとPresentational Componentを分けようという動きが生まれました。
しかし、React未経験の開発メンバーにしっかりとコードをもとに、Container ComponentとPresentational Componentの違いを説明・共有出来ていなかったこともあり、Container Componentの認識が人によって異なるという状況が生まれていました。
結果、Container Componentの中にPresentationに関するコードが含まれてしまったりと、結局、ContainerとPresentationalで分離されていないコンポーネントが出来上がってしまうことにもなりました。
そこで、改めて、今回の改善期間の中で作成した「コンポーネント設計・実装ガイドライン」の中に、Container ComponentとPresentational Componentの説明をサンプルコードを交えて組み入れ、再度、認識の擦り合わせを行うようにしました。
目的の共有
そもそも、Container ComponentとPresentational Componentに分けることによって、どんなメリットがあるのかすら共有出来ていなかった為、その辺も含め、ガイドラインに組み込むようにしました。
可読性・保守性の向上
データや振る舞いに関心を持つContainer Componentと、見た目に関心を持つPresentational Componentに分離することで、どこに何が書かれているかが分かりやすくなり、アプリケーションのコードの理解がしやすくなるし、既存コードに機能追加や修正を行なう際には、どこに何を追加・変更すれば良いかが分かりやすくなる。また、1ファイル内のコード量も減るため、コードの見通しも良くなる。
再利用性の向上
Container ComponentとPresentational Componentに分けることで、Presentional Componentが特定のVuex側で持っている状態に依存しなくなり、他の異なるデータソースに差し替えても使い回しが出来るようになる。また、同様にContainer Component自体も同じデータや振る舞いに関心を持つPresentational Componentに対して、使い回しが効くようになる。
テスト容易性の向上
Container ComponentとPresentational Componentを分けることで、Presentational Componentのみを簡単に単体テスト出来るようになったり、リグレッションテストが出来るようになる。
並行作業の容易性向上
Container ComponentとPresentational Componentに分けることで、一人はAPIの繋ぎこみや振る舞いに関するロジックの実装、もう一人はコンポーネントのマークアップやスタイリングを行なうというように、ファイルのコンフリクトを気にせず、並行作業がしやすくなる。
Storybookの追加容易性の向上
Container ComponentとPresentational Componentを分けることで、Vuexのモックを用意するなどせずに、簡単にStorybookにコンポーネントを追加することが出来るようになる。
Presentational Component
特徴
- 見た目に責務を持つ
- VuexのstoreやVue Routerのrouteなどのアプリケーションの状態に依存せず、他のVueアプリケーションにも流用できる。
- 必要なデータがどのように読み込まれるか、また、どのように変更されるかを指定しない
- コンポーネント自身の状態は滅多に持たない。(仮に状態を持つとしても、それはUIに関する状態のみ)
状態を持たせない
Presentational Componentは親からprops
を通して、必要なデータを受け取り、自身を描画するだけのものです。本来のPresentational Componentの特徴としては、UIに関する状態であれば、その状態を持っても良いとあります。
しかし、私たちのチームで別途、作成したVuexのガイドラインでは「パフォーマンスに影響があるなどの特別な理由がない限り、全ての状態をVuexに寄せる」という方針があるため、Presentational Componentが状態を持つことはありません。
仮にVuex側で状態を持たせない方が良い場合は、後述するContainer Component側に持たせます。
サンプルコード
<template>
<div>
<h1 v-if="isOpen">Hello World</h1>
<p>{{ message }}</p>
<button @click="handleClick">toggle</button>
</div>
</template>
<script>
export default {
name: "HelloWorld",
props: {
message: {
type: String,
default: "I am not controlled"
},
isOpen: {
type: Boolean,
default: false
}
},
methods: {
handleClick() {
this.$emit("click");
}
}
};
</script>
Functional Component
いわゆる、Leaf Componentと呼ばれるコンポーネントのように、ループ処理の中で同じコンポーネントが描画されるような場合には、Functional ComponentとしてPresentational Componentを実装することも検討します。
Functional Componentは単なる関数でインスタンスを持たないため、描画コストを少なく抑えることが可能です。
Container Component
特徴
- データや振る舞いに責務を持つ
- データや振る舞いをPresentational Componentや他のContainer Componentに提供する
- VuexのstoreやVue Routerのrouteを参照しても良い(しなくても良い)
- 通常、DOMのマークアップやCSSスタイルを持たない。(仮にDOMを持つとしても、それはラッパー用のdivタグなど)
サンプルコード
// Presentational Component
import SamplePage from "./SamplePage.vue";
/*
以下の`connect`は、Presentational Componentを引数に取り、そのコンポーネントが関心を持つ、
VuexのmoduleのデータとVue Routerのメソッドへのアクセスを与えたContainer Componentを返す高階関数
*/
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
computed: {
count() {
return this.$store.state.count;
}
},
methods: {
handlePageChange({ to }) {
this.$router.push(to);
}
},
render(createElement) {
return createElement(WrappedComponent, {
props: {
count: this.count
},
on: {
pageChange: this.onChangePage
}
});
}
};
};
/*
次の2行は以下のコードをContainerの説明の為に、より明示的にしたもの
export default connect(SamplePage);
*/
const SamplePageContainer = connect(SamplePage);
export default SamplePageContainer;
export { SamplePage };
コンポーネントの共通化に関して
現状、フロントエンド開発チームが抱える1つの課題として、似たようなコンポーネントが複数存在していたり、同じような処理が複数のコンポーネントに散らばっていたりする状況が多々あるというものです。
この状態に至った背景としては、開発の進め方や開発者同士の連携不足に原因があったり、コンポーネント分割やコンポーネント共通化に関する考え方・方法に対する理解不足に原因があるようでしたが、今回作成した「コンポーネント実装・設計ガイドライン」では、共通化の方法論について実際にサンプルコードをもとに共有・認識の擦り合わせを行なうようにしました。
共通化の手法
共通化を実現する方法としては、「継承」であったり、Vueの「Mixin」がありますが、継承は親と子が密結合な関係になってしまいますし、「Mixin」もまた暗黙の依存関係が生まれてしまいます。(※Reactも以前はMixinをサポートしていましたが、Mixinは暗黙の依存関係を生むため廃止しています。)
そのため、Reactコミュニティーではお馴染みのHigher Order Componentを用いたり、Reactコミュニティーで用いられるRender PropsやRender Childrenパターンと同様のことを実現するVueのScoped Slotsを用いて、データや振る舞いの共通化を行います。
ここでは、Higher Order ComponentとScoped Slotsを用いてContainer Componentを実装する簡易的な例を掲載します。
Higher Order Componentを用いた例
以下は、仮にクライアント側で認証を行うSPAであると仮定した場合に必要になりそうな、クライアント認証用のロジックやデータを提供するHigher Order Componentの例です。
Higher Order Componentは引数にComponentを取り、別のComponentを返す高階関数です。
Higher Order Component側
const requireAuth = WrappedComponent => {
return {
name: `${WrappedComponent.name}-protected`,
computed: {
isAuthenticated() {
return this.$store.state.isAuthenticated;
}
},
created() {
// JWTトークンが存在、または、失効しているかどうかをチェック
// トークンが無い、または、失効していたらログインページへリダイレクト
},
render(createElement) {
return createElement(WrappedComponent, {
props: {
isAuthenticated: this.isAuthenticated
}
});
}
};
};
export default requireAuth;
使う側の例
// router.js
export default new Router({
mode: "history",
base: process.env.BASE_URL,
routes: [
{
path: "/",
name: "home",
component: requireAuth(HomePage)
},
{
path: "/about",
name: "about",
component: requireAuth(AboutPage)
}
]
});
Scoped Slotsを用いた例
以下は、マウスの位置情報を提供するContainer Componentの例です。
Container Component側
export default {
name: "MouseMoveTracker",
data() {
return {
mousePosition: {
x: 0,
y: 0
}
};
},
methods: {
handleMouseMove({ clientX, clientY }) {
this.mousePosition.x = clientX;
this.mousePosition.y = clientY;
}
},
mounted() {
this.$refs.wrapper.addEventListener("mousemove", this.onMouseMove);
},
beforeDestroy() {
this.$refs.wrapper.removeEventListener("mousemove", this.onMouseMove);
},
render(createElement) {
return createElement(
"div",
{
ref: "wrapper"
},
[
this.$scopedSlots.default({
mousePosition: this.mousePosition
})
]
);
}
};
使う側
<template>
<div id="app">
<MouseMoveTrakcer>
<template slot-scope="{ mousePosition }">
<SampleComponent :position="mousePosition" />
</template>
</MouseMoveTrakcer>
</div>
</template>
<script>
import SampleComponent from "./components/SampleComponent";
import MouseMoveTrakcer from "./components/trackMouseMove";
export default {
name: "App",
components: {
SampleComponent,
MouseMoveTrakcer
}
};
</script>
Container Componentにラップされる側
※ マウスの位置情報に関心があるコンポーネントであると仮定。
<template>
<div>
<p>X: {{ position.x }}</p>
<p>Y: {{ position.y }}</p>
</div>
</template>
<script>
export default {
name: "SampleComponent",
props: {
position: {
type: Object,
required: true
}
}
};
</script>
コンポーネント実装時の細かな決め事
他にも命名規則やデータ型毎の初期値など、コンポーネント実装時のルールとして定めました。
また、これまでは「JavaScript Standard Style」を適用するESLintプラグインは導入していましたが、Vue用のESLintプラグインは導入していなかったため、まずは、ルールが緩め目の「vue/essensial」を導入し、自分たちでガイドラインを決めなくても良いものに関しては、VueのESLintプラグインが提供してくれるルールに乗っかることにしました。
イベントハンドラーとイベント名の命名規則
これまで特にイベントハンドラーやイベント名にルールがなかったため、例えば、人によってはイベントハンドラー名をhandleXXX
のようにhandle
で始めたり、onXXX
のようにon
で始めたりと、名前の付け方がバラバラでした。
そのため、一貫性を持たせコードの見通しを良くするためにも、以下のように命名規則の基本方針を固めました。
イベントハンドラー名
イベントハンドラー名はhandleCancelのようにhandleで始めます。 また、イベントが発生した対象をイベントハンドラ名に含める必要がある場合は、handleModalCloseのように、「on + 名詞 + 動詞」のように命名します。
methods: {
// Good
handleCancel () {
}
// Good
handleModalClose () {
}
// Good
handleKeyPress () {
}
// Good
handleMouseMove () {
}
// Bad
onCancel () {
}
// Bad
onCloseModal () {
}
// Bad
onPressKey () {
}
// Bad
onMoveMouse () {
}
}
イベント名
emitするイベント名は、命名規則に沿ったメソッド名からhandleを取り除いた文字列とします。
methods: {
handleCancel () {
this.$emit('cancel')
}
handleModalClose () {
this.$emit('modalClose')
}
handleKeyPress () {
this.$emit('keyPress')
}
handleMouseMove () {
this.$emit('mouseMove')
}
}
各データ型の初期値
コンポーネントのdataやpropsで持つデータ型の初期値に関して、個々の開発者によって認識がバラバラだったため、初期値としてより適切な値がある場合はそちらを優先することとしながら、基本的な方針として各データ型の初期値についてまとめることにしました。
データ型 | 初期値 |
---|---|
Boolean | false |
Number | null |
String | null |
Object | {} |
Array | [] |
Function | null |
Date | null |
※ Object型の初期値を{}
としている理由は、後でobjectに追加したkeyの値がリアクティブになるため
propsのガイドライン
これまで、propsの定義に関しては最低限、propの型の指定はされていましたが、人によって、必須のpropにrequired: true
の指定がされていなかったり、default
値の指定がされていなかったりと緩い状態になっていたため、VueのESLint Pluginでチェックが走るものもありますが、以下のように方針を固めました。
必須のpropsにはrequiredをtrueにする
props: {
name: {
type: String,
required: true
}
}
任意のpropsにはdefaultを設定する
props: {
isOpen: {
type: Boolean,
default: false
}
}
データ型を指定する
props: {
name: {
type: String
},
count: {
type: Number
},
isOpen: {
type: Boolean
},
item: {
type: Object
},
mode: {
type: String
},
selectedIds: {
type: Array
},
date: {
type: Date
},
customFunction: {
type: Function
}
}
propsの検証
propsで受け取る値が特定の条件に当てはまる場合は、propsの値にバリデーションを適用します。
以下はバリデーションの一例です。
props: {
name: {
type: String,
validator (value) {
return value.length > 0
}
},
count: {
type: Number,
validator (value) {
return value >= 0
}
},
item: {
type: Object,
validator (obj) {
const EXPECTED_KEY = 0
const EXPECTED_VALUE_TYPE = 1
const expectedPairs = [
['name', 'string'],
['count', 'number']
]
const pairs = Object.entries(obj);
return pairs.every(([KEY, VALUE], index) => {
return KEY === expectedPairs[index][EXPECTED_KEY] && typeof VALUE === expectedPairs[index][EXPECTED_VALUE_TYPE]
})
}
},
mode: {
type: String,
validator (value) {
const modes = ['easy', 'difficult']
return modes.includes(value)
}
},
selectedIds: {
type: Array,
validate (values) {
return values.every(value => typeof value === 'string')
}
},
date: {
type: Date,
validate (value) {
return value >= new Date(2000, 01, 01)
}
}
}
CSSに関して
前提として、モチベーションクラウドのフロントエンド開発においては、styled-components
のようなCSS-in-JS
は使っておらず、Scoped CSS
の環境下でSassを利用し、CSSを書いています。
Scopeが切られた環境ということもあり、フロントエンド開発チームにはBEM記法は行わず、また、他に細かなルールを決めるということもしておらず、個々の開発者にCSSの書き方は委ねるといったスタンスでおりました。
一応、クラス名はclass-name
のようにハイフンで区切ったケバブケースにするという簡単な決まり事はありましたが、やはり、これだけでは、次第に辛いところが出てきました。
そのため、追加で「最低限、これだけは守ろう!」といった方針をガイドラインに組み込むことにしました。
要素型セレクターは使わない
p
やul
など、要素セレクターは使用しません。代わりにclassセレクターを使用します。
BAD
<template>
<div class="sample-component">
<div>
<p>Hello World</p>
</div>
</div>
</template>
<style lang="scss" scoped>
div {
...プロパティを定義
& > p {
...プロパティを定義
}
}
</style>
GOOD
<template>
<div class="sample-component">
<div class="box">
<p class="message">Hello World</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.box {
...プロパティを定義
& > .message {
...プロパティを定義
}
}
</style>
ケバブケースのクラス名を&や変数展開で繋がない
ケバブケースのクラス名を&
や#{}
を用いた変数展開で繋ぐと検索性や可読性を損ねるため、これらをクラス名の連結を目的として使用しません。
BAD
<template>
<div class="sample-component">
<div class="box">
<p class="box-description is-large">
<span class="box-warning>"Alert Message</span>
</p>
</div>
<ul class="product-list">
<li class="product-list-item">A</li>
<li class="product-list-item">B</li>
<li class="product-list-item">C</li>
</ul>
</div>
</template>
<style lang="scss" scoped>
.box {
$box: #{&};
...プロパティを定義
&-description {
...プロパティを定義
&.is-large {
...プロパティを定義
#{$box}-warning {
...プロパティを定義
}
}
}
}
.product-list {
...プロパティを定義
&-item {
...プロパティを定義
}
}
</style>
GOOD
<template>
<div class="sample-component">
<div class="box">
<p class="description is-large">
<span class="warning>"Alert Message</span>
</p>
</div>
<ul class="product-list">
<li class="item">A</li>
<li class="item">B</li>
<li class="item">C</li>
</ul>
</div>
</template>
<style lang="scss" scoped>
.box {
...プロパティを定義
.description {
...プロパティを定義
&.is-large {
...プロパティを定義
.box-warning {
...プロパティを定義
}
}
}
}
.product-list {
...プロパティを定義
.item {
...プロパティを定義
}
}
</style>
まとめ
今回作成した「コンポーネント設計・実装ガイドライン」には、この記事の中では触れていないものもありますが、主要な部分に関しては共有出来たと思います。
ガイドラインは実際に運用出来ないと意味がないので、今後、新たにコードを書く際、既存のコードをリファクタリングする際、コードレビューを行なう際に、こちらのガイドラインで決めたことを適用して行きたいと思います。
また、ガイドラインは育てていくものでもあるので、実際に運用に乗せながら、試行錯誤をしつつ、必要に応じて追加・修正を行なって行きたいと思います。
関連記事
こちらの記事はモチベーションクラウド Advent Calendar 2018に投稿した記事です。
他にも、以下の記事をモチベーションクラウド Advent Calendar 2018に投稿しています。
- Vueを用いた開発プロジェクト用にカスタムジェネレーターを作ってみる
- Vueを用いた開発プロジェクト用に「コンポーネント設計・実装ガイドライン」を作った話
- Vuexを用いた開発プロジェクト用にガイドラインを作成した話
Vuexを用いた開発プロジェクト用にガイドラインを作成した話
はじめに
こんにちは、モチベーションクラウドの開発にフリーのエンジニアとして参画している@HayatoKamonoです。
この記事は、「モチベーションクラウド Advent Calendar 2018」9日目の記事となります。
概要
モチベーションクラウドの開発チームでは2018年10月から改善期間と称して、開発に関するガイドラインやルール作りをはじめとする、様々な改善活動に取り組んでいます。
私が所属しているフロントエンド開発チームでは、すでに別記事でご紹介している「WebAPI設計ガイドライン」であったり、「コンポーネント設計・実装ガイドライン」を作成しました。
他にも作成したガイドラインはありますが、今回はモチベーションクラウドのフロントエンド開発で「状態管理パターン + ライブラリ」として導入しているVuexの利用に関するチーム内のガイドラインをサンプルコードや説明を交えながらご紹介出来ればと思います。
目次
- Storeの構造について
- 原則としてVuexで全ての状態を管理する
- Vuexのヘルパー関数を利用する
- ComponentからStoreのcommitを実行しない
- module間の依存関係を作らない
- moduleのactionをグローバルに登録しない
Storeの構造について
全モジュールにおいてnamespaceを切る
const modules = {
namespaced: true,
modules: {
moduleA: {
namespaced: true,
// 略
},
moduleB: {
namespaced: true,
// 略
}
}
}
元々、モチベーションクラウドのフロントエンドアプリケーションではVuexのnamespaceは切っておりませんでした。
そのため、コンポーネント側からVuexのStore側にdispatchするイベント名の重複、衝突を防ぐために、1つのファイルでイベント名を定数管理し、それらの定数化されたイベント名をコンポーネント側やStoreのmodule側で読み込んで利用する方法を採用しておりました。
しかし、開発が進むに連れて、今後、アプリケーションの規模が更に大きくなった場合を考えると、名前空間をこのような泥臭いやり方で管理し続けるのは「辛い」、「不安」と言った声がチーム内で挙がってくるようになりました。
そこで、今回、Vuexのガイドラインを作成するにあたって、「namespaceを全モジュールで切るべきか、部分的に切るべきか?」をチーム内で議論し、意見が割れるところもありましたが、最終的には全モジュールでnamespaceを切るという方針に固まりました。
それに伴い、今後はイベント名を定数で管理し、毎回、利用する箇所で定数を読み込むといったことは辞め、イベント名を直接文字列で扱って行くことにもなりました。
実際、毎回、新たにdispatchするイベント名が登場する度に、定数管理するファイルにそのイベント用の定数を追加し、そして、その定数を利用する箇所で読み込まなければいけないという作業は冗長でストレスを伴うものであったため、この苦行から解放されるのはとても良いことだと思います。
ドメインとUIで状態を分けて管理する
Store内のmoduleをまず、ドメインに関する状態を管理するmoduleと、UIに関する状態を管理するmoduleで大きく2つに分けることにしました。
そして、それら2つのmodule下に更に付随するmoduleを展開していくといったmoduleの管理方法です。
サンプルコード
const modules = {
namespaced: true,
modules: {
domain: {
namespaced: true,
modules: {
users: {
// 略
},
organizations: {
// 略
},
}
},
ui: {
namespaced: true,
modules: {
common: {
// 略
},
operations: {
// 略
}
}
}
}
}
APIを通してサーバーサイドから取得するデータは必要に応じて、加工や正規化をして、domain
以下のモジュールでそれぞれ管理します。
また、breadcrumbやmodalの状態のようなページ共通で持っておくことが好ましい状態は、ui
以下のcommon
モジュール内で管理し、、その他のページ固有のUIに関する状態はui
以下にそのページ用のモジュールを置いて、そちらで管理します。
原則としてVuexで全ての状態を管理する
これまでフロントエンド開発チームにおいて、Vuex側で管理する状態と管理しない状態についての共通の方針が存在していなかったため、各開発者によって状態を管理する場所がバラバラでした。
そこで、今回、Vuex利用に関するガイドラインを作成するにあたり、これについても、チーム内で議論しました。
普通に考えると、「ドメインに関する状態はVuexのStoreで持ち、また、ページ遷移後も保持しておきたいようなUIに関する状態をVuexのStoreで持つ」、そして、「それ以外のUIに関する状態をコンポーネント側で持つ」、というのが自然かなと思いますが、最終的にチーム内で合意に至ったのは「パフォーマンスが懸念されるなどの特別な理由がない限り、原則として全ての状態をVuexのStoreで管理する」という方針です。
この決定に至った理由としては以下が挙げられます。
- Time Travel Debuggingが可能になるため、デバッグがしやすくなる
- テストがしやすくなる
- 複数人による開発の中で状態管理に一貫性が生まれる
個人的には、このようにVuexやReduxなどの状態管理ライブラリに全ての状態を寄せるという開発は初めてなので、やってみないと分からないところもありますが、実際に運用していくうちに「パフォーマンスの懸念」以外の理由で他にも、コンポーネント側で状態を持たせた方が良いケースが出てくるかもしれません。
その際は随時、チーム内で議論して、柔軟にガイドラインに調整をかけて行ければ良いと思っています。
dispatchするaction payloadの型
Storeのdispatchメソッドの第2引数に渡す値の型に関しても、今回のガイドライン作成を機に以下のように方針を固めました。
interface ActionPayload = {
payload?: any,
meta?: object,
error?: boolean
}
元々、私自身がReact、Reduxを用いた開発の際には、Flux Standard Actionを取り入れているので、そのまま、Flux Standard Actionを参考にガイドラインに組み込みました。
サンプルコード
this.$store.dispatch('fetchUsers')
this.$store.dispatch('postUser', {
payload: { id: 1, name: 'foo' },
meta: { delay: 3000 }
})
this.$store.dispatch(validationFailed, {
payload: { message: 'hello' },
error: true
})
Vuexのヘルパー関数を利用する
Storeのmoduleのstateやactionを参照する方法は複数ありますが、それらを参照する際にはVuexのヘルパー関数を必ず利用するという制限を設けることにしました。
また、Vuexの各ヘルパー関数にも複数の使い方が存在しますが、それらの使い方に関しても、ガイドラインで決めた方法のみを利用するといった制限を設けることにしました。
狙いとしては、コードの一貫性を高めること、また、それによって、Vue未経験のフロントエンドエンジニアや、普段はサーバーサイドを担当している既存のメンバーがフロントエンド開発に加わるようになった時の学習コストを下げるところにあります。
createNamespacedHelpersの使用
Container ComponentでVuexのnamespaceが切られたmoduleを参照する場合は、必ず、ヘルパー関数のcreateNamespaceHelpersを使用するものとします。
import { createNamespacedHelpers } from 'vuex';
const {
mapState: mapStateOfUsers,
mapActions: mapActionsOfUsers
mapGetters: mapGettersOfUsers
} = createNamespacedHelpers('domain/users');
createNamespaceHelpersは引数に与えたmoduleのネームスペースがバインドされたVuexのヘルパー関数を返します。
命名規則
import { createNamespacedHelpers } from 'vuex';
const {
mapState: mapStateOfUsers,
mapActions: mapActionsOfUsers
mapGetters: mapGettersOfUsers
} = createNamespacedHelpers('domain/users');
const {
mapState: mapStateOfOrganizations,
mapActions: mapActionsOfOrganizations
mapGetters: mapGettersOfOrganizations
} = createNamespacedHelpers('domain/organizations');
Container Componentの中で複数のVuexのmoduleを参照することは良くあることです。
そのため、createNamespacedHelpersが返すmapState、mapActions、mapGettersには別名をつけて、名前の衝突を防ぎます。
その際の命名規則として、mapStateOfUsersのように、「ヘルバー関数名 + モジュール名」をlowerCamelCaseで名前を付けるものとします。
mapState関数の使用
import { createNamespacedHelpers } from 'vuex';
import UsersPage from './UsersPage.vue';
const {
mapState: mapStateOfUsers
} = createNamespacedHelpers('domain/users');
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
computed: {
...mapStateOfUsers(['users, isLoading'])
},
render(createElement) {
return createElement(WrappedComponent);
}
};
};
export default connect(UsersPage);
export { UsersPage };
createNamespacedHelpersで返すnamespaceがバインドされたmapState関数を使用します。
参考までに、上記のコードのcomputed部分は以下と同じです。
computed: {
users: this.$store.state.domain.users.users,
isLoading: this.$store.state.domain.users.isLoading
},
mapActions関数の使用
import { createNamespacedHelpers } from 'vuex';
import UsersPage from './UsersPage.vue';
const {
mapActions: mapActionsOfUsers
} = createNamespacedHelpers('domain/users');
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
created () {
this.fetchUsers()
},
methods: {
...mapActionsOfUsers(['fetchUsers', 'postUser']),
handleUserSave(payload) {
this.postUser(payload)
}
},
render(createElement) {
return createElement(WrappedComponent, {
on: {
save: handleUserSave
}
});
}
};
};
export default connect(UsersPage);
export { UsersPage };
createNamespacedHelpersで返すnamespaceがバインドされたmapActions関数を使用します。
参考までに、上記のコードのcreatedとmethods部分は以下と同じです。
created () {
this.$store.dispatch('domain/users/fetchUsers')
},
methods: {
handleUserSave(payload) {
this.$store.dispatch('domain/users/postUsers', payload)
}
}
mapGetters関数の使用
import { createNamespacedHelpers } from 'vuex';
import UsersPage from './UsersPage.vue';
const {
mapGetters: mapGettersOfUsers
} = createNamespacedHelpers('domain/users');
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
computed: {
...mapGettersOfUsers(['highlyEngagedUsers', 'dormantUsers'])
},
render(createElement) {
return createElement(WrappedComponent);
}
};
};
export default connect(UsersPage);
export { UsersPage };
createNamespacedHelpersで返すnamespaceがバインドされたmapGetters関数を使用します。
参考までに、上記のコードのcomputed部分は以下と同じです。
computed: {
highlyEngagedUsers: this.$store.getters['domain/users/highlyEngagedUsers'],
dormantUsers: this.$store.getters['domain/users/dormantUsers']
}
mapMutations関数の使用禁止
Vuexのヘルパー関数にはmapMutations
というものもありますが、こちらのヘルパー関数には後述する「ComponentからStoreのcommitを実行しない」というチーム内の方針により、使用禁止としました。
Vuexへの参照はContainer Componentで行なう
別記事でご紹介した「コンポーネント設計・実装ガイドライン」の中でも触れていますが、
Vuexへの参照はPresentational Componentでは行わず、Container Componentで行ないます。
Bad
Presentational Component内でVuexのStoreを参照している例
<template>
<div class='users-page'>
<ul>
<li v-for='user in users' :key='user.id'>{{ user.name }}</li>
</ul>
</div>
</template>
<script>
import { createNamespacedHelpers } from 'vuex';
const {
mapState: mapStateOfUsers,
mapActions: mapActionsOfUsers
} = createNamespacedHelpers('domain/users');
export default {
name: 'UsersPage',
created () {
this.fetchUsers()
},
computed: {
...mapStateOfUsers(['users'])
},
methods: {
...mapActionsOfUsers(['fetchUsers'])
}
};
</script>
Good
Container Component内でVuexのStoreを参照している例
import { createNamespacedHelpers } from 'vuex';
import UsersPage from './UsersPage.vue';
const {
mapState: mapStateOfUsers,
mapActions: mapActionsOfUsers
} = createNamespacedHelpers('domain/users');
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
created () {
this.fetchUsers()
},
computed: {
...mapStateOfUsers(['users'])
},
methods: {
...mapActionsOfUsers(['fetchUsers'])
},
render(createElement) {
return createElement(WrappedComponent, {
props: {
users: this.users
}
});
}
};
};
export default connect(UsersPage);
export { UsersPage };
<template>
<div class='users-page'>
<ul>
<li v-for='user in users' :key='user.id'>{{ user.name }}</li>
</ul>
</div>
</template>
<script>
export default {
name: 'UsersPage',
props: {
users: {
type: Array,
default: []
}
}
};
</script>
ComponentからStoreのcommitを実行しない
ComponentからStoreのcommitメソッドを実行しないというルールは、モチベーションクラウドの初期のフロンドエント開発メンバーによって以前決められた方針として既に存在していました。
このルールはどういったものかと言うと、ComponentからStoreのstateに変更を加える際に、例え、同期的な変更処理であったとしても、Storeのcommitメソッドは使わず、Storeのdispatchメソッドを必ず通して、Storeのstateに変更を加えるというものです。
正直、同期的に走る変更処理を行なう際に、わざわざ、dispatchメソッドを経由してから、commitメソッドを実行するというのは面倒に感じるところはあります。
しかし、以下の理由により、引き続き、今回作成したガイドラインに組み入れた方が良いということになりました。
理由
- 上記の図にあるVuexの単方向データフローに合わせるため
- Component側で通知したイベントをStore側が同期的に処理するのか、非同期的に処理するのかを知らなくても良くなるため
- Component側で常にstoreのdispatchメソッドのみを使うことで、コードに一貫性が生まれ可読性が向上するため
- 同期的処理、非同期的処理に関わらず、Component側でdispatchメソッドを通せば、storeのactionから複数のcommitメソッドをまとめて実行することも出来るため
Bad
import { createNamespacedHelpers } from 'vuex';
import UserPage from './UserPage.vue';
const {
mapMutations: mapMutationsOfUserPage,
} = createNamespacedHelpers('ui/userPage');
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
methods: {
...mapMutationsOfUserPage(['clearForm']),
handleFormClear() {
this.clearForm();
}
},
render(createElement) {
return createElement(WrappedComponent, {
on: {
reset: this.handleFormClear
}
});
}
};
};
export default connect(UserPage);
export { UserPage };
Good
import { createNamespacedHelpers } from 'vuex';
import UserPage from './UserPage.vue';
const {
mapActions: mapActionsOfUserPage,
} = createNamespacedHelpers('ui/userPage');
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
methods: {
...mapActionsOfUserPage(['clearForm']),
handleFormClear() {
this.clearForm();
}
},
render(createElement) {
return createElement(WrappedComponent, {
on: {
reset: this.handleFormClear
}
});
}
};
};
export default connect(UserPage);
export { UserPage };
module間の依存関係を作らない
あるmoduleから別のmoduleに対して、Storeのcommitメソッドを呼んだりしていると、コードを追いづらくなったりしますし、テスト容易性にも影響をしてきます。
そのため、module間では依存関係を作らないという制約を設けることにしました。
moduleの中でrootState、rootGettersを参照しない
Bad
modules: {
moduleA: {
namespaced: true,
getters: {
someGetterA (state, getters, rootState, rootGetters) {
return rootState.moduleB.foo
},
someGetterB (state, getters, rootState, rootGetters) {
rootGetters['moduleB/someGetterC']
}
},
actions: {
someAction ({ dispatch, commit, getters, rootGetters }) {
dispatch('someOtherAction', rootGetters.moduleB.someGetter)
},
someOtherAction (ctx, payload) { ... }
}
},
moduleB: {
namespaced: true,
state: { foo: null },
getters: {
someGetterC (state) {
return state.foo
}
}
}
}
moduleから他のmoduleに対して、dispatch、commitを実行しない
Bad
modules: {
moduleA: {
namespaced: true,
actions: {
someAction ({ dispatch, commit }) {
dispatch('moduleB/someOtherAction', null, { root: true }) // -> 'moduleB/someOtherAction'
commit('moduleB/someMutation', null, { root: true }) // -> 'moduleB/doSomethingElse'
}
}
},
moduleB: {
namespaced: true,
actions: {
someOtherAction (context, payload) { ... }
},
mutations: {
someMutation (state, payload) {...}
}
}
}
モジュール内から他のモジュールに対して、storeのdispatchやcommitを実行するのはNG。
Good
modules: {
moduleA: {
namespaced: true,
actions: {
someAction ({ dispatch, commit }) {
dispatch('someOtherAction') // -> 'moduleA/someOtherAction'
commit('someMutation') // -> 'moduleA/doSomethingElse'
},
someOtherAction({ dispatch, commit }) {...}
},
mutations: {
someMutation (state, payload) {...}
}
}
}
モジュール内から同じモジュールに対して、storeのdispatchやcommitを実行するのはOK。
モジュール内から他のモジュールにdispatchしたい場合
モジュール内から他のモジュールにdispatchしたい場合は、以下のサンプルコードのように、storeのmodule側でpromiseを返し、Container Component側でpromiseをchainします。
Store側
modules: {
moduleA: {
namespaced: true,
actions: {
someAction ({ dispatch, commit }) {
return api.fetchSomeData()
.then(response => {
return Promise.resolve(response)
})
.catch(error => {
return Promise.reject(error)
})
.finally(() => {
commit('someMutation')
})
},
},
mutations: {
someMutation (state, payload) { ... }
}
},
moduleB: {
namespaced: true,
actions: {
someOtherAction (context, payload) { ... }
}
}
}
Container Component側
import { createNamespacedHelpers } from 'vuex';
import SamplePage from './SamplePage.vue';
const {
mapActions: mapActionsOfModuleA
} = createNamespacedHelpers('moduleA');
const {
mapActions: mapActionsOfModuleB
} = createNamespacedHelpers('moduleB');
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
created () {
this.someAction()
.then((response) => this.someOtherAction())
.catch(console.error)
},
methods: {
...mapActionsOfModuleA(['someAction']),
...mapActionsOfModuleB(['someOtherAction']),
},
render(createElement) {
return createElement(WrappedComponent);
}
};
};
export default connect(UsersPage);
export { UsersPage };
moduleのactionをグローバルに登録しない
namespaceを切ったmoduleであったとしても、actionを以下のように、グローバルに登録することは可能ですが、actionをグローバルに登録することも禁止としました。
BAD
modules: {
moduleA: {
namespaced: true,
actions: {
someAction: {
root: true,
handler (namespacedContext, payload) { ... }
}
}
}
}
最後に
今回ご紹介した「Vuexの利用に関するガイドライン」には、この記事の中では触れていないものもありますが、主要な部分に関しては共有出来たと思います。
今後、こちらのガイドラインで決めたmodule構造に、既存のコードのmodule構造を変えて行く必要があります。
そのリファクタリングを安全に行えるように現在、フロントエンド開発チームでは、別記事「Cypressを使ったインテグレーションテストの導入」でも共有されている通り、Cypressによるインテグレーションテストを充実させているところです。
また、いろんなルールを決めたとしても、そらら全てをコードレビュー時にチェックするのは大変なので、独自のルールは独自のLintルールを作成して、コードレビューのコストを軽減することを検討中です。
関連記事
こちらの記事はモチベーションクラウド Advent Calendar 2018に投稿した記事です。
他にも、以下の記事をモチベーションクラウド Advent Calendar 2018に投稿しています。
- Vueを用いた開発プロジェクト用にカスタムジェネレーターを作ってみる
- Vueを用いた開発プロジェクト用に「コンポーネント設計・実装ガイドライン」を作った話
- Vuexを用いた開発プロジェクト用にガイドラインを作成した話
採用が恋愛なら、オンボーディングをしないのは釣った魚に餌をやらないということになっちゃうので整理してみた
おはようございます。@oturu333と申します。
この記事はモチベーションクラウド Advent Calendar 2018の10日目として書いています。
タイトルちょっとおふざけしましたが、本気です。
入社を決めていただいた方と会社を恋愛のステップで例えるならお付き合いの始まり。「この人となら幸せになれるかもしれない!わくわく!でもちょっと不安もあるな」という状態なのかなと思います。
そしてオンボーディングがないというのは、お付き合いの直後に恋人を放置する、残念な彼、彼女ということになってしまいます。
入社をしていただいて、恋から愛に発展していくためのファーストステップ。大事にしたいですね。
モチベーションクラウドではもともとオンボーディングをまとめた資料がなく、改善活動の一環として最近まとめはじめているところです。
今回はその内容について書いていきます。
事前準備
- PCやアカウントなど事前に準備できるものを準備
- オンボーディングができるよう時間、人の確保
組織やプロダクトについてより深く知っていただく
プロダクトのエンジニアとしてジョインしていただく場合、携わるプロダクトはもちろん、それに関連する様々な情報を提供することで理解度がより深まり、業務へのモチベーションが高まると考えています。
そのために具体的に提供すべきと考えている情報
- 会社やプロダクトの歴史について
- 会社がどのようなものを目指していて、このプロダクトはどのような意義を持って生まれ、現状どのようになっていて、どんな未来を目指すのか
- 配属先の組織について
- 目指す未来に向け、どのような体制で何をしているのか
- 今後どのようなことをしていこうと思っているのか
- プロダクトについての詳細
- ロールや機能について
- 関連組織について
- 他部署の人たちはどんな仕事をしていて、どのように協力しあって業務をしているのか
お互いを知る
- 人の紹介
- 入社いただいた方の自己紹介、一緒に働くメンバーの紹介
- 関連部署で特に関わりのある人への紹介
- 座席表
- ウェルカムランチへのいざない
働く環境を知っていただく
- 業務で利用するツールの説明やアカウントの発行
- Slackだったら必要なチャンネルへの招待など
- オフィスの案内
- 備品の使い方
- 勤怠連絡について
業務について知っていただく
- 存在するリポジトリについて
- 開発環境について
- ドキュメントの在り処
- 開発〜リリースまでのプロセスについて
- 各種環境について
- 必要な権限発行なども含め
- Slack開発系チャンネルへの招待
- スキーマの説明
- ディレクトリ構成などコードについての説明
最後に
最初の不安を払拭したり、モチベーションを高めるためにも必要なオンボーディング。
PDCAを回しつつ、改善を続けていこうと思います。
心理的安全性ガイドライン(あるいは権威勾配に関する一考察)
はじめに
「心理的安全性」とは、「対人リスクを取っても問題ないという信念がチームで共有されている状態」であるとか、「自分のキャリアやステータス、セルフイメージにネガティブな影響を与える恐れのなく、自分を表現し働くことができること」というような定義がなされています。
心理的安全性という言葉はともすれば、ただ快適で居心地のよい職場という意味にも聞こえます。そのため、ぬるま湯で緊張感のない関係性のことを「心理的安全性が高い」と言うのではないかと考えても不思議はありません。
そのため、友人関係のようにプライベートの時間を長く共有する関係になることが、心理的安全性が高いのだろうと考え、飲み会やバーベキュー、慰安旅行などを企画してみたりとプライベートでも遊ぶ機会を増やそうと考える人もいるでしょう。
いわゆる「アットホームな会社です」とアルバイトの求人記事に書かれているような状態です。こういった求人内容を見た時に、私のようにちょっとひねくれた人は、なんだか不穏な気配がして、そこに申し込むのはやめておこうかなと考えたりします。
プライベートで仲がいいような状態でも、「対人リスク」を感じることや「自分の地位」を脅かされるような怖さはないかもしれません。ある意味で、間違いなく「心理的安全性」が高いのだと言えます。
しかし、その状態が「高い生産性」と本当に関係するのでしょうか。高い生産性につながるような「心理的安全性」と、緊張感のないぬるま湯的な「心理的安全性」にはどのような違いがあるのでしょうか。
私は、そのキーワードの意味をできる限り正確に捉えるために、観測可能な事実を重要視します。「心理的安全性」の意味するところ、それは、チーム中にある個人の関係において、「様々な形で課題や問題についての提起がされる」ということに他なりません。これらを観測することによって初めて、内的な感情というヴェールの向こう側にある事態を知ることができます。
つまるところ、心理的安全性が高いとは、「些細な問題であっても提起される」「多く問題に対して自己主張がなされる」という観測可能なチームの状態を意味しています。もし、「心理的」に「安全」だとあなたが思っていたとしても、自己主張を誰もしない状態であれば、それは「心理的安全」の意味するところとは違うと考えられるべきなのでしょう。
この状態はどちらかといえば、「気後れしない」「生意気な」メンバー同士の闊達な議論の様子が目に浮かびます。これは、いわゆる「仲の良い」という関係よりもお互いに対するリスペクトがありながらも喧々諤々の議論の多いチーム像が浮かびます。
様々な概念がバズワードとして消費されがちな昨今、生産性の肝となる「心理的安全性」はどのように理解すれば良いのでしょうか。
下から上への情報の透明性
心理的安全性が低いチームにおいては、現場で発生した問題や課題はしばしば隠蔽され、意思決定者の耳目に入らなくなります。何が起きているかがどんどんと見えなくなります。
明確に嘘とは言えなくても、小さな嘘のようなものが組織中に蔓延するようになります。
そうすると、経営者はまるで五感を遮断して歩くような危険な状態になります。知っている道をただ走るならそれでも構いませんが、不確実性の時代において、これは自殺行為です。言い換えるなら、「心理的安全性」は、下から上への情報の透明性とも言えます。
ソフトウェアエンジニアリングのチームにおいて、このように「些細な問題」でも報告し合い、「本質的な課題に向き合う」という性質が強く要求されることから、生産性と心理的安全性の関係が導かれるのです。
これらの心理的安全性とソフトウェアエンジニアリングの関係性については、Google のRe:workプロジェクトあるいは、拙著である「エンジニアリング組織論への招待」を参照していただければと思います。
今回は、これらの知見を踏まえた上で、別の角度から「心理的安全性」を構成する要素を洗い出してみるという試みです。
権威勾配(authority gradient)について
さて、些細な問題であっても、報告し、それについて真剣に取り扱うということの重要さを身にしみてわかっている業界は、ソフトウェアエンジニアリングの業界だけではありません。
むしろ、1つのミスが重大な事故に繋がりかねない医療・製薬やパイロットの業界においてこれらの「心理的安全性」に類することへの知見は議論されてきました。
このような業界では、ある二者関係において、この「問題を報告しやすい」という関係性を「権威勾配」としてモデル化してきました。
権威勾配:もともと、飛行機のコックピットの中での機長と副操縦士の関係を表したもので、航空業界で用いられてきた。
マネジメントの評価指針として活用され、二者の関係の勾配が急になれば、大事故につながると考えられている。勾配が急すぎると、ワンマンな運営になって、目下の人からの危険に関する情報伝達が遅れる傾向から判断が遅れ、逆に相対的に上長の権威が弱いと決定が遅れて事故になる確率が高まるという。
http://d.hatena.ne.jp/keyword/%B8%A2%B0%D2%B8%FB%C7%DB
権威の格差が大きいほどに、目下の者から仰ぎ見る仰角は高くなります。この角度の大きさが「物申す」ことが難しいことを意味しています。これらは、単純に職位の違いといったファクターだけではなく、極めて多くの構成要素から生まれます。
このとき、権威勾配は
- 組織構造による関係性
- 組織文化
- 個人の性質的な差異
などに多種多様な要素を掛け合わせることで生まれます。
そのため、権威勾配を小さくし、「心理的安全性」を向上させるためには、どのようにしてそれらが生まれるのかを把握する必要があります。そして、その1つ1つの構成要素に対して改善のアクションを取っていけば、少なくとも明日、今よりは良い状況を作ることができます。
そして、チームがもしn人いれば、O(n^2)の関係性が生まれます。その相互の権威勾配の総計が「心理的安全性の低さ」を示唆しているのだと考えると、「心理的安全性」自体のより明晰な定義や理解に近づくのではないかと考えられます。
権威勾配を構成する変数
心理的安全性について、理解するために「些細なことでも自分の意見を発する」ことの障壁となる権威勾配についての考察を進めて見ましょう。航空会社や医療業界のいくつかのテキストと、筆者の観察してきた様々なソフトウェア開発の現場での知見から権威勾配に寄与しているであろう変数には、次のようなものがあると考えられます。
これらは決してすべてリストアップできているわけではありません。
しかし、多くのチームや職場で目にする最大公約数的なものだと言えるでしょう。
これらは、常に「私」と「誰か」のバイネームの問題です。ある任意の二人が会話をする時に思ったことを言えるのかどうかを決める要因がどこにあるのかを知り、改善していくために使えるように、それぞれについての解説を述べたいと思います。
組織構造による関係性
権威勾配を発生させるもっとも単純な原因は、どのような組織的に与えられたロールとの関係性を持っているかによって決まります。組織の役割には、メンバーの差配を行うことで職責を果たすという目的から、一定の権限が割り当てられます。
この権限があることと、個人的な「偉さ」や「正しさ」とは関わり合いのないことなのですが、コントール権を持つものに対して、警戒感を抱いてしまうのは当然のこととも言えます。
職位の格差
Q.あなたとAさんの職位の差は何段階か?(メンバー < マネージャー < 部長 など)
部長やマネージャーといった職位を持つものと、それを持たないメンバーであるというのは、当然のことながら権威勾配を発生させます。
この権威勾配を減らすための手法としては、率先した自己開示、とりわけ個人の弱さをどのように見せていくのかということが挙げられます。これを「飲みの場」でうまく行える人もいます。しかし、飲みニケーションは案外難しいもので、冷静な判断の効かない自分に対して信頼ができると言う人には向いているかもしれませんが、うまくいかないことも多いでしょう。
どちらかというと、自己開示のためのセッションを設計して、誰かにファシリテーションをお願いするか、ランチミーティングや1on1などを通じて定期的な自己開示を行う必要があります。
自己開示にあたって、自分を取り繕うように「自慢げ」にしてしまうのは問題です。自分がどのように悩み、何を考え、どう生きていきたいのかを着飾ることなく伝えましょう。職位が上になると責任感から、完璧に振舞おうとしてしまいます。正しくあろうとすることが、”権限”を”権威”のように見せてしまうことがあります。
一方、職位の低い方が自らを人質に不必要に高圧的に上司に対してコミュニケーションを取るというケースがあります。これは、転職しやすく求人倍率の高い職ほど起こりやすくなります。転職し、職場を選べる人にとっては職場の権威というのはもののかずではありません。不愉快なことがあれば、転職することチラつかせることもできます。
それゆえ、職位が上の人間は難しい立場に置かれます。権力構造が逆転してしまうのです。これは、従来の職位が上の方が権威を振りかざしてきた環境においては、環境を好転させるための素晴らしい変化なのですが、一方、立場の弱い中間管理職を精神的に追い詰め悪い事態を招くことがあります。
これはジョハリの窓です。自己開示とフィードバックを受けることによって、双方の理解が深まります。このような、サイクルが立場の違いを乗り越えるコミュニケーションの第一歩となります。
部門のオーナーシップ
Q. あなたは相手に依頼する側か?依頼は何部門を経由して行われるか?
業務プロセスの中における上流工程、たとえば「企画部門」に対して「生産部門」「開発部門」といった下流工程側に位置すると思われる部門の間には、業務のオーナーシップがどこにあるのかといった問題から、権威勾配が発生します。
これは職位と同様に強い格差を生み出す可能性があります。職位に比べて問題が大きいのは、職責と一致しないことが多いからです。もし、この権威勾配の発生を防いで生産性を向上させるのであれば、オーナーシップを持つ部門の成果につながる話なので率先して取り組む価値があるように思えます。
しかし、企画部門の1メンバーが、開発チームに対して率先した自己開示やコミュニケーションを通じて、心理的安全性を作るための行動を起こせるかというとなかなか難しいことが多いです。
必要であるからマネージメントしようとする人より、マネージメントする役割だからマネージメントを行う人の方が多いからです。
組織に、部門オーナーシップにかかる問題が頻発するのであれば、設計を見直した方がいいでしょう。一方、個人として対処できることは、お互いを理解する時間を設けて、「〜〜部門の人」ではなく、「個人名」と「その人の性質」から判断するという関係構築を行うことです。
契約形態
Q. あなたは彼/彼女と対等などのような契約か?(同僚 < 直契約 < 間接契約)
上流工程の部門である以上に、対処がしづらいのは、発注側と受注側という関係性になっているケースです。相互により明確な金銭的な契約があり、この契約の決定権がある・あるようにうつっている場合に権威勾配の高さは顕著になります。
また、契約の形態によっては「個々の面談・フィードバック」を禁じられている場合があり、さらに契約が二次受け・三次受けというように多段構造になっている場合があります。このような関係では、対策の幅が狭く、コミュニケーションのパスも間接的になるため、心理的安全性を構築することが極端に難しくなります。
- 一つはこのような状況を抜け出せるようにすること。
- 一つはこのような関係性での信頼関係の構築を意識的に行うこと
が必要です。後者については、
- 一人一人の名前を覚えること
- 名前で呼ぶこと
- 挨拶をすること
- 感謝を伝えること
などをかなり意識的にやる必要があります。これらは、すごく当たり前のことですが、「(会社名)さん」「派遣さん」と呼ぶなど線引きをするような言動や他社の人間として会話をしないといったことをしがちです。
組織文化
組織構造や個人的な性質から生まれる関係は、現実的に改善しきれないことは多々あります。如何に職位の高さがメンバーに対する権威勾配を構成するからといって、すべての人の職責をフラットにしてしまうようなことは現実的でなかったりします。それは責任の所在が不明瞭になりがちだからです。
一方で、ある関係で見たときに同じ関係性であったとしても、たとえば二階級上の上長とのコミュニケーションであったとしても、非常にフラットな雰囲気で行える場合とそうでない場合というのが存在します。
これらは、国家・年代・地域・会社・部門というように多重に社会構造に組み込まれているものがあります。経営層は、このような組織文化の構築・改善を行うことで強い事業組織を育てることができます。逆に、脆弱になりがちな組織はこの文化資本に関しての投資が弱い傾向があります。
ホフステード指数
国家ごとの文化を6つの観点から比較するホフステード指数(https://www.hofstede-insights.com/ )は、国際的な文化傾向の比較をするのに用いられます。職場の文化においてもどのような傾向があるか、類似する基準で見ることができるでしょう。
スコア項目 | 詳細 | アメリカ | 日 本 | 中 国 |
---|---|---|---|---|
権力格差(Power Distance) | 上下関係の強さ。 | 40 | 54 | 80 |
個人主義(Individualism) | 個人主義傾向の強さ。 | 91 | 46 | 20 |
男性主義(Masculinity) | 男性優位の強さ。 | 62 | 95 | 66 |
不確実性忌避(Uncertainty Avoidance) | リスクテイクしない傾向。新しいものをやらない傾向の強さ | 46 | 92 | 30 |
長期志向(Long Term Orientation) | 短期の利益より長期的な価値を重視するか。 | 26 | 88 | 87 |
快楽志向(Indulgence) | 快楽を求めることを是とするか。禁欲を尊ぶか。 | 68 | 42 | 24 |
権力格差が大きい国の文化圏では、権威勾配が大きくなります。また、個人主義であるほど自己主張がしやすくなるため、意見が生まれやすくなります。男性主義的であると、女性から男性への意見をしづらいと感じる社会であることを意味しています。また、不確実性忌避の傾向が高い国では新しいことや常識の外にあることを受容する力が弱くなり権威勾配が大きくなる傾向があります。
文化的権力格差
Q. あなたの職場では職位を尊称として使うか?たとえば、「〜〜部長」「〜〜課長」など。年少の同僚を「〜〜くん」や呼び捨てするなどの傾向はあるか?
Q. あなたの職場では上長の発言に疑義があっても明確な理由がなければ、反論すべきでないという風土があるか?
Q. あなたの職場では年齢が若い人は年齢が上の人の意見に反論すべきでないという風土があるか?
年齢や権威に対してものが言えなくなる文化が強い場合、実際の職位の乖離を大きな権威勾配へと引き上げてしまいます。上記の質問にあるような上下関係を意識したコミュニケーションが強い文化圏での組織の場合、ちょっとした言葉尻からも権威勾配を生じさせてしまうことがあります。
これらに対する対処としては、年齢や職位を理由にしたコミュニケーションを捉え、減らしていくことです。そして、職位や年齢が下であったとしても「思ったことを意見することは重要」だと伝えていきましょう。
ダイバーシティ・異質性の寛容度
Q. あなたのチームには女性の比率はどの程度いるのか?
Q. あなたのチームには異なる文化圏のメンバーがどの程度いるのか?
Q. あなたのチームは異質な価値観についてどれほど寛容か?
ダイバーシティというと、「女性の活躍」や「外国人」といった文脈を想像しますが、それだけではなく、異なる価値観で生きてきた人たちとどのようにコミュニケーションをしていくべきか知っているということが、様々な意見を取り入れるという土壌を育むことになります。
機能横断型チームの中で育まれた文化圏は、職能型組織に育まれた文化圏よりも多様性に対する受容・包摂が強くなります。たとえば、デザイナーの文化とエンジニアの文化が混ざり合う場所というように。
また、多様性を前提としたチーミングが行われているとき、前提となる文化的背景の違いや、考え方の違いが生まれやすいため、合意点や説明をできる限り明晰に行うという習慣が身につきやすくなります。
こういったことから、「常識と違う考えだから」という理由で、自分自身の考え方が排除されたり、退けられたりしないであろうという関係性が気付きやすくなります。
また、男性優位な社会においては、女性の意見をことさらに特別視したり、軽く見る傾向があります。そのような社会ないし会社で、女性は権威勾配を強く感じます。
このようなことが発生しづらくするためには、
- 自分は女性だからといって意見を軽く見たりするつもりはないこと
- それでもそのように感じたら、自分に伝えてもらうか、別の人に伝えてほしいこと
- 伝えたことで不利になるようなことがないこと
- 自分がもしそのように思わせたら、改善できるように努力すること
などをあらかじめ宣言していくことが重要です。
様々なマイノリティ・多様性に対して、「害意がなくても相手に権威を感じさせる」ことがありうることを自覚し、改善する意思があることを伝えていくことが重要です。
人間ですから、伝え方のミスや無自覚な差別意識が存在することはありえますし、それ自体は仕方なのないことです。問題は、自覚し改善できるようにすることと、フェアネスという価値に対してコミットするつもりがあることを繰り返し伝えることです。
不確実性・変化への恐れ
Q. あなたのチームには直近1年で入社した人がどの程度いるか?
Q. あなたのチームには直近ジョインした人はどの程度いるか?
Q. 何かを決めるときに「今までのやり方」をどの程度重視するか?
多様性とどうように「変化」に対する感受性もまた、権威勾配を生み出しやすい文化的背景になり得ます。たとえば、10年も20年も特定のやり方をしてきた人に対して、別のやり方を提案するのは少し気が引けます。また、相手もなかなか受容しづらいでしょう。やったことがないことをやらなければならないというのは思いの外恐怖を煽ってしまいます。新しいことに対応できるのだろうか、対応できない自分には価値がないと思われるのではないかという意識があると様々な変化に対して、快く思わないという文化が生まれてしまいます。
一方、流動性が高く変化が激しい業界において、去年やっていた仕事と今年やる仕事が変わることは当たり前で、そういった動きがあるからといって、自分の身に危険が及ぶとは考えません。
このような「今までのやり方」を踏襲ようとするマインドがあると、あるゆる場面で意見を求めるということをしづらくなってしまいます。
個人の性質差
個々人がそれまでに過ごしてきた背景や、そこから生まれる性格・言葉づかい、経験の有無などによって高圧的に見えたり、意見を閉じ込めるように聞こえたりすることはあります。
仲介・調整のスキル
Q. あなた/相手は、プライベートでトラブルの仲裁をしたことがあるか?
Q. あなた/相手は傾聴やアサーティブコミュニケーションのトレーニングを受けたことがあるか?
Q. あなた/相手は人と話すことに慣れていて、表情や感情を読み取るのが得意だと思うか?
何か意見を思いついた時に、相手に聞いてもらえるように説明したり、伝えたりする能力の高い人と低い人という個人差は絶対的に生まれます。そのため、何か意見を伝えようとしても伝えられないというもどかしさを感じると、徐々に意見を言わなくなってしまいます。
逆に、相手がうまく聞き出してくれるのであれば、本心や悩みのようなものも拾い上げやすくなります。これは、ソーシャルスキルとして訓練可能なものではありますが、一方で個人的な体験からくる得手不得手はあります。
年齢差
Q. あなたと相手の年齢差はいくつか?
学生時代の悪癖なのか、わずかな年齢差を権力の格差だと錯覚している文化圏の人からすれば、年齢差というのは、大きな権威勾配を生み出します。また、10年も20年も年が離れると世代間の感性のギャップなどから自然と会話する量が減ってしまうといった現象も考えられます。
しばしば、誤解されがちなのですが、「敬意を持って接する」ことと「インナーサークルから排除する」ことは異なります。気を遣って、年齢差のあるメンバーを何かの同僚間のイベントに誘わなかったり、コミュニケーションに壁を設けたりするのは、それは単なる排除であって気を遣っているのとは違います。
ジェンダー・性差
Q. あなたと相手は異性同士か?
Q. あなた/相手はLGBTなどのセクシャルマイノリティか?
女性の社会進出が先進国の中で遅れている日本において、ジェンダーや性差による権威勾配は、思ったよりも深刻なものです。それはLGBTなどのセクシャルマイノリティから見ればなおさらです。
内向的・外向的な性格
Q. あなた/相手は内向的な性格か、外交的な性格か?
内向的な性格の場合、自分の感じた違和感を探り当て、それがなんであるかはっきりと理解できるまで、そのことを誰にも伝えないという傾向があります。慎重であるという美徳ではあるものの、一方で、ストレスや違和感を感じてもなかなか伝えられないということがあります。
職務経験
Q. あなた/相手は権威勾配を強く感じる職場での経験をしてきたか?
Q. あなた/相手は異なる業界慣習で職務経験を重ねてきたか?
これまでの職務経験において、自己主張することや思ったことを伝えることを抑圧されている場合、新たに自己主張をしても良い会社にきたとしてもなかなか発言できないということがあります。
客先常駐型で、マネージメントをあまり受けていない場合、自己主張するよりも黙々と作業していた方がトラブルに巻き込まれないという知恵を身につけてしまい、どこから伝えて良いもので、どこから伝えてはいけないものなのかという判断が鈍ってしまいます。
また、異なる業界から転職してくると、業界慣習を理解するために違和感を感じても一定期間は別の世界の常識を取り入れようとするため、意見を言うよりもむしろ業界慣習の理解に徹すると言うことも考えられます。これ自体は悪いことではないのですが、様々な業界で考えれてきた知見を現在の職場に当てはめる時にどのような問題解決ができるのかという視点まで失ってしまうと発言する機会がどんどんと失われてしまいます。
対策として、どんな仕事をしてきたか、どんな立場だったのか、今はどこが違うと思うのかなどを共有していくと徐々に互いに自分の考えを伝えれるようになっていきます。
個人的経験
Q. あなた/相手は家族や学校などから抑圧的に育てられたか?
Q. あなた/相手は権威や権力を笠にきた誰かに理不尽な精神的・肉体的暴力を受けたことがあるか?
性格とも職場経験とも関連していますが、なかなか原因を特定しづらく、また改善が難しいことが多いものが極めてプライベートな経験によるものです。何か問題を発見しても怒られたり、意見を言うと抑圧されたりすると言う幼少時代を過ごしてしまうと、そのことが心理深くに突き刺さっていて、何かを伝えようとすると動悸が強くなったり冷や汗をかいてしまうと言うことがあります。
そこまででなくても、誰かに何かを言うと「嫌われてしまうのではないか」「見放されてしまうのではないか」など不安にかられてしまい、思考が空転すると言うこともあります。
これらは、同じく個人的な経験から書き換えられることも多いのですが、外部から改善することが難しいことでもあります。たとえば、カウンセリングやメンタリングなどを通じて徐々にこういった個人的な体験によるものだと言う自覚をしていくなどの改善方法が考えられます。ここからはプロフェッショナルの領域になるかと思うので、マネージメントはそういったことを勧めてみるくらいまでができることでしょうか。
あまり、それら個人的な体験自体に深入りせずに、自分の意見を付箋に書き出すという時間を作ったり、文章でまとめてみると言うトレーニングを繰り返しながら、自己主張をしやすくするといったことのほうがうまくいくことが多いです。
健康状態・疲労状態
Q. あなた/相手は健康な生活を続けるのに強い不安を感じているか?
Q. あなた/相手は過度なストレス・疲労や環境変化(結婚などの幸福なイベントも含む)が近々にあったか?
権威勾配の実際的な大きさは、二者間において固定的なものであると言うよりもむしろ、健康状態やストレスといったその場その場で大きく異なるものです。
疲れていそうな人に厳しい意見を言うのはやめておこうと考えてしまうのも人情ですし、その逆に疲れている時に意見するのは大変疲れます。ストレスは人を怒りっぽくさせたり、無意識のバイアスを暴露しがちです。
このストレスの中には、不幸なイベントだけでなく、結婚などの一見ポジティブなイベントによっても引き起こされます。
たとえば、「結婚したんだから、しっかりと家庭を守るんだぞ」と上司に言われたあとでは、改善の指摘も遠慮がちになったりするものです。
難易度を知り、対処する
このように心理的安全を脅かす「権威勾配」についての論点を見ていくと、「こんなものすべてに意識をしてコミュニケーションするなんて不可能だ」とか「気にしすぎて何もできない」と感じてしまう方もいるかもしれません。
本稿の目的は、1つ1つの要因について知ることで、ある二者の関係構築がどのような難しさを抱えているのかを理解することです。そして、難しさの程度と個別の対処方法さえ理解してしまえば、いきなりは無理でも少しづつ良い状態を目指すことができます。
注意しなければならないのは、すべての摩擦や権威勾配をゼロにすることはできないと言うことです。
しかし、難しい状況であっても明日の方が昨日より良いと言えるように何ができるかがわかっていれば、問題はありません。そのためのガイドラインです。
同調圧力・社会的排除
これまでの議論で、心理的安全性を構築するための要素に:
- フェアネス
- 透明性
- 多様性
- 社会的包摂
といった自由主義社会を構成する基本要素と重なり合う側面が大きいことがわかるかと思います。調和を大事にする日本社会という言い方をしては、このことはなかなか見えてきづらいのですが、「同調圧力の強さ」というのは、つまりは異なる常識を持つ人々を社会的に排除するということです。
つまり、リスクをリスクであると声を上げる人を排除してしまうことが「同調圧力」を形作ります。これは、権威勾配と同じものだと言えます。
責任と心理的安全性の四象限
本稿では、「心理的安全性」をつくり上げるために、「権威勾配」と言うパースペクティブから、改善のためのガイドラインを導くものです。
「エンジニアリング組織論〜」でも述べていることではありますが、心理的に安全であると言うことは、「物を言いやすい環境である」ということを意味しています。一方で、「何も言うことがない」のであれば、言いやすくても何も改善はしていきません。これをコンフォートゾーンと言います。
ちょっと耳の痛い改善をやっていこうと話し合いができるのは、チーム全体が心理的安全性だけでなく、事業への共感やミッション達成への責任感を持っているからに他なりません。
一方で、責任感はあってもフラットに意見を言い合える関係でなければ、不安に感じてしまい、どんどんと疲れてしまいます。これが不安ゾーンです。
この心理的安全性と責任の四象限において、どのように「ラーニングゾーン」に導いていくかがチームの生産性が向上するための必要条件だということも忘れてはいけないでしょう。
おわりに
本記事は、モチベーションクラウドアドベントカレンダーの11日目です。モチベーションクラウドの開発チームは、現在社員とパートナー企業、フリーランサーという混合チームで開発を行っています。僕自身も組織構築の支援という形で参加しています。
国際色も多様で、女性のエンジニアやデザイナーも一定比率います。アドベントカレンダーに参加し、知見をシェアするという組織文化が生まれつつあります。心理的安全を構築する上での難しさを乗り越えながら、日々けんけんがくがくの議論ができるチームになってきています。
あわせて読みたい
組織で技術的負債に立ち向かうための取り組み
これはモチベーションクラウドAdvent Calendar 12日目の記事です。
エンジニアとして働いていると技術的負債に悩まされたことはあるのではないでしょうか。技術的負債は開発を継続する中で発生した「理想とかけ離れたコードの状態」を指した比喩ですが、どこの開発現場でもサービスを継続する上では少なからず存在するものだと思います。
特にプロジェクトの期間が長くなってきたり、開発メンバーが増えてくると技術的負債の問題が大きくなってきます。組織構造や開発体制によって技術的負債の扱いは変わると思いますが、以下の理由で中々改善が進まない場合があります。
技術的負債のジレンマ
捉えどころの難しい技術的負債という概念
技術的負債と言ってもそれが具体的に何を表していて、どのぐらいのボリュームがあるのかを把握するのは中々難しいのではないでしょうか。代表的な負債であれば、全体のうちどの部分が複雑化していて、どのような改善を行うべきなのかは思いつくかもしれませんが、その改善の効果がどの程度あるのかを定量的に説明するのは将来の要件を予測して見積をするようなものでほぼ不可能でしょう。結果的に工数の見積や実際にコードに手を入れる際にバッファを設けたり、調査工数に時間を設けたりします。
非エンジニアと技術的負債について議論する難しさ
技術的負債というものをエンジニアも漠然と捉えているので、非エンジニアにも中々上手く伝えることができません。そのため、エンジニアと非エンジニアでは問題の大きさや優先順位の認識が揃わず、お互いストレスを抱えてしまいます。
このままでは技術的負債の返済は見送りとなり、「開発速度の低下」という形で徐々に問題が大きくなっていき、最終的には多大な工数をかけてシステムの大改修や再構築するなどの対策を打つ必要が出てきたり、改修コストと予算が見合わなければサービスを停止するなどの恐れもあります。
技術的負債についてさらに詳しく知りたい方はエンジニアリング組織論への招待を読むことを強くお勧めします。
技術的負債に立ち向かうための取り組み
では、私たちはどのようにこれらの問題に立ち向かうべきなのでしょうか。
ここからは、私たちが実際に行なっている施策について一部をご紹介します。
技術的負債を見える化する
まずは技術的負債という得体の知れないものをテーブルの上に上げるために見える化する必要があります。そのため、Code Climateというコードの品質を計測するためのツールを導入しました。
Code Climateでは複数の言語をサポートしており、循環的複雑度など10点の検査項目で特定した技術的負債によってA〜Fのランク付けをしてくれます。以下はサンプルコードで出した結果ですが、Code Climateのサマリー画面になります。
また、それらの問題解決にかかる時間を"修復時間(remediation time)"として予測値を算出してくれます。機能の詳細はここでは割愛しますが、これはコードの品質についてチームで共通認識を持つためだけでなく、開発サイドとビジネスサイドで改善の必要性を議論する上で役立ちます。
メンバー同士の物理的な距離を近くする
私たちの開発チームは社内のエンジニアだけでなく、複数のパートナー企業とフリーランサーも多数参加しています。このような多種多様なメンバーがいる開発組織だと物理的に距離が近いことで話しかけやすくなり、信頼関係の構築に繋がります。後述する様々な施策を打つにあたり、この信頼関係はとても重要な役割を果たします。
メンバー同士で問題意識を共有する
開発チームが増えてチームが分かれた頃にチーム間での連携や思想の統一が難しいことがありました。そのため、週に1回各チームのメンバーが集まり、問題になっている事や改善したい事について共有する場を作りました。私たちはこれを「改善MTG」と読んでいますが、例えば以下のような事を議題として取り上げてきました。
- コンポーネントの設計思想について
- ライブラリの導入可否やアップデートのタイミングについて
- リファクタリングをいつ・どのように進めるかについて
- LinterやCIの設定変更について
議題をもとに問題の優先順位を話し合い、解決に向けたアクションを"宿題"としてタスクに追加します。宿題は次のMTGで進捗確認にして、当初の問題が解決されればクローズします。
時にはモブプログミングを行い、各メンバーの知識共有も行いました。
最初はフロントエンドメンバーのみで始めましたが、最近ではプロジェクトマネージャーやバックエンドエンジニアも一緒に参加するようになっています。
改善する時間をつくる
最近では開発チーム全体での改善活動も始まりました。これは「改善Day」と呼ばれ、隔週に1日の改善の期間を設けます。この時間は各々が普段感じている問題の対処に時間を使って良い日です。この時間を使って、宿題として登録されたタスクをこなしたり、後回しにしていた負債コードの改修にあたります。
また、この改善Dayは能動的に参加してもらうために強制にはしておらず、スケジュールの都合などで普段の開発タスクを優先する事も可能としています。
バグをドラゴンと呼ぶ
技術的負債が溜まってくるとバグの発生率も上がりますが、エンジニアにとってバグ対応は心理的な負荷が高いものです。"バグ"という言葉はエンジニアに精神的ダメージを与え、結果的に生産性の低下に繋がる恐れがあります。※もちろん、バグを出してしまったエンジニアにも責任があることは重々承知しています...。
そんな中、とあるメンバーの発案で"バグ"を"ドラゴン"と呼ぶことにしました。また、バグのインパクトに応じて"しんりゅう"、"リザードマン"、"ドラゴンキッズ"というドラクエ譲りのラベルを付け、バグ対応にあたるエンジニアを"ドラゴンスレイヤー"と名付けるようにしました。彼らをサービスを守る勇者であると開発チームで讃えることにしたのです。これによって、開発現場で"バグ"という言葉がほとんど使われなくなりました。
モチベーションクラウドのエンジニアリングチームでは、バグや障害を「ドラゴン」と呼ぶことに決め、奴らを倒すものに伝説の剣を与えるという文化が生まれたそうです。 pic.twitter.com/MV7wu4Al6t
— 広木 大地/ エンジニアリング組織論への招待 (@hiroki_daichi) November 16, 2018
終わりに
技術的負債への取り組み方は各々の開発現場で様々な工夫をされているのではないかと思います。
上記の取り組みは私たちが現在行なっているものであり、今後も状況に応じて変化させていく可能性は十分にありますが、モチベーションクラウドの開発チームはサービスだけでなく、組織を大事にする文化があります。引き続き、より良いサービスの改善に向けて組織で一丸となっていきたいと思います。
合わせて読みたい
スクラムマスターを経験して味わった成功体験と失敗体験
※この記事はモチベーションクラウド Advent Calendar 2018の13日目の記事となります。
おはようございます。
モチベーションクラウドの開発に参画している@kiiiiitaです。
はじめに
マネージメント未経験でスクラム開発経験も4ヶ月だった私が、スクラムマスターを経験して味わった成功体験と失敗体験を振り返ります。
スクラムマスターになった経緯
時系列で書くと
- スクラム導入
- 開発チームの一員になる
- 所属していたチームのスクラムマスターがエンジニアに転向するため離任
- 後任として私に白羽の矢が立つ
- 偉い人たちに呼ばれお願いされる
- 最初は断るが押しに負ける
- 開発メンバー兼スクラムマスターの誕生
のような流れです。
イベントや体制など
スクラムイベント
- モーニングスクラム(朝会)毎日
- スナックスクラム(夕会)毎日
- スクラムマスターMTG 毎日
- 振り返り(KPT)週1
- スプリントプランニング 週1
- スプリントレビュー 週1
- 振り返りFB 週1
イベントは多分他と比べて多かったです。
他と明らかに違うだろう点は振り返りFBがあることですかね。
内容は週1振り返りでやっている内容の振り返りです。
コンサルとしてJOINしているRector社の松岡さんと広木さんにチームで振り返った内容を共有して、アドバイスを頂き振り返りの質を上げようという場です。
チーム体制
- PO×1
- スクラムマスター×1
- 開発メンバー×4~6くらい
チームには含まれませんが、デザイナーやテストチームも存在して必要であれば連携していくスタイルです。
スプリント期間
初期は1週間、途中から2週間で回していました。
スクラムでの成功体験
ストーリーのタスク分けを細かくした
タスクやストーリー自体が大きすぎると抽象度が上がり、見積もった以上の実績がかかることはよくあると思います。
その乖離をなくすために
- タスクは細くする
- 予実の乖離を起きづらくする
- ストーリーに対してプランニング時にガッチリ仕様を詰める
- ストーリーを進める上での影響を洗い出し不確実性をなくす
をしました。
予実管理はバーンダウンチャートを使用していたのですが、予定通り落ちていくと安心感が違います。
終わらなかったストーリーの問題を深堀りした
約束したストーリーを全部終わらせられないことはよくありました。
その時は振り返りで
- 終わらなかったストーリーのポイントを見返す
- 適正だったかを議論する
- 問題を深掘りする
- 具体的にどういうところに時間をかけたか分析
- ハマりポイントを明確にする
を行い次回のプランニング時に同じことを繰り返さない対策を打っていました。
メンバー間の心理的安全性を保つ
これはスクラムマスターになって特に重要度を感じました。
スクラムはチームで仕事をするのでチーム力やコミュニケーション力が非常に大事です。
チームメンバーの一人ひとりが気兼ねなく発言できることは、チームの状態や仕事をしていく上でのパフォーマンスにかなり影響があります。そしてコミュニケーション量を増やし、チームの雰囲気向上にも繋がります。
また、振り返り、プランニング、コードレビューでも、それぞれの考えをぶつけ合うことで質が上がる要因になっていたと思います。
スクラムでの失敗体験
2週間スプリントの難しさ
1週間スプリントに慣れてきた時に2週間スプリントに移行したのですが、難易度の高さを感じました。何故なら、MTGの時間はそのままだがプランニングの量は倍になりタスクの抽象度が上がる不確実性が増していったからです。
また、スプリントが長いため遅れていても取り返せるんだと考えがちでしたが、実態はリカバリー案がなく終わらないことが多かったです。
このことから2週間先の見込みを十分に立てることの重要性と、こまめな進捗管理が2週間スプリントでは学びました。
一つ勘違いしてはいけないのは、タスクの抽象度を上げるということは悪ではありません。成熟したチームなら抽象度が高くても仕様決めから開発までをやり遂げるでしょう。
チームの大幅な入れ替え
元々スクラムはメンバーの入れ替えに弱いです。
チームが成熟している時に追加や入れ替えが発生すると、チームビルディングをやり直す必要があるからです。ましてや大幅な入れ替えとなるとそれは必然となるでしょう。
今までのやり方が合わない新メンバーもいるため、チームビルディングをし直すこと自体はすごく意味があると思います。
しかし、既存のメンバー(私も含む)からすると大きくやり方を変えるのは難しいです。何故なら成功体験があるからです。
メンバーそれぞれの目指す方向を共通認識として持つことがスクラムでは重要だと学びました。
MTGのタイムマネージメントをできなかった
スクラムはイベント(MTG)が多いですがそのほとんどが長くなりがちでした。
その原因の一つにMTGに参加する人数が多いということが挙げられます。もちろんいて欲しい場合がありますがその殆どがその限りではありません。人が多いと議論が活発化され着地しづらくなります。必要である場合以外はMTGを最少人数でやるようにすべきでした。
また、MTG中の時間管理もしっかりできていませんでした。そのため長くなると集中力が切れ良い意見も出ません。中には自分の作業をしだして議論に参加しないメンバーもいました。
そうならないためにMTGをアジェンダ通りに進めることや、状況次第では第二回を開催するなどの対策を取るべきだと学びました。
何でもかんでも自分でやりすぎた
スクラムマスターはチームへの妨害を排除することが目的です。なので私はメンバーが開発の邪魔になりそうことはやりました
具体的に何をやったかというと
- 本番ブランチと開発ブランチのコンフリクト対応
- 開発環境に問題があった場合の調整
- 仕様の最終決定をPOと調整
- 他チームとの連携・調整
- リリースの段取り
- 実際のリリース作業
- ストーリーをバックログに追加する
などです。
これ以外に自分のタスク(開発のストーリー、スクラムイベントへの参加、振り返り資料作成)も抱えていましたが、最後はパンクしました。
ここまでやった理由としてストーリーが終わらなかったときの理由を他責にして欲しくなかったことが強かったです。
しかしメンバーを信頼して作業分配すべきだったと学びました。
まとめ
スクラムマスターを経験して多くの学びがありました。
チームとして、組織として成果を出すことの難しさを痛感してます。
また、スクラムとは単なるフレームワークなのでチーム状況に応じた柔軟な対応がいかに大事かを学びました。
最後にスクラムにとって特に重要なこと
それは心理的安全性が保たれてることだと思いました。
改めて振り返ってみると良い状態のときはあって悪い状態にはなかった要素かなと思ってます。多分この要素を満たしているだけでチームが良い状態になる可能性が高まるのではないでしょうか。
割と問題はヒトとヒトの間にあることが多いです。
チームや関わる人たちに言いたいことを言い合える環境こそが、スクラムをやる上での重要なことだと感じました。
ただし心理的安全性が保たれている=いじりや悪口など何でも言っていいということではありません。
相手を思いやる気持ちとリスペクトの精神を忘れないようにして、質の高いコミュニケーションを心がけましょう。
私はそれで何度も失敗しました
Macを触ったことのなかった新人エンジニアが再帰プログラムを説明してみた
はじめまして。リンクアンドモチベーションでモチベーションクラウドの開発をしている@ohtatakatoです。
この記事は、「モチベーションクラウド Advent Calendar 2018」の記事となります。
新卒2年目から未経験で急遽エンジニアとなり、現場に入ってまだ1ヶ月の私ですが、研修を通して学んだことをまとめていきます。
この記事では、再帰プログラムとは何なのかが分かっていない方の為に具体例を示しながらなるべく分かりやすく説明しようと思います。
再帰プログラムとは
再帰とはwikipediaに下記のように記載があります。
再帰(さいき)は、あるものについて記述する際に、記述しているものそれ自身への参照が、その記述中にあらわれることをいう
つまり、再帰プログラムとはプログラム中で自身への参照をしているプログラムのことを言うわけですが、私はこれだけではいまいち何を言っているのか分かりませんでした。
具体例 : クリスマスツリーに星を飾る
クリスマスツリーに星を一つずつ飾ってn個の星が飾られた状態をつくります。
仮に10個の星が箱の中に入っていたとして、ツリーに星を飾る回数を数えるときに、
下記のように考えることができます。
「10個の星を飾り付けるためには9個の星を飾り付けた状態のツリーにさらに1個星を飾れば良い」
これを数式で表すと下記のように書けます。
kazatteru_hoshi(n)
でn個の星が飾られた状態を示すとすると
kazatteru_hoshi(10) = kazatteru_hoshi(9) + 1
このように10個の星を飾るためには9個の星が飾られている必要があり、
同様に9個の星を飾るためには8個の星が飾られている必要があります。
まさにこれが再帰的な試行なのです。
n個の星を飾る行為は下記のように記述することができます。
def kazatteru_hoshi(n)
if n < 1
return 0
else
return kazatteru_hoshi(n - 1) + 1
end
end
※nはintとする
再帰プログラムは漸化式だった!
ここまで学習してみて、再帰プログラムを作成する際には高校数学の漸化式の作成と同様のことをしていることに気がつきました。
今回のクリスマスツリーを飾る試行の漸化式は下記のようになります。
f(1) = 1
f(n) = f(n-1) + 1
この漸化式を解くとf(n) = n
wikipediaで「漸化式」を調べてみると
漸化式(ぜんかしき、英: recurrence relation; 再帰関係式)は、各項がそれ以前の項の函数として定まるという意味で数列を再帰的に定める等式である。
つまり、高校生の頃に学んだ漸化式を作成することができれば、再帰プログラムを組むことは容易にできるということです。
↓フィボナッチ数列の漸化式も再帰で書けますね
f(0) = 0
f(1) = 1
f(n) = f(n-2) + f(n-1)
def fibo(num)
if num < 0
return "numは0以上の整数"
else
case num
when 0
return 0
when 1
return 1
else
return fibo(num-2) + fibo(num-1)
end
end
end
注意点
再帰プログラムにおいて自身を呼び出す回数が多くなると計算量が膨大になってしまう可能性があるので注意が必要です。
これに関してはお正月休みに詳しくまとめようと思います。
最後に
モチベーションクラウドは社員と複数のパートナー企業様、フリーランスの方の混合チームで開発しています。
エンジニアになる以前はスクラムマスターとして携わっていたのですが、その時からプロダクトだけでなく、その根底にあるチーム創りを大切にしています。
興味がある方はモチベーションクラウド Advent Calendar 2018の下記記事をご覧ください。
Vue使いなら知っておきたいVueのパターン・小技集
はじめに
こんにちは、モチベーションクラウドの開発にフリーのエンジニアとして参画している@HayatoKamonoです。
この記事は、「モチベーションクラウド Advent Calendar 2018」15日目の記事となります。
概要
モチベーションクラウドのフロントエンド開発では、JavaScriptのフレームワークとしてVueを採用しています。
私がモチベーションクラウドの開発にジョインしたのは2018年7月です。
それまで私はReactを使ったフロントエンド開発を2、3年ほど行なってはいたものの、Vue自体は未経験の状態でした。
今回は、そんなVue未経験だった私が、この5ヶ月弱の間に、実務や日々の学習を通して蓄積してきたVueのパターンや小技、また、Reactコミュニティーから拝借したパターンなどを簡易的なサンプルコードとともに、共有させて頂きたいと思います。
v-modelのカスタマイズ
<some-component v-model="isExpanded" />
v-model
はデフォルトでは以下のように書き換え可能です。
<some-component @input="someFunction" :value="isExpanded" />
しかし、このデフォルトの挙動を以下のようにカスタマイズすることも可能です。
model: {
prop: "isExpanded",
event: "toggle"
},
props: {
isExpanded: {
type: Boolean,
default: false
}
},
フォーム系のコンポーネントであれば、v-modelに紐づくprop名とevent名をそのまま、value
とinput
にしても良いですが、フォーム系"以外"のコンポーネントの場合、そのコンポーネントの振る舞いにより適したprop名とevent名を、上記の例のように割り当ててあげるのが良いと思います。
$once('hook:beforeDestroy')
<script>
export default {
name: "SampleComponent",
created () {
this.someEventHandler = () => {
console.log("実際の開発ではイベントは間引こう!");
};
document.addEventListener("mousemove", this.someEventHandler);
},
beforeDestroy () {
document.removeEventListener("mousemove", this.someEventHandler);
}
};
</script>
時折、このようにcreated
のタイミングで何らかのイベントに特定の処理を紐付け、beforeDestory
のタイミングで同じイベントに紐づけておいた処理を除去したいこともあるかと思います。
こういった場合は以下のように書くと、よりシンプルにコードを書くことが可能です。
<script>
export default {
name: "SampleComponent",
created() {
const eventHandler = () => {
console.log("実際の開発ではイベントは間引こう!");
};
document.addEventListener("mousemove", eventHandler);
this.$once("hook:beforeDestroy", () => {
document.removeEventListener("mousemove", eventHandler);
});
}
};
</script>
ポイントはcreated
メソッドの中で、一度だけ実行される$onceメソッドを呼び出し、Vueのhook:beforeDestroy
イベントに対して、beforeDestory
メソッドの中で行いたい処理を記述するということです。
watchプロパティーのimmediate: true
Vueでは特定のprop
の値が変化した時に、何らかの処理を実行したい場合、watch
プロパティーを使用します。
しかし、watch
プロパティーは普通に使うと、監視対象のprop
の値が変化した時にのみ、監視対象のprop
に紐づけたメソッドが実行されます。
watch: {
isOpen () {
this.count = this.count + 1
}
}
例えば、この場合は親コンポーネントから受け取るprop
であるisOpen
の値が変化する時にのみ、このコンポーネント自身が持つcount
がインクリメントされます。
watch: {
isOpen: {
immediate: true,
handler() {
this.count = this.count + 1;
}
}
}
しかし、このように書き換えることで、このコンポーネント自身が最初にマウントされたタイミングにも、count
がインクリメントされるようになります。
Render Function
多くの場合、Vueでは<template>
タグを使用していれば事が足りますが、render function
というVNode
をプログマティックに生成するAPIを使う事で、描画部分のコードをより簡潔に書けたり、柔軟に処理を行えたりします。
<template>
<p :style="{ color: 'red' }">Hello World</p>
</template>
<script>
export default {
name: "HelloWorld"
};
</script>
上記は単純に'Hello World'と赤文字で表示するだけの簡単な例です。
<script>
export default {
name: "HelloWorld",
render(createElement) {
return createElement("p",
{ style: { color: 'red' } },
"Hello World"
);
}
};
</script>
先ほどのコードをrender function
を使って書くとこのようになります。
render(createElement) {
return createElement("p",
{ style: { color: 'red' } },
"Hello World"
);
}
render function
の引数にはcreateElement
という関数が渡ってきます。このcreateElement
を使って、VNodeを生成します。
createElement
の第一引数には要素名やコンポーネント名を渡し、第2引数にはprops
やclass
などの設定オブジェクトを任意で渡します。そして、第3引数には、子要素になるVNodeや文字列を渡します。
<script>
export default {
name: "HelloWorld",
props: {
level: {
type: Number,
default: 1,
validator(value) {
return value > 1 && value <= 6;
}
}
},
render(createElement) {
return createElement(
`h${this.level}`,
{ style: { color: "red" } },
"Hello World"
);
}
};
</script>
render function
を使えば、上記の例のように、親からlevel
というprop
を通して、1〜6の見出しレベルを受け取り、その受け取った見出しレベルに対応するHタグを動的にcreateElement
の第一引数に渡してあげることも可能になります。
これを<template>
タグを用いて行おうとすると、<template>
タグの中で対応させたい見出しレベルの数だけ条件分岐を行わなければなりませんが、render function
を使えば、簡潔にコードを書く事が出来るようになります。
Functional Wrapper Component
条件毎に異なるコンポーネントを描画したいような場合は、Functional Component
でラップして条件に対応したコンポーネントを描画してあげると、コードがクリーンになります。
以下は配列の中にデータがある場合は、データがある場合のコンポーネントを描画し、データがない場合はデータが無い場合のフォールバック用コンポーネントを描画するという簡易的な例です。
<template>
<div id="app">
<smart-item-list :items="items" />
</div>
</template>
<script>
import SmartItemList from "./components/SmartItemList";
export default {
name: "App",
components: {
SmartItemList
},
data() {
return {
items: [{ id: 1, name: "apple" }, { id: 2, name: "banana" }]
};
}
};
</script>
ここでは単に、SmartItemList
コンポーネントのitems prop
に2つのオブジェクトを持つ配列を渡しているだけです。
<script>
import ItemList from "./ItemList";
import EmptyData from "./EmptyData";
export default {
functional: true,
props: {
items: {
type: Array,
default() {
return [];
}
}
},
render(createElement, { props }) {
const Component = props.items.length > 0 ? ItemList : EmptyData;
return createElement(Component, {
props: {
items: props.items
}
});
}
};
</script>
ここでは、親から受け取ったitems
配列の中のオブジェクトの数をチェックして、配列の中身が無い場合は、EmptyData
コンポーネントを描画し、配列の中身がある場合は、ItemList
コンポーネントを描画しています。
<template>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
</template>
<script>
export default {
name: "ItemList",
props: {
items: {
type: Array,
default() {
return [];
}
}
}
};
</script>
データがある場合は上記のコンポーネントが描画されます。
<template>
<div>No Data...</div>
</template>
<script>
export default {
name: "EmptyData"
};
</script>
データが無い場合は、'No Data...'と表示されるだけのコンポーネントが描画されます。
Error Boundary
ReactではError Boundaryという、子孫コンポーネントでエラーが発生した際にクラッシュしたUIを表示させる代わりに、フォールバックのUIを表示させる手法が存在します。
それをVueで実現する為には、VueのerrorCaptured
フックを使用します。
以下はその簡易的な例です。まずは、Error Boundaryの使い方から見ていきます。
<template>
<div id="app">
<ul>
<template v-for="item in items">
<error-boundary :fallback="fallbackItem" :key="item.id">
<dummy-item :item="item" />
</error-boundary>
</template>
</ul>
</div>
</template>
<script>
import ErrorBoundary from "./components/ErrorBoundary";
import DummyItem from "./components/DummyItem";
import FallbackItem from "./components/FallbackItem";
export default {
name: "App",
components: {
ErrorBoundary,
DummyItem,
FallbackItem
},
data() {
return {
items: [
{ id: 1, name: "apple" },
{ id: 2, name: "banana" },
{ id: 3, name: null }
]
};
},
computed: {
fallbackItem() {
return FallbackItem;
}
}
};
</script>
ここでは、エラーが発生しうるコンポーネントをErrorBoundaryと名付けたコンポーネントでラップしています。ErrorBoundaryコンポーネントのpropsであるfallback
には、エラーが発生した際に代わりに表示させたいフォールバック用のコンポーネントを渡しています。
props
を通して、フォールバック用のコンポーネントを設定出来るようにすることで、ErrorBoundary
コンポーネントの利用者がエラー発生時に表示させたいコンポーネントを自由に選べるようになります。
<script>
export default {
name: "ErrorBoundary",
props: {
fallback: {
type: Object
}
},
data() {
return {
hasError: false
};
},
errorCaptured() {
this.hasError = true;
},
render(createElement) {
return this.hasError
? createElement(this.fallback)
: this.$slots.default[0];
}
};
</script>
次に、ErrorBoundaryコンポーネントを見ていきます。ここでやっていることは単に、子孫コンポーネントで発生したエラーを、errorCaptured
メソッドで捕獲してあげて、エラーであった場合はフォールバック用のコンポーネントを描画し、そうでない場合は、自身のslotsコンテンツを描画してあげています。
<template>
<li>{{ item.name.toUpperCase() }}</li>
</template>
<script>
export default {
name: "DummyItem",
props: {
item: {
type: Object
}
}
};
</script>
このコンポーネントがエラーが発生しうるコンポーネントです。親コンポーネントからpropsを通して受け取るitemオブジェクトのnameプロパティーは文字列であるため、<template>
の中では例として、item.name.toUpperCase()
を実行して文字列を大文字に変換しています。
しかし、APIから取得したデータに異常値が含まれている場合などを想定した場合、itemオブジェクトのname
プロパティーが欠損していたり、null
であるかもしれません。
そういった場合に、文字列ではないデータ型に今回のようにtoUppserCase
メソッドを実行すると、レンダリングエラーが発生してしまいます。
<template functional>
<li>nah...</li>
</template>
<script>
export default {
name: "FallbackItem"
};
</script>
エラーが発生した場合は、ErrorBoundaryコンポーネントのpropsのfallback
に渡していた、FallbackItemコンポーネントが代わりに表示されます。
Higher Order Component
Higher Order Componentはデータや振る舞いを共通化したい時に利用する、Reactではお馴染みのパターンですが、Vueでもrender function
を使えば可能です。
Higher Order Componentは、引数にComponentを取り、別のComponentを返す高階関数です。
以下は、仮にクライアント側で認証を行うSPAであると仮定した場合に必要になりそうな、クライアント認証用のロジックやデータを提供するHigher Order Componentの例です。
Higher Order Component側
const requireAuth = WrappedComponent => {
return {
name: `${WrappedComponent.name}-protected`,
computed: {
isAuthenticated() {
return this.$store.state.isAuthenticated;
}
},
created() {
// JWTトークンが存在、または、失効しているかどうかをチェック
// トークンが無い、または、失効していたらログインページへリダイレクト
},
render(createElement) {
return createElement(WrappedComponent, {
props: {
isAuthenticated: this.isAuthenticated
}
});
}
};
};
export default requireAuth;
使う側の例
export default new Router({
mode: "history",
base: process.env.BASE_URL,
routes: [
{
path: "/",
name: "home",
component: requireAuth(HomePage)
},
{
path: "/about",
name: "about",
component: requireAuth(AboutPage)
}
]
});
クライアント認証のロジックを適用したいコンポーネントをrequireAuth
関数の引数に渡してあげれば、引数に渡されたコンポーネントはクライアント認証のロジックを持つようになります。
Container Component、Presentational Component
Reactコミュニティーではお馴染みのパターンの1つに、データと振る舞いに関心を持つContainer Componentと、描画に関心を持つPresentational Componentを分けて実装するというものがあります。
render function
とHigher Order Componentのパターンを使えば、Vueでも同じことが実現可能です。
// Presentational Componentをimport
import SamplePage from "./SamplePage.vue";
/*
以下の`connect`は、Presentational Componentを引数に取り、そのコンポーネントが関心を持つ、
VuexのmoduleのデータとVue Routerのメソッドへのアクセスを与えたContainer Componentを返す高階関数
*/
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
computed: {
count() {
return this.$store.state.count;
}
},
methods: {
handlePageChange({ to }) {
this.$router.push(to);
}
},
render(createElement) {
return createElement(WrappedComponent, {
props: {
count: this.count
},
on: {
pageChange: this.onChangePage
}
});
}
};
};
/*
次の2行は以下のコードをContainerの説明の為に、より明示的にしたもの
export default connect(SamplePage);
*/
const SamplePageContainer = connect(SamplePage);
export default SamplePageContainer;
export { SamplePage };
Renderless Component(Scoped Slots)
VueにはHigher Order Componentの他にも、データやロジックを共通化する方法として、Scoped Slotsを利用したものがあります。
Scoped SlotsはReactコミュニティーでお馴染みのRender ChildrenやRender Propsパターンのようなものです。
以下はScoped Slotsを用いたContainer Componentの例です。
<script>
export default {
name: "DataProvider",
props: {
url: {
type: String
}
},
created() {
fetch(this.url)
.then(response => response.json())
.then(json => (this.data = json))
.catch(console.error);
},
data() {
return {
data: []
};
},
render(createElement) {
return this.$scopedSlots.default({
data: this.data
})[0];
}
};
</script>
上記のコードでは例として、propsで渡ってきたURLにGETリクエストを行い、成功時に返って来たデータを呼び出し元にscoped slotsを通して渡しています。
this.$scopedSlots.default()
自体はVNodesを含んだ配列を返す為、このメソッドの結果をそのまま、render function
の中でreturnしてあげれば、return createElement('div', [this.$scopedSlots.default()]
のように、何らかのDOM要素でラップしてあげる必要もありません。
<template>
<div id="app">
<data-provider url="https://jsonplaceholder.typicode.com/todos">
<template slot-scope="{ data: todos }">
<ul>
<li v-for="todo in todos" :key="todo.id">{{ todo.title }}</li>
</ul>
</template>
</data-provider>
</div>
</template>
<script>
import DataProvider from "./components/DataProvider";
export default {
name: "App",
components: {
DataProvider
}
};
</script>
呼び出し元では、DataProviderコンポーネントの中でfetchしたデータをslot-scope
を通して受け取り、受け取ったデータをslotコンテンツに渡して描画してあげています。
このようなアプローチを取ると、DataProviderコンポーネントが持っているロジックを使い回すことができますし、DataProviderコンポーネントに内包されるコンテンツは差し替え可能になります。
Provide / Inject
VueにはPlugin開発向け、コンポーネントライブラリ開発向けのAPIとしてprovide & inject
APIが用意されています。
通常、コンポーネントから他のコンポーネントへデータを受け渡す際は、「親コンポーネントからその子コンポーネントへ」、「その子コンポーネントからその子コンポーネントへ」といった具合に、バケツリレーのようにデータの受け渡しを行なわなければなりません。
しかし、provide & inject
APIを使えば、親コンポーネントから孫コンポーネントへデータを直接受け渡すことも可能です。
ReactではContext APIがこれに当たります。
<template>
<div>
<h1>Parent</h1>
<child />
</div>
</template>
<script>
import Child from "./Child.vue";
export default {
name: "Parent",
components: { Child },
data() {
return {
// provide対象のデータをreactiveにする為には、このように別オブジェクトで内包する必要がある
sharedState: {
message: "Hello World"
}
};
},
mounted() {
// ここでは、provide対象のデータが更新された時にリアクティブになっていることを単に確認しているだけ
setTimeout(() => {
this.sharedState.message = "Hello Everyone";
}, 1000);
},
provide() {
return {
providedState: this.sharedState
};
}
};
</script>
providedState
という名前でthis.sharedState
を、この後、孫コンポーネントで受け取る事が可能になる。
<template>
<div>
<h1>Child</h1>
<grand-child />
</div>
</template>
<script>
import GrandChild from "./GrandChild.vue";
export default {
name: "Child",
components: { GrandChild }
};
</script>
ご覧にのように、GrandChildコンポーネントにはprops
でバケツリレーでデータを受け渡してはいない。しかし、この後、孫コンポーネントでは、先ほど親コンポーネントでprovideの対象としたデータを受け取る事が出来る。
<template>
<div>
<h1>Grand Child</h1>
<p>{{ message }}</p>
</div>
</template>
<script>
export default {
name: "GrandChild",
inject: ["providedState"],
computed: {
message() {
return this.providedState.message;
}
}
};
</script>
上記のように上位階層のコンポーネントにおいて、provide
で公開されたデータを、inject
で受け取る事が可能になる。
provide & inject
はバケツリレーの回数が多い場合に便利。
ただし、注意点としては、おそらく、ReactのContext APIが辿ったように、VueでもこのAPIは仕様が変更になることが予想されるのと、また、Presentational Componentの中で直接、VuexのStoreを参照している時と同じように、provide側とinject側で強い依存関係が生まれてしまうので、Higher Order Componentなどを用いて、抽象化してあげた方がAPI仕様の変更にも強く、また、疎結合にもなるので、そういった対応が必要になります。
Vue.js のコンポーネント用 Storybook を Single File Component (SFC) で書く
はじめに
はじめまして。Motivation Cloud 開発メンバーの新参者 @navy-field です。
この開発現場では、フロントエンジニアとデザイナー間、あるいはフロントエンジニア同士の円滑なコミュニケーションのために Storybook を活用しています(参考:デザイナーとStorybookをS3上で共有)。
本記事では、そんな中で発見した、VueコンポーネントのStorybookを書く上でちょっと便利な小技を紹介いたします。
よくあるVueコンポーネントのStoryの書き方
Vue.jsコンポーネントのStoryの書き方として、以下のようなパターンをよく見かけます。
import { storiesOf } from "@storybook/vue";
import Test from "./Test.vue";
storiesOf('Test', module)
.add('story as a component', () => ({
components: { Test },
data () {
return {
message: "Hello!"
};
},
template: `<test :msg="message"></test>`
}));
オフィシャルのドキュメントにもこのような形で書かれていますが、この書き方には次のような不便な点があります。
- templateが文字列としてJS内に書かれるのでエディタのハイライトや補完が効かない
- StoryにCSSを当てたいときに別ファイルに定義したりテンプレート内にインラインで書く必要があり、たちまち複雑化する
Vue.jsのオフィシャルガイドに書かれているSFCのメリットの裏返しですね。
これら問題は、Story を Single File Component (以下 SFC) で書くことで解消できます。
Storybook の設定ファイル
SFCでStoryを書く前に、Storybookの設定ファイルを変更して.vueファイルで書かれたStoryを読み込むようにましょう。
import { configure } from '@storybook/vue';
const req = require.context('../../src/components', true, /.story.(vue|js)$/)
function loadStories () {
req.keys().forEach(req)
}
configure(loadStories, module);
このようにrequire.context
をつかうことで、components
ディレクトリ以下にあるファイルのうち、名前が.story.vue
または .story.js
で終わるファイルを全てStorybookで読み込むことができます。
SFCで書き直したStory
上記 test.story.js
をSFCで書き直したサンプルは以下の通りです。
<template>
<test :msg="message"></test>
</template>
<script>
import Vue from "vue";
import { storiesOf } from "@storybook/vue";
import Test from "./Test.vue";
const testStory = {
components: {
Test
},
data () {
return {
message: "Hello!"
}
}
};
export default testStory;
storiesOf("Test", module)
.add("story as a single file component", () => testStory);
</script>
<template>
内の export default testStory;
によって、 vue-loader
がtestStory
にtemplateを組み込んでくれます。
またその下の add()
の第2引数の関数の戻り値にtestStory
を指定することで、このStoryをtemplateを含むコンポーネントとしてStorybookに渡すことができます。
これでめでたくStoryをSFCで書くことができました。
おわりに
Storybookで自前のコンポーネントカタログを作ってそのクオリティを徐々に上げていくと能率がアップするし、とても楽しいです。
他にもTypeScriptでStory書いたり、Storyの整理術とかもあるので追々書いていこうと思います。
今後もStorybookを使っていきたいと思っていますが、最近Storybookのコミッター界隈で色々な動きがあったようですね。
(これ とか これ)
今後、宗派が別れて揉めたり、営利企業の介入によって廃れたりしなければ良いなと思うこの頃です。
We are hiring!
モチベーションクラウドは
「すべての組織を変える」
「世界の経営指標を変える」
事を本気で考え、心からそれを実現したいチームが開発しています。
組織に課題を感じたことのあるエンジニアのかたは少なくないと思います。
もし、少しでもこれらミッションやビジョンに共感したり興味があるというかたは
こちら から是非ご連絡ください!
おまけ
Storybook はフレームワークやアドオンのバージョン違いで動かなくなることがよくあるので、
今回のサンプルを動かした環境の package.json を記しておきます。
{
"name": "sfc-story-sample",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"storybook": "start-storybook -p 9001 -c config/storybook"
},
"dependencies": {
"@vue/cli": "^3.2.1",
"vue": "^2.5.17",
"vue-class-component": "^6.0.0",
"vue-property-decorator": "^7.0.0"
},
"devDependencies": {
"@babel/core": "^7.2.2",
"@storybook/vue": "^4.1.1",
"@vue/cli-plugin-babel": "^3.2.0",
"@vue/cli-plugin-typescript": "^3.2.0",
"@vue/cli-service": "^3.2.0",
"awesome-typescript-loader": "^5.2.1",
"babel-loader": "^8.0.4",
"babel-preset-vue": "^2.0.2",
"node-sass": "^4.9.0",
"sass-loader": "^7.0.1",
"typescript": "^3.0.0",
"vue-loader": "^15.4.2",
"vue-template-compiler": "^2.5.21"
}
}
採用未経験の私が、なぜエンジニアを採用できたのか?
はじめに
こんにちは、リンクアンドモチベーションでテクノロジー職種の採用責任者をしています@entrylmi(尾上)です。
この記事は、モチベーションクラウド Advent Calendar 2018の17日目の記事となります。
現在リンクアンドモチベーションは、テクノロジー企業化に向けて第二創業期を迎えています。
弊社プロダクトであるモチベーションクラウド及び新規プロダクトの更なる開発体制の強化に向けて、約半年前にエンジニアをはじめとするテクノロジー職種の採用を本格的に推進することにしました。
その責任者として白羽の矢が立ったのが私ですが、エンジニア採用どころか、採用自体もやったことも無い全くの素人でした。
本記事では、そんな私が何を実施しどんな失敗を経験したのか、採用活動当初に戻れるなら、何を実施すべきかについて共有できればと思います。
想定する読者
- エンジニア採用担当者(特にこれからエンジニア採用を行う担当者)
- エンジニア採用に関わるエンジニア・エンジニアリングマネジャー
まず実施したこと(立上げフェーズ:~3ヶ月)
とりあえずエンジニアの方々にお会いする
当初の私は「ダイレクトリクルーティングってなに?」くらいの知識量で、何から手を付けてよいのか全く分からなかったため、とりあえず「餅は餅屋に聞け!」と思い、様々なエンジニアの方々と積極的にお会いしました。とにかく多くの人と面談し自分の感覚を磨くことに注力した結果、工数は想定以上にかかりましたが、エンジニアの方々が転職先に求めることや弊社にマッチする人物像を描くことが出来ました。
小心者の私は「こんなこと聞いたら失礼かな?」「うざいと思われないかな?」と挙動不審な態度で質問していましたが、予想に反し、応募者の方は嫌な顔ひとつせずに、丁寧にフィードバックくださる方ばかりでした。話が盛り上がりすぎて後日飲みに行ったケースもあります(笑)
本フェーズでは、採用担当者が積極的に教えを乞う姿勢が重要だと思いますので、是非実践してみてください。
また、弊社にご入社頂くまでの工程が他社と比べた時に多いと感じる方もいらっしゃいます。
私たちは、弊社と応募者が相思相愛の状態で入社頂きたいという想いから、選考途中でも入社後に一緒に働くメンバーとの会食や面談を複数回設定し、相互理解を図る場を設けている為です。
このような場を設定することにより、弊社と応募者のエンゲージメントが高まり、ご入社頂く確立も各段とUPします。
本フェーズで実施した内容を次に記載します。
ダイレクト・リクルーティング
最初に実施したことは、Wantedly,BizReachにてスカウトメールを月200通程度送付し、1日2~3人の方と面談させて頂きました。面談の場では、弊社のことをお話しさせて頂いただけではなく「スカウトメール文は魅力的でしたか?修正するならどこを修正した方が良いですか?」などとフィードバックをもらえるように質問を準備していました。応募者の方々も親身になって相談に乗ってくださり、「スマホのポップアップはこのように見えるためタイトルをもう少し短くした方が良い」、「そもそも本文が長くて読む気がしないので、プロダクトの説明などはリンクで飛ばした方が良い」などと具体的なフィードバックを頂け、結果としてスカウト返信率も約20%まで向上しました。
転職エージェント
当初、10社以上の転職エージェントの方々にお声掛けしましたが、その時点ではどのような人物像が弊社にマッチするのか、求めるスキルレベルはどのくらいかを明文化出来ていませんでした。
(感覚的には分かるが言語化が出来ていない状況)
そこで、エージェントの方とも基準を一致させるためにも、求める人物像やスキルレベルを明文化しましたが、基準に曖昧な箇所も多かったため、とにかく紹介頂いた方のほとんどとお会いし、応募者の具体的なご発言やご経験を基にエージェント会社へフィードバックするように心掛けました。
それでも、まだお伝えしきれない箇所があったため、次の施策を実行しました。
- エージェントの方が応募者へ説明するための資料/トークスクリプト作成
- エージェントの方々への説明会開催
- エージェントの方とのディナー/ランチを開催し、弊社のビジョンや実現したい世界感の共有
- エージェントの方との定例MTGを実施
上記施策を実行することにより弊社が求める人物像やスキルレベルの基準が擦り合うようになり、結果として開発責任者やテックリードを採用することが出来ました。
様々なエンジニアにお会いしたが採用に繋げられなかった理由
一方、応募者の方々と面談させて頂きましたが採用に繋げられなかった理由も振り返りたいと思います。
魅力的に事業・プロダクトのことを語れない
ありがちなのが人事担当者が面談で募集内容や条件面のみを説明し、応募者へ全く動機づけ出来ていないケースです。まさに私たちもその落とし穴にはまっていました。面談者が弊社の将来像やビジョン、今後の事業の方向性を魅力的に語れなかったため、面談で終了してしまうケースがありました。将来像やビジョンを魅力的に語れるか否かが面接に進んで頂けるかの鍵になるため、トークスクリプトを作成し、毎日のように語る練習をしました。
結果として、面談に来て頂いた方の90%以上が次に進みたいと言ってもらえるまでに改善することが出来ました。お互いカルチャーマッチしない
弊社のカルチャーで重視している例として、STYLEという行動指針がありますが、その指針にあまり共感頂けない方もいます。私たちは、この行動指針に従って日々の業務を行っており、且つこの行動指針はエンジニア文化と非常に密接な関係があるため、今後のエンジニア文化形成の礎として大切にしていきたい指針です。詳細については、技術アドバイザーとして参画頂いているレクター社の広木氏へのインタビューをご参照頂ければと思います。
次に実施したこと(拡大フェーズ:~半年)
リンクアンドモチベーションをテクノロジー企業として認知してもらう
一般的なリンクアンドモチベーションのイメージは「コンサル・営業会社」であり、それを「テクノロジー企業」に認知を変えるために採用広報活動を本格的に行うことにしました。
採用広報活動を開始した当初は、3ヶ月程度広報施策を打ち続ければ、自然と応募も増えるため、応募から採用出来れば良い程度に考えていましたが、応募数は採用広報活動を行う前とほぼ変わらず、目に見える効果は現れませんでした。今となれば当然ですが、採用広報活動は、中長期的に実施することにより効果が現れます(はずです)
採用広報活動において、実施した内容を次に記載します。
採用広報戦略策定
まず、誰に対してどのようなメッセージを届けるかを3Cを用いて整理しました。特に競合と比較し、自社の強み・弱みを整理し、自社のポジショニングを明確にした上で、ターゲットへのメッセージを抽出したことがポイントになります。
リンクアンドモチベーションの世間からの印象を細分化し、それを払拭するようにメッセージを詳細化しました。
最後に、メッセージをどのチャネルでどの順番で届けるかを整理し、アクションに反映しました。
Twitterの活用
私自身リンクアンドモチベーションに転職する際に、入社前に接点を持った人事の方から会社の雰囲気やカルチャーを感じたのを思い出し、その会社で最初に出会い最も深くかかわる人事担当者のパーソナリティーが採用において、とても重要な役割を果たすと考えています。
そこで、弊社で最初にエンジニアの方々にお会いする可能性の高い私のパーソナリティーを、より多くの方々に知って頂くために、Twitterを利用することにしました。
リンクアンドモチベーションの雰囲気やカルチャー及び私自身のキャラに親近感を感じて頂き、一回くらい会ってやっても良いかなと思って頂けるように日々ツイートを発信しています。
ブログの配信
ブログは、Wantedlyを用いて配信しています。
まだまだ、上手く記事化出来ていないため、改善の余地が沢山ありますが、今後はイベントレポートからインタビュー記事まで幅広く掲載していく予定です。
イベント開催・協賛
銀座Rails
リンクアンドモチベーションは、技術交流の場の提供を目的に、GINZA SIXのイベントスペースを提供しています。現在、銀座Railsに毎月利用頂いており、
少しずつではありますが、リンクアンドモチベーションというブランドが技術界隈にも浸透し始めてきました。
元々はイベントオーナーの河野氏にご協力頂き、弊社GINZA SIXのイベント会場を利用してもらうことから始めたことをきっかけに、現在ではRuby on Railsに関わる様々なイベントへの繋がりを広げております。
Ruby on Rails界隈でリンクアンドモチベーションの名をより広く認知させていく為に、
2019年も様々なイベントへの協賛を予定しています。銀座寿司会
銀座寿司会は、エンジニアの方々が技術の悩みを有識者に相談出来る場があったら良いと考え、弊社オフィスが銀座にあることから(銀座と言えば寿司!ということで)、銀座寿司会というイベントを開催しました。
有識者にはエンジニアリング組織論への招待の著者である広木氏だけではなく、他の著名の方々にもお越し頂ける何とも豪華な場にすることが出来ました。
Ruby関連のイベント協賛
Rubyを活用している企業として、Rubyの発展と貢献を目的にRuby Association、Rails Girls、に協賛させて頂きました。これからも積極的にRuby関連のコミュニティやイベントに協賛し、技術の発展に貢献します。
この半年を振り返り、当初から実施すべきだったこと
採用チーム以外の協力を積極的に仰ぐ
立上げフェーズでは、短期施策であるダイレクト・リクルーティングや紹介エージェント経由をメインに活用しており、拡大フェーズでは、中長期施策である採用ブランディングをメインで実施してきましたが、当初からブランディングを実施すべきだったこと、拡大フェーズでも短期施策に注力し続けるべきだったと内省しています。
以前は、採用担当の工数不足を理由に、施策を絞らざるを得ないと考えていましたが、採用活動は採用チームだけが実施するのではなく、全社的に実施するものだと再認識しました。今では、開発チーム全員がスカウトメール送付に協力してくれ、返信率も向上しています。
短期施策と中長期施策の両立を実現するためにも、他チームのメンバーも採用活動に協力してもらえるよう意識を醸成することが重要だと感じています。
当初からあらゆる採用手法を試行しPDCAを回すべきだった
当初からTwitterでの採用活動は知っていたのですが、Twitter経由でのブランディングや採用に懐疑的だったため利用していませんでした。もし、当初からTwitterを利用しておけば、ブランディングも加速していたかもしれないと内省しています。
おわりに
弊社では、採用を最重要業務として位置づけられているのですが、入社当初は正直ピンと来ていませんでした。
しかし、実際に採用責任者として採用させて頂く上で、その人がいなかったら会社の未来が変わっていたかもしれないと感じることが多々ありました。
裏を返せば、採用出来ていないということは、採用出来ていればプラスに変わったかもしれない会社の未来を摘んでいることになります。
また、採用した人が入社後に活躍する姿を見ることが、採用責任者としての一番のご褒美だと感じています。
これからも、採用活動を通じてリンクアンドモチベーションの未来を創っていきます。
SendGridのEventWebhookでBounceイベントがPostされたのにメールは送信できていた話
はじめに
これはモチベーションクラウドAdvent Calendar 18日目の記事です。
モチベーションクラウドではメール配信にSendGridを利用させていただいています。
この記事では先日、SendGridのEvent Webhookの挙動に関して、SendGrid日本代理店へ問い合わせして得られたちょっとした知見を書いていきます。
前提
SendGridにはEvent Webhook機能があり、メールを送信する際に発生するイベントを指定のURLにPOSTできます。
モチベーションクラウドではこのEvent Webhookを利用して、受信側メールサーバへ正常に配信できなかったメールの一覧をエラーメール一覧機能として提供しています。
具体的にはEmail Activityの deferred
drops
bounces
block
などが発生したメールをエラーメールと位置付けています。
起きたこと
とあるお客様からエラーメール一覧に表示されているメールが正常に受信できている旨の問い合わせがありました。
Event WebhookのPOST先サーバログを確認したところ、以下、2件のEventがPOSTされていました。
- 09:05:16に
deliverd
イベントがPOSTされる
{
"email": "sample-1@***.com",
"event": "delivered",
"ip": "******",
"response": "250 Message accepted for delivery"
}
- 09:09:52に
bounce
イベント(type:blocked
)がPOSTされる
{
"status": "5.0.0",
"reason": "552 We failed to deliver mail because the volume of recipient's mailbox exceeded the local limit. sample-2@***.com",
"event": "bounce",
"email": "sample-1@***.com",
"type": "blocked"
}
今回のEvent Webhookの挙動に対する疑問
このページのイベントツリーを 見る限り、
delivered
イベントとbounce
イベントはどちらかに切り分けられるように見えるが違うのか。
なぜ、deliverd
イベントの直後にbounce
イベントが発生しているのか。bounce
イベントのreason
を見る限り、ソフトバウンスである。
ソフトバウンスの際は、deferred
イベントが発生し、72時間自動再送をしてもだめだった場合にblock
イベントが発生するとあるが、なぜ、今回はdeferred
イベントが発生していないのか。
問い合わせた内容とその回答(一部抜粋)
1回目の問い合わせ
下記の事象が発生したため、SendGrid側のEventWebhookの仕様を確認させてください。
【発生事象】
1. 09:05:16に`deliverd`イベントがPOSTされる
{
"email": "sample-1@***.com",
"event": "delivered",
"ip": "******",
"response": "250 Message accepted for delivery"
}
2. 09:09:52に`bounce`イベントがPOSTされる
{
"status": "5.0.0",
"reason": "552 We failed to deliver mail because the volume of recipient's mailbox exceeded the local limit. sample-2@***.com",
"event": "bounce",
"email": "sample-1@***.com",
"type": "blocked"
}
3. 該当ユーザーへのメールは正常に送信される
【質問事項】
1.
受信者のメールボックスが一杯等のソフトバウンスはdeferredイベントが発生すると記載されておりますが、
短時間にbounceが発生する原因は何が考えられるでしょうか?
2.
deliverdが発生後、bounceが発生するのは想定される挙動でしょうか?
1回目の回答
各ご質問へ回答する前に、事象に関してご説明いたします。
ご連絡いただいた事象は、3で該当ユーザが正常に受信されていることから
宛先サーバが「sample-1@***.com」宛てのメールを正常に受信したのち
転送設定などによって他の宛先へ送信しようとして、そこでバウンスが発生したものかと存じます。
エラー内容は宛先サーバによって異なるため、推測となりますが
メッセージにある「sample-2@***.com」への転送でバウンスしたと考えられます。
Deferredイベントによる再送は宛先のメールアドレスに対するものであるため、
今回のような転送先ではイベントが発生いたしません。
バウンスのTypeは応答内容によってbounceかblockedか決まり、
今回はblockedイベントと識別されたものとなります。
以上より、ご連絡の以下状況となったかと存じます。
・宛先の「sample-1@***.com」にはメールが正常に届き、Deliveredイベントが発生した
・転送先の宛先でエラーとなったため、Blockedイベントが発生した
> 1.
> 受信者のメールボックスが一杯等のソフトバウンスはdeferredイベントが発生すると記載されておりますが、
> 短時間にbounceイベントが発生する原因は何が考えられるでしょうか?
前述のとおり、今回の場合は宛先の先でバウンスが発生したため、
deferredは発生せずblockedイベントが発生したものとなります
> 2.
> deliverdが発生後、bounceが発生するのは想定される挙動でしょうか?
今回のケースに限らず、宛先へリレーする中で問題が起きるケースはございます。
遅延バウンスと呼ばれるもので、詳細については以下をご確認いただけますでしょうか。
https://support.sendgrid.kke.co.jp/hc/ja/articles/206128762
2回目の問い合わせ
【質問事項】
1. 宛先メールアドレスで起きたバウンスなのか転送設定されたメールアドレス起きたバウンスなのかの切り分けなどはできないでしょうか?
2. 転送設定されたメールアドレスがソフトバウンスの場合はdeferredは発生しないとのことですが、一定時間(72時間)自動的に再送も行われない認識で良いですか?
2回目の回答
> 1. 宛先メールアドレスで起きたバウンスなのか転送設定されたメールアドレス起きたバウンスなのかの切り分けなどはできないでしょうか?
今回のようにバウンスのREASONによってはある程度の推測が可能なこともあるかと存じますが、
REASONは宛先サーバごとによって異なるため、切り分けはできません。
> 2. 転送設定されたメールアドレスがソフトバウンスの場合はdeferredは発生しないとのことですが、一定時間(72時間)自動的に再送も行われない認識で良いですか?
以前ご回答差し上げたとおり、転送先の送信は
宛先サーバが行う送信であり、SendGridは関与しないため、
応答内容をみたイベントは発生せず、再送も行いません。
まとめ
-
遅延バウンスや今回の転送先メールアドレスで配送不能となった場合は、
deliverd
イベントの後にbounce
イベントが発生する - 転送先の送信はSendGridが関与せず、宛先サーバーが行う送信のため、
deferred
イベントは発生しない。しかし配送不能レポート(Non-Delivery Report/NDR)はSendGridに返るため、bounce
イベントが発生する。 - 転送先メールアドレスでのバウンスか宛先メールアドレスでのバウンスかは正確な切り分けはできない。
スクラム未経験者がチームメンバーと共に暗黒の時代を抜けて、エンゲージメント・レーティングをAAまであげた1年を振り返る
はじめに
この記事は、「モチベーションクラウド Advent Calendar 2018」19日目の記事となります。
モチベーションクラウドにフリーのエンジニアとして参画しておりました@ultrasevenstarです。
現在は英語学習のため一時モチベーションクラウドを離れフィリピンのセブ島で暮らしてます。
ここ何ヶ月かは完全に英語漬けで、全くコードを書いてません。
概要
モチベーションクラウドの開発現場ではスプリント毎にチーム状態を把握するため、モチベーションクラウドで
サーベイを取り、チーム状態を可視化しております。
その指標の一つとしてエンゲージメントスコアとエンゲージメントレーティングがあります。
そのエンゲージメントレーティングが上から2番目のAAになったまでの経緯をつらつらと書いていこうと思います。
当時の背景
参画した当時はモチベーションクラウドは2チームに別れ、それぞれのチームがスクラムマスター、POをもち個別に開発をしておりました。
僕はその一方のチームで開発を担当しておりました。
参画当時は特に大きな問題はなく、平穏に過ごしてましたが
開発の難易度が上がりだしした頃から、徐々にチームの雰囲気が怪しくなってきました。
ベロシティーが上がらないどころか、目標が達成できず落としてしまうことがよくあり
KPTでも「疲れた」や「終わりが見えない」などの根が深そうなプロブレムが頻出し、
振返りも十分に出来ていない状態で、トライを発表しても劇ツッコミを喰らうことも多々。
そんな状態なので、他のチームやお客さんから心配やお叱りの言葉を受けることが多々あり、チーム状態は燦々たる有様でした。
エンゲージメントレーティングも確かCぐらいだったかと。
いわゆる暗黒時代でした。
まずはスクラムを理解するとこから
モチベーションクラウドに参画した当初はフリーランスになって間もないってこともあり、イキってたんやろうなと思います。
スクラムよくわからんしとりあえず俺のやることだけやっときゃええやろう。
とりあえず俺の担当分だけ終わらせとけば問題なし!
スクラムマスター?何それ?PLみたいなん?
みたいな感じでした。
でもそれじゃ二進も三進もいかん状況になってきました。
チーム状況は改善される予兆もなく、相変わらずベロシティーも悪いまま。新たな問題も噴出しつつある。
そんな時にある人から
「君達はスクラムの基本が全然出来ていない。スクラムテストでも受けるぐらいのことはしてみなさい」
と手痛いお言葉を頂きました。
結局この言葉が引き金にはなり、自分の不甲斐なさやその他諸々に怒りも覚えたこともあって、
改めて自分自身振り返ってみると、結局僕がスクラムを全く理解出来ていない
理解出来てないのに改善なんてできるわけがない。って結論に達しました。
なのでまずは最低限のことだけでも理解せねばと
スクラムの入門書と名高い「SCRUM BOOT CAMP」を読むことにしました。
役割の線引き
SCRUM BOOT CAMPを読み終わって、チームの現状と照らし合わせた時に一番乖離があったのがスクラムマスターの立ち位置でした。
SCRUM BOOT CAMPを読む前はスクラムマスターってPL/PM的な立ち位置でチームのマネージメントや意思決定権をもつ人なんやろうなと思ってました。
なので、とりあえず色んなことをスクラムマスターに押し付けてました。
極端ですが本来チームメンバーが対応する必要があるものも何から何までスクラムマスターに押し付け、メンバーは開発だけやってた感じがありました。
スクラムは本来チームメンバーが主体となって色々決めていかないといけないはずが、スクラムマスター頼りのところがかなり多かった。
それって正常なスクラムの形ではないですよね。歪にひん曲がっている。
そんな状態やと改善することも難しい。
まずは正常な形にもっていかないと個々の役割があやふやになり、改善の指標も立てにくくなる。
なので正常なスクラムの形に持っていくために、スクラムマスターにはなるべく本来の仕事だけしてもらうよう、本来の業務以外のタスクは剥がし、役割の分担を行いました。
結論をだす
スプリントの振返りが正常に機能していない原因としては、結論が正しく導き出されていないと感じました。
振返りとしてKPTをやっており、プロブレムからトライを出す段で皆で議論をします。
議論をするんですが、皆思うところはあり、様々な意見が出ます。
色々な意見が出ることは良いんですが、収束させずに話が広がるだけ広がり収集が付かず、時間を迎えてしまうことが多々ありました。
一つのプロブレムに対して深掘りするのは良いんですが、深掘りしてるつもりが別の問題に発展しそれがまた別の問題につながっていく。そして気づけば愚痴になってる。みたいなことも良くありました。
だからファシリテートが必要で、ファシリテーターが結論に持って行ってあげないといけない
じゃないと議論が広がるだけ広がって、時間だけ無駄に過ぎていき、結局何も決まらない
もしくは無理やり強引に結論を作り上げる。みたいなことになります
プロブレムって結構出ますが、グルーピングすると3〜5になり
その3〜5のうちほんまにトライをうち解決が必要なものって1か2に絞れるはずです。
大体一週間もしくは二週間で速攻で解決しないといけない問題が山ほどあるとしたら
そのチームって崩壊してますよね。KPTなんかやってる場合やあらへん。
ですので、挙がったプロブレムを整理し、絞り、それを深掘りし結論に持っていくのが
ファシリテータの役目と考えています。
議論が逸れそうになると本来の道筋に戻してあげるのもファシリテータの役目です。
それさえ気を付けていればそれほど時間もかからず結論も出る議論になるはず
当事者意識
チームでは毎朝モーニングスクラムと言う名の進捗共有会をやっています。
やっているんですが、メンバーの数名かは遅れてきたり途中でトイレに行ったり雑談を始めたりすることが時々ありました。
そうなると余計に時間がかかったり、情報格差が生まれたりします。
最初は腹たったりしてたんですが、改めて客観的にモーニングスクラムを見てると無駄な時間が多いことに気づきました。
ファシリテータが一つの問題に対して深掘りしたいがあまり、一対一の会話になり他のメンバーが置いてけぼりをくらう。
進捗共有にしても、メンバーがファシリテータに対しての報告をする姿勢になっているので他のメンバーは聞いていない。
こんな状態なので、個々の問題は個々の問題のまま。
スクラムが提唱する個の課題はチームの課題という状態には程遠い。
なので、モーニングスクラムの時間を短く15分をめどに終わるようにしし、なるべく皆が集中しやすい環境を作りました。
深掘りについては後ほど時間を取って必要なメンバーだけで話をするようにし、課題の共有を図りました。
そうすることにより、ノイズが排除され課題の共有に集中することが出来、メンバーが課題を認識できるようになり
個の課題がチームの課題、チームの課題が個の課題とすることが出来ました。
他責は避ける
KPTをやってると他責を起因としたプロブレムが出ることがあります。
他責って「言うは易く行うは難し」の典型だと思っています。
同一チーム内では凄く共感を持ちやすいし、凄く言うてる感がある。
なので他責のプロブレムが上がると皆食いついて、それの深掘りをするんですよね。
でも他責のプロブレムって一朝一夕で解決するような問題ではないことが多いです。
だって関わる人数が必然と多くなるし、他人を変える必要があったりする。
どこの現場でも良く出る問題が
- ○○部署が全然情報をくれません。情報共有が出来ていません!
- なんだと?!それは由々しき問題だ!解決しないと
- 私、情報共有するスプレッドシートのフォーマットを作りました
- おっ!そいつは素晴らしい!早速〇〇部署に渡して書いてもらうようにしよう
1週間後
- 〇〇部署が全く書いてくれません
- なんだとっ?!だからあいつらはダメなんだ!
言葉は違えど上記のようなことは良くありました。
そもそも言うてやるぐらいなら言われる前からやってるはず。
この手の問題って言う方言われる方どちらもストレスが貯まり、時間も消費無駄に消費する。
そんな無駄な労力使うぐらいなら自分から聞きに行って、自分で共有してる方が建設的で速効で終わります。
他責が起因となるプロブレムを解決するなとは言いませんが、手間も暇もかかるものだと認識し、他人を変えるのではなく自分で何か出来る手立てはないかを考える方が得策です。
特にKPTでのトライのような速効性に重きを置くものとしては他責の解決は向いてないと考えます。
ホワイトボードで見える化
僕もそうなんですが、進捗の遅れがあったりするとどうしても目を背けたくなるのが人情ではないでしょうか。
でも臭いものには蓋をしてても、進捗の遅れは取り戻せたりしません。
なのでホワイトボードに付箋でタスクの管理を始めました。
ホワイトボードなのでそんな難しいことはせず、残課題をホワイトボードに貼り、終われば剥がす。
ただそれだけ。
ただそれだけなんですが、ホワイトボードに付箋なので否が応でも目につきます。
強制的な見える化です。目は背けれません。
これがもしスプレッドシートや他のウェブサービスなら一度そのURLを叩く行為が必要になり
必要が無ければ目にする必要もない。
でもそれじゃ結局スクラムマスターや一部のメンバーしか状況が把握出来ない。
それじゃ当事者意識も生まれにくい。
なので、否が応でも目に入るようにホワイトボードでタスクを管理し皆の目に入るようにし、タスクの共有化を図りました。
情報は足で稼ぐ
メンバーの状況を把握するため、一日最低一回チームメンバーと話をすると勝手に自分の中で決めました。
なので時間が出来ると皆の席まで行きほぼ雑談ですが話をしてました。
日々のモーニングスクラムやslackでも良いんですが、情報は一方通行で生の声は得づらい。
やはり個別に無駄話でも良いので話をしていると色々と情報が得られるし、仲も深まります。
また他のチームメンバーとも話も出来るので他のチームの状況も知れてそれを自チームにも落とし込めます。
お陰で皆には良く「暇なんですか」と言われましたが。
まとめ
そんな感じで細かい改善を繰り返してると、
自チームのモチベーションクラウドのエンゲージメント・レーティングがAAになっており、その状態をキープすることが出来ました。
改めて振り返ってみるとSCRUM BOOT CAMPに載っているような改めて書くようなことでもない基本的なことだらけでした。
でもその基本的なことが一番有用なのだなと感じた一年でした。
QAとしてアジャイル開発で実践すべきこと
はじめに
モチベーションクラウドの開発にQAマネージャー、テストエンジニアとしてジョインしている
masao-shimadaです。
日々プロジェクトの中でQAとしてどのようなことが出来るかを模索する日々です。。。。
概要
アジャイル開発において、QAの目線で失敗した経験談から実践すべきことを投稿させていただきます。
「アジャイルのQAってどうすりゃいいの?何すんの?」って方に読んでほしいです。
アジャイル開発プロセスについて
QAは、開発のスクラムチームと同一チームにする
開発とQAを別のスクラムチームとして実際に運用しましたが、遥かに同一チームでいることの方がメリットが大きいと感じました。
別チームによる運用した時の、体制と役割、起きた事象について説明します。
- 実際に運用したチーム体制と役割
チーム | メンバー | 役割 |
---|---|---|
機能開発チームA,B | PO、SM、開発メンバ | ストーリーから機能開発、単体テスト実施する |
QAチーム | SM、QAメンバ | 開発された機能について結合テストし、 リリース可能か判定する |
-
運用して実際に起きた事象
- 機能開発チームは単体テストまで実施することに注力するため、QAチームまで意識がいかない
- 機能開発チームのPO,SMは自チームの開発に注力しているため、QAメンバーが仕様把握したくても時間が無い(チームメンバのフォローでいっぱいいっぱい)
- QAメンバーは、仕様把握不十分のためテスト設計、実行の考慮漏れがスプリント後半で発生し、手戻りが発生
- テストを設計、実行中に仕様変更が発生しても、別チームのためQAメンバが気づけない
プロジェクトがアジャイル開発に慣れてきた頃、KPIであるベロシティの数値向上に全力だった各チームにとって、別チームの状況を把握する余裕がなかったというのが現状でした。
同一チームに変更後、SM、PO、開発、QAが連携し、ベロシティの安定、向上することを目標にすることで
良い品質のプロダクトが生まれていくことを実感しました。
ストーリー内のタスクを開発とQAで分担する
開発するうえで、ストーリーを開発、QAで分割するか、同一にするかの運用も苦慮しました。
例えばストーリーを分割した場合、以下のようにしていました。
- 開発,QA分割ストーリーの完了条件(例)
ストーリー名 | タスク担当 | 完了条件<機能> | 完了条件<タスク> |
---|---|---|---|
サーベイ担当者は、 エンゲージメントサーベイの スコアを表示したい |
開発メンバ | 結果画面にスコアに表示されること スコアの計算結果が正しいこと |
仕様把握 実装 単体テスト |
サーベイ担当者は、 エンゲージメントサーベイの スコアを表示したい《結合テスト》 |
QAメンバ | 結果画面にスコアに表示されること スコアの計算結果が正しいこと |
テスト設計 テスト実行 |
ここでの失敗は、完了条件としての分割ではなく、担当者単位のタスクでの分割を安易に選択してしまったことでした。
-
運用して実際に起きた事象
- QAストーリーは、開発に依存するため、完了できないことがあった。(テスト設計タスクのみ完了している状態)
- スプリント計画後にバックログ内のQAストーリー入替が都度発生し、管理が煩雑になってしまった。(テスト実施出来ない為、新しいストーリー入れる、など)
- リリース時、ストーリー単位で管理しているため、開発、QAどちらもストーリーが完了しているか把握することに工数がかかってしまった。
正しい選択としては、①ストーリーの完了条件を分割、②スプリント期間内で終わらせる見積で計画を立て、③開発、QAでタスクを分担することでした。
結果、開発、QAが一つのストーリーに向け完了する動きが生まれ、計画やリリース管理もしやすい運用もしやすい状況が生まれました。
テストについて
テスト対象範囲は、ユーザーストーリーの完了条件から洗い出す
テストをする際、必要となる情報は以下のようなものがあります。
- 対象画面(対象機能)
- 実行手順
- テスト観点(画面表示、ページ遷移など)
- テスト結果
- テストパターン(バリデーションチェック、エラーパターンなど)
これらは、ストーリーの完了条件から推測し、洗い出すことが重要です。
逆を言えば、完了条件に無いものについては、対象範囲から外すことを意識しました。
仕様認識合わせは、PO、開発を巻き込んで対面で行う
テスト設計する上で仕様認識合わせは必須です。
テスト対象範囲と判断したものが正しいか判断が必要な為です。
私個人としては、ストーリーに関連するPO、開発、QAが対面で行うこと重要だと考えています。
実施タイミング
スプリント開始直後
事前準備
PO … 事前にストーリーの内容を把握
開発 … ストーリーから実装範囲、不明点を整理
QA … ストーリーからテスト対象範囲、不明点を整理
認識合わせでやること
①ストーリーの対象範囲を詳細まで洗い出し、実装漏れ、テスト対象漏れが無いように確認する
②各担当者の不明点を確認し、不確実性のあるものが無いようにする
スプリント開始直後でストーリーに対して共通認識を持つことで後半に発生する抜けや手戻りを最小限に抑えることが出来ます。
また、2者間(POと開発、開発とテストなど)のみで行った際の他者への共有漏れなども防ぐことが出来ます。
1,2週間のスプリントで、1時間、1日の遅延は致命的のため、無駄な工数は可能な限り発生しないように動いていました。
この考えは、コンサルとしてジョインしているRector社の広木さんに教わり、下記書籍を読んで実践したことにあります。
エンジニアリング組織論への招待
~不確実性に向き合う思考と組織のリファクタリング
テストケースは、境界値分析、バリデーションチェックに関連する事項から優先的に洗い出す
テスト工数は、テストケースの増加に基本比例します。
そのため、ケース増加に関連する境界値分析やバリデーションチェックに関わるテストケースを優先し洗い出すことで、テスト工数の把握が容易になります。
テスト工数から実施完了日、人数をスプリント完了までに一人で完了が厳しい場合は、即座にSMと連携し、テスト実施人員の調整をすることも可能です。
常に意識すること
プロジェクトメンバー全員へのRespectを忘れない
QAは仕様把握やテストデータ作成、環境構築など開発やPOに依存する部分が多く、他のプロジェクトメンバーの作業を止めてでも協力を仰ぐ場面が多々あります。自分の役割の作業が多くある中、手を止めて話を聞いてくれます。
その姿勢や考え方に、感謝、尊敬する気持ちや行動を常に意識することが重要だと思います。
モチベーションクラウドの開発チームには、Respectの文化があり、メンバー個人がメンバーを尊重する文化が根付いています。チーム全体で意識しあえる、この関係性が最高のプロダクトを生む環境であると確信してます。
最後に
このAdvent Calenderの投稿機会を頂いて、自分を見つめなおしアウトプットする良い時間になりました。
今後も、チームメンバーで良いプロダクトになるようQAとして携わっていきます。