ハウテレビジョン開発者ブログ

『外資就活ドットコム』を日夜開発している技術陣がプログラミングネタ・業務改善ネタ・よしなしごとについて記していきます。

職場を選ぶ際に個人的に心掛けていること

f:id:PyHedgehog:20201215170133p:plain

どうも、2020年11月からハウテレビジョンにジョインしたエンジニアの@Syoituです。

この記事を書く時点で丁度ジョインしてから一ヶ月半経ちまして、今回の転職は結論から言うと成功でした。 その結果を踏まえて、職場を選ぶ際に心掛けた方が良いことの話をしようと思います。

ここで言う職場はあくまでエンジニアとして働く環境、楽しく働けるかの話です。

自分の転職歴

社会人になってから今年で三年目(記事を書く時)、そしてハウテレビジョンはちょうど三社目になります。

ほぼ一年に一度くらいのペースで転職してきました。 転職活動する際に、大体週三日くらい面接を入れて、 特に今年はリモートでできる面接も増えたお陰で、多い時は平日は毎日面接を入れていました。

もちろん、できれば転職したくない、同じ会社で長く働きたいのが本音です。 転職活動は大量に時間が必要ですし、本当に疲れます。 でも会社が採用する技術が自分が求めるものと違う時、どうしても技術を優先してしまいます。

転職サービスどう選ぶ

現在転職サービスは非常に多くて、自称「エンジニア転職特化」のサービスも少なくありません。 私が現在利用してるサービスは以下のものになります。

  • Paiza
  • GReen
  • Wantedly
  • LAPRAS
  • Findy

優劣を語る訳ではありませんが、この五つのサービスは大体二種類に分けることができます。

自己アピールデータアピール 、ちょっとわかりにくいかもしれませんが

自己アピール

いわゆるサイト上で自分で履歴書を書いたり、アピールしていくスタイル。 上記の五つのサービスの中では、Paiza、GReen、Wantedlyがそれにあたります。

データアピール

サイト上で自己アピールを書くものもあると思いますが、主にデータ連携でアピールします。 連携先は主にGitHub、Qiita、teratailなどです。 上記の五つのサービスの中では、LAPRASとFindyはそれにあたります。

この自己アピールとデータアピールでは何が違うのか。 もう一方のサービス利用者である採用企業側のユーザのポジション(エンジニア/非エンジニアである人事)が違うと私は考えます。

自己アピールのスタイルは人事サイドの方が見てて、そしてデータアピールはエンジニアサイドの方が見てます。

なので、自己アピールのサービスを通して面接すると、人事の方が最初に面接をしてくださる確率が高く、 データアピールのサービスを利用すると、最初にエンジニアの方が面接をしてくださる確率が高かったです。

もちろん絶対とは言えません、エンジニアと人事のペアで面接担当してくださることもよくありました。

相性が良いのは最初にエンジニアの方が面接してくれる企業でした。 最初の面接もしくはカジュアル面談で受ける企業のエンジニアと話をすることで、 よりこれからの働くイメージも湧きやすく、早い段階で技術面の相性がわかりますので、お互いに時間ロスになりにくいです。

私のような採用する技術目当てで転職する人にとっては、最初にエンジニアとお話ができるのは本当に助かります。

面接で注意すべきもの

リモート面接が増えた今、面接を受ける側にとって、面接で得られる情報もより増えて正直有難いです。

極端な例をあげると、もし面接官が自宅でリモート面接してくださるのであれば、面接官の部屋をちょっと見た方が良いです。 まるで廃墟のような場所でしたら、その会社の待遇面が優れてるとは考えにくいです。

コミュニケーション力

冗談はさておき、面接はある意味では、将来一緒に働くことになるかもしれない方々との最初のコミュニケーションでもあります。 どんな仕事でも、コミュニケーションがうまく取れなければ仕事になりません。

入社後、初めて触るシステムのタスクをもらう際に、特に疑問点は多く出てくると思います。 仕様はこうだったのか、それとも不具合だったのか、 うまくコミュニケーションが取れる古株のメンバーがいないと、相当苦労すると思います。

もし面接中に明らかに話が進みにくいと感じたら、入社後の仕事もある程度進みにくいかもしれません。

面接官の身嗜み、精神状態

コミュニケーションと少し被る内容でもありますが、 面接官を担当する方、特にエンジニアの方の人物像は、入社後の自分もそうなるかもしれないと思った方が良いです。

ブランド品を身に付けた方がいいという話ではないのですが、面接を行なっていただく面接官はその会社の顔を張る方でもあります。 社内のリモート会議中にだらしない格好をするの分には別にいいですが、 社外の人に対して、見るに耐えないような身嗜みでしたら、常識ないのか、適当に面接しているか、 もしくは、生活に余裕がないかもしれません。

精神状態は一概とは言えないですが、明らかにやつれていれば、何が原因なのか一言確認した方が良いです。

笑顔

これが一番大事かも知れません。 面接官に笑顔があれば、頑張ればコミュニケーションをとれる気がするし、身嗜みもある程度気にならなくなります。 面接中にずっと険しそうな顔をしていたら、一緒に仕事するにもプレッシャーが大きいでしょう。

タスクの配分はどうなる

募集ページに開発言語はGoと書いてあるのに、実際入社後にもらったタスクはほとんどRubyでした。 もしくは、リプレースする案件でジョインしたのに、貰ったタスクは殆どレガシープロダクトの運用保守でした。

あんまりないことだと思うでしょうけど、実際私は一度経験してますw。

入社後、どんな配分でタスクをもらうのか、面接時に担当者とある程度事前に決められた方が良いです。 エンジニア採用時に、その人に何をやってもらいたいのかある程度は決まってるはずです。 そこで返答が曖昧でしたら、ちょっとタスク内容が期待したものから外れる可能性があります。

最後に

私が1社目の時、業界で一番マイナーかもしれない技術を使用していました。 2社目は、面接時に聞いていた技術とは違う業務が待っていました。 3社目のハウテレビジョンは私にとって初めてやりたい技術ができる環境でもあります。

Go言語を独学で学んで、実務経験を積みたくハウテレビジョンに入社しました。 今のメイン業務はGo(Ginフレームワーク)を使ったシステムの運用保守、新機能の開発をやっています。勉強してきたつもりではありますが、ちゃんとしたGoのユニットテストは書いたことがなくて、今携わってるでプロダクトで業務を通して大変勉強をさせていただいています。

弊社、今でも絶賛エンジニア採用中ですので、ご興味ある方は以下をご覧ください🍺

hrmos.co

LT会やりました(2020-10)

どうも、CTOのGo里です。

恒例の社内LT会を10月にも実施したので、そこでのLT資料を紹介いたします。 今回は、Rust、コンパイラ、Webpack、AR、ベクトル探索などのテーマでLTが行われました。

1本目「Rui Ueyama先生の低レイヤを知りたい人のためのCコンパイラ作成入門をRustでやってみる」

ハウテレビジョンOBでLT会に遊びに来てくれた@ymgytさん発表。 普段の開発ではなかなか意識することのない機械語へのコンパイルについて、簡単な四則演算の処理を例にお話しいただきました。

余談ですが、もともとGolang大好きだった@ymgytさんに「連休中どこか行きましたか?」と聞くと「Rustに留学してました」と返されたことがあります。

あと、ハウテレビジョンの開発チームでも、数年前にRustを触ってみたことがありました↓

blog.howtelevision.co.jp

2本目「ビルド時間を30秒から1秒に縮めた話」

今年4月に入社したフロントエンドエンジニアの @koki3090 さんの発表。 開発環境でReactのビルド時間が長すぎるという地味だけど生産性に大きく影響する問題を改善したよという内容で、地道にチームに貢献してる感があってとても良い取り組みでした。

3本目「Nice to meet you, LiDAR scanner」

今年1月からジョインしたバックエンドエンジニアの @yuichi-saito-howtv さんの発表。 こちらも普段の業務ではなかなか触れることのないARを使ってみたという内容で、実際に試した際のデモ動画もふんだんに盛り込まれており会場もひときわ盛り上がっていました。

4本目「眠気を覚ます方法を見つける」

最後の発表は @Doarakko さん。 外資就活ドットコムで一番PVのあるページがサービス内検索においては類語検索対応できてないという問題をベクトル探索を活用して改善しようと試みた意欲作でした。 @Doarakkoさんは内容もさることながらスライドの構成が秀逸でユーモアがあり、毎度LTが一番ウケてる気がします。

ハウテレビジョンでは、絶賛エンジニア採用中ですので、ご興味ある方は以下をご覧くださいー🙌

hrmos.co

LT会やりました(2020-09)

どうも、CTOの Go里です。

8月から始めたLT会ですが、順調に毎月開催しておりました。 大変遅ればせながらですが、9月に開催したLT会の内容をシェアしたいと思います!

ちなみに9月はエンジニア職のサマーインターンを2日程開催したため、それに合わせてLT会も2回開催しました。

1本目「ベンチャーCTOのお仕事」

www.slideshare.net

ハウテレビジョンのサマーインターンに参加してくれた22卒学生向けにお話しており、ハウテレビジョンというWeb系ベンチャーに5年在籍しCTO1年目となった僕が考えるCTOの役割についてお話しました。 スライド内でも出てきますが、「CTOは技術系の役職ではありません」という衝撃フレーズがハイライトです。

2本目「AWS SSOを導入してみた」

www.slideshare.net

5月からSREチームにジョインした @YutaOkoshiさんの発表でした。 これまであまり真面目に管理できていなかったAWSのIAMユーザーをSSOを使って一本化したよ、という内容です。 社内に複数サービスができたり、本番とステージングなど複数環境が必要になったりしてAWS環境が増えたときでも開発者のアカウントをラクに管理できる優れものです 👏

3本目「エンジニア向けデザイン入門」

創業メンバーであり、エンジニアとデザイナーの両方のスキルを兼ね備える @YTsuda さんの発表。 エンジニアであっても押さえておくべき4つのデザイン原則について分かりやすくレクチャー頂きました。

余談ですが、@YTsudaさんに「デザイナーかエンジニアかどっちなんですか?」と聞くと「モテそうだからデザイナーです」と答えます。

4本目「遊戯王カードをドローする前に」

昨年12月に入社したサーバサイドエンジニア @Doarakko さんの発表です。 CLI上に遊戯王カードを表示するコマンドをGoで実装したよ、という力作でした。「遊戯王API」というパワーワードで会場はこの日イチの笑いに包まれました。

5本目は「エンジニアだからこそ投資をしよう」

speakerdeck.com

SREチームで活躍されてる @8823-scholarさんが、なぜか投資について熱弁を奮ってくれました。(技術関係ない) ハウテレビジョンのLT会は技術系でもプロダクト系でも趣味系でも何でもOKのゆるい会です。インターン生の中には投資は身近でないという人も多かったと思うので新鮮なテーマになったかも知れません。

スライドはお見せできないですが、この他にも以下のようなLTがありました。

  • Liigaで今年リリースした転職エージェント口コミサービスという新サービス立ち上げの裏側
  • ReactNativeForWebについて(発表スライドがReactNativeForWeb製でした)
  • SIerの闇(!?)
  • 初心者向けサウナ講座(!!??)

どうしてもブログ上でシェアできないLTもありますが、ハウテレビジョンのLT会は外部ゲストの参加も大歓迎です🙌

ご興味ある方は、以下からご応募頂きカジュアル面談にお越しいただくか、Go里宛にDM頂いても結構です!

hrmos.co

10月〜12月分も、まもなくアップ予定です!!

LT会はじめました

どうも。毎年今くらいの時期になるとなぜか新しい自転車を買いたくなってしまい、今年はグラベルロードを注文して現在納車待ちのGo里です 🚴‍♂️

今日は8月から始めた社内LT会の取り組みについて紹介したいと思います。

第1回LT会の模様(弊社オフィス)
第1回LT会の模様(弊社オフィス)

コロナでオンライン主体の開発チームに

6月に渋谷から六本木の新オフィスに移転したハウテレビジョンですが、コロナ禍ではリモートワーク主体となり、対面でのコミュニケーションがめっきり減ってしまいました。

4~5月頃の業務に関してはオンラインのコミュニケーションでも問題なく行えていたのですが、徐々にコロナが収束してオフラインのコミュニケーションをする機会が増え始めると「やっぱり対面の方が意思疎通が早かったり発言しやすかったりするなぁ」と感じる場面がありました。

社歴の長いメンバーはある程度コミュニケーションの貯金があるので何とかなるのですが、入社したばかり、特にコロナ禍に入社したメンバーはオンボーディングが難しかったのかなぁと感じてます。

エンジニア急増でコミュニケーション不足に

さらに、ハウテレビジョンではこの1年でエンジニアの人数が倍近くに増え、チームが違うために話したことのない人がいたり、他のチームがどんな技術を使っててどんな施策をやってるのか知らなかったり、ということが次第に増えてきました。

エンジニアの人数が少ない頃はスプリントごとに全体でKPTをやっていて、そこで誰からともなくプライベートな話も放り込んでワイワイするみたいなことがあったのですが、KPTの参加者が15人とかになると時間がかかりすぎて発言しづらくなってしまい、会議形態の変更を余儀なくされました。

LT会は、他のチームが持っている技術的知見を社内で共有しつつ、LT用に作ったスライドをそのままブログに掲載してしまい、あまつさえピザを食べたりボードゲームしたりしてエンジニア同士の中を深める…、

  • 社内の技術力UP
  • 採用広報としての発信力UP
  • チーム間のコミュニケーション量UP

の一石三鳥の作戦なのです!やったぜ!!

こんな感じでやってます

LT会は原則オフラインでオフィスのセミナースペースでやります。 オフラインのコミュニケーションを促進する施策なのでオンライン参加者向けの中継などは敢えて行わず、参加しなかったメンバーには後日資料が共有されるスタイルです。

話すテーマは、技術ネタ、UI/UXデザインネタ、業務でやってることなどの真面目な話から完全に自分の趣味の話などでもOKと、とてもゆるい感じでやってます。

ギリギリ業務時間の18時から開始し、LTを始める段階からお酒を飲みながら聞いて良いというルールです。お酒や食べ物は会社から支給されます🍺🍣🍕🍺🍣🍕

参加は義務ではなく、基本的には任意参加としています。LTをする登壇者はある程度いないと困るので、自薦がいなければ僕の方から指名して発表してもらう感じです。

頻度はまずは月1回からはじめ、もう少し参加者が増えたら頻度を増やしていきたいと考えています。

LT会(#001)のスライド紹介

第1回はトライアルだったのと夏休みのメンバーが多かったのと体調不良者がちょこちょこいたのとで参加者が少なく、ソーシャルディスタンス取り放題な感じでした😇 登壇者にもキャンセルが出てしまいLTは3本になってしまいましたが、今回はその時のLTを紹介していきます。

1本目は、僕の最近やってる取り組みについて。

技術ネタと言うより「3月にCTOになってからこんなことやってました」という報告みたいなLTです。ここ最近、自分がやってる仕事について現場のメンバーに語る場があまりなかったので良い機会になりました。

スライドの中でも話していますが、SREチームの立ち上げは開発チームにとってここ最近の大きな第一歩でした。当たり前のことですが、きちんとエンジニアを採用してチーム化することで技術課題の解消スピードが圧倒的に速くなりました。

2本目は、来年4月に新卒として入社予定の @clover0 の発表。

まだ入社していない彼ですが、今年の2月(学部3年の2月)から週2くらいでSREの仕事をしてくれており、長らく放置されていた積年の課題に取り組んだ内の1つの取組みについて話してくれました。

新しくJoinしたメンバーならではの目線でおかしい部分、非効率な部分をガンガン指摘してくれ、オーナーシップを持ってカイゼンを進めてくれてます。

最後は、昨年8月から一緒に働いているフリーランスの@8823-scholarさんの発表。

ハウテレビジョンではほとんど使っていないheroku。PaaSとして一世を風靡したあのheroku。そんなherokuの現在を追った意欲作でした。

オフショット

エンジニアとPdMのメンバーがゴキブリポーカーに白熱する図

まとめ

今回は社内LT会をはじめたよ、というゆるい報告でした。 今後もLT会をやるたびにその模様を開発ブログで発信していきたいと思います。

ゆくゆくは社外の人もお招きし、ハウテレビジョンの開発チームについて知ってもらいがてら親睦を深め、あわよくば採用に繋げていきたい所存です( ˘ω˘)

ハウテレビジョンでは、絶賛エンジニア採用中ですので、ご興味ある方は以下をご覧くださいー🙌

hrmos.co

ハウテレビジョン サマーインターン2019

こんにちは。CTOの Go里 です。

2019年9月開催したハウテレビジョンのサマーインターンについて振り返りたいと思います。

f:id:bumcru0310:20200518100337j:plain
Goを使ったWebAPI実装についての講義

概要

今回のインターンは9月2日(月)〜17日(火)までの期間で開催。営業日換算で11日間のインターンに、総勢14名の学生が参加しました。

最初の3日間は個人課題パート、残りの8日間がチーム開発パートです。

個人課題パート

参加した学生のレベルはまちまちで、プログラミング初学者でWebアプリケーションの開発経験があまり無い人もいれば、Web系ベンチャーでインターン経験がありバリバリコードを書いてきた人もいました。

最低限の知識レベルを揃えるべく、参加者にはインターン参加前の約3週間を使い、HTML,CSS,Linux Command,Git,SQL,Go,JavaScript,React,PHPについて、基礎的な部分のみ学習してきてもらいました。(学習教材にはProgateを使わせていただきました)

その上で、インターン初日は自己紹介などのオリエンが終わり次第 講義を行います。

講義の内容は、「ブラウザ」や「URL」「DNS」といったWebの基礎的な理解に始まり、MySQLなどのRDBMS、MVCアーキテクチャ、RESTfulAPIの原則、Goを使ったWebAPIの実装、クリーンアーキテクチャ志向のレイヤードアーキテクチャ、Webフロントエンドの概略、チーム開発におけるgit運用ルールまで、外資就活ドットコムの開発に必要な技術や決め事の大枠を駆け足でインプットするものでした。

講義で使った資料はGithubのこちらのリポジトリで公開しています。

f:id:bumcru0310:20200518101601j:plain
React/ReduxによるWebフロントエンド開発についての講義

f:id:bumcru0310:20200518221046j:plain
外資就活ドットコムのレイヤードアーキテクチャの講義

その後、参加者はバックエンドかフロントエンドのどちらかを選択して個人課題に取り組みます。

課題の内容は「外資就活ドットコム日記」という架空の新機能の実装で、バックエンドコースでは「日記を投稿する」「日記一覧を取得する」などの一連のAPIの実装を、フロントエンドコースではそのAPIを利用してUIの構築を、3日間で行います。

既存の外資就活ドットコムのコードベースを拡張する形で開発を行うため、技術基盤の構築に時間を割かなくて良い反面、プロダクションコードの設計やコーディング作法の理解が必要になります。

f:id:bumcru0310:20200518101647j:plain
講義以外でもメンターが手厚く解説

参加者の生の声

座学パートについて、参加者の感想です。

・技術的には、一初心者としては絶望的な難易度だったが、個人課題に取り組む時間が用意されていたため、徐々に慣れることができた。

・自分自身初心者だったので初めは戸惑いましたが、日を追うごとに成長出来たのでちょうどいい課題になりました。

・フロントとバックで別れており、片方に集中できたのでよかった。ただ、フロント開発経験が全くなかったので難しくはあったと思う。

全体的に「難易度が高かった」という声が多かったですが、「成長を実感できた」という声も多く聞かれました。

チーム開発パート

4日目以降は参加者同士で2~3人のチームを組み、チーム開発に取り組みます。

お題は「外資就活ドットコムへの機能追加」で、「どんなものを作るか?」からチームごとに考えて実装までを行います。

評価するポイントは「ユーザーへの価値」と「技術的挑戦」の2軸で、優勝チームには賞金5万円が贈呈されます。

f:id:bumcru0310:20200519091038j:plain
肩を突き合わせてチーム開発する様子

f:id:bumcru0310:20200519091141j:plain

各チームが取り組んだ開発の内容の一部をご紹介します。

外資就活Wiki

外資就活ドットコムにはコラムやコミュニティ(ユーザー同士の掲示板機能)などがありますが、就活初心者のユーザーはそれらのコンテンツ内に登場する単語(MBB、BIG4、ガクチカ、ES…etc)の意味が分からないことが多くあります。

外資就活Wikiは、そうした外資就活特有の単語のWikiをユーザー同士で編集・公開する機能です。

この発想はハウテレビジョン社内には無かったもので、参加者自信が外資就活ユーザーであるがゆえに生まれた素晴らしいアイデアでした。

結果、外資就活Wikiの開発チームが優勝し、サマーインターン終了後に実際のリリースを目指すプロジェクトが発足し、今も鋭意開発中です。

興味のない募集情報を非表示にする機能

外資就活ドットコムで最も利用されている機能の1つに「募集をさがす」という機能があります。これは外資就活ドットコムが取り扱っている企業の求人情報の全てを一覧で見ることができる機能なのですが、あるチームは「募集一覧で興味のない募集を非表示にすると、今後表示されないようにする」という機能を実装しました。

「募集をさがす」に掲載している募集情報は企業がお金を払って掲載している商品という側面もあるため、これほど思い切った仕様は社内からなかなか出てこないものでしたが、「もっと効率よく就活したい!」というユーザーのニーズを上手く捉えた秀逸なアイデアでした。

特別講義「ソフトウェアエンジニアキャリア論」 by 伊藤直也

f:id:bumcru0310:20200519095001j:plain

インターン10日目の夜には、ハウテレビジョンの技術顧問・伊藤直也さんによる特別講義が行われました。

詳細は外資就活ドットコムのコラムとして近日公開予定ですが、ソフトウェアエンジニアという職業をとりまく業界の動向や、現代のエンジニアとして働く上での心構えについてお話し頂きました。

参加者の中にはエンジニアという職業をファーストキャリアに選ぶことに悩みを抱えている人もおり、

・エンジニアの将来性というのは本当に知りたかった部分で、そこのところを伊藤さんの意見を聞けたのは貴重な体験をしたと思いました

・web業界で長年エンジニアを経験した方の話をきく貴重な機会であり、内容も充実していた

・なんどもなるほどなと思わせられる話の内容であった

…と、学びの多いセッションとなりました。

全体を振り返って

初のサマーインターン開催ということで運営側の反省点は多くありましたが、多くの参加者が成長を実感することが出来たと思います。 以下に、全体を通した参加者の声を紹介します。

・就活用語wikiを初め提案する時、思いつきのようなものだったので少し提案するにも抵抗があったのですが、メンターのTさんがすぐに「めちゃめちゃいい」と言ってくださって、どんな提案でも真剣に向き合って改善のための話し合いを積める雰囲気は、僕はとても好きでした。実際、当初想定していたよりも色々な機能が追加されていって、実装できなかった部分が大半ですが、前向きな姿勢が産んだ可能性だと思います。

・社員の方がとても懇切丁寧に指導していただき、非常に開発がスムーズであった。

・このインターンをやる前と後では明らかに自身の成長を感じます。このような機会を設けてくださりありがとうございました。

・技術レベルが非常に高く、実際に業務で使われている言語やフレームワークでの開発が体験できたため。<中略>また給与や交通費など金銭面的に学生が負担となることがないのもおすすめできる。

今年も開催します!

ハウテレビジョンでは2020年夏にも、サマーインターンプログラムを開催予定です!

gaishishukatsu.com

昨年を上回る充実した内容になるよう鋭意企画中ですので、Webエンジニアとしての成長を求める方の参加をお待ちしております!

エンジニアリングマネージャーやるにあたり別の会社で副業始めたらめちゃくちゃ良かったぞ、という話

f:id:bumcru0310:20190713173038p:plain

どうも、O里 です。

今回は副業のはなしです。

(TOP画像だと1週間休み無く毎日働いてる感がありますが、そんなことはないです)

話題のツイート

少し前に副業禁止の会社に関するtweetがバズってましたが、ハウテレビジョンでは副業を全面的に推奨しています。 もちろん事業的に競合する会社はダメとか、体を壊すような無茶な働き方はダメとか一定の基準はありますが、あらかじめ申請した上で副業しているメンバーは結構います。

僕もその1人で、2018年11月から hey という会社で副業エンジニアとして働いており、主にGoで書かれたWebアプリ開発のお手伝いをしています。(厳密にはheyの100%子会社のコイニー社と業務委託契約しています)

開発をお手伝いしていたのは新たな決済サービスで、簡単にいうと「スマホがあれば、リアル店舗でツケ払いで買い物できる」というサービスです。

www.bynw.jp

このプロジェクトは内密に進めていたheyの新規事業だったので、あまりおおっぴらにどんな事をやっているか話せなかったのですが、 去る5月にようやくプレスリリースも出て世間にお披露目されたので、副業エンジニアをやってみて分かったことなどをお話していきたいと思います。

副業をはじめたのはEMとしての経験値不足を補うため

ハウテレビジョンに入社してから丸3年たった2018年9月頃、僕は開発部長に正式に任命されました。世にいうエンジニアリングマネージャー(EM)というやつです。

それまでも開発チームの代表として、自分自身コードも書きつつ、開発案件の整理や計画・進捗の管理、エンジニア採用などやっていましたが、 EMになって一番大きな変化は他のエンジニアメンバーの評価、あけすけにいうと給料を決める立場になったことでした。

その時点で僕はエンジニアになって(=プログラミングを始めて)4年半。前職は受託やSESをやっている会社におり、いわゆるWeb系ベンチャーでの経験はハウテレビジョン1社しかない状態。

開発チームの中には自分より年上でエンジニアとしての業務経験も豊富なメンバーもいる中で、そんなエンジニアの評価についてメンバーや経営陣が納得する判断をできるかが不安が募ります。

副業を考え始めた最大の理由は、「EMとして自信を持てるためにより多くの経験値を獲得したいから」でした。

EMの役割とは何か?

少し話は逸れますが、EMの役割について私見を述べます。

EMに求められるものはチームごとに異なるかもしれませんが、僕が考えるEMの役割は

エンジニアメンバーの給料を上げること

に集約されます。

エンジニアメンバーの給料を上げるためには

  • 開発チームが事業インパクトの高い開発をして成果を出す
  • 成果の出る開発をスピーディーに進められる開発チームを作る
  • 個々人の技術スタックが増え、技術レベルが上がる

などが必要で、これらを実現することで最終的に事業成長に貢献できると考えています。

つまり、EMとは開発チームに働きかけることで事業成長に貢献する人、という定義です。

より具体的には、開発ロードマップの策定、メンバーとの1on1、トラブル解決の旗振り役、メンバーの採用、開発以外の部署との調整ごと、予算管理などを担当していて、 『エンジニアのためのマネジメントキャリアパス』 という本の第6章中で紹介されている「技術部長」のジョブディスクリプションが近しいイメージです。

heyとの出会い

副業の話に戻ります。

heyとの出会いは、2018年夏にWantedlyでスカウトを貰ったことからでした。

僕は転職するつもりが無くても面白そうな会社からメッセージを貰ったらカジュアル面談しに行くことがあります。 冷やかしのようで気が引けますが、他社の開発チームの内情を聞くこと自体が自分の糧になったりすんですよね。

そんなこんなでheyに話を聞きに行った所、

  • Goで開発してる
  • 新規事業をやろうとしてる
  • チームメンバーが優秀な人ばかりで、雰囲気もめちゃくちゃ良い
  • オフィス内にタダで飲めるお酒が大量に置いてある(!?)

と、自分にとって魅力的な条件が揃っていました。

とはいえ当時は転職という状況にはならなかったため、ダメ元で「副業という形でチームに入れてくれないか」とコイニー代表の佐俣さんにお願いしたところお許しを頂けることになりました。

どんな感じで副業してるか?

まず、ハウテレビジョンでの仕事は引き続き週5フルコミットでやっています。

今年の4月 晴れて東証マザーズに上場したハウテレビジョンですが僕が副業を始めた2018年11月時点はまさに上場が決まるか決まらないかという大事なタイミングでした。

UXの改善、新商品の開発、セキュリティ課題への対応、上場に備えてのコーポレートサイトのリニューアル、エンジニアメンバーの採用…などの仕事が山ほどある上に、EMとしてメンバーを評価するという重責も担うようになったので副業をするからといって本業を疎かにしてよい状況では全くありませんでしたし僕もそうするつもりはありませんでした。

なのでheyの業務は月32時間を目安に基本 土日に行っており たまに平日の早朝や帰宅後にもやるようにしました。

開発マシンは私物のMacで自宅作業。完全にフルリモートの副業という感じです。

hey開発チームとのコミュニケーションはSlackやGithubで行っており、週に1回 appear.in を使って10分程度のオンラインミーティングをして開発進捗やプロジェクトの状況シェアをしています。(これは平日の昼休憩の時間でやってます)

あと、ハウテレビジョンのオフィスからheyのオフィスまで歩いて12分くらいなので新しいメンバーが増えたときなどには一緒にランチに行ったり、リリース後の打ち上げに参加させてもらったりしてます。

業務委託費用について。始めは稼働開始と終了をSlackで報告する方式にして実働時間分を請求するスタイルにしていたのですが、スキマ時間で開発することも多く報告が煩雑になってきたので、最近は担当するIssueごとに事前に見積もり工数を出してその月にPRを出したIssueの工数分を請求するスタイルに変更しました。

どんな開発をしてるか?

「ツケ払い」はWebアプリケーションで、お店で買物をする購入者向けのスマホ画面や、お店のスタッフが決済を登録する画面、運営側が使う管理画面などがあります。

技術スタックは、

  • Go
  • Vue.js
  • Docker
  • MySQL
  • TravisCI

などを使っています。

話せる範囲で具体的に担当したIssueを挙げると、

  • 管理画面の登録・更新機能に入力値バリデーションを追加する
  • 店舗担当者がログインを一定回数失敗した場合にアカウントをロックする
  • 返済が滞っているユーザーを利用停止にするための管理機能
  • 運営向けの管理画面でページングのコンポーネントを実装する
  • GoのErrorハンドリングでerrorsパッケージでWrapするようにする

などがあり、やや大きなものから細かいものまでやっています。

f:id:bumcru0310:20190713184912p:plain

↑は自分が担当したIssueがリリースされた時のSlackの様子です。

副業をやってよかったこと

Goと言えば並列化ですが、2つの開発チームに所属することで得られる経験値も倍増しました。

技術面での経験値が増えた

Goは外資就活ドットコムの開発でも使っていますが、使っているフレームワークやライブラリ、アーキテクチャなどは異なるため、自分の中の引き出しが増えました。 知らなかったGoの挙動を発見したこともありました。

また、決済という事業ドメインも外資就活ドットコムというメディア事業・人材事業とはまったく異なり、その裏側に登場する技術も普段触れることがないものが多くあります。

たとえばツケ払いではSMS送信の機能があるのですが、外資就活の開発では今の所SMSを使う場面はなかったので、もし今後SMSを使って何かしたくなった時に役立ちそうです。

マネジメント的な経験値も増えた

  • GithubのIssueにテンプレート機能があること
  • commitの分け方についてのポリシー
  • コードレビューについてのポリシー

といった開発寄りのノウハウや、

  • 副業エンジニアとの付き合い方
    • どんなIssueをお願いできるか
    • どのくらいの頻度でコミュニケーションが必要か

などを学ぶことができました。

実際、7月に就業体験目的で副業エンジニアの方を1ヶ月だけ受け入れる機会が発生したのですが、その時にも自分が副業をしていて得た知見を活かすことができました。

まとめ

実際、エンジニアとしてもEMとしてもまだまだ修行が足りませんが、副業をすることで経験値を増やせることは間違いないように思います。

最近では転職する前に副業してミスマッチを防ぐようなやり方をしている人も増えてきました。

ハウテレビジョンでも副業エンジニアの受け入れは積極的に進めたいと考えているので、GoやReact、ReactNativeなど書きたい!!という人がいましたらいつでもオフィスに遊びに来て下さい 😀

募集しているポジション一覧は以下をご覧ください。

株式会社ハウテレビジョン 求人一覧

もっとライトに質問や相談したいという方はtwitterのDMでも受け付けております。

Go里🚴Kensuke Osato (@bumcru0310) | Twitter

Go/Mongo/KubernetesでSlack Botを作る

こんにちは、Go2のReleaseを楽しみにしているymgytです。 この記事では、Go,Mongo,Kubernetesを使ったSlack Botの作り方について書いていきます。 Kubernetesに簡単なapplicationをdeployしてみたい方や、GoでSlack Botを作って見たい方に向けた記事です。 source codeはこちらで公開しています。

主に以下のTopicを扱います。

  • Kubernetes上に、mongo(replica set)とwebappを作成
  • Moduleを有効にしたDockerfileの作成
  • Slack RealTimeMessaging APIの利用
  • Github Webhookのhandling
  • MongoによるCRUD処理

Botの概要

作成するbotの名前はgobotとしました。 GithubのPullRequest ReviewのSlackへの通知とUserのCRUD処理を行います。

f:id:yamaguchi7073xtt:20190430151130p:plain
概要

Github PullRequest Notification

f:id:yamaguchi7073xtt:20190430142618p:plain
PRでreviewerを指定する

f:id:yamaguchi7073xtt:20190430142814p:plain
SlackにReviewが依頼された通知が飛ぶ

f:id:yamaguchi7073xtt:20190430142942p:plain
コメントされたり

f:id:yamaguchi7073xtt:20190430142905p:plain
approveされたりしても通知される

User CRUD

Github UserとSlack Userの対応はgobotが独自に管理しています。UserのCRUDはCLI likeに行えます。

f:id:yamaguchi7073xtt:20190430143532p:plain
Create

f:id:yamaguchi7073xtt:20190430143832p:plain
Read

f:id:yamaguchi7073xtt:20190430143918p:plain
Update

f:id:yamaguchi7073xtt:20190430143956p:plain
Delete

準備

Domain

GithubのWebhook URLに登録するdomainが必要です。gobotではautocert packageを利用してCertificateを取得するので、明示的なTLS設定は必要ありません。

Kubernetes

kubectlが実行できる状態にしておきます。 gobotでは、Google Kubernetes Engine(GKE)を利用しました。GCPまわりの設定についてふれると長くなってしまうので本記事では、詳しくはふれません。 gcloudコマンドをinstallしたうえで以下のコマンドにてclusterを作成しました。

gcloud components install kubectl
gcloud config set project ${PROJECT}
gcloud config set compute/zone asia-northeast1
gcloud container clusters create ${CLUSTER_NAME} --no-enable-cloud-logging --no-enable-cloud-monitoring --machine-type=f1-micro --num-nodes=1
gcloud container clusters get-credentials

Cloud Datastore

現状では、TLSを利用するために、golang.org/x/crypto/acme/autocert packageを利用しており、cache機構としてをlocal filesystemではなく、GCP Cloud Datastoreを利用しております。GCP ProjectのDatastoreを有効にして、IAM & adminからgobot用のService Accountsを取得し、Key fileをjson formatで取得してください。

Slack

SlackのRealTimeAPIを利用するために、Slack Appを登録します。

1 ここからslack appを登録

2 https://api.slack.com/apps/{ID}/bots? からBot Userの設定

f:id:yamaguchi7073xtt:20190430153031p:plain

3 OAuth & PermissonsからBot UserOAuth Access Tokenの値を確認します。このTokenは実行時にgobotに渡してやる必要があります。

Github

PullRequestの通知を実施したいRepositoryにてWebhookを設定します。(複数Repositoryに設定して問題ありません) Settings > Webhookから設定をおこないます。

f:id:yamaguchi7073xtt:20190430154202p:plain
Github設定

Payload URLに準備しておいたdomainを、pathは/github/webhook として進めます。 Content typeにはapplication/jsonを指定し、Secretはgobotに渡してやる必要があります。 Eventsは、Pull requestsとPull request reviewsを指定します。(issueは開発中のため、指定してあるだけです)

f:id:yamaguchi7073xtt:20190430154705p:plain
Webhook設定

gobot

準備の各設定をおこない、必要な設定項目を取得したので、いよいよGoのsource codeをみていきます。 ここでは、local環境の設定からdocker imageのpushまでについて述べます。 imageがpushできたら、kubernetesにdeployして完成です。

開発環境

gobotのsorceをpullしてある前提で話をすすめます。 利用するツールは以下の通りです

環境変数

.envrc_templateをcopyして.envrc fileを作成しておくと便利です。

GO111MODULE

moduleは明示的にonにしておきます。 go mod vendorコマンドでvendoringができます。

GOBIN

$(pwd)/binの値をセットしておきます。

GOBOT_VERSION

$(cat VERSION) として、Versionを定義したfileを利用します。

GOBOT_LOGGING_LEVEL

debug としておきます。

GOBOT_GCP_PROJECT_ID

Cloud Datastoreを有効にしたGCPのProject IDです

GOBOT_GCP_SERVICE_ACCOUNT_CREDENTIAL

$(cat path/to/gcp_credential.json) Servce Account上で作成した鍵fileのcontentを指定します

GOBOT_GITHUB_WEBHOOK_SECRET

Github Webhook設定で指定したSecretの内容を渡します

GOBOT_GITHUB_PR_NOTIFICATION_CHANNEL

PullRequestを通知するSlack Channelを指定します

GOBOT_SLACK_BOT_USER_OAUTH_ACCESS_TOKEN

Slack Appの設定画面から取得したBOT User用のtokenを指定します。SlackのRTM API接続時に必要になります。

GOBOT_MONGO_DSN

localではmongodb://localhost:27017を指定します。

GOBOT_MONGO_DATABASE

gobot-localを指定しておきます、なんでもよいです。

依存ツールの管理

Goで開発していると、gomockやlinterといった、go製のtoolのversionを固定したくなります。 Moduleを有効にした状態でこれを実現するには以下のようなtools.goのようなfileを作成し、build tagを設定しておきます。

// +build tools

package tools

import (
    _ "github.com/CircleCI-Public/circleci-cli"
    _ "github.com/golangci/golangci-lint/cmd/golangci-lint"
    _ "github.com/google/wire/cmd/wire"
    _ "github.com/magefile/mage"
)

こうしておくとgo install github.com/google/wire/cmd/wire実行時に、go.modで指定されたversionのbinaryが${GOBIN}以下に配置されます。

Slack RTM API

開発環境も整ったので、codeをみていきます。まずは、Slack RTM APIです。 slack APIはgithub.com/nlopes/slackを利用しています。

// Slack -
type Slack struct {
    *SlackOptions
    Client             *slack.Client
    AccountResolver    *AccountResolver
    DuplicationChecker *DuplicationChecker
    MessageHandler     SlackMessageHandler

    user          string
    userID        string
    rtm           *slack.RTM
    githubChannel *slack.Channel
}

// Run -
func (s *Slack) Run(ctx context.Context) error {
    if err := s.init(); err != nil {
        return err
    }

    return s.run(ctx)
}

func (s *Slack) init() error {
    if err := s.authorize(); err != nil {
        return err
    }
    if err := s.populateChannel(); err != nil {
        return err
    }
    return nil
}

func (s *Slack) authorize() error {
    authRes, err := s.Client.AuthTest()
    if err != nil {
        return errors.Annotate(err, "authorization to slack failed. check your slack token.")
    }
    log.Info("slack authorization success", zap.Reflect("response", authRes))

    s.user = authRes.User
    s.userID = authRes.UserID
    return nil
}

func (s *Slack) populateChannel() error {
    channels, err := s.getChannels()
    if err != nil {
        return err
    }
    for i := range channels {
        if channels[i].Name == s.GithubPRNotificationChannel {
            s.githubChannel = &channels[i]
        }
    }

    if s.githubChannel == nil {
        return errors.Errorf("github pull request notification channel(%s) not found", s.GithubPRNotificationChannel)
    }
    log.Debug("github pr notification channel found",
        zap.String("channel_id", s.githubChannel.ID),
        zap.String("channel_name", s.githubChannel.NameNormalized))
    return nil
}

func (s *Slack) getChannels() ([]slack.Channel, error) {
    excludeArchive := true
    channels, err := s.Client.GetChannels(excludeArchive, slack.GetChannelsOptionExcludeMembers())
    return channels, errors.Trace(err)
}

func (s *Slack) run(ctx context.Context) error {
    s.rtm = s.Client.NewRTM()
    go s.rtm.ManageConnection()

    log.Info("listening for slack incoming messages...")

    for eventWrapper := range s.filter(s.rtm.IncomingEvents) {
        switch event := eventWrapper.Data.(type) {
        case *slack.HelloEvent:
            log.Debug("receive slack event", zap.String("type", eventWrapper.Type))
        case *slack.ConnectingEvent, *slack.ConnectedEvent:
            log.Info("receive slack event", zap.String("type", eventWrapper.Type))
        case *slack.MessageEvent:
            s.handleMessage(event)
        case *slack.RTMError:
            log.Error("receive slack event", zap.String("type", eventWrapper.Type), zap.Int("code", event.Code), zap.String("msg", event.Msg))
        default:
            log.Debug("receive unhandle slack event", zap.String("type", eventWrapper.Type), zap.Reflect("data", event))
        }

        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
    }

    return nil
}

func (s *Slack) filter(events <-chan slack.RTMEvent) <-chan slack.RTMEvent {
    ch := make(chan slack.RTMEvent, 10)
    go func() {
        defer close(ch)
        for event := range events {
            typ := event.Type
            if typ == "user_typing" ||
                typ == "latency_report" {
                continue
            }
            ch <- event
        }
    }()
    return ch
}

APIの認証に成功すると、Bot Userの名前とIDが取得できるので、以降はこの名前でmentionされた際に、処理を起動するようにします。 初期化時に、Githubへの通知用channelが取得できるかも確かめています。 Goらしく、SlackのEventはchannelから取得できます。gobotでは、slack.MessageEventを処理の対象にしています。ここでSlack Channel Create Eventを拾えば、Channel作成の通知処理なんかも行えそうですね。

次に、MessageEventの処理をみていきます

// SlackMessageHandler -
type SlackMessageHandler interface {
    Handle(*SlackMessage)
}

func (s *Slack) handleMessage(msg *slack.MessageEvent) {

    // bot(integration)が投稿したmessageにはsubtype == "bot_message"が設定される.
    if msg.Msg.SubType == "bot_message" {
        log.Debug("slack/ignore bot message", zap.String("sub_type", msg.Msg.SubType))
        return
    }

    // menuのApps gobotから話しかけるとChannelの先頭文字がDとして送られてくる.
    isDirect := strings.HasPrefix(msg.Channel, "D")

    mention := strings.Contains(msg.Text, "@"+s.userID)
    // @gobotがついていないければ無視する.
    if !mention {
        log.Debug("handle_message", zap.String("msg", "not being mentioned"))
        return
    }

    user, err := s.Client.GetUserInfo(msg.User)
    if err != nil {
        log.Warn("handle_message", zap.String("msg", "Client.GetUserInfo()"), zap.Error(err), zap.Reflect("event", msg))
        return
    }

    go s.MessageHandler.Handle(&SlackMessage{event: msg, user: user, client: s.Client, isDirect: isDirect})
}

Bot自身が発言したMessageも取得してしまうのですが、判別できるようになっているので、無視します。(これをしておかないとloopしてしまいます) mentionされているかや、Direct Messageで話しかけられたかどうか、発言者の情報等のコンテキストを取得して、MessageHandlerに処理を委譲します。

Github Webhook

続いて、GithubのWebhook Eventの処理をみていきます。webhook eventのbindingにはgopkg.in/go-playground/webhooks.v5/githubを利用しています。

// Github -
type Github struct {
    Webhook *github.Webhook
    Slack   *app.Slack
}

var targetEvents = []github.Event{
    github.PullRequestEvent,
    github.PullRequestReviewEvent,
    github.IssuesEvent,
}

// HandleWebhook -
func (g *Github) HandleWebhook(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
    payload, err := g.Webhook.Parse(r, targetEvents...)
    if err != nil {
        log.Error("parse github event", zap.Reflect("request", r))
        return
    }
    switch payload := payload.(type) {
    case github.IssuesPayload:
        spew.Dump("issues", payload)
    case github.PullRequestPayload:
        g.handlePullRequest(w, r, &payload)
    case github.PullRequestReviewPayload:
        g.handlePullRequestReview(w, r, &payload)
    default:
    }
}

// see https://developer.github.com/v3/activity/events/types/#pullrequestevent
func (g *Github) handlePullRequest(w http.ResponseWriter, r *http.Request, pr *github.PullRequestPayload) {
    switch pr.Action {
    case "review_requested":
        g.handlePullRequestReviewRequested(w, r, pr)
    default:
        g.handlePullRequestUndefinedAction(w, r, pr)
    }
}

func (g *Github) handlePullRequestReviewRequested(w http.ResponseWriter, _ *http.Request, pr *github.PullRequestPayload) {
    log.Info("github/handle event", zap.String("event", "pullrequest"), zap.String("action", pr.Action))

    msg := &app.PRReviewRequestedMsg{
        Owner:          pr.PullRequest.User.Login,
        OwnerAvatarURL: pr.PullRequest.User.AvatarURL,
        URL:            pr.PullRequest.HTMLURL, // URLはapiのresourceを指す
        Title:          pr.PullRequest.Title,
        Body:           pr.PullRequest.Body,
        RepoName:       pr.Repository.Name,
    }

    // 複数指定されうる
    msg.RequestedReviewers = make([]string, len(pr.PullRequest.RequestedReviewers))
    for i := range pr.PullRequest.RequestedReviewers {
        msg.RequestedReviewers[i] = pr.PullRequest.RequestedReviewers[i].Login
    }

    if err := g.Slack.NotifyPRReviewRequested(msg); err != nil {
        log.Error("github", zap.String("event", "pullrequest"), zap.String("action", pr.Action), zap.Error(err))
    }

    // githubへは200を返す
    w.WriteHeader(http.StatusOK)
}

github.Webhook.Parseでpayloadの検証とbindingまでおこなってくれるので、必要な情報だけ抽出して、slackに通知します。 slackへの通知する際に、概要にあったようなmessageを送るためにattachmentを設定します。

// PRReviewRequestedMsg githubのPullRequestでReviewerを指定した際にslackに通知するための情報.
type PRReviewRequestedMsg struct {
    Owner              string // prを作成したuser name(login)
    OwnerAvatarURL     string
    URL                string   // prへのlink
    Title              string   // prのtitle
    Body               string   // prのcomment
    RepoName           string   // prが紐づくrepositoryの名前
    RequestedReviewers []string // reviewerとして指定されたuser name(login)
}

func (m *PRReviewRequestedMsg) attachment(s *Slack) slack.Attachment {
    pretext := func(reviewers []string) string {
        var mention string
        for _, reviewer := range reviewers {
            mention += s.MentionByGithubUsername(reviewer) + " "
        }
        msg := fmt.Sprintf(":point_right: %s your review is requested", mention)
        return msg
    }
    return slack.Attachment{
        Fallback:   "pull request review requested message",
        Color:      slackColorGreen,
        Pretext:    pretext(m.RequestedReviewers),
        AuthorName: m.Owner,
        AuthorIcon: m.OwnerAvatarURL,
        Title:      m.Title,
        TitleLink:  m.URL,
        Text:       m.Body,
        Footer:     "Github webhook " + footerSuffix(),
        Ts:         json.Number(fmt.Sprintf("%d", time.Now().Unix())),
        Fields: []slack.AttachmentField{
            {
                Title: "Repository",
                Value: m.RepoName,
                Short: true,
            },
        },
    }
}

// NotifyPRReviewRequested -
func (s *Slack) NotifyPRReviewRequested(msg *PRReviewRequestedMsg) error {
    // https://github.com/ymgyt/gobot/issues/7
    // when multiple reviewer are requested, multiple event emitted.
    var err error
    if ok := s.DuplicationChecker.CheckDuplicateNotification(msg.URL, (4 * time.Second)); ok {
        _, _, err = s.Client.PostMessage(s.githubChannel.ID, slack.MsgOptionAttachments(msg.attachment(s)))
    }
    return err
}

注意点としては、Reviewerが複数指定された場合、Review Request Eventが複数発火するので、素直に処理すると通知が重複して飛んでしまいます。 そこで、DuplicationCheckerを定義して、確認処理をいれています。興味がある方はsourceのほうで実装をみてみてください。

Userのmention

slackの仕様でmentionを飛ばすには、対象Userのslack上のUserIDが必要です。そのため、PRを通知するには、Github UserName -> Slack Email -> Slack UserIDの変換が必要です。 この変換のために、gobotでは独自にUserをmongoで管理しています。Github UserNameとslack UserIDのマッピングにSlack Emailを使う必要は必ずしもないのですが、slackの管理している情報で全ユーザが必ずもっていて、変化しにくいのでemailを使うようにしました。 このあたりの変換処理は以下のような感じでおこないました。

// MentionByGithubUsername githubのusernameをslackでmentionできるようにする.
func (s *Slack) MentionByGithubUsername(name string) string {
    user, err := s.AccountResolver.SlackUserFromGithubUsername(name)
    // 見つからなければそれがわかるように元の名前で返す
    if IsUserNotFound(err) {
        return fmt.Sprintf("<@%s> (could not resolve slack user by github user name)", name)
    }
    if err != nil {
        return fmt.Sprintf("<@%s> (%s)", name, err)
    }

    return Mentiorize(user.ID)
}

func Mentiorize(slackUserID string) string {
    // mentionするには <@user_id>
    return fmt.Sprintf("<@%s>", slackUserID)
}

// AccountResolver resolve user identities across multi service. ex. github <-> slack.
type AccountResolver struct {
    SlackClient *slack.Client
    UserStore   UserStore
    Mu          *sync.Mutex

    slackUsers []slack.User
}

// SlackUserFromGithubUsername -
func (ar *AccountResolver) SlackUserFromGithubUsername(githubUserName string) (slack.User, error) {
    users, err := ar.UserStore.FindUsers(context.Background(), &FindUsersInput{
        Limit:  1,
        Filter: &User{Github: GithubProfile{UserName: githubUserName}},
    })
    if err != nil {
        return slack.User{}, errors.Annotatef(err, "github username=%s", githubUserName)
    }
    user := users[0]

    return ar.SlackUserFromEmail(user.Slack.Email, false)
}

// SlackUserFromEmail -
func (ar *AccountResolver) SlackUserFromEmail(email string, updateCache bool) (slack.User, error) {
    ar.Mu.Lock()
    defer ar.Mu.Unlock()
    return ar.slackUserFromEmail(email, updateCache)
}

func (ar *AccountResolver) slackUserFromEmail(email string, updateCache bool) (slack.User, error) {
    if ar.slackUsers == nil || updateCache {
        if err := ar.updateSlackUsersCache(); err != nil {
            return slack.User{}, errors.Trace(err)
        }
    }

    for i := range ar.slackUsers {
        if ar.slackUsers[i].Profile.Email == email {
            return ar.slackUsers[i], nil
        }
    }

    // update cache then retry
    if !updateCache {
        return ar.slackUserFromEmail(email, true)
    }

    return slack.User{}, ErrUserNotFound
}

func (ar *AccountResolver) fetchSlackUsers() ([]slack.User, error) {
    return ar.SlackClient.GetUsers()
}

func (ar *AccountResolver) updateSlackUsersCache() error {
    users, err := ar.fetchSlackUsers()
    if err != nil {
        return errors.Trace(err)
    }
    ar.slackUsers = users
    return nil
}

Mongo

CLI interface

ここまででPRを通知できるようになったので、githubとslackのuserをマッピングする設定のInterfaceを作成します。 個人的な構想として、gobotはあくまで、slackとのinterfaceやwebのendpointの提供にとどめ、kubernetes上の他のserviceとgrpcでやりとりしていこうと考えております。 そこで、できるだけ拡張性があるように、CLI Likeなinterfaceにしようと考えました。 そのあたりを以下の処理で行っております。

type SlackMessage struct {
    event    *slack.MessageEvent
    user     *slack.User
    client   *slack.Client
    isDirect bool
}

type MessageHandler struct {
    CommandBuilder interface {
        Build(*SlackMessage) *cli.Command
    }
}

func (h *MessageHandler) Handle(sm *SlackMessage) {
    ctx := setSlackMessage(context.Background(), sm)
    h.CommandBuilder.Build(sm).ExecuteWithArgs(ctx, h.readArgs(sm))
}

func (h *MessageHandler) readArgs(sm *SlackMessage) []string {
    args := strings.Fields(sm.event.Msg.Text)
    normalized := make([]string, 0, len(args))
    for _, arg := range args {
        if arg == "" {
            continue
        }
        normalized = append(normalized, arg)
    }
    if len(normalized) > 0 {
        // if type "@gobot hello", we got "<@AABBCCDD> hello"
        normalized = normalized[1:]
    }
    return normalized
}

slackから@gobot ls usersのようにmention付きでコマンドが実行されるとslack上の情報をcontextにsetしたうえで、通常のcli appのような処理を開始します。 cli packageとして自作のgithub.com/ymgyt/cliを利用しています。 (cobraはspf13氏の他のpackageにかなり強く依存しており、採用しませんでした)

type CommandBuilder struct {
    UserStore UserStore

    once     sync.Once
    commands chan *cli.Command
}

func (b *CommandBuilder) Build(sm *SlackMessage) *cli.Command {
    b.once.Do(func() {
        b.commands = make(chan *cli.Command, commandBuffer)
        go b.run()
    })

    root := <-b.commands
    b.setupRecursive(root, sm)
    return root
}

func (b *CommandBuilder) run() {
    for {
        b.commands <- b.build()
    }
}

func (b *CommandBuilder) build() *cli.Command {
    rootCmd := rootCmd{}
    cmd := &cli.Command{
        Name:      "gobot",
        ShortDesc: "slack bot",
        LongDesc:  "Usage: @gobot <COMMAND> <OPTIONS> <ARGS>",
    }
    if err := cmd.Options().
        Add(&cli.BoolOpt{Var: &rootCmd.printHelp, Long: "help", Description: "print help"}).
        Err; err != nil {
        panic(err)
    }

    return cmd.
        AddCommand(NewVersionCommand()).
        AddCommand(NewUptimeCommand(b)).
        AddCommand(NewAddCommand(b)).
        AddCommand(NewLsCommand(b)).
        AddCommand(NewUpdateCommand(b)).
        AddCommand(NewDeleteCommand(b))
}

func (b *CommandBuilder) setupRecursive(cmd *cli.Command, sm *SlackMessage) {
    b.setup(cmd, sm)
    for _, sub := range cmd.SubCommands {
        b.setupRecursive(sub, sm)
    }
}

func (b *CommandBuilder) setup(cmd *cli.Command, sm *SlackMessage) {
    w := &literalWriter{w: sm}
    if cmd.Run == nil {
        cmd.Run = func(_ context.Context, cmd *cli.Command, _ []string) {
            fmt.Printf("cmd %s run\n", cmd.Name)
            cli.HelpFunc(w, cmd)
        }
    }
    cmd.Stdout, cmd.Stderr = w, w
}

type literalWriter struct {
    w io.Writer
}

func (lw *literalWriter) Write(msg []byte) (int, error) {
    var b bytes.Buffer
    b.WriteString("```\n")
    b.Write(msg)
    b.WriteString("```")
    return lw.w.Write(b.Bytes())
}

通常のcliと異なるのは、std{out,err}がslack channelへの通知なのでそこをwrapしている点です。

UserのCRUD

ようやく、UserのCRUD処理までたどり着きました。 addUserはこのような感じです

func (c *addUserCommand) runFunc(users UserStore) commandFunc {

    // TODO dupulicate check
    validateUser := func(user *User) error {
        if err := user.Validate(); err != nil {
            return errors.Annotate(err, "user validation failed")
        }
        return nil
    }

    return func(ctx context.Context, cmd *cli.Command, args []string) {
        if c.printHelp {
            cli.HelpFunc(cmd.Stdout, cmd)
            return
        }
        if len(args) < 1 {
            cli.HelpFunc(cmd.Stdout, cmd)
            return
        }
        sm := getSlackMessage(ctx)

        user, err := ReadUserFromArgs(args)
        if err != nil {
            sm.Fail(err)
            return
        }

        if err := validateUser(user); err != nil {
            sm.Fail(err)
            return
        }

        if err := users.AddUser(ctx, user); err != nil {
            sm.Fail(err)
            return
        }

        text := "user successfully added"
        sm.PostAttachment(slack.Attachment{
            Fallback:   text,
            Color:      slackColorGreen,
            Pretext:    slackEmojiOKHand + " " + text,
            AuthorName: sm.user.Profile.DisplayName,
            AuthorIcon: sm.user.Profile.Image48,
            Title:      "user profile",
            Text:       Literalize(user.Pretty()),
        })
    }
}

mongo側の処理はこのようになりました。

package store

import (
    "context"
    "time"

    "github.com/juju/errors"
    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
    "go.uber.org/zap"

    "github.com/ymgyt/gobot/app"
    "github.com/ymgyt/gobot/log"
)

const (
    userCollection = "users"
)

type Users struct {
    *Mongo
    Now func() time.Time
}

func (u *Users) AddUser(ctx context.Context, user *app.User) error {
    now := u.Now()
    user.CreatedAt = now
    user.UpdatedAt = now

    result, err := u.collection().InsertOne(ctx, user)
    if err != nil {
        return errors.Annotatef(err, "user:%v", user)
    }

    log.Debug("add user", zap.Reflect("insertOneResult", result))
    return nil
}

func (u *Users) UpdateUser(ctx context.Context, input *app.UpdateUserInput) error {
    input.User.UpdatedAt = u.Now()
    result, err := u.collection().ReplaceOne(ctx,
        input.Filter.BsonDWithoutTimestamp(),
        input.User)
    if err != nil {
        return errors.Annotatef(err, "input=%v", input)
    }
    log.Debug("update user", zap.Reflect("updateOneResult", result))
    return nil
}

func (u *Users) FindUsers(ctx context.Context, input *app.FindUsersInput) (app.Users, error) {
    opts := options.Find()
    if input.Limit > 0 {
        opts.SetLimit(input.Limit)
    }

    cur, err := u.collection().Find(ctx, input.Filter.BsonDWithoutTimestamp(), opts)
    if err != nil {
        return nil, errors.Annotatef(err, "input=%v", input)
    }
    defer cur.Close(ctx)

    var users app.Users
    for cur.Next(ctx) {
        var user app.User
        if err := cur.Decode(&user); err != nil {
            return nil, errors.Annotate(err, "failed to decode user")
        }
        if user.IsDeleted() && !input.IncludeDeleted {
            continue
        }
        // currently, mongo does not store timezone.
        user.ApplyTimeZone(app.TimeZone)
        users = append(users, &user)
    }
    if len(users) == 0 {
        return nil, app.ErrUserNotFound
    }
    return users, nil
}

func (u *Users) DeleteUsers(ctx context.Context, input *app.DeleteUsersInput) (*app.DeleteUsersOutput, error) {
    if input.Filter == nil && !input.All {
        return nil, errors.New("unsafe deletion process. if you want to delete all, enable the all flag")
    }
    if input.Hard {
        return u.hardDeleteUsers(ctx, input)
    }
    return u.softDeleteUsers(ctx, input)
}

func (u *Users) hardDeleteUsers(ctx context.Context, input *app.DeleteUsersInput) (*app.DeleteUsersOutput, error) {
    result, err := u.collection().DeleteMany(ctx, input.Filter.BsonDWithoutTimestamp())
    if err != nil {
        return nil, errors.Annotatef(err, "failed to delete user. input=%v", input)
    }

    return &app.DeleteUsersOutput{
        HardDeletedCount: result.DeletedCount,
    }, nil
}

func (u *Users) softDeleteUsers(ctx context.Context, input *app.DeleteUsersInput) (*app.DeleteUsersOutput, error) {
    result, err := u.collection().UpdateOne(ctx,
        input.Filter.BsonDWithoutTimestamp(),
        bson.D{
            {Key: "$set", Value: bson.D{
                {Key: "deleted_at", Value: u.Now()},
            }},
        })
    if err != nil {
        return nil, errors.Trace(err)
    }
    return &app.DeleteUsersOutput{
        SoftDeletedCount: result.ModifiedCount,
    }, nil
}

func (u *Users) collection() *mongo.Collection { return u.Mongo.Collection(userCollection) }

mongo-dirver/bsonの扱いがまだわかっていない点があり、もっとよい方法があると思っています。

Dockerfile

作成したappをdocker imageにしてpushします。Dockerfile上では、moduleをoffに指定してvendoringを利用しています。

FROM golang:1.12.4-alpine3.9 as build

WORKDIR /go/src/github.com/ymgyt/gobot

ENV GO111MODULE=off

RUN apk --no-cache add ca-certificates

COPY . ./

ARG VERSION

RUN echo $VERSION

RUN CGO_ENABLED=0 go build -o /gobot -ldflags "-X \"github.com/ymgyt/gobot/app.Version=$VERSION\""


FROM alpine:3.9

WORKDIR /root

COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /gobot .

EXPOSE 80
EXPOSE 443

ENTRYPOINT ["./gobot"]

あとは、docker hubにログインした状態で、mage allを実行すればDocker Registryにimageがpushされます。

Kubernetes

pushしたimageを動かすためにKubernetesの環境を作成していきます。

Mongo

まず、Kubernetes上にMongoを作成します。

3つのResourceを作成します。

  • ConfigMap
  • Service
  • StatefulSet

ConfigMapにmongoの初期化scriptを定義します。 作成したあとはkubectl port-forward mongo-1 27018:27017(podはmasterを指定する必要があります)のようにすると、localのMongoDB Compass等のmongo clientから接続できて便利です。

ConfigMap

apiVersion: v1
kind: ConfigMap
metadata:
  name: mongo-init
data:
  init.sh: |
    #!/bin/bash
    until ping -c 1 ${HOSTNAME}.mongo; do
      echo "waiting DNS(${HOSTNAME}.mongo)..."
      sleep 2
    done

    until /usr/bin/mongo --eval 'printjson(db.serverStatus())'; do
      echo "connecting to local mongo..."
      sleep 2
    done
    echo "connected to local."

    HOST=mongo-0.mongo:27017

    until /usr/bin/mongo --host=${HOST} --eval 'print(db.serverStatus())'; do
      echo "connecting to remote mongo..."
      sleep 2
    done
    echo "connected to remote."

    if [[ "${HOSTNAME}" != 'mongo-0' ]]; then
      until /usr/bin/mongo --host=${HOST} --eval 'printjson(rs.status())' | grep -v "no replset config has been received"; do
        echo "waiting for replication set initialization"
        sleep 2
      done

      echo "adding self to mongo-0"
      /usr/bin/mongo --host=${HOST} --eval="printjson(rs.add('${HOSTNAME}.mongo'))"
    fi

    if [[ "${HOSTNAME}" == 'mongo-0' ]]; then
      echo "initializing replica set"
      /usr/bin/mongo --eval="printjson(rs.initiate({'_id': 'rs0', 'members': [{ '_id': 0, 'host': 'mongo-0.mongo:27017'}]}))"
    fi

    echo "initialized"

    while true; do
      sleep 3600
    done

Service

apiVersion: v1
kind: Service
metadata:
  name: mongo
spec:
  ports:
  - port: 27017
    name: peer
  clusterIP: None
  selector:
    app: mongo

StatefulSet

---
apiVersion: apps/v1beta1
kind: StatefulSet
metadata:
  name: mongo
spec:
  serviceName: "mongo"
  replicas: 3
  template:
    metadata:
      labels:
        app: mongo
    spec:
      containers:
      - name: mongodb
        image: mongo:3.4.1
        command:
        - mongod
        - --replSet
        - rs0
        ports:
        - containerPort: 27017
          name: peer
        volumeMounts:
        - name: database
          mountPath: /data/db
        livenessProbe:
          exec:
            command:
            - /usr/bin/mongo
            - --eval
            - db.serverStatus()
          initialDelaySeconds: 10
          timeoutSeconds: 10

      # this container initializes the mongodb server, then sleeps.
      - name: init-mongo
        image: mongo:3.4.1
        command:
        - bash
        - /config/init.sh
        volumeMounts:
        - name: config
          mountPath: /config
      volumes:
        - name: config
          configMap:
            name: "mongo-init"
  volumeClaimTemplates:
  - metadata:
      name: database
    spec:
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 2Gi

Deploy

Mongoを設定したので、いよいよgobotをdeployします。

3種類のResourceを作成します

  • ConfigMap
  • Service
  • Deployment

ConfigMap

apiVersion: v1
kind: ConfigMap
metadata:
  name: gobot-ymgyt-configmap-v1.0.2
  labels:
    project: gobot
    env: ymgyt
data:
  GOBOT_LOGGING_LEVEL: debug
  # more GOBOT_* env config

これをみておかしいと思われる方もいらっしゃるかもしれません。そうです、Credential情報をConfigMapで管理しています。 KubernetesにはCredential情報を管理するResource(Secrets)があるので、ConfigMapには、Token情報は格納すべきではありません。 ConfigMapの変更を確実に反映するために、nameにversion情報をいれておいたほうがよいと思います。

Service

apiVersion: v1
kind: Service
metadata:
  name: gobot-ymgyt-loadbalancer
  labels:
    project: gobot
    env: ymgyt
spec:
  type: LoadBalancer
  loadBalancerIP: "your global IP"
  ports:
    - name: https
      protocol: TCP
      port: 443
      targetPort: 443
    - name: http
      protocol: TCP
      port: 80
      targetPort: 80
  selector:
    app: gobot
    env: ymgyt

domainをGCP LoadBalancerにmappingするために、ひとつIPを確保しておいて、DNSに設定してあります。 AWSですと、domainにELBのdomain nameをaliasとして登録できるのですが、GCPで同じことをやる方法がわかりませんでした。

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: gobot-ymgyt-deploy
  labels:
    project: gobot
    env: ymgyt
spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  replicas: 1
  revisionHistoryLimit: 2
  selector:
    matchLabels:
      app: gobot
      env: ymgyt
  template:
    metadata:
      labels:
        app: gobot
        env: ymgyt
    spec:
      containers:
        - name: gobot-container
          image: docker.io/ymgyt/gobot:v1.1.0
          imagePullPolicy: Always
          envFrom:
            - configMapRef:
                name: gobot-ymgyt-configmap-v1.0.2
                optional: false
          ports:
            - name: https
              protocol: TCP
              containerPort: 443
            - name: http
              protocol: TCP
              containerPort: 80
          resources:
            limits:
              memory: 100Mi
              cpu: 500m
            requests:
              memory: 10Mi
              cpu: 250m

imageにはさきほどpushしたDocker Hubを指定します。

apply

上記のfileをkubectl apply -f <resource.yaml>で適用すれば完了です。

まとめ

前回に続いて2本目の開発者ブログいかがだったでしょうか。 誤りのご指摘やご意見等あればtwitterまでいただけるとうれしいです。 本当は、mageやDIのcode生成ツールwire, golangci-lintについても書きたかったのですが、断念しました。

ハウテレビジョンでは現在、エンジニア採用を積極的に進めています。 Missionである"全人類の能力を全面開花させ、世界を変える"は、多少宗教的いろあいがあることは否めませんが、5つのValueとして掲げている

  • Challenge
  • Transparency
  • Ownership
  • Userfirst
  • Respect

は真っ当なので、大丈夫です!

特にこの記事を読んでくれる方には、是非インフラエンジニア募集にご応募していただきたいです。

一緒にGCP/AWS + Kubernetes/Container + GoでHappy Developingしましょう。

参考

https://goenning.net/2017/11/08/free-and-automated-ssl-certificates-with-go/ http://shop.oreilly.com/product/0636920043874.do