🔬redux-saga
redux-sagaの動きを調べた。
redux-sagaは redux-sagaで非同期処理と戦うで説明されているように、非同期処理などを直列プログラムのような形式(直接形式; direct style) で書くためのライブラリである。 そのためにタスクを導入し、その切り替えを制御している。
複数のタスクを協調制御するという点で、コルーチンや軽量スレッド、fiberなどに類似していると感じた。
🔎対象
redux-saga v0.15.3を対象とする。ただし一部コードは説明のためにエラー処理や終了処理を省略する。
また counter-vanilla を元にした以下のプログラムの動きを追う。
// counter.js ////////////////////////////////////////////////////////////////////////// // Reducerの定義 // INCREMENTが来たら +1 する reducer function counter(state, action) { if (typeof state === 'undefined') { return 0 } switch (action.type) { case 'INCREMENT': return state + 1 default: return state } } ////////////////////////////////////////////////////////////////////////// // Sagaの定義 const effects = ReduxSaga.effects const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) // INCREMENT_ASYNC の1秒後にINCREMENTを発生させる。 function* counterSaga() { while(1) { yield effects.take('INCREMENT_ASYNC') yield effects.call(delay, 1000) yield effects.put({type: 'INCREMENT'}) } } ////////////////////////////////////////////////////////////////////////// // redux-sagaの初期化 const createSagaMiddleware = ReduxSaga.default const sagaMiddleware = createSagaMiddleware() var store = Redux.createStore( counter, Redux.applyMiddleware(sagaMiddleware)) ////////////////////////////////////////////////////////////////////////// // タスクの初期化・実行 sagaMiddleware.run(counterSaga) ////////////////////////////////////////////////////////////////////////// // イベントハンドラ document .getElementById('incrementAsync') .addEventListener('click', function () { store.dispatch({ type: 'INCREMENT_ASYNC' }) })
これは Increment async
ボタンを押すと、1秒後にカウンタがインクリメントされるプログラムである。
🚀redux-sagaの初期化
reducer等を定義したのち、以下のようにredux-sagaの初期化を行なう。
// counter.js const sagaMiddleware = createSagaMiddleware() Redux.createStore( ..., Redux.applyMiddleware(sagaMiddleware))
createSagaMiddleware
createSagaMiddleware
は以下のように定義されている。
// src/internal/middleware.js export default function sagaMiddlewareFactory({ context = {}, ...options } = {}) { // 渡されたオプションが妥当であることを確認する if(logger && !is.func(logger)) { throw new Error('`options.logger` passed to the Saga middleware is not a function!') } // sagaMiddlewareを定義する function sagaMiddleware({ getState, dispatch }) { /* snip */ } // 定義した関数を返す return sagaMiddleware }
引数が妥当であることを確認をした上で、内部で定義した sagaMiddleware
を返す。
sagaMiddleware.runの初期化
sagaMiddleware
は applyMiddleware
の内部で呼び出される。 この関数は以下のような定義されている。
// src/internal/middleware.js function sagaMiddleware({ getState, dispatch }) { // sagaMiddleware.run を初期化する sagaMiddleware.run = runSaga.bind(null, { context, dispatch, /* snip */ }) return next => action => { /* snip */ } }
sagaMiddleware.run
に runSaga
を代入し、sagaの実行をできるようにする。 この際、 Function.prototype.bind
を使って dispatch
などのReduxとやりとりするために必要な関数が runSaga
に渡されるようにしている。
Reduxのミドルウェアとして動く next => action => ....
については、イベントハンドラの動きを追う際に見る。
🏃タスクの作成・実行
// counter.js
sagaMiddleware.run(counterSaga)
sagaMiddleware.run
によって counterSaga
の実行が開始される。
タスクの生成
sagaMiddleware.run
には runSaga
が代入されている。 これは以下のような定義となっている。
// src/internal/runSaga.js export function runSaga( storeInterface, saga, ...args ) { // ジェネレータを呼び出す let iterator = saga(...args) // タスクを作成する const task = proc( iterator, /* snip */ ) return task }
saga(...args)
で counterSaga
を呼び出している。 これはジェネレータなので、ここではイテレータが返るだけで関数本体は実行されない。
ここで作ったイテレータを proc
に渡し、タスクを生成する。 proc
は以下のようなコードになっている。
// src/internal/proc.js export default function proc(iterator, /* snip */) { // タスクを作る const task = newTask(parentEffectId, name, iterator, cont) // タスクを実行するnext を呼ぶ next() // タスクを返す return task // タスクを実行する関数 function next(arg, isErr) { /* snip*/ } }
newTask
によって、タスクを管理するオブジェクトを生成している。 その後、タスクを実行する next
を呼び出したのち、タスクを返している。
タスクの実行
next
は以下のようなコードで定義される。
// src/internal/proc.js function next(arg, isErr) { // イテレータを進め、次のyieldまでを実行する let result = iterator.next(arg) // 返ってきた値に応じて処理をする runEffect(result.value, parentEffectId, '', next) }
iterator.next(arg)
でイテレータを進め、その返り値をrunEffect
に渡している。
runEffect
での処理が完了したのち、タスクの実行を再開できるようにするため、 runEffect
には自分自身である next
を渡している。
📤アクションを待つ
counterSaga
は以下のように定義されているので、イテレータが進めたれた際に yield effects.take('INCREMENT_ASYNC')
まで実行される。
// counter.js function* counterSaga() { while(1) { yield effects.take('INCREMENT_ASYNC') // <- ここまで実行される yield effects.call(delay, 1000) yield effects.put({type: 'INCREMENT'}) } }
effects.take('INCREMENT_ASYNC')
の返り値は以下のようなオブジェクトになっており、これがそのままiterator.next()
の返り値になる。
{ "@@redux-saga/IO": true, "TAKE": { "pattern": "INCREMENT_ASYNC" } }
このオブジェクトが runEffect
に渡されると以下のような分岐を経て、 runTakeEffect
に渡される。
// src/internal/io.js // どの種別のエフェクトなのかを判定するための関数群を定義する const TAKE = 'TAKE' const createAsEffectType = type => effect => effect && effect[IO] && effect[type] export const asEffect = { take : createAsEffectType(TAKE) } // src/internal/proc.js function runEffect(effect, parentEffectId, label = '', cb) { let data // effectの種類に応じて、専用の関数を呼ぶ return ( // Non declarative effect is.promise(effect) ? resolvePromise(effect, cb) : is.helper(effect) ? runForkEffect(wrapHelper(effect), effectId, cb) : is.iterator(effect) ? resolveIterator(effect, effectId, name, cb) // declarative effects : is.array(effect) ? runParallelEffect(effect, effectId, cb) : (data = asEffect.take(effect)) ? runTakeEffect(data, cb) : (data = asEffect.put(effect)) ? runPutEffect(data, cb) : (data = asEffect.all(effect)) ? runAllEffect(data, effectId, cb) : (data = asEffect.race(effect)) ? runRaceEffect(data, effectId, cb) : (data = asEffect.call(effect)) ? runCallEffect(data, effectId, cb) : (data = asEffect.cps(effect)) ? runCPSEffect(data, cb) : (data = asEffect.fork(effect)) ? runForkEffect(data, effectId, cb) : (data = asEffect.join(effect)) ? runJoinEffect(data, cb) : (data = asEffect.cancel(effect)) ? runCancelEffect(data, cb) : (data = asEffect.select(effect)) ? runSelectEffect(data, cb) : (data = asEffect.actionChannel(effect)) ? runChannelEffect(data, cb) : (data = asEffect.flush(effect)) ? runFlushEffect(data, cb) : (data = asEffect.cancelled(effect)) ? runCancelledEffect(data, cb) : (data = asEffect.getContext(effect)) ? runGetContextEffect(data, cb) : (data = asEffect.setContext(effect)) ? runSetContextEffect(data, cb) : /* anything else returned as is */ cb(effect) ) }
チャンネルへの登録
runTakeEffect
は以下のような定義となっている。
// src/internal/proc.js function runTakeEffect({channel, pattern, maybe}, cb) { channel = channel || stdChannel channel.take(cb, matcher(pattern)) } // src/internal/channel.js function take(cb, matcher) { cb[MATCH] = matcher takers.push(cb) }
runTakeEffect
では、タスク間の通信に使われるチャンネルに対して take
を呼び、 チャンネルの takers
配列に cb
を追加している。 この cb
は next
であるため、あとでこれを呼び出せばcounterSaga
の実行が再開できる。
ここまでで sagaMiddleware.run
の実行は完了し、redux-sagaの初期化が完了する。
👀イベントハンドラ
イベントハンドラを見ていく。
// counter.js document .getElementById('incrementAsync') .addEventListener('click', function () { store.dispatch({ type: 'INCREMENT_ASYNC' }) })
Increment async
ボタンがクリックされると、Reduxのディスパッチャに INCREMENT_ASYNC
アクションが渡される。
アクションの配信
先程は省略した sagaMiddleware
は以下のように定義されている。
// src/internal/middleware.js function sagaMiddleware({ getState, dispatch }) { const sagaEmitter = emitter() // .... return next => action => { // 次のミドルウェアにアクションを転送する const result = next(action) // アクションを配信する sagaEmitter.emit(action) return result } }
次のミドルウェアにそのままアクションを転送することで、reducerを起動する。 その後、アクションを sagaEmitter.emit
に渡す。
emitter
emitter
は以下のように定義されており、 emit
されると対応する subscribers
が起動する。
// src/internal/channel.js export function emitter() { const subscribers = [] function subscribe(sub) { subscribers.push(sub) return () => remove(subscribers, sub) } function emit(item) { const arr = subscribers.slice() for (var i = 0, len = arr.length; i < len; i++) { arr[i](item) } } return { subscribe, emit } }
チャンネル
チャンネルの take
で利用していた stdChannel
は以下のように定義されている。
// src/internal/proc.js export default function proc( iterator, subscribe = () => noop, /* snip */ ) { // procの引数として渡されたsubscribeを用いてチャンネルを作成する const stdChannel = _stdChannel(subscribe) .... } // src/internal/channel.js export function _stdChannel(subscribe) { // eventChannel を用いてチャンネルを作成する const chan = eventChannel(cb => /* snip */) return { ...chan, take(cb, matcher) { /* snip */ } } } export function eventChannel(subscribe, buffer = buffers.none(), matcher) { const chan = channel(buffer) // 何かがemitされた場合は、それをチャンネルにputする subscribe(input => { chan.put(input) }) return { take: chan.take, flush: chan.flush } }
eventChannel
で入力をそのままチャンネルに put
する関数を登録している。 そのため、ディスパッチャに渡されたアクションが、チャンネルへと put
される。
チャンネルへのput
チャンネルの put
は以下のように定義されている。
// src/internal/channel.js function put(input) { // takers配列が空の場合はバッファに追加する if (!takers.length) { return buffer.put(input) } // takers配列に関数が登録されている場合は、それに入力を渡す for (var i = 0; i < takers.length; i++) { const cb = takers[i] if(!cb[MATCH] || cb[MATCH](input)) { takers.splice(i, 1) return cb(input) } } }
takers
配列に格納されている関数に入力を渡している。 今回は next
が登録されているため、counterSaga
の実行が再開される。
つまり、redux-sagaのミドルウェアからの put
(emit
)と、counterSaga
の take
がチャンネルを挟んで対になって動作する。
🤝 プロミスの実行
counterSaga
は 以下のように定義されているので、実行が再開されると effects.call(delay, 1000)
まで実行される。
// counter.js function* counterSaga() { while(1) { yield effects.take('INCREMENT_ASYNC') // <- さっきはここまで実行した yield effects.call(delay, 1000) // <- ここまで実行される yield effects.put({type: 'INCREMENT'}) } }
take
の場合と同様に、この返り値は runEffect
内の分岐を経て、 runCallEffect
に渡される。
// src/internal/proc.js (再掲) function next(arg, isErr) { // イテレータを進め、次のyieldまでを実行する result = iterator.next(arg) // 返ってきた値に応じて処理をする runEffect(result.value, parentEffectId, '', next) } // src/internal/proc.js (再掲) function runEffect(effect, parentEffectId, label = '', cb) { let data // effectの種類に応じて、専用の関数を呼ぶ return ( // Non declarative effect is.promise(effect) ? resolvePromise(effect, cb) : is.helper(effect) ? runForkEffect(wrapHelper(effect), effectId, cb) : is.iterator(effect) ? resolveIterator(effect, effectId, name, cb) // declarative effects : is.array(effect) ? runParallelEffect(effect, effectId, cb) : (data = asEffect.take(effect)) ? runTakeEffect(data, cb) : (data = asEffect.put(effect)) ? runPutEffect(data, cb) : (data = asEffect.all(effect)) ? runAllEffect(data, effectId, cb) : (data = asEffect.race(effect)) ? runRaceEffect(data, effectId, cb) : (data = asEffect.call(effect)) ? runCallEffect(data, effectId, cb) : (data = asEffect.cps(effect)) ? runCPSEffect(data, cb) : (data = asEffect.fork(effect)) ? runForkEffect(data, effectId, cb) : (data = asEffect.join(effect)) ? runJoinEffect(data, cb) : (data = asEffect.cancel(effect)) ? runCancelEffect(data, cb) : (data = asEffect.select(effect)) ? runSelectEffect(data, cb) : (data = asEffect.actionChannel(effect)) ? runChannelEffect(data, cb) : (data = asEffect.flush(effect)) ? runFlushEffect(data, cb) : (data = asEffect.cancelled(effect)) ? runCancelledEffect(data, cb) : (data = asEffect.getContext(effect)) ? runGetContextEffect(data, cb) : (data = asEffect.setContext(effect)) ? runSetContextEffect(data, cb) : /* anything else returned as is */ cb(effect) ) }
Promise.prototype.thenへの登録
runCallEffect
は以下のように定義されている。
// src/internal/proc.js function runCallEffect({context, fn, args}, effectId, cb) { // callの引数に渡された関数を起動する。 let result = fn.apply(context, args) // 返り値としてプロミスが返ってくるので、resolvePromiseに渡す return resolvePromise(result, cb) } function resolvePromise(promise, cb) { // Promise.prototype.then にコールバック関数を登録する promise.then( cb, error => cb(error, true) ) }
call
エフェクトに渡された delay
が runCallEffect
内で呼び出される。 その返り値となるプロミスは、resolvePromise
に渡される。 resolvePromise
内では、Promise.prototype.then
に cb
を登録する。
この cb
は counterSaga
の next
であるので、プロミスの実行が完了したのち counterSaga
の実行が再開される。
🔈アクションのディスパッチ
プロミスの実行が完了したのち、next
によって effects.put({type: 'INCREMENT'})
まで実行が進む。
// counter.js function* counterSaga() { while(1) { yield effects.take('INCREMENT_ASYNC') yield effects.call(delay, 1000) // <- さっきはここまで実行した yield effects.put({type: 'INCREMENT'}) // <- ここまで実行される } }
take
エフェクトや put
エフェクトの場合と同様に、 runEffect
を通じて runPutEffect
が呼び出される。
Reduxへのディスパッチ
runPutEffect
は以下のようになっている。
// src/internal/proc.js function runPutEffect({action, resolve}, cb) { // Reduxのdispatchに引数を渡す let result = dispatch(action); // コールバック関数にその結果を渡す return cb(result) }
引数に渡された {type: 'INCREMENT'}
をそのままReduxのディスパッチャに渡す。 これにより counter
reducer が動き、カウンタの値がインクリメントされる。
その後、cb
に代入された next
を呼び、counterSaga
の実行を継続する。 counterSaga
は以下のように定義され、effects.take('INCREMENT_ASYNC')
までループし、これまでと同様の処理が続いていく。
// counter.js function* counterSaga() { while(1) { yield effects.take('INCREMENT_ASYNC') // <- ここまで戻る yield effects.call(delay, 1000) yield effects.put({type: 'INCREMENT'}) // <- さっきはここまで実行した } }
✅まとめ
簡単なシーケンス図にまとめると以下のようになる。 直列的に実行されるように書かれている counterSaga
の処理が何度も中断され、条件が満たされるたびに実行が再開されている。
このようにredux-sagaではタスクの切り替えを制御することで、特定のアクションが来るのを待ったり、プロミスの完了を待つなどの処理を、直接形式で書けるようにしている。
🌐Webサーバ
JavaScriptの動作確認をするときなどに、簡単なWebサーバを使いたいことがある。
これまではWebrickで書いたWebサーバを使っていたが、Rustで書き直して単一バイナリで動作するようにした。
📦インストール
https://github.com/mzp/tiny-web-serverからダウンロードできる。また以下のコマンドでもインストールできる。
curl -L -o tiny-web-server https://github.com/mzp/tiny-web-server/releases/download/1.0.0/tiny-web-server-x86_64-apple-darwin chmod a+x tiny-web-server ./tiny-web-server
📤簡単なWebサーバ
同一ネットワークの別マシンにファイルを受け渡すときに、わざわざDropboxやNASを経由すると面倒なのである。 また、 JavaScriptの動作を確認したいときも、ローカルのファイルではうまく動作しないことがある。
こういった場合、ローカルのファイルを公開するだけのWebサーバを使うと便利である。
これまではWebrickで書いた以下ようなサーバを使っていた。
#!/usr/bin/env ruby require 'webrick' require 'erb' include WEBrick s = HTTPServer.new(:Port=>8055,:DocumentRoot=> Dir::pwd, :DirectoryIndex => ["index.html","index.htm","index.cgi","index.rhtml"]) trap("INT"){ s.shutdown } s.start
また、Pythonなら python -m SimpleHTTPServer
で同様のことができる。
🦀Rust
Rustの勉強がしたかったので、Rustで書いてみた。 単一バイナリで動いたほうがなにかと便利かもしれないと思っている。
macOS用のバイナリは🔀Rust クロスコンパイルで述べた方法で作っている。
❤️所感
- 所有権が難しい。特にクロージャと組み合わせると、より難しい。 わりと慣れてきた感じはするが最初はまったく分かなかった。
- OptionとかResultとかあるし関数型言語っぽく書けるのかな、と思ったけど、そこまででもなかった。
エラーメッセージにいわるままmutをつけるマンになっている。
— mzp (@mzp) 2017年4月19日
今日のRust勉強会は所有権について学びました pic.twitter.com/PEywwNFvv3
— mzp (@mzp) 2017年4月26日
🔀Rust クロスコンパイル
Docker for Mac内のRustでmacOS向けのプログラムを書けるようにした。
Rustのクロスコンパイルの設定ができたので、Docker for MacでmacOS用のプログラムが書けるようになってきた
— mzp (@mzp) 2017年5月2日
⭐️要約
以下のDockerfireでクロスコンパイルのできるイメージを作成できる。
FROM multiarch/crossbuild # reinstall osx cross compiler to link with SDK 10.7 ENV DARWIN_OSX_VERSION_MIN="10.7" RUN rm -rf /usr/osxcross \ && mkdir -p "/tmp/osxcross" \ && cd "/tmp/osxcross" \ && curl -sLo osxcross.tar.gz "https://codeload.github.com/${OSXCROSS_REPO}/tar.gz/${OSXCROSS_REVISION}" \ && tar --strip=1 -xzf osxcross.tar.gz \ && rm -f osxcross.tar.gz \ && curl -sLo tarballs/MacOSX${DARWIN_SDK_VERSION}.sdk.tar.xz \ "${DARWIN_SDK_URL}" \ && yes "" | SDK_VERSION="${DARWIN_SDK_VERSION}" OSX_VERSION_MIN="${DARWIN_OSX_VERSION_MIN}" ./build.sh \ && mv target /usr/osxcross \ && mv tools /usr/osxcross/ \ && ln -sf ../tools/osxcross-macports /usr/osxcross/bin/omp \ && ln -sf ../tools/osxcross-macports /usr/osxcross/bin/osxcross-macports \ && ln -sf ../tools/osxcross-macports /usr/osxcross/bin/osxcross-mp \ && rm -rf /tmp/osxcross \ && rm -rf "/usr/osxcross/SDK/MacOSX${DARWIN_SDK_VERSION}.sdk/usr/share/man" # install toolchain RUN curl https://sh.rustup.rs -sSf | sh -s -- --default-toolchain stable -y ENV PATH=/root/.cargo/bin:$PATH # install target RUN rustup target add x86_64-apple-darwin WORKDIR /work
カレントディレクトリに hello.rs
を配置した上で、以下のコマンドを実行すると、macOS向けのバイナリが生成される。
docker build -t rust-cross-compile . docker run -v $PWD:/work -it -e CROSS_TRIPLE=x86_64-apple-darwin rust-cross-compile rustc --target=x86_64-apple-darwin hello.rs
📝調べたこと
上記のDockerfileを書くまでに調べたことをメモしておく。
標準ライブラリ
macOS向けにビルドされた標準ライブラリをインストールする。 これは rustup
から行なえる。
$ rustup target add x86_64-apple-darwin info: downloading component 'rust-std' for 'x86_64-apple-darwin' info: installing component 'rust-std' for 'x86_64-apple-darwin'
リンカ
リンカがMach-O形式を理解しないので、ビルドできずエラーになる。
$ rustc --target=x86_64-apple-darwin hello.rs error: linking with `cc` failed: exit code: 1 | (snip) = note: hello.0.o: file not recognized: File format not recognized collect2: error: ld returned 1 exit status error: aborting due to previous error
multiarch/crossbuildが各ターゲットのツールチェインを含んでいるのでこれを使うようにする。 このイメージには crossbuild
というツールチェインを切り替えるシェルスクリプトが含まれているので、これを使うようにする。
CROSS_TRIPLE=x86_64-apple-darwin crossbuild rustc --target=x86_64-apple-darwin hello.rs
macOS向けの設定
標準ライブラリは OSX 10.7 向けにビルドされているが、multiarch/crossbuild に含まれるリンカは 10.6向けのものなので、うまくビルドできない。
CROSS_TRIPLE=x86_64-apple-darwin crossbuild rustc --target=x86_64-apple-darwin hello.rs error: linking with `cc` failed: exit code: 1 | (snip) ld: warning: object file (...) was built for newer OSX version (10.7) than being linked (10.6) ld: targeted OS version does not support use of thread local variables in __ZN4core6result13unwrap_failed17h374a94f6b7a942a9E for architecture x86_64 clang: error: linker command failed with exit code 1 (use -v to see invocation) error: aborting due to previous error
そのため、macOS向けのツールチェインを再ビルドする。
export DARWIN_OSX_VERSION_MIN="10.7" rm -rf /usr/osxcross \ && mkdir -p "/tmp/osxcross" \ && cd "/tmp/osxcross" \ && curl -sLo osxcross.tar.gz "https://codeload.github.com/${OSXCROSS_REPO}/tar.gz/${OSXCROSS_REVISION}" \ && tar --strip=1 -xzf osxcross.tar.gz \ && rm -f osxcross.tar.gz \ && curl -sLo tarballs/MacOSX${DARWIN_SDK_VERSION}.sdk.tar.xz \ "${DARWIN_SDK_URL}" \ && yes "" | SDK_VERSION="${DARWIN_SDK_VERSION}" OSX_VERSION_MIN="${DARWIN_OSX_VERSION_MIN}" ./build.sh \ && mv target /usr/osxcross \ && mv tools /usr/osxcross/ \ && ln -sf ../tools/osxcross-macports /usr/osxcross/bin/omp \ && ln -sf ../tools/osxcross-macports /usr/osxcross/bin/osxcross-macports \ && ln -sf ../tools/osxcross-macports /usr/osxcross/bin/osxcross-mp \ && rm -rf /tmp/osxcross \ && rm -rf "/usr/osxcross/SDK/MacOSX${DARWIN_SDK_VERSION}.sdk/usr/share/man"
ビルド
これでビルドできるる。
$ CROSS_TRIPLE=x86_64-apple-darwin crossbuild rustc --target=x86_64-apple-darwin hello.rs $ file hello hello: Mach-O 64-bit x86_64 executable
複数列Slack
Slack のデスクトップアプリは、1つのチャンネルしか表示できないため一覧性が悪い。 そこで、複数のチャンネルを一度に表示できるアプリを作った。
📦ダウンロード
https://github.com/mzp/SlackStack
😫チャンネル切り替え作業の増加
Slack のデスクトップアプリは、チャンネルを1つしか表示できない。
そのため、参加するチャンネルに比例し、切り替え作業が増えていく。 さらに切り替え作業中に未読が増え、延々とチャンネルを切り替え続けることになる。
また同様の理由でメッセージを見落すことも増え、チャットのレスポンスも悪化していく。
📑Slack☆Stack
そこで一画面で複数チャンネルの内容を確認するためのアプリを作成した。 オフィスでは縦置きのモニタを使っているので、横方向にも縦方向にも重ねれるようになっている。
🔧開発の様子
ブラウザを並べる
Slackを開いたブラウザを複数並べて、複数のチャンネルを見れるようにした。 Magnet – Window manager for Macでウインドウを整列した上で、Slack: Hide sidebar when window is narrow | Userstyles.orgでチャンネルリストを非表示にした。
Slackで複数チャンネルの様子をうかがうのつらいんですが、みなさんどうしてるんですか。
— mzp (@mzp) 2017年4月21日
とりあえずブラウザの別ウインドウで開きまくって横に並べてる。
Slackアプリ
WebViewだけはりつけたら、あっというまにslackアプリになってしまった pic.twitter.com/aINOAxPgsh
— mzp (@mzp) 2017年4月29日
WebViewを貼りつけただけのアプリを作ったら、それなりに動くようになった。 Slackはすごい。
複数行化
複数チャンネルSlack pic.twitter.com/G4IJ9vV6UE
— mzp (@mzp) 2017年4月29日
そのまま横に並べて複数行化した。 また、読み込み時にカスタムCSSを読み込ませ、チャンネルリストを非表示にしている。
チャンネルの追加、削除に対応
メニューを実装し、チャンネルの追加・削除をできるようにした。
NSStackView
NSStackViewを使い、何個でも横に並べれるようにした。
何個でも横に並べれるようになった pic.twitter.com/bReSsfaEDF
— mzp (@mzp) 2017年4月29日
行の追加
複数行、複数列モード pic.twitter.com/ame7S4nJSG
— mzp (@mzp) 2017年4月30日
NSStackView自体をNSStackViewで重ねるようにし、縦方向にも重ねれるようにした。
fastlane
fastlane/gym at master · fastlane/fastlane · GitHubを使って、ビルド・署名するようにした。 LoveLiverのGymfileを参考にしている。
アイコン
StackのアイコンとYosemite風のアイコンが簡単に作れるツール作った - Qiitaを組合せて、アイコンを作った。
配布用zip
Github releases にアップロードするためにzipファイルを作成したら、なぜか署名が破損した。 Finderから圧縮するようにしたら破損しなくなった。
macOSのアプリをzipしてunzipしたら、署名部分が破損した..
— mzp (@mzp) 2017年4月30日
finderで圧縮すれば大丈夫
— mzp (@mzp) 2017年4月30日
🐬Docker for Mac without qcow2
Docker for MacのファイルIOが遅い。VirtualBox上で動いているDockerと比較しても遅い。
これにはいくつかの原因があるが、その1つとしてディスクイメージのフォーマットとしてqcow2を用いていることがあげられる。
qcow2は書き込み時に必要な容量を確保するcopy on write方式のディスクイメージだが、これを事前に必要な容量をすべて確保するrawファーマットに差し替えたところ、パフォーマンスが改善した。
使い方
既存のDockerイメージは消えます
https://github.com/mzp/docker.img
curl https://raw.githubusercontent.com/mzp/Docker.img/master/install.sh | bash
ベンチマーク
fioを用いてベンチマークを行なったところ、Docker Machine(VirtualBox)と同程度のパフォーマンスはでるようになった。*1
読み込み
Average(MiB/s) | Min(MiB/s) | Max(MiB/s) | |
---|---|---|---|
native | 361 | 361 | 361 |
docker machine | 105 | 105 | 105 |
qcow2 | 46 | 46 | 46 |
raw | 147 | 147 | 147 |
書き込み
Average(MiB/s) | Min(MiB/s) | Max(MiB/s) | |
---|---|---|---|
native | 487 | 487 | 487 |
docker machine | 51 | 51 | 51 |
qcow2 | 29 | 29 | 29 |
raw | 45 | 45 | 45 |
やっていること
Docker for Macはhyperkitを使い、macOS上でLinuxを動かし、その上でDockerを動かしている。
hyperkitはrawフォーマットのイメージに対応しているので、Docker for Macが起動する際にhyperkitに渡す引数を変更することで、ディスクイメージをqcow2からrawに差し替えている。
ただ起動時に渡す引数を設定で変更することができないので、hyperkitのバイナリをシェルスクリプトに差し替えて、無理矢理変更している。
*1:実行したコマンド等は https://github.com/mzp/docker.img に書いた
ぺろぺろ - Github pull request bot framework -
名古屋Ruby会議03で発表した。
発表資料
関連記事
原稿
導入
自己紹介
こんにちは、mzpです。
大須はたまにくるので、この大須演芸場も気になってはいたんですが、なかなか入る機会がありませんでした。 まさかこっち側に立つのが最初だとは思っていませんでした。
よろしくお願いします。
会社紹介
仕事では、Misocaという会社でMisocaというWebサービスを作っています。この名古屋Ruby会議のスポンサーでもあるそうです。すごいですね。
Github
会社としてのMisocaはおいといて、サービスとしてのMisocaはGithubのプライベートレポジトリ上で開発を進めています。そのためGithubが開発の中心になります。
プルリクエスト
さらに言えばプルリクエストが中心になります。そのためプルリクエストにまつわるさまざまなルールや運用が生まれます。例えば「コンフリクトに気付いたら声をかけてあげようね」ですとか「githubでコメントしたらSlackでも教えてあげよう」などなどなどです。 最初はこれでもよいのですが、だんだんと面倒になっていきますよね。
bot
そこでbotを作り、こういった運用を自動化しました。
このbotは機能を固定したものでなく、gemによって拡張できるbot frameworkとして作りました。 今日はそのbot frameworkを紹介したいと思います。
使い方
まずは簡単に使い方を話します。
Deploy to heroku
レポジトリに deploy to herokuボタンがあるので、押せばherokuにデプロイできます。
Webhookの設定
あとはGithubでWebhookの設定をすれば動きます。
カスタマイズ
デプロイされたgitレポジトリをcloneしてきてGemfileを更新すれば挙動を拡張できます。 例えば、コンフリクトしているプルリクエストにラベルを自動でつけるようにしたい場合は、Gemfileに gem ‘prpr-conflict_label’ と追記してpushすればOKです。
既存プラグインの紹介
次は各gemでできることを紹介していきます。
prpr-checklist
各プルリクエストに自動でチェックリストを投稿するgemです。
機能を更新したけどヘルプを更新するのを忘れた、とか、バリデーションルールを追加したら既存データと矛盾するようになってしまった、みたいな単純な失敗をしてしまうことありますよね。 そういうときは古来よりチェックリストを使うとよいと言われているので、それです。
チェックリストの内容
チェックリストの内容は、レポジトリ上にあるCHECKLIST.mdという名前のファイルを使うようにしているので、変更も容易ですし、バージョン管理もできます。
余談: これを作ったときにはなかったんですが、今のGithubにはプルリクエスト テンプレートがあるので、そっちつかってもいいかもしれませんね。
prpr-mention_comment
コメント中に「@xxx 」といった文字列が含まれていた場合は、そのままSlackに転送するgemです。
Githubの通知をSlackに流している方も多いと思うんですが、GithubコメントでメンションしてもSlackではメンションにはならないんですよね。 これだとなかなか気づけませんし、気付いてもらえません。
Slackの通知は気付いてくれる人が多いので、これで解決です。
アカウント名の対応
工夫ポイントとしては、Slackのユーザ名とGithubのユーザ名が違う人もいるので、ユーザ名の変換もやっていることです。 これはレポジトリのルートに以下のようなファイルを置くことでカスタマイズできるようになっています。
prpr-gemfile
gemGemfile.lockのdiffを確認して、メジャーバージョンアップとマイナーバージョンアップをしている箇所にコメントをいれるgemです。
定期的にbundle updateをしてプルリクエストを送るbotを飼っているんですが、毎回Gemfile.lockの差分を見ながら「ふむふむこれはbugfixだけだし、いれていいでしょ」「う、こっちはメジャーバージョンがあがってる。ちゃんと調べなきゃ」というのをやるのは面倒なので自動化しています。
prpr-conflict_label
何本もプルリクエストが並行して動いてる状況だと、ちょいちょいコンフリクトが発生します。ただ、Githubのプルリクエスト一覧ではどれがコンフリクトしているかがわからず、個別のプルリクエスト画面をひらいて初めてコンフリクトしていることがわかります。
マージイベントが発生するたびに全プルリクエストを確認し、コンフリクトラベルのつけはずしをするようにしましたgemです。
これで一覧をみるだけでどれがコンフリクトしているかが分かるようになります。
まとめ
以上が、標準的なprprのgemです。 たぶんだいたいのシチュエーションで便利だと思います。 ボクも自分のプライベートプロジェクトで使っていたりします。
Misoca開発フローとの統合
これらとは別にMisocaでの開発フローにあわせて作ったgemもあります。 次はこういったgemについて紹介していきたいと思います。
開発フロー
まずは、我々の開発スタイルについて簡単に説明します。
- 各開発者がプルリクエストを作る。このときはまだWIP(work in progress; 作業中)というラベルをつけておき、まだレビューしなくてもいいよ、ということを明示します。
- プルリクエストが完成したら、レビューを依頼する。 指摘がついたら対応する。
- 最低1人、できれば2人がOKを出したあとでマージする。
- いくつかマージされたプルリクエストがたまってきたらデプロイする。
その他の特徴
補足情報としては、
- レビュー担当者などは特に固定せず、互いにプルリクエストレビューを行なう
- 基本的にはSlackを使ってやりとりする
といった特徴もあります。
prpr-review_label
レビュー依頼部分を支援するgemです。 このgemはREVIEWというラベルがついた場合は、Slackに「レビュー待ちにしました」という通知を流します。
この通知にはラベルをつけた人の名前とアイコンを使うようにしているので、誰が依頼をしたかがすぐ分かるようになっています。
prpr-lgtm
次は「マージする」という部分を支援するgemです。最初は雰囲気でやっていたのですが、
そこで、 * OKの数をラベルとして表現する * 二人がOKをした場合は、プルリクエストにassignされている人にメンションを飛ばす というbotを作りました。
一覧をみるだけで何人がOKをだしているかがわかる、二人がOKを出いた瞬間に気づけるようになり、テンポよくマージしていけるようになりました。
自己
余談ですが、このgemを作る過程でうっかり暴走させてしまい、同僚に大量のメンションを飛してしまい、たいへん申し分けない感じになりました。ごめん。 これは自分のコメントに反応してさらにコメントをつけてしまい、それに反応して…という図です。
prpr-merged
「マージする」という部分を支援するgemはもう一つあります。
「マージする」という行為は影響が大きいので、できるだけみんなに把握しておいてほしいです。 そのため、マージが発生するとslackに通知されるgemを作りました。
もちろんslackのgithub連携機能でも同じ情報は流れてきますが、よりマージを目立たせるという効果があります。
デプロイ
最後はデプロイの支援です。
Misocaのデプロイはチャット経由でできるようになっています。
- チャットで「@bot デプロイしたい」と発言する
- botがデプロイ用の内容をまとめたプルリクエストを作る。 これはマージ先がmasterではなくリリース用ブランチになっています。
- デプロイ用のプルリクエストをマージすると、デプロイプロセスがはじまる
- デプロイされる
という流れです。
このうち、3の「デプロイ用のプルリクエストをマージすると、デプロイプロセスがはじまる」の部分はprprがやっています。
prpr-code_deploy
Misocaのデプロイ作業はAWSのCodedeployを使っています。 codedeployにコミットIDを送ると、デプロイしてくれます。
そこで、デプロイ用のブランチにマージされた場合は、AWS codedeployにそのコミットIDを送り、デプロイプロセスがはじまるようにしています。
まとめ
以上がMisoca開発におけるprprの使い方です。
あとはこのprprを作ったときの話をしていきたいと思います。
先行事例
作るときに参考ににしたものや、あとから見つけたやつなどがあるのでそれを紹介します。
クックパッド
チェックリストの投稿などはクックパッド開発ブログで紹介されています。 ただこれは自分たちで作っていこうな、という趣旨の記事なので、汎用のbotがあるわけではありません。
trailing space bot
Github上で動作するbotというのもいくつかあります。 たとえば、末尾のスペース(trainling space)を検知して修正するプルリクエストを作成するbotもいます。
これはそいつが送ってくるプルリクエストです。
しかしこれは挙動が固定されているため自分たちにあわせて機能を追加・削除していくのは困難です。
拡張可能なbot
プラグインで挙動を拡張できるbotは多数存在しており、開発関係だとhubotやrubotyなどが有名な気がします。
ただこれはチャット経由でなにかする、というものがほとんどで、プルリクエストに反応して何かをするものは見付けれませんでした。
設計
目標
自由に拡張できるgithubのbotフレームワークはないことがわかったので、自分で作ることにしました。 作成時に標榜した目標は以下の通りです。
gemにより挙動を拡張できる。 コア部分と、自分たちの開発を便利にするための機能を切り離す。
botの設定変更は誰でもできるようにする。 これは先ほど紹介したチェックリスト投稿が分かりやすいと思うんですが、チェックリストの内容変更は管理者だけができる、ということになると、チェックリストへの追加作業のハードルがあがってしまいます。そこで、レポジトリ上のファイルが設定ファイルになるようにし、レポジトリにコッミトできる人間なら誰でも設定変更できるようにしました。 設定変更もgitで履歴管理されるようになるのもメリットです。
herokuで動く。 自分でちゃんとデプロイするの大変ですしね。
すばやく作る。 とりあえず日々の運用を楽にするのが先決なので、わりとさっさと作りたかったです。
構成
構成としては以下のようになっています。
- どのイベントに反応するかを決めるHandler
- 挙動を決めるAction
- 環境変数やレポジトリ上のファイルから設定を読み込むsettings
- Slack等に通知するpublisher
といった部分で構成されています。 またイベントを受け取る部分は通常のwebhookの他にテスト用のCLIもあります。
各プラグインがやるのがhandler/actionあたりです。
例: Handler
マージ通知をするgemを例に、もうちょっとだけ具体的に説明します。
マージだけに反応するればいいので、handlerは以下のようになっておりプルリクエストのcloseに反応するようになっています。
例: Action(½)
Actionはこのようになっております。
- 本当にマージされたかどうかの判定
- publisher経由で通知する
例: Action(2/2)
送信するメッセージは、このように環境変数から読むようになっています。
やらなかったこと
prprは最初のバージョンを早めに出して、プルリクエストにまつわる運用を楽にしたかったので、割り切って作った部分もいくつかあります。
WebUIを作らない。 設定画面等をつけようかとも思ったんですが、認証とかを考えると面倒そうだったのと、Webhook受け取るだけでとりあえず動くだろ、ということでWebUIをつけるのはやめました。
Bitbucketのことは忘れる。 最初はGithubとbitbucketの両対応をしようかと思ったんですが、抽象レイヤーを作るのが面倒そうだったのと、bitbucketを使う予定がないので忘れることにしました。
命名
次は名前の話ですね。
コードを書きはじめる前に名前を決める派なので、けっこう初期からこの名前でした。 プルリクエスト=PRに反応するbotつくりたいけどなにがいいかなー?って相談しました。 よくみると夜中の12時くらいに決めてるので、まあそういうことだよなって気持ちになりました。
ヲタクっぽくて気にいっています。
よかったところ
あとは感想です。
- gem化したのでどこでからがプラグインかが明確になって、クラス設計等がしやくなった
- CLIでテストできるようにしたのでデバッグが楽
- 外部のAPIを叩く部分をすべてプラグインにしたので、本体のspecでモックを作るのが簡単だった。
苦労したところ
- githubの新機能(Approve/Project/…)などの機能がなかなかAPIにおりてこない。 ReviewのApproveのAPIがくるまで一ヶ月ほど待ちました。
- PreviewAPIはoctokitにはいってこないので、わりと無理矢理呼ぶことになる。 こんな感じです。
- gemに分割したのでgemspecを書いたり、レポジトリを大量に作るのが面倒だった。
まとめ
まとめます。