俺のためのタスク管理サービスを作った

みなさんさようなら,またひとつサービス作ったよ.

俺のためのタスク管理サービス.

Fascia

めずらしく痛くないというのが一番の特徴じゃないですかね.

動機

ToDoリストをどこかで管理したい

これに尽きる.

だいたい普段生活していると,メモできなかったり,PCが近くにない状況に限って「あ,あれやらなきゃじゃん」というのを思い出したり,思いついたりする. これをいかにして管理していこうかと悩んで,いろいろ使ってはみたものの,どれも定着しなかった.

iPhoneにアプリを入れて,出先でも登録できるようにしても,なぜか定着してない.

なんで定着しないんだろう?

github管理下のToDoとそれ以外のものが並列に出てくるから

俺の脳内では,「あ,Whalebirdにあの機能積んでおこう」という思いつきも,「あ,仕事であれやっといたほうがいい」という思いつきも,「キッチンペーパー買ってこないと」という思いつきも,すべてが並列に出てくる. とくに何かについて考えていない時に,こういうものが順不同に適当に出てくる.

これらのToDoリストを,例えばtrelloで管理するとどうなるか.

  • Whalebirdのタスク

    trelloにメモ -> githubにissue立てる -> PRが出てくる -> PRとissueがcloseされる -> trelloのタスクを自分でdoneにする

  • 仕事のタスク

    trelloにメモ -> redmineにissue立てる -> PRが出てくる -> PRとissueがcloseされる -> trelloのタスクを自分でdoneにする

  • キッチンペーパー

    trelloにメモ -> 買い物に行く -> trelloのタスクを自分でdoneにする

フロー違いすぎ,手順多すぎ,めんどくさすぎ,無理.

一度,全部github管理にしようかとも思ったんですよ. でもキッチンペーパーの買い物とかが入る,家用のリポジトリ作るの?

コード0行だよ?

というわけで自作することにした.

使い方

いや,特に説明するようなことはないです.

githubログインにしておくと,project作るときに,githubリポジトリ関連にするかどうかの選択ができるんですが,そのくらいじゃないですかね.

あとはtrelloと同じ感じで使えます.

リポジトリ関連にしておくと,ちょっと複雑なことができます.

  • github側に勝手に同期される
  • webhookが設定されるので,github側の変更も同期される

コンセプト

俺のためにって書いたとおり,自分で使いたいものを作ったので,俺が便利だと思うように動くようにしてあります.

そして,個人のToDoを管理したかったので,チームで使うような用途は想定していません. なので,プロジェクトを他人と共有したり,チームを作成したり,何かをパブリックにしたりする機能はないです.

そのため,githubのissue管理として運営される類似のサービス(waffle.ioやzenhub)のように,チーム開発やスクラム向けの機能は提供しません.

ただ,githubのissue管理機能については,もっと便利にしていきたいので,

  • issueやPull Requestのコメント関連機能

あたりは近いうちに実装するかもしれません.

開発

リポジトリはこちら.

github.com

サーバサイド

サーバは全部goで書きました. あまり機能的には多くないアプリですが,それなりに行数増えてしまった.

golangを書いていると,設計をいろいろ悩んだりするので,とてもよかった. 特にpackageという分離は,classみたいに手軽に使えないよねっていうのが,今の感想.

それと,エラーハンドリングについてはかなり悩まされました. できれば,本当に不要なパターンを除いて,ほとんどのメソッドerror 型の戻り値をつけておいた方が良い.

フロントエンド

ほとんどReact Reduxです. ログインとか,そのへんだけはjs使わなかったので,サーバサイドでhtmlレンダリングしている.

SPAと決めて,覚悟しちゃうとこれはかなり使いやすいなー. Reactのサンプルでは,よくToDoリストのサンプルが載せられていて,「これ簡単にいけるかなー」って思ったけど,全然その後が大変だった. サンプルはみんな載せてくれるけど,本当に大変なのはあの先である.

あと,こういう形でjsレンダリングしたかったのは,のちのちアプリを作ることを考えて,サーバを全部APIにしておきたかったというのもある.

俺のために

あとはiOSアプリにすれば,俺の欲望は満たされることになる. しかし先は長そうだなこれ…….

Shoryuken,それと例外検知gemを作った話

みなさんさようなら. Railsの非同期処理って何使ってますか?

Sidekiq?Resque?,え?DelayedJob?

個人的にはSidekiqが結構好きで,よく使っていたのですが,最近Redisのメモリが足らなくなってきて…… たまにエンキューに失敗します.

もちろんインスタンスタイプを上げればいいんですが,それなりの値段するじゃないですか.

Shoryukenを使ってみる

Shoryukenというのがあってですね,だいたいSidekiqと同じ要領で使える非同期処理用のgemなんですが,バックエンドがAWS SQSなんですよ. だから,これRedisの運用とか真面目に考えなくてもいけるんじゃね?とりあえず全部メッセージに突っ込んでも,重くならないし!

そんなこんなでShoryukenを使ってみました. というか,既に運用しているasumibotに入れて,運用してます.

github.com

Shoryukenの細かい動作についてはこのへんにまとめました.

qiita.com

使用感:

  • SQSを使うのでメモリのことを気にしなくても良く,エンキュー数が増えてきても安心
  • 料金的に$0.5/1M という非常に安い価格設定なので,Redisを増設するより安い
  • (そもそもキューってRedisに入れるほどの情報でもないし……
  • マルチスレッドで,だいたいSidekiqと同じ要領で使える
  • ローカルでのSQS再現にはFakeSQSを使えばいいので,開発環境でのAWSSQS課金についての心配は不要
  • 今のところ例外が起こってもどこにも通知されない
  • リトライの機構についてはSQSを頼ることになるので,若干不便
  • 速度的には,SQSのポーリグ間隔(によってSQSの料金が変わる)に依存するので,Sidekiqほど即時に処理してくれるわけではない

例外が通知されないのは困る

上記の中でも特に例外のところが困っていて,RailsのアプリだとExceptionNotification等で例外を検知していると思うのですが,そこで検知してくれない.

Sidekiqなんかだと,ExceptionNotification側に既に用意されていて,割と簡単に用意できたりします.

github.com

これがないのが困っていて,みんなどうしているのかなー?

ExceptionNotification::Shoryuken

と思っていても始まらないので,gemを作りました.

github.com

だいたい,ExceptionNotificationのSidekiqをパクった感じです.

普通にインストールしたら,config/initializers/exception_notification.rb あたりに,

require 'exception_notification/shoryuken'

ExceptionNotification.configure do |config|
  # setup some notification
end

とか書いておけば,勝手に検知して,その他の例外と同じように設定してある通知先(メールとかSlackとか)に通知してくれます.

Shoryukenってまだ直しどころがある

実はShoryuken本体の方にもPullRequestを投げたりしているのですが,Shoryukenってまだ未熟な感じがします. 使ってると足らないところを結構発見するので,直し甲斐があるというか,PullRequestが積まれるというか.

ソース的にはSidekiqを参考にしている色が強く出ていて,PullRequestやIssueの議論でもSidekiqの話がたまに例に出されます.

そういう話をして,Sidekiqのソースを見に行くと「完成度たけーなおい」と思うしかない.

Shoryukenはそれに比べると,機能的にも少ないし,完成度もまだまだな感じがします. それは盛り上がり方とか,作者のやる気にも寄るんでしょうけど…….

使う人が増えてくれれば嬉しいんですが,みんな本当に非同期処理って何使ってるんだろう?

SQSの機能が,Sidekiq+Redisほどブラックボックスとして扱えないので,SQSの知識が必要になってしまうという障壁はあるんですが. それでも,それなりの大きさのRedis運用をまともに考えるよりは,かなり手抜きができて使いやすいと思うんですけどね.

shoryukenの諸動作

Railsの非同期処理として、今までDelayedJobで運用していたものを、shoryukenに載せ替えたので、使い勝手をまとめます。 最初はsidekiqにしようと思っていたので、ちょいちょいsidekiqとの比較が出てきます。

メッセージのポーリング

メッセージのポーリング間隔はshoryuken.ymlで設定します。ちなみに、ここのポーリング間隔と、エンキューされるキューの合計数でSQSの料金が変わってくるみたい。ただ、SQSの料金プランによると100万件ごとに課金されるので、そこまでシビアに考える必要はなさそうだけれど。

:delay: 25  # 単位は秒

以下のようにしておくと、リクエストを投げ続けてくれる。

:delay: 0

ちなみに、このdelay値は、エンキューされてから、処理が開始されるまでの時間にモロに響いてくるので、もし「即時送らねば!」という処理を積みたい場合は、金額を試算した上で短くすることをおすすめします。

リトライ

リトライ機構

sidekiqではsidekiq.ymlに

:retry: 25

と書いておけば、リトライ回数を設定できたが、shoryukenではそういうわけにはいかない。

重要となるのは、SQSのVisibility Timeoutです。 SQSに蓄積されたメッセージは、蓄積直後、どのshoryukenワーカーからも見える状態になっています。 そして、1つのワーカーがメッセージを受信した時点で、そのメッセージは見えなくなります(処理中という扱い)。 しかし、処理が一定時間終わらなければ、SQS側では「なにか失敗したかな?」ということで、メッセージを再びどのワーカーからも見える状態に戻します。この処理中扱いにしてくれる時間が、Visibility Timeoutです。

処理の成功・失敗にかかわらず、Visibility Timeoutが来てしまえば、再びメッセージが受信可能な状態になり、いずれかのワーカーに処理される可能性があります。

成功時

処理の成功・失敗にかかわらず、Visibility Timeoutが来ればメッセージが受信可能状態になる、と言いましたが、shoryukenにはauto_delete というオプションがあり、これが成功時にメッセージを削除してくれます。 なので、auto_deleteを有効にしておけば、成功したメッセージが再び処理されることはなくなります。

class SampleWorker
  include Shoryuken:Worker
  shoryuken_options queue: "default", auto_delete: true

  def perform(sqs_msg, body)
    # something
  end
end

auto_delete: false はどんなときに使うのか、イマイチ想像できないですね……。

リトライ回数指定

というわけで、失敗時には、メッセージが削除されず、Visibility Timeoutが来た後再びどこかのワーカーが受信し、処理します。 では、このリトライ回数に上限は指定できるのでしょうか?

これは、shoryuken側の設定ではなく、SQS側で設定します。 SQSにはDead Letter Queueというものが設定できます。これは、「n回受信(ワーカーがメッセージを取得)したらDead Letter Queueとして指定された別のキューにメッセージを移す」というものです。 Dead Letter Queueに入ってしまえば、キューが変わるので、shoryukenのワーカーからはまったく見えなくなります。

なので、リトライ回数の指定は、Dead Letter Queueの受信回数条件で設定することが可能です。

:queues:
  - asumibot-patient-queue

みたいなキュー設定をしたら、AWS SQSにDead Letter Queueを作ります。 新しく、asumibot-dead-letter-queue というキューを作り、asumibot-patient-queue の設定画面で、

f:id:h3poteto:20200327214530p:plain

こんな設定をしてやれば、20回目の受信で、Dead Letter Queueに移してくれて、それ以降リトライされなくなります。

リトライしたくない場合は、ここのMaximum Receives を1にしておけば良さそう。

リトライ間隔

リトライ間隔は以下のように、ワーカーごとに設定できます。

class CopyCheckWorker
  include Shoryuken::Worker
  shoryuken_options queue: "default", auto_delete: true, retry_intervals: [60, 120, 180] # 単位は秒

この状態だと、1回目のリトライが60秒後、2回目が120秒後となります。 ただし、retry_intervals で設定した値を超えた回数リトライが発生した場合は、通常通りVisibility Timeoutに依存したリトライとなります。 また、ここで指定するのはあくまで間隔だけで、リトライ上限回数はDead Letter Queueでしか設定できませんでした。

※ただし、retry_intervals はこの時間後にメッセージが見えるようになるだけで、厳密にはポーリング間隔との兼ね合いがあり、ポーリング間隔が長い場合は即時受信してくれるわけではない。

Visibility Timeoutの変更

Visibility Timeoutがかなり重要なことはご理解いただけたと思います。 このVisibility Timeout、デフォルトでは30秒となっています。

もし、ジョブの実行時間が30秒をオーバーしそうな場合には、Visibility Timeoutを延ばす必要がでてきます。

class SampleWorker
  include Shoryuken::Worker
  shoryuken_options queue: "default", auto_delete: true, retry_intervals: [60, 120, 180]

  def perfom(sqs_msg, body)
    sqs_msg.visibility_timeout = 60
    # something
  end
end

スレッド

shoryukenのスレッドは、shoryuken.ymlのconcurrency で設定します。

:concurrency: 25

キューごとのスレッド数を指定したい場合は、

:concurrency: 25
:queues:
  - [ default, 4 ]
  - [ asumiss, 10 ]

と書いておき、合計値がconcurrency を超えないようにしておきます。 この辺はsidekiqと同じですね。

番外編:fake_sqs

ローカルで以上のようなshoryukenの動作を実現したい場合、AWS SQSではなくfake_sqs を使うという手があります(安いとはいえAWS SQSではお金かかりますからね)。 http://qiita.com/iemon7stars/items/d4efdd8872d287906d29

fake_sqsの場合、ほとんどAWS SQSと同等の動作をしてくれますが、2点重要なことがあります。

  1. メモリ上にキューが作られるので、fake_sqsを起動するたびにキューを作ってやる必要がある
  2. retry_intervalsの設定が効かない

1は、まぁ仕組み上仕方ないでしょう。 http://qiita.com/iemon7stars/items/d4efdd8872d287906d29 こちらの記事通り、どこかでキューを作ってやるしかないです。

2については、shoryukenのmiddlewareで失敗時に、sqs_message.attributes['ApproximateReceiveCount'] からattempts (失敗回数)を導き出して失敗後の処理をしていますが、fake_sqsを見る所attributesが空になっているのが原因です。 おそらく、fake_sqsではメッセージのattributesに対応していないので、ここはどうにもならないですね……。

ReactComponentにおけるkeyは結構大事

ReactComponentでhtmlをレンダリングしていて,コンソールにwarningが出ていることがある. 無視しても大丈夫なものもあるのかもしれないが,エラーが出ることもあるので,気になって調べてみた.

こんな感じのエラー.

Warning: setState(...): Cannot update during an existing state transition (such as within `render`). Render methods should be a pure function of props and state.
Warning: Each child in an array or iterator should have a unique "key" prop. Check the render method of `ListView`. See https://fb.me/react-warning-keys for more information.

mapしているところで発生している

いろいろと自分のコードを消したりしてみると,どうやらrender内でmapしている部分で発生している.具体的にはこんなコード.

{list.map(function(list, i) {
  return (
    <div className="list">
      <div ...></div>
    </div>
  )
}, this)}

この部分をごっそり消すと,見事warningは両方共でなくなる.

mapに問題があるのか? とか,forEachで表示したほうがいいのか? とか,やってみたものの特に変化はない.

keyを指定したらwarningが消えた

unique "key"と書いてあるのが気になって,keyについて調べてみた. そこで,試しにkeyを入れてみたら,見事にwarningが消えた.

{list.map(function(list, i) {
  return (
    <div key={i} className="list">
      <div ...></div>
    </div>
  )
}, this)}

keyの役割

具体的なkeyの役割についてはこちらの記事が詳しい.

qiita.com

つまりは,mapするようなレンダリングをやると,繰り返し内で描画されるオブジェクトの区別がつかなくなり,差分レンダリングができなくなってしまう. そのために,繰り返しで同じオブジェクトをいっぱい生成するにしても,React側で区別がつくkeyを設定して欲しいと.

意外にもかなり重要な役割を担っていたくせに,今まで全然知らなかった.

key大事.

でもなぜ,setStateのwarningが消えたのかは謎

keyを設定しろって言ってるwarningが消えるのは理解できるが,setStaterender内にあると言っていたwarningが,この修正だけでなぜ消えるのかは,不明です. 誰か知ってたら教えてください.

クラウドワークスを支えてないページネーション技術

この記事は Crowdworks Advent Calendar 2015 20日目の記事になります.

みなさんRailsのページネーションは何を使っていますか? kaminari ? will_paginate ? どちらも使い勝手の良いページネーションライブラリですが,僕が入った時,クラウドワークスでは will_paginate が使われていました.

ページネーション用のクエリが重い

ページネーションはページ下部にページング用のビューを出力していますが,その内部ではレコード件数のカウントをします.

f:id:h3poteto:20200327214209p:plain

このような見せ方をしているため,どうしても最終ページのページ番号を知る必要があります. ページに表示するレコードを取得するクエリには,OFFSETLIMIT をつけていますが,最終ページを知るためにはどうしても OFFSETLIMIT のついていないクエリで COUNT する必要があります.

というわけで kaminariwill_paginate もカウントしています.

絞り込みが複雑なページが重くなる

クラウドワークス内にもページネーションをしている場所がいくつもあります. そういうページで一部,やたら重いページが見受けられました.

これ,絞込のSQLクエリがやたら複雑で,それについてのカウントクエリを投げていて,現状一番のボトルネックになっているのはそこなんです. もちろんそのカウントクエリとは,ページネーションの中で呼び出している COUNT でした.

もちろん表示する分のレコードを取得するのも重いわけですが,LIMITOFFSETがついているのでCOUNT ほどではない.

キャッシュする?

一番最初に思いつくのはキャッシュです.ページ数をキャッシュしてしまえば,速そうな気がします. ただ,単純なページ数のキャッシュには問題もあります.

レコード数が増えた時にどうするか

レコード数が増えた時に,ページ数をキャッシュしてしまっていると,最後のページへのリンクが生成されずに,全件にアクセスできなくなってしまいます. これはページに載せているコンテンツにもよるでしょうけど,あまり望ましくありません.

であるならば……

載せるコンテンツが増えた時にキャッシュを再生成する

これは確かに手段としてはありです. ただし,結構な複雑度になります.なにせこれだけ重いページなのだから,同期処理でキャッシュの再生成はできない.レコードが増えた時に,非同期でキャッシュクリア&再生成をしなければならない. さらに言うなら,こういうカウントクエリ原因で重いページはいくつもあります. なので,対象になるレコードについてはすべて create 時にキャッシュの処理をしてやらなきゃいけない.

いや,めんどくさくないですか? それ,表示側だけでなんとかならんの?

そもそもそのページ数,正確に出す必要あるの?

たいてい,そういう重いページネーションをしているページは,そもそもレコード件数が多く,強敵の中には4000ページを超える奴らが潜んでいます. これって,1ページ目を見た時に最終ページが何ページなのか正確に知りたいですか? それを知りたい人ってどのくらいいるんですか?

googleの検索結果などをみていただけるとわかると思うんですが,最終ページのページ数を正確に出す必要ってないんですよ,たぶん.概算の数を出しておいて,いざ最終ページアクセスがあったときに,「あ,ごめん,これしかなかったわ」と出せばいいんじゃないでしょうか.

f:id:h3poteto:20200327214223p:plain

これは感覚なんですが,ページ数の表記において有効数字は3桁程度あればいいと思うんですよね. 4561ページだろうが,4562ページだろうが,そんな大差ないと思うんですよ.

じゃぁ概算のページ数だけ出しておこう

方針

  1. 本当に一番最初に1ページ目を表示したときに,ページ数をCOUNT クエリ発行してカウントする
  2. その値を(クエリをKeyとして)Redisに保存しておく
  3. ラストページの値は,最初にカウントした値を元に,多めに見積もって適当にceil して出しておく
  4. アクセスされたとき,もしレコード件数が0件だったら,もう一度COUNT クエリを発行して正確なラストページの値を算出し,ラストのレコードを表示する
  5. そのとき,Redisに保存されている値と違う値になっていたら,Redisの値を更新する

Redisじゃなくてmemcacheとかに保存してもいいんですが…….もちろんキャッシュの値が消えていた時はカウントしなおします.だけど,別に消える必要もなくて,expireを設定するようなものでもない.必要なときには値を更新していくので,勝手に消えてもらうとむしろパフォーマンスが下がるだけなんですね.なのでmemcacheはあんまり適切ではないです.

この方式でカウントクエリにより重くなるのは,本当に初回の一人目と,ラストページを見に来た人だけです. Redisの値が吹っ飛ぶことを想定しないのであれば,稼働後はラストページを見に来るときだけ COUNT クエリを発行します.

Gemにした

というわけでこんな機能をもつページネーションライブラリを作りました.

github.com

使い方

redisの設定

config/initializers/redis.rb みたいなのを用意します.

GuessPaging::RedisClient.setup do |config|
  config.redis_host = '127.0.0.1'
  config.redis_port = 6379
end

controllers

class RecordsController < ApplicationController
  def search
    @guess = GuessPaging::Paginate.new(
      query: Record.where(category_id: params[:category_id].to_i),
      per_page: 10,
      essential: 3)
    @guess.guess(
      page_params: params[:page]
    )
  end
end

kaminari のように ActiveRecord の拡張にはしていません.ページネーション用のオブジェクトなのに,ActiveRecord にいるのって,なんか変な感じしません?

per_page はわかると思うんですが,essential という設定は,ページ数を丸めるときの有効数字の桁数になります.この設定だど3桁なんで,ラストが4561ページだとすると,4570ページと表示されますね.

views

<% @guess.records.each do |record| %>
  <%= record.hoge %>
<% end %>
<%= paging(@guess) %>

ActiveRecord の拡張にしていないので,GuessPaging のオブジェクトからレコードを取り出してやります.records で取り出されるのは,ActiveRecord のオブジェクトなので,この先はいつもどおりに使えます.

<%= @guess.count %>

これでレコードが何件あるのかを表示できますが,ここで表示するのはあくまで概算の数です.ページ数と同じで,カウントクエリを発行せずに概算を出すので,ラストページに行くまでは正確な数値はわかりません.

assets

app/assets/stylesheets/application.css に以下の行を追加します.

//= require 'guess_paging'

速い

ちょっと手元で複雑なJOIN をするレコードを構成するのがめんどくさかったので,適当に10万件くらいのレコードでページネーションしました.なので差が微妙ですが…….

  • kaminrai f:id:h3poteto:20200327214250p:plain f:id:h3poteto:20200327214258p:plain

  • guess_paging f:id:h3poteto:20200327214311p:plain f:id:h3poteto:20200327214320p:plain

というわけでクラウドワークスではこんなページネーションの技術が使われて・・・・・・いません! タイトルにもある通り,全然支えてないです.今も will_paginate してます.毎日NewRelicに「重いよこれ」って怒られる日々です.

なにこれ.