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

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

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

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でも受け付けております。

O里🚴@2019SR目指します (@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

golang.tokyo#19の参加レポと、GoでOGP画像を作った話

どうも、7月にとあるプロジェクトの振り返り記事の前編を書いて以来、後半を書かずに4ヶ月経過している O里 です。 冨樫先生を見習い、堂々と長期休載してゆく覚悟です。ウソです。

遅くなりましたが、11/14に行われた golang.tokyo #19 にブログ枠で参加させてもらいましたので、せっせとレポします。

テーマは 「golang × 画像処理」

f:id:bumcru0310:20181120212418j:plain

(チラ裏に描いたgopherくん)

golang.tokyoでは毎回 golang×何らかのテーマ(テスト、並行処理、文字列処理…)とゆるくテーマが設定されていますが、今回のテーマは画像処理 でした。

我らが外資就活ドットコムでも、さる9月に 「外資就活相談室」という新サービスをリリースしました。
平たく言うと今流行りの質問箱のようなサービスですが、回答者は運営側で厳選しているため普段なかなか会うことの出来ないすごい社会人(+内定者)に誰でも気軽に質問できるよー、というのが好評を博しています。

質問箱系のサービスで必須なのがOGP画像ですね。
↓のように、「質問」を画像で、「回答」をTweetの本文で表現したりします。

このOGP画像はgoで生成してるのですが、開発にあたり色々と苦労があったので、レポの後にその辺の話もチョロっと書きます。

会場はGunosyさん

みなさんご存知 Gunosy社は六本木ヒルズ森タワー内にあります。
何度か来てますが、僕は毎度 入館の仕方を忘れます。

f:id:bumcru0310:20181120223730j:plain

着くの早すぎ、ほぼ一番乗り。

f:id:bumcru0310:20181120224127j:plain

ウェルカムドリンク。青鬼があるあたり心憎いですね。

f:id:bumcru0310:20181120224251j:plain

LTのタイムアップを知らせる用と思われる銅鑼(ドラ)。しかし鳴らすと近所迷惑にも程があるらしく、主催者さんの首が飛ぶらしいです。

f:id:bumcru0310:20181120224342j:plain

発表×2、LT×4→解散

今回は懇親会パートなどは無く、ドリンクを飲みながら発表を聞く、というスタイルでした。 タメになったこと、気になったことなどザッと記していきます。

発表①: Head First Golang Image Package (@timakinさん)

speakerdeck.com

  • 相談室の開発で自分が経験したことととても近い内容の発表でした。
  • 自分のやってたことが世間と大きくズレていなかったぽい、ということを確認できました。

発表②: Goにおける画像ファイル処理(@harukasanさん)

speakerdeck.com

  • そもそも「画像とは何か?」「人間とは画像をどのように認識するか?」という話。
    • 人間は色情報より輝度情報に敏感なので、色情報は多少サンプリングしてもOKらしい。なるほど。
  • JPEG、GIF、PNG、WebPなど、それぞれの画像フォーマットの歴史。
    • golangの image.Image はどのフォーマットでも表現できるように上手いこと抽象化してくれてるぽい。
    • なので、フォーマットの異なる画像を合成することができる。ふむふむ。
  • 普通に実装しているとなかなかここまで深く知る機会は無いので勉強になりました。さすが画像を扱うプロ集団のPixivさんですね。

LT①: Go画像と東京アメッシュ(@otiai10さん)

f:id:bumcru0310:20181120224409j:plain

  • ターミナル上に画像を出す話。
  • 一番熱量が高く、会場の笑いをさらってました。

LT②: goのimagemagickを使ってレンチキュラー写真を作ったハナシ(@junpaymentさん)

docs.google.com

  • ドット単位で打ち込み位置を計算すれば、2つの画像をマージしたレンチキュラー写真(見る角度によって絵が変わるアレ)も作れる、というアイデア作。
    • やろうと思えばマジで何でも出来るんだなと勇気をもらいました。
  • 「デジタルの世界と紙の世界は違う」という深い示唆を頂きました。
  • 唯一 gographics/imagick を使った事例を紹介されていて、トラウマが開く音がしました。

LT③: とあるライブラリをGoに移植した結果……(@makki_dさん)

speakerdeck.com

  • 移植駆動学習すごい、の一言。
    • golangにまだ無いサードパーティ製のライブラリを見つけては他言語の実装に倣って移植すると学習が捗る。
    • ついでに既存ライブラリのバグを見つけて修正PRを送るチャンスまである。
    • 移植元のライブラリを叩けば入出力のペアを簡単に得られるのでテストを書きやすい。
    • テストカバレッジを測るとどこが理解できてないか可視化出来る。

LT④: とあるgopherがOpenCVをさわってみた話(@usk81さん)

f:id:bumcru0310:20181120224523j:plain

  • CERNでPython→golangの乗り換えがあったらしい。
    • 機械学習系でgoの機運?
    • もれなくシュタゲを連想するなど。
  • GoCVを使うと、集合写真を認識して個々人の顔だけ切り出したりできる。
  • 「画像生成」だけじゃなくて「画像認識」もgoでできる、という啓示を得られた気分でした。

OGP画像をgoで作った時の苦労話

先述の通り、外資就活相談室のOGP画像をgoで実装しましたので、こぼれ話をいくつか紹介します。

ImageMagick を使って爆死

「golangで画像を扱うなら標準の image パッケージでしょ?」と、今なら言えます。そう、今ならね。

夏頃に「よっしゃ実装や!」と張り切っていた時、「golangでImageMagickを触りたい」という記事を発見。「これは天恵!」とばかりに、gographics/imagick を使って実装を始め一通り完成に至りました。

しかしこの時、完全にデプロイのことを考えていませんでした。

外資就活のgoのAPIはAWS ECSのコンテナ上で動いているのですが、軽量な alpine のコンテナ内にクロスコンパイルした実行ファイルをポンと置く、というシンプルな手順でデプロイしています。

gographics/imagick は実行環境で apt-get install libmagickwand-dev などして依存するライブラリをインストールしておく必要があるのですが、これでは「クロスコンパイルして実行ファイル置くだけ良い」というgolangの旨味をまったく享受できません。また、開発環境や実行ファイルのビルドには golangイメージを 、本番での実行環境では軽量な alpineイメージをと使い分けているため、開発環境と本番環境で動作が異なる可能性も出てきてしまいます。

さらに追い打ちのように「ImageMagickに脆弱性があるかも!!」というNEWSが飛び込んだため、ImageMagickとの訣別を決めました。

さよならっ!!!!

読みやすく改行するため禁則処理をライトに実装

Webページであれば文字の折返しはブラウザが良い感じにやってくれますが、画像に文字を埋め込むとなると画像から文字が溢れないように改行する処理も自前で実装する必要があります。

画像に対する文字サイズや余白の広さ、使用するフォントなどは仕様次第で変わるため、この辺はトライアンドエラーを繰り返してマジックナンバーを探し出す必要がありました。

そんなこんなで改行を実装した結果が↓左側です。

f:id:bumcru0310:20181120203132p:plain

「ャ」や「、」などの文字が行頭に来てしまい読みづらいでです。英文に至っては英単語の途中で改行されてしまうため、とても読める状態ではありません…。

ここで必要になるのが 禁則処理 です。

ブラウザやワープロ系のソフトウェアに実装されているアレです。(このとき禁則処理という言葉を初めて知りました)

さすがにこんなニッチなライブラリをgoで見つけることは出来なかったため、自前で実装することにしました。

試行錯誤の末、以下の様なアルゴリズムで文章を改行可能な単位の文字列(ブロック)に分割しスライスに格納。

今度はこのブロックごとに行を組み立てていき、文字幅を計算して溢れそうになったら次の行を追加してまた結合…、を繰り返します。

画像にレンダリングした後の文字列の正確な幅を取得するのは骨が折れますが、全角/半角を考慮して大体の文字列幅を計算し、試行錯誤を繰り返して係数をチューニングしていけば、そこそこの精度で文字を置くことができます。

全角/半角を考慮した文字幅の取得には mattn/go-runewidth を使わせて頂きました。ありがてぇ。

もちろん真面目に禁則処理を考えるとこんなにライトな実装ではツッコミどころ満載かとは思いますが、まぁ今のところ必要十分に機能しています。

まとめ

  • golang.tokyoに参加するのは今回で3回目ですが、画像処理は直近で向き合っていたテーマだったのでいつも以上に深い理解を得られました。

Go書きたいエンジニア募集中!

ハウテレビジョンでは

  • 「Goを書いてみたい」
  • 「とにかくGoが大好き」
  • 「好きすぎてgopher君に顔が似てきた」
  • 「Goのことなんて全然好きじゃないんだからねっ!(好き)」

というエンジニアを絶賛採用中です!

ご興味ある方はぜひぜひ!、まずはカジュアル面談でお会いしましょう。

ご連絡はこちらから!

業務未経験からエンジニアになって半年を振り返る

こんにちは、ハウテレビジョンエンジニアのFです。

突然ですが、ご存知の通りここ最近のプログラミング学習ブームを機に、

  • プログラミングを勉強する非エンジニアの方
  • スクール等に通いエンジニアに転職する方

が増えてきているかと思います。

私自身もその一人。 私がハウテレビジョンにジョインしたのは2017年10月で、それまでエンジニアとしての業務は完全未経験でした。 Webエンジニアを養成するスクール「TECH::EXPERT

tech-camp.in

を経て、 エンジニアとして採用されました。

そんな私が、

  • Web系エンジニア養成スクールに通われている方
  • 未経験からエンジニアを目指している方

などに向け、 エンジニアになって半年ちょっとの間でやってきた業務、感じたことなどを書き綴ります。

エンジニアとして採用されるまで

大学時代は商学部で主にマーケティングを学ぶ、根っからの文系人間でした。 前職は商社にて法人営業をやっており、 これまで「プログラミング」とは無縁の生活を送っておりました。

そんなあるとき、プログラミングに関する雑誌記事に出会いました。 そこに書かれていたチュートリアル通りに小さなアプリを作るべくコードを書いてみると、 これが本当に楽しい。就業後、家に帰ってはPCを開き、時間をかけながらも一つのアプリを作り上げました。

これをきっかけに「ものつくり」の楽しさに目覚め、独学でプログラミングを学びはじめました。 営業の仕事を終え、毎日2時間ほど週末も一日使って勉強をしていましたが、 正直「もっとやりたい」という気持ちがありました。

次第に「フルコミットでプログラミングを学びたい」「ゆくゆくはエンジニアとして働きたい」という気持ちが強くなり、 たまたまTwitter上で見つけたTECH::EXPERTの相談会に足を運んでみました。

その半年後には商社を退職し、TECH::EXPERTでの勉強漬けの毎日(1日10時間×週6日×3ヶ月間)が始まったのです。 約3ヶ月のカリキュラムを終え就職活動をする中で、サービス志向を持つエンジニアを目指していきたいと考え「自社サービスを開発する企業」に興味を持ちました。 そうして、面接を受ける中で自分に合うと感じたハウテレビジョンに入社しました。

半年間の主な業務

自分にとってはここからが本当のスタートです。 入社してからの半年とちょっとの間、「行動すればいろいろなことにチャレンジできる環境」の中、 様々な業務を行ってきました。 具体的には下記のようなことを主に行いました。

PHP(フレームワークはCakePHP)を使った既存サービスの保守・運用、新機能の開発

 - ビジネスサイドから上がってくる要望に基づく実装。小さな機能や少し大きな開発(長期インターンサービスのリリースなど)。

  - 日々上がってくる不具合修正

SQL・Python、Google Analitics・BigQueryを使ったデータ分析・集計

 - こちらもビジネスサイドから上がる要望を基にデータ集計

 - 開発〜効果検証まで

エンジニアインターン生(大学生)のフォロー

 - 開発の手助けやレビュー

半年間で担当した開発

上記のような業務の中で、実際に自分が関わりリリースされた機能を紹介します。

長期インターンページ

gaishishukatsu.com

入社して2ヶ月経った頃に関わった、はじめての大型開発でした。 それまでは小さな運用改善の開発ばかりだったので0からの開発には手こずりましたが、 リリースされたときの達成感は大きかったです。 こちらは現在でも、改善に向けたプロジェクトの開発に携わっています。

企業配下(企業の年収/社風などを紹介する)ページ

gaishishukatsu.com

ここ最近だとこちらの開発に関わりました。 見積もりから進め、ビジネスサイドと密に話をしながら開発していった案件でした。

ベテランエンジニアの方からみれば小さな開発ではありますが、 完全未経験だった頃から考えたらたった半年ほどで様々な開発に携われていることに驚きがあります。

スクール時代の勉強と実際の業務とのGap

HTML、CSSやRuby、PHP、その他GIthubの使い方など、スクールで最低限の基礎は身につけられていたので、 そのあたりは入社後もある程度キャッチアップしやすかったです。 一方で、スクールのカリキュラムだけでは十分に身につけられなかった部分、「入社前にもっと勉強しておけばよかったなあ」 と感じた部分ももちろんありました。 例えば、

ネットワークとかインフラの基礎知識

情報系学部を卒業していれば「ネットワークの仕組み」みたいなところから学べるだろうがそうでもなく。 スクールのカリキュラムに説明はあったが、さっと触れる程度。 業務で実際に必要になった場面はないが、経験あるエンジニア達の会話についていくときに必要と感じた。

SQL

こちらもカリキュラムではほんの触りのみ程度だった。 エンジニアになる前に思っていた以上に分析系タスクが多かった。

「もっと勉強しておけば...」なんて部分は上げたらキリがないですが、特にこの2点はGapがあったなと感じました。 上記の課題を克服する上で、まずは下記のような本を読み漁り、勉強を進めました。

ネットワークの基礎知識に関して

その他、身近な駆け出しエンジニアの中には基本情報技術者試験を受験していた人もおりました。

SQLに関して

この一冊をやれば基礎部分は十分身につくかと思います。

ちなみにこれらの本は、弊社の本棚に置いてある、 もしなければ会社負担で購入もしてもらえる制度があります。

こうしてスクールで学べることと実際の業務のGapを書きましたが、 決してTECH::EXPERTを否定するつもりではありません。 エンジニアとしての基礎技術力・基礎体力を身につけられたことや、 就職支援には非常に感謝しておりますm( )m

エンジニアになってからの学習や意識について

エンジニアになれたとはいえ、もちろん学習が終わりではありません。 より技術力を高める、会社・チームに貢献するために何をすべきか自分なりによく考えました。 ここではその後の勉強法や意識してきたことについて書いていきます。

プライベート開発

自社サービスの運用では、どうしても「サービスを一から開発する」という機会はなかなかないと思います。 また、どうしてもコードを書く時間が限られる場合もあります。(弊社の場合、基本的には書けますが。) 技術力を高めていくためには、エンジニアを目指していた頃同様に業務時間外でも開発・学習が必要になるかと思います。 個人的に考えているのは、写経でもなんでもとにかく手を動かしてアウトプットすることが大事だということ。 もちろん自身で作りたいサービスを考えて作ることができたら素晴らしいですが、 とにかく「量をこなす」「手を動かす」ことこそが大事だと思うので、 あまり内容にはこだわり過ぎずに気楽にやっていくのが良いかと思ってます。 ここで私が入社してからの間、週末などの時間を使ってやってきたことを書きます。

やってきた勉強法

定番

言うまでもない定番どころの学習サービスを使った勉強法です。 ド初心者の言語などはこのあたりで学ぶのが効率的かなと思います。 Udemyは環境構築〜ちょっとしたサービスを作り〜リリースまで学べるコースがあるので、開発の流れを掴むという点ではおすすめです。

経験あるエンジニアの方には参考にならない面もあるかもですが、 駆け出しエンジニアや非エンジニアだけどプログラミングをかじってみたい方などにおすすめです。

その他

  • 既存サービスを真似て作ってみる
  • 知り合いの依頼で開発のお手伝い
  • LINEbot作成
  • MENTAココナラでメンターを雇って質問
  • IoT機器を使って自宅をスマートハウス化(人感で自動で点灯するライトを自作)
  • Twitterエンジニアアカウントをフォローして情報収集

定番ではないですが、その他やってきたことです。 「既存サービスを真似て作ってみる」に関しては、「DB構造はどうなっているのか」「ここをこうしたほうが良いサービスになるな」と考えるだけでも勉強になります。

これらプライベート開発での成果はもちろん業務の中でも役立っております。

技術面以外での意識

自社サービスの理解

とても基本的なことですが、自社のサービスに「どのような機能があるか」「どのようなロジックでできているか」を できるだけ多く自社のサービスに触ることで把握するようにしました。 おかげでビジネスサイドからの相談をスムーズにこなせるようになりました。 (まだまだ先輩エンジニアの方々には遠く及びませんが。)

自身のバックグラウンドを活かす

「情報系学部出身ではない」「前職が他業種」であることを引け目と感じるのではなく、 「個性」「強み」のひとつとして考えました。 私の場合、前職が営業職だったこともあり、「ビジネスサイドとのコミュニケーション」を特に意識しました。 おかげで仕事もやりやすく、信頼されているかどうかはまだしも ビジネスサイドからよく開発相談や開発依頼をいただけるようになりました。

技術力だけで勝負をすれば、経験豊富な先輩エンジニアには及びません。 それでも自分なりに会社やチームに貢献できるところはあるはず。 これからも個性を生かして貢献していけたらと考えています。

終わりに

偉そうに書いてきましたが、まだ業務経験も1年に満たない駆け出しのエンジニアです。 未だにわからないことやつまづくことばかり(その際は社内の先輩エンジニアが優しくご指導くれます笑)なので、 これからも日々精進していきたいと考えています。

弊社では引き続き、私のように「業務は未経験」だけど「エンジニアになるために必死に勉強をしている」方も 積極採用中(実際にTECH::EXPERT経由で新たに1名採用決定!!)です。 ご興味ある方はぜひ弊社に遊びにきてください。

ご連絡はこちらから。

Docker初心者がRails+MySQLの環境構築をDockerでやってみた

はじめに

先日リリースした弊社のリニューアルされたiOSアプリのサーバーサイドはDockerで環境構築されております。
Dockerはとても便利で、設定されていば簡単なコマンドを叩くだけであっという間に開発環境が整ってしまいます。
しかし、その便利さ故にDockerを特に意識することなく時だけが過ぎてしまいました、、、
先日何気なく弊社のDocker周りの設定をふと見てみたら、何が書いてあるか全く理解できず(TдT)
これはまずいと思いDockerの勉強をはじめることにしました。
今回はDocker初心者が勉強がてらRails+MySQLの環境構築ができるか試してみました。

f:id:hy616:20180716143345p:plain

環境

今回構築するのは以下の環境です。

  • Ruby 2.5.1
  • Rails 5.2.0
  • MySQL 5.7

参考(事前勉強)

Dockerfile リファレンス — Docker-docs-ja 17.06.Beta ドキュメント
クイックスタート・ガイド:Docker Compose と Rails — Docker-docs-ja 17.06.Beta ドキュメント

Dockerfile作成

Dockerfileとは、Dockerコンテナの構成内容をまとめて記述しておくファイルです。
ここに書かれた内容をもとに、Dockerイメージが構築されます。
以下を記述します。

FROM ruby:2.5.1
ENV LANG C.UTF-8
RUN apt-get update -qq && apt-get install -y build-essential mysql-client nodejs
RUN gem install bundler
WORKDIR /tmp
ADD Gemfile Gemfile
ADD Gemfile.lock Gemfile.lock
RUN bundle install
ENV APP_HOME /myapp
RUN mkdir -p $APP_HOME
WORKDIR $APP_HOME
ADD . $APP_HOME

Dokerfileで使用している命令について軽く説明します。

FROM(必須)

コンテナのベースとなるイメージを指定します。
DockerHubで公開されているイメージや、自作のイメージも指定できます。

ENV

環境変数を設定します。

RUN

FROMで指定したイメージ上で、コマンドを実行します。
パッケージのインストールなどに用います。

WORKDIR

作業するディレクトリを変更します。

ADD

指定したファイルやディレクトリをコンテナ内にコピーします。

※上記以外にも命令はいくつかあるのでご興味があれば公式ドキュメントを参照ください。

Gemfile作成

今回はversion:5.2.0を指定しています。

source 'http://rubygems.org'
gem 'rails', '~> 5.2.0'

Gemfile.lockを作成

Gemfile.lockが必要なので、空のファイルを作成しておきます。

$ touch Gemfile.lock

docker-compose.yml作成

複数のコンテナを一括管理するための構成管理ファイル。
docker-compose up コマンドで記述したサービスが全て立ち上がります。
今回はRubyとMySQLの2つのコンテナの設定をします。

version: '3.4'
services:
  db:
    image: mysql:5.7.17
    ports:
      - "3306:3306"
    volumes:
      - ./docker/mysql/volumes:/var/lib/mysql
    env_file: .env.dev
  web:
    build:
      context: .
      dockerfile: ./docker/rails/Dockerfile
    command: /bin/sh -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    ports:
      - "3000:3000"
    volumes:
      - .:/myapp
    environment:
      RAILS_ENV: development
    env_file: .env.dev
    depends_on:
      - db

上の記述について軽く解説します。

version

Compose ファイルのバージョンを指定できます。

image

ベースイメージを指定します。

ports

ホスト側とコンテナ側の両方のポートを指定できます。(ホスト:コンテナ)
コンテナポートのみを指定した場合は、ホスト側のポートはランダムで指定されます。

volumes

ボリュームをマウントするときに指定します。

env_file

指定したファイルから環境変数を追加します。

depends_on

コンテナの作成順序と依存関係を決められます。
※開始の順序を制御するだけで、コンテナ上のアプリケーションが利用可能になるまで待つという制御は行いません。

※上記以外にも命令はいくつかあるのでご興味があればこちらについても公式ドキュメントを参照ください。

.env.dev作成

パスワードは別で管理したいため、.env.devを作成します。
ここに任意のユーザーとパスワードを設定します。

MYSQL_USER=****
MYSQL_PASSWORD=****
MYSQL_ROOT_PASSWORD=****

Railsアプリケーション作成

docker-compose runコマンドでwebコンテナの中でrails newコマンドを実行します。
ここでdatabaseにMySQLを指定します。

$ docker-compose run --rm web rails new . --force --database=mysql --skip-bundle

イメージをビルド

$ docker-compose build

config/database.ymlを編集

default: &default
  adapter: mysql2
  encoding: utf8
  pool: 5
  username: root
  password: <%= ENV['MYSQL_ROOT_PASSWORD'] %>
  host: db

development:
  <<: *default
  database: development

コンテナを起動

下のコマンドでRailsサーバーとMySQLサーバーを起動してくれます。

$ docker-compose up

データベースを作成

最初はデータベースがまだ作られていないので、以下のコマンドでデータベースを作成します。

$ docker-compose run --rm web rake db:create

http://localhost:3000 にアクセス

なんとかRailsが動いていることが確認できました。

f:id:hy616:20180702015927p:plain

まとめ

今回はDockerを用いて、Rails+MySQLの環境構築を試してみました。
Dockerに関しての知識があまりなくても、最低限ですがRails+Mysqlの環境構築はできました。

個人的にはDockerの学習は、コンテナを立ち上げるまでは学習コスト低めだと思いますが、実際の運用にはもっと多くのノウハウが必要なので、総合して学習コストは高いのかなと感じました。
しかしその反面、他の作業者と同じ環境が簡単に用意できるのはとても魅力的だと感じました。
まだDockerを触ったことがない方はぜひ触ってみてはいかがでしょうか。

ハウテレビジョンでは絶賛エンジニア採用中です!

ここまで読んで頂きありがとうございます。
Docker、Rails、ReactNative、Goなどを業務で使ってみたいエンジニアの方がいらっしゃいましたら、ぜひとも一度オフィスに遊びに来てお話しましょう!

ご連絡はこちらからお願いします!

半年でエンジニア4人→12人に急増したチームで、iOSアプリ(ReactNative)とAPI(Golang)を作り直して無事リリースした話(前編)

はじめに

どうも。 先日行われた開発合宿に自転車(ロード)で行こうと思ったら台風の中120kmも走るハメになった O里 です。(開発合宿については別記事にて書きます)

さて、去る2018年5月中旬に、外資就活ドットコムiOSアプリを全面リニューアルしました🎉

prtimes.jp

このプロジェクトでの僕の役割は、バックエンド側の設計・実装・進捗管理とメンバーの採用で、それに加えてアプリリニューアル以外の開発案件のとりまとめなども並行してやってました。

今回のリリースはiOSアプリのデザインを根本的に見直すことでもっともっと使いやすいアプリにすることが最大の目的でしたが、ReactNativeGoDockerを使うという、技術的に大きな挑戦も含んでいました。

外資就活はWebアプリケーションとスマホアプリ向けのAPI、iOSアプリとandroidアプリを提供していますが、WebとAPIはCakePHP(2.x) で、iOSアプリはSwift、androidアプリはJavaでそれぞれ書かれており、開発環境の構築には VirtualBOX+Vagrant+Chefを使っていました。(一部今も使ってます)

f:id:bumcru0310:20180713110538p:plain

今回の記事では、この大きな技術的転換点をどのように乗り切ったのか、チームビルディングの観点から振り返りたいと思います。超大作になりそうなので2〜3記事に分けて書きます。

序章 ~ プロジェクト発足の背景 ~

スマホアプリからのアクセスが急増

https://img.esa.io/uploads/production/attachments/5008/2017/07/26/5972/3fada706-08f0-449e-baaa-5f1dec3d7280.png

スマホアプリをリリースしたばかりの2015年頃はスマホよりPCからのアクセスが大勢を占めていた外資就活も、いつしかスマホからの利用が上回るようになり、特にアクティブ率の高いユーザーが多いスマホアプリ改善への要望も高まっていました。

リニューアル前の外資就活アプリはログインしないとほとんどの機能が使えない仕様で、「すでに会員登録済みのユーザーにより便利に使ってもらうアプリ」という代物。デザイン的にも最新のAppleUIガイドラインに準拠していませんでした。

これを、「とりあえずアプリをDL→めっちゃ便利→面倒だけど会員登録してみよう」という順番にUXを入れ替え、さらにデザインも直感的に使えてより便利なUIに作り変えようという声が高まり、iOSアプリのリニューアルが外資就活の最重要ミッションになりました。2017年8月の話です。(ちなみにandroidよりiOSユーザーの方が圧倒的に多いため、まずiOSから作り直すことになりました)

iOS専門のエンジニアがいない → ReactNativeだ!!!!

しかし当時の開発チームにはWeb出身のエンジニアしかおらず、スマホアプリネイティブの改修となると腰が重たい状況が続いていました。小さな開発チームでは各社課題になっていることですね。 当時は開発リソースが少なかったこともあり「ネイティブアプリだけど中身はWebViewの箱のようなアプリにしてしまおう!」という企みも検討されましたが、結局は「ユーザーにぬるぬるサクサク快適に使ってもらうアプリを作る上でネイティブでの開発が必須」という結論に至りました。

そうは言ってもWebとiOSとandroid全部修得してその後もメンテし続けるのはやはり現実的ではありません。 そこで当時にわかに人気が高まっていたReactNativeの導入を検討するようになりました。この背景には、スマホアプリだけでなく何ならWeb側もバックとフロントのコードを完全に分離し、フロントはReactで固めたら最高だぜ!という企みがありました。(まだ企んでます)

このブログを執筆時点でもReactNativeの最新バージョンは 0.56 で、「メジャーバージョンがまだ 0 のもの使って本当に大丈夫か!?」という懸念はありましたが、メルカリさんがReactNativeの開発案件で求人を出し始めたのを見て背中を押され「よし、RNで行こう!」と決意が固まったのを覚えています。

ちなみに僕はJS大好きっ子なので、この選択を心から歓迎していました。

アプリ作り直すならバックも作り直さないとしんどい… → Goだ!!??

外資就活のバックエンドは複数に分かれたWebアプリ(本体のWebアプリ、スマホ向けのAPI、社内向けの管理システムなど)がDBにつながっており、そのWebアプリ同士が一部無秩序に相互参照していたり、それぞれがほとんど同じコードを重複して持ってしまっていたり(コピペコード問題)、CakePHP2系のサポートが切れそうだったり、そもそもPHPを使っていたり、と、いくつもの課題を抱えていたため、かねてから技術的負債解消への糸口を探していました。

最大の課題はDB設計の不味さ(外部キーが無い、良くないポリモーフィック、今は使われていないテーブルやカラム達、命名規則バラバラ…など)なのですが、前述のような状況では手の出しようがありません。

また、スマホアプリを大幅に作り直すにあたりユーザー認証周りの仕様を大幅に変える必要があったのですが、既存のバックエンドのコードに新仕様でのコードを追加していくのは影響範囲が広すぎて設計が難しく、何よりお世辞にも楽しい作業とは言えませんでした。

こうして既存APIを拡張するのではなく、新APIを構築して徐々に既存処理を統合していく方針が決まり、技術選定へ。Java、Go、Kotlin、PHP7など、いくつかの言語やフレームワークでAPIのサンプルを作成し、どれにするか検討しました。

負債解消は先が長いプロジェクトで、しかも非エンジニアのメンバーからはその必要性が理解されづらいものです。そこで、今回の技術選定ではエンジニア目線での「楽しさ」を重視することにしました。 楽しければそのプロジェクトは勝手に進むし、やりたい人も自ずと集まってくるものだと確信したからです。(※ 後で書きますが、素敵なエンジニアが勝手に集まってくることはありません。相応のアクションは必要です。)

そんな中でGoを選んだのは以下のような理由からです。

  • 漠然とした「イケてる」感
  • シンタックスがシンプルで取っつきやすい親近感
  • 静的型付けがある安心感(PHPのふわふわ感にいい加減 辟易していた)
  • コンパイル言語で動作が高速
  • コードフォーマッタが予め組み込まれているのでコードの質を担保しやすい
  • ginでAPI作るだけならnginxとか無くても1ファイルに数行書くだけでOK
  • エンジニア採用においても優位性を出せるかも
  • Java家系じゃない

ちなみに、Goの中でさらにどのFWを使うかを決める際は gin にするか echo にするか悩みましたが、botmanの鶴の一声でサクッと決まりました。

Hello, Docker. Goodbye Chef.

また、開発環境および本番環境のインフラ構築にはDockerを使いました。「今どきコンテナ環境使わないのやばい」という世の中の見えない圧力を感じ、当たり前のようにDockerで環境を作り始めました。

実際Chefと比べても、各コンテナごとに何をやっているのかシンプルに記述されているため、インフラが苦手な自分でも理解しやすいという印象でした。

第2章 ~ メンバー集め ~

こうしてプロジェクトが始動したのが2017年8月下旬。当時の開発チームにはエンジニアが4人しかおらず、ReactNativeやGo、iOSアプリ開発の経験がある人はいませんでした。Dockerはかろうじて開発環境で使ったことがありましたが、本番運用の経験はありません。ここまで聞くとあまりに無謀過ぎて「アホなの(^ν^)?」と自分でも思います。

10月: iOSネイティブの超ベテラン降臨

何はともあれエンジニアを採用しないと翌年5月のリリースに間に合わない!そんな逼迫感からまず募集をかけたのはiOSアプリのネイティブエンジニアです。

前述の通り、リニューアル前のiOSアプリはSwiftで書かれているのですが、新アプリの開発に加え、既存のアプリのメンテや緊急度の高い機能追加もやる必要がありました。 また、RNで開発するとは言え、Webとは違うアプリ開発の常識を心得ていて、一部のネイティブとのブリッジコードを書いてくれるネイティブエンジニアの存在は必要不可欠でした。

このときジョインしてくれたNさんは、iOSアプリの開発案件が日本で出始めたばかりの頃(10年前くらい)からアプリ開発を専門でやっている超ベテランでした。 10月から8ヶ月間ともに働くことが出来ましたが、他のメンバーからも「Nさんみたいなエンジニアになりたい」と憧れられるような偉大な存在でした。

Nさんを紹介してくれたのはギークスさんという会社で、数人紹介してくれた中でNさんと出会い、約1ヶ月後から一緒に働き始めることになりました。業務委託という形式にも関わらず開発合宿にも参加してくれるなど柔軟に対応頂き、本当にありがたい限りでした。

geechs.com

10月: Webエンジニア2名ジョイン!!

iOSアプリのリニューアルが最重要案件だとしても、明らかな不具合や会員数を伸ばすためにどうしても試したい施策、売上を達成するための新商品開発など、サービスを運営し続けている以上目先の開発タスクは山のように降って湧いてきます。本当に優先度の高いものだけに絞ったとしても、いくつかの案件には対応せざる終えません。「アプリはリニューアルして良くなったけど、その間にユーザーが離れてました」では本末転倒です。

しかし、腰を据えて新しい技術を習得する上では、これらの細々とした案件が障害となり、リニューアルプロジェクトを停滞させてしまいます。 明らかにエンジニアの手が足りなくなり、「バリバリ手を動かしてタスクを片付けてくれる人が欲しい!」と切望した時、奇跡が起きました。

何と、若手のWebエンジニアの入社が1ヶ月たらずで決まったのです。しかも2名同日入社!

この時、採用に力を貸してくれたのはTECH::EXPERT(テックエキスパート)さん。迅速すぎる対応で、募集開始〜入社までが1ヶ月を切るという異例のスピード採用でした。

tech-camp.in

テックエキスパートについてざっくり説明すると「エンジニアに転職したい未経験者をみっちり育ててIT企業とマッチングする」というものです。 なので、業務経験のないエンジニアをある程度教育する必要はありますが、何より「エンジニアとして働きたい」というモチベーションの高いメンバーを採用することができます。

ここで入社した2人はその後、1人はリニューアルプロジェクトのバックエンドチームに参加しGoでAPIをバンバン実装し、もう1人はリニューアルプロジェクト以外の開発案件をほぼ1人で受け切るなど、それぞれ活躍してくれました。 本当にこのタイミングで入ってくれた2人には感謝しています。

次回予告

さて、リリースを半年後に控えた時点でエンジニアはまだ7名。GoとReactNativeの経験者はまだいません。

そろそろ長くなってきたので、続きは次のブログにて。

次回は「怒涛のフリーランス入社ラッシュ」「沖縄からの襲来者(Goエンジニア)」「錯綜する設計、終わらないモブプロ、狂う見積り」などの内容でお送りする予定です。

次号を待て!!

ハウテレビジョンでは絶賛エンジニア採用中です!

ここまで読んで頂きありがとうございます。ReactNativeやGo、Dockerを業務で使ってみたいエンジニアの方がいらっしゃいましたら、ぜひとも一度オフィスに遊びに来てお話しましょう!

ご連絡はこちらから。

リモートワークをやってみてわかったメリット・デメリットと制度化に必要な工夫

リモートワーク導入2ヶ月のまとめ

リモートワーク。

自宅、カフェ、コワーキングスペースなどオフィス以外の場所で勤務する働き方。朝夕の通勤ラッシュに揉まれることなく、オフィスの空調戦争に巻き込まれることもない。例えば自分の好きな場所で、好きな音楽を聞きながら、リラックスして仕事に集中できる。

そんなリモートワークがこの5月より弊社にも導入されました。
「事前にSlackで申請をすることにより、週2回/月4回までリモートワークが可能」という制度です。 対象となるのはエンジニア職のみで、まだ制度として試行段階ではありますが 実際に2ヶ月体験して感じたメリット・デメリット、そして今後の課題などをまとめました。

体験してわかったリモートワークのメリット3つ

f:id:yumasukey:20180703141939j:plain

「サラリーマンの夢=出社しない」を実現

なんといってもこれが一番大きなメリットだと思います。 毎朝通勤ラッシュに揉まれて、オフィスにつく頃にはヘトヘトでモチベーションもだだ下がり。 そんな状況から開放されるのは、サラリーマンの夢だといっても過言ではないと思います。 リモートだと当然ですが通勤の必要がありません。 朝はちょっと遅めに起きて、ご飯を食べたらシャワーでも浴びて、スッキリとした気分で自室のデスクに向かう。 通勤ラッシュという苦行をこなす必要がないので、仕事を始めるモチベーションも上がりますよね。

「ちょっといいですか?」に邪魔されない

メールチェックやミーティングなども終わり、ようやく仕事に集中しだした頃にやってくる一言。

「あの、ちょっといいですか?」

経験ある方もいると思いますが、これをやられるとせっかくの集中力が途切れてしまいます。 一度切れた集中力を戻すのは大変。「質問は後にしてくれよ」と思ってる方も多いはず。 リモートワークだと、コミュニケーションは基本的にメールかチャットベース。 なので自分の好きなタイミングでチェックでき、集中したいときに邪魔されることはありません。

リラックス感が半端ないって

誰にも邪魔されることなく、好きな格好で好きな音楽を聞きながら仕事をする。 この暑い季節、オフィスで繰り広げられる不毛な空調戦争とももちろん無縁です。 そりゃ当然オフィスで仕事するよりリラックスできます。 ちなみに私の場合、自宅だったので甚兵衛を着て、スピーカーでBGMを流しながら作業してました。

メリットまとめ

リモートワークで感じたメリットをまとめると まず通勤にかかる時間とラッシュによるストレスがなくなります。 次に誰にも邪魔されずリラックスできるので、高い集中力を持続させやすくなります。 ゆえに作業効率がアップし、仕事も捗ります。

逆にデメリットも3つある

f:id:yumasukey:20180703141747j:plain

もちろんいいことばかりではなく、デメリットもあります。 次にあげるようなことが、実際感じた・思ったリモートワークのデメリットです。

ちゃんと伝わってる?!コミュニケーションが難しく

リモートだと、基本的にメールかチャットなどテキストベースでやりとりすることになります。 テキストベースだと、どうしても微妙なニュアンスが伝わりづらく、文章に工夫が必要になります。 言葉では簡単に伝えられるのに、文字だと言いたいことが正確に伝わらないというストレスを感じることがありました。 対面に比べて伝わる/伝える情報量が少ないので、会議や打ち合わせといったケースは特に対応が難しいかもしれません。

現場の温度感・緊急性がわかりづらい

テキストベースでのコミュニケーションに起因するものとして、現場の温度感を感じ取りにくいというデメリットもあります。 たとえば顧客から緊急の問い合わせがあり、すぐに回答がほしいのに、その「緊急感」が文字からは読み取れなかったり チャットやメールの確認が遅れたりして、結果としてトラブルになってしまうケースも考えられます。

煩悩との戦い!高い自律が求められる

オフィスとちがって誰も見ていないので、サボろうと思えばいくらでもサボれてしまいます。 ゲーム、テレビ、ネットなどオフィスに比べて誘惑はいっぱいあります。 「ちょっとくらいいか〜」と気を許したらもうダメです。ズルズルと時間を溶かしてしまいます。 誰にも見られていないとはいえ、業務中であるという認識と、誘惑に負けない自律性が必要です。

デメリットまとめ

文字から伝わる情報は対面と比較して乏しいため、相手に正確に伝えるためには文章表現に工夫が必要になります。 そして口頭でのコミュニケーションとは異なり、リアルタイムにやり取りできないので現場との空気感・緊張感の共有が難しく、後で問題になることも。 上記に加え、誘惑に負けないように自分を律する必要があります。ですので誰にでも合う働き方ではないと思います。 特に自室で一人勉強するよりも、友達と勉強したり、自習室にいったほうが捗るといったタイプの人には向かないかもしれません。

「ハイブリッド型」のリモート制度がいいのでは?

2ヶ月ほど週1回のペースでリモートワークを体験してみて感じたことをまとめてみました。 まだまだ制度としては試行錯誤の段階ですが、私の場合はプラスの面のほうが多かったように思います。 確かにどうしてもリモートでは対応できない状況もありますので、フルリモートではなく、週にN回までといったハイブリッド型の制度にするのが良いと感じています。

一方でデメリットであげたように、リモートワークというスタイルは誰にでも合う働き方ではないと思います。 人や業務によって合う合わないがあると思います。 それ以外のデメリットについては、チャットやメールだけでなく、Webカメラやマイクを使用して音声でもやりとりしたり こまめに進捗を報告したりと、やり方を工夫をするなど改善を重ねていくことにより魅力的な制度になっていくはずです。