cobra/viperでコマンドラインツールを作っていて良かった所、詰まった所

まえおき

最近、Androidアプリを開発するのが全然おもしろくなくなってきたので、Dartを触り始めた。(※ Flutterではない)

で、Dartコマンドラインツール作ろうと思ったけど、コマンドラインツールといえば、とりあえずGoの cobra/viper 知っとかないと・・・

ということでGoを5年ぶりくらいに触り始めた。

何を作った?

github.com

appetize.io っていうWebベースのエミュレータAPIを便利に使うコマンドラインツールを作ってみた。 brew tapでインストールできるようにリポジトリを適当に作った ので、たぶん

brew tap YusukeIwaki/appetize
brew install appetize

で使えると思われる。

※ 完全に余談だけど、今回はとくに「自分自身が "使いたい!" と思う形でリリースする」ということにこだわった。

「必須だけどユーザによって使う値はだいたい決まっている、けどコマンドラインパラメータでオーバーライドもできる」が瞬殺だった

会社の同僚がQiitaに書いていて、そのまんまなんだけども。

APIトークンとかって、毎回毎回 --token=tok_79ad08a09dca80dc9a みたいなのを打つのはあまりに面倒。

  1. ~/.appetize.ymlapi_token: tok_9ads8ad9a7dsa みたいな設定値があればそれを使う
  2. 環境変数APPETIZE_API_TOKEN=tok_7987adaca9d7ca8c7 みたいな変数があれば、それを使う
  3. コマンドラインパラメータで --token=toka9sd7sa9d87a みたいなパラメータが指定されていれば、それを使う

で、同時指定の場合は、3 > 2 >1 の優先順位で動いてくれるのが望ましい。

これ、viperとcobraの典型的なユースケースで、わりと瞬殺で実装できてしまう。

// initConfig reads in config file and ENV variables if set.
func initConfig() {
    // Find home directory.
    home, err := homedir.Dir()
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }

    // Search config in home directory with name ".appetize" (without extension).
    viper.AddConfigPath(home)
    viper.SetConfigName(".appetize")
    viper.BindEnv("api_token", "APPETIZE_API_TOKEN")

    // If a config file is found, read it in.
    if err := viper.ReadInConfig(); err == nil {
        //fmt.Println("Using config file:", viper.ConfigFileUsed())
    }
}

func init() {
    cobra.OnInitialize(initConfig)

    rootCmd.PersistentFlags().String("token", "", "Appetize api token")
    viper.BindPFlag("api_token", rootCmd.PersistentFlags().Lookup("token"))
}

これだけだ。なんて便利。

「上書き保存用のフォームオブジェクト」ってどうやってつくるの?

App
  |- PublicKey
  |- Timeout
  |- Disabled

みたいなのがサーバー側でモデルとしてあって、JSON APIで書き換えるときに詰まった。

1つの属性だけ書き換えたい時にどうするか?

たとえば、 ./appdemo update --timeout 100 って打った時に、勝手にdisabled=falseを上書きするような実装だと困る。

コマンドラインパラメータの処理

cmd.Flags().GetBool("Disabled") だと、

  • ユーザが指定せずデフォルトパラメータとして falseが返っている
  • ユーザが意図して --disabled false を指定している

がわからない。

調べてみると、cobraリポジトリにissueが既に上がっていて、

Question: See if a flag was set by the user · Issue #23 · spf13/cobra · GitHub

Changed を使えばわかるよ!って書いてあった。

if cmd.Flags().Changed("Disabled") {
  attributes.Disabled = cmd.Flags().GetBool("Disabled")
}

ってすればいいらしい。

パラメータの構造体の定義

if cmd.Flags().Changed("Disabled") { のなかでいきなりJSONを組み立てるのとかはさすがに筋が良くないので、多くの場合は一旦それ専用の構造体をつくることになる。

ここでも、「ユーザが意図してfalseを設定したのか、デフォルト値としてのfalseなのか・・・」というのを迷うことになった。

type AttributesForUpdate struct {
  PublicKey  string
  Timeout     int
  Disabled    bool
}

愚直に↑みたいなフォームを作ると、Disabled = false のときの処理に困ることになる。(ちなみに、これは正直Goに限った問題ではなくて、だいたいどの言語でも同じようなことは言える)

var attributes AttributesForUpdate
attributes.Timeout = 100

fmt.Println(attributes.Disabled) // => false

これは、調べてみるといくつかのパターンはあって、みんなそれなりに苦しんでいるっぽい?

nilもいけるように、ポインタ型にしちゃう?

proposal: spec: option types · Issue #7054 · golang/go · GitHub

type AttributesForUpdate struct {
  PublicKey  *string
  Timeout     *int
  Disabled    *bool
}

*int がなかなかに鬼畜、ってissueでコメントしてる人がいる。

Optional型を作っちゃう

GoogleのCloud PlatformのSDKがこの方式を使っている

nilでToBoolするとpanicする、ってなかなか激しいぞ・・・

type AttributesForUpdate struct {
  PublicKey  optional.String
  Timeout     optional.Int
  Disabled    optional.Bool
}

JSONのMarshalとかをやらないなら、アリかもしれない。

ぜんぶstring型にしちゃおう

さすがにこれをやってる例は見なかった。

type AttributesForUpdate struct {
  PublicKey  string
  Timeout     string
  Disabled    string
}
if attributes.Disabled == "true" {
  params.Add("disabled", true)
} else if attributes.Disabled == "false" {
  params.Add("disabled", false")
}

ヤバイw

いっそのことstructじゃなくて、 map[string]interface{} にする

var attributes map[string]interface{}

if cmd.Flags().Changed("Disabled") {
  attributes["Disbaled"] = cmd.Flags().GetBool("Disabled")
}

うん、タイポしててもわからないね・・・。

これはさすがに却下。


ということで、とりあえずGoogleがやってるように、Optional型つくる、かなぁー・・・

まとめ

cobra/viperは素晴らしい

「通知」という機能について考える

f:id:YusukeIwaki:20180426131044p:plain

Webサービスを作ってると、メール通知とかアプリのプッシュ通知のような機能が求められることは非常によくある。 「ユーザがいちいちWebページを見に来なくてもいいように通知してあげないといけない」というのは誠にそのとおりだ。

さて、とりあえずRailsで作ったWebサービスがあるとしよう。どうやって通知する?

とりあえず購入時に購入内容をメール通知しよう

「最初からいろんな通知方法を考えて設計するのは無駄だし、とりあえずあとで必要になったらリファクタリングしよう。まずは必要な場所にぺぺって書いてしまおう」

class PaymentRecordController

  def create
    if record = PaymentRecord.create(payment_record_params)
      redirect_to dashbord_path

      send_notification(record)
    else
      flash[:error] = record.errors.full_messages.join("\n")
    end
  end

  private

  def send_notification(payment_record)
    UserMailer.item_purchased(payment_record)
  end
end

はい。

自分が書いた商品レビューに「いいね!」がついたらメール通知してほしいな

「ん、それってユーザ的に本当に必要な"通知"なんだっけ?」という若干の疑問をもちつつ、コントローラに書いて複雑化するレベルには達していないので、とりあえずコントローラに同様に書きます、と。

class LikeController
  def create
    if like = Like.create(like_params)
      send_notificatioin(like)

      ...

  private

  def send_notification(like)
    target_user = like.likeable.owner
    from_user = like.user
    UserMailer.review_liked(target_user, from_user, like.likeable)
  end
end

はい。

アプリを作ることになったので、スマホアプリのプッシュにも対応してほしいな

「まじか!!でも確かに必要だよね。」「でも、いいね!が付いたくらいで通知がくるのはうざいよね。」うーん、コントローラに・・・書く・・・?

class PaymentRecordController

  ...

  private

  def send_notification(payment_record)
    UserMailer.item_purchased(payment_record)
    PushNotification.send_item_purchased(payment_record)
  end
end
class LikeController

  ...

  private

  def send_notification(like)
    target_user = like.likeable.owner
    from_user = like.user
    UserMailer.review_liked(target_user, from_user, like.likeable)
  end
end

はい・・・だんだん危険な香りがしてきましたね。

プッシュ通知は消したら見えなくなるから、アプリ上で "通知した内容一覧" が見れるようにAPIをつくりたい

「なん・・・だと・・・?!」

詰みました・・・。

というのも、通知というのは今までは「投げっぱなし」とおもっていました、メール通知もプッシュ通知も。なので、Rails上で通知に関するモデルは作っていません。つまり、今まで通知した内容はどこからも取得ができません。

「とりあえず今までの内容は取れないのでそこは許してください!」

心機一転、通知まわりの機構を1から作り直せることになりました。さあどうする?

要件としては

  • メール通知
  • プッシュ通知
  • 通知したもの一覧API

です。

# id: integer  primary key
# title: string     not null
# body: string    not null
# created_at: datetime    not null
# notifiable_type: string
# notifiable_id: integer
class NotificationItem < ApplicationRecord
  belongs_to :notifiable, polymorphic: true
  after_create_commit :send_notification

  private

  def send_notification(record)
    NotificationService.new(record).execute
  end
end


class NotificationService
  def initialize(notification_item)
    @notification_item = notification_item
  end

  def execute
    # ここでメール通知とプッシュ通知のロジックがいろいろ
  end
end

いろいろやり方は有ると思います。たとえば、こんな感じでしょうか?

「あれ、これだと通知一覧が作れない?」

そう、各ユーザにどういう通知を届けたのか、という履歴がないと通知一覧は作れませんね。

# id: integer  primary key
# notification_item_id: integer foreign_key
# user_id: integer foreign_key
# created_at: datetime not null
class NotificationHistory < ApplicationRecord
  belongs_to :notification_item
  belongs_to :user
end
class NotificationService
  def initialize(notification_item)
    @notification_item = notification_item
  end

  def execute
    # ここでメール通知とプッシュ通知のロジックがいろいろ

    # 通知した内容を保存する
    NotificationHistory.create!(
      user: target_user,
      notification_item: @notification_item
    )
  end
end
class Api::User::NotificationHistoriesController < Api::ApplicationController
  def index
    @notification_histories = NotificationHistory.where(user: current_user)
                                             .order(created_at: :desc)
                                             .includes([:notification_item])
  end
end
json.notifications @notification_histories do |notification_history|
  json.partial! "notification_item", notification_history.notification_item
  json.sent_at notification_history.created_at
end

みたいなかんじでしょうか。だいぶ端折ってますが

通知文言はユーザのセグメントごとに変えたいな

「む。NotificationItem側にtitle/bodyがあるとダメじゃん!」「じゃあ、NotificationHistory側に・・・とおもったけどこれは履歴テーブルだ」「うーん、結局タイトルとかを入れるのはNotificationService・・・?」

そろそろ辛くなってきましたね。


さて、擬似サンプルコードも書き疲れたのでようやく本題に。

結局 "通知" ってなんなの?

通知って「投げっぱなし」で実装しがちなんですが、大抵の場合はそうではなくて、「リアクティブなイベント購読」と考えるべきなんじゃないかと最近感じ始めてます。

  • 通知が必要かもしれない イベント がある(Pub)
  • イベントを 適宜フィルタ/変換しながら購読する サブシステムがある(Sub)
  • 投げっぱなしに見える通知というのは、Subの create/updateトリガーである

ようは、Railsのコントローラとかでやるのは、「ユーザAがアイテムXを購入した」とか「ユーザBがアイテムYにレビューRを書いた」とか「ユーザAがレビューRにいいね!を付けた」とかそういうイベントをPublishするまで。個々の通知実装はその先で、たとえば

  • Event.where(type: item_purchased).on_new_record(:send_item_purchase_confirmation_notification)
  • Event.where(type: review_liked).on_new_record(:send_review_liked)

みたいな感じで、いい感じにsubscribeする。

そうすると、たとえばアプリのプッシュにくわえてブラウザプッシュも対応したいとかなっても、NotificationHistoryからエスパーして履歴を作ったりしなくても済む。

f:id:YusukeIwaki:20180426130420p:plain

まとめ

とりあえず「通知」っぽいものを作るときには、今すぐ必要でなくても「イベント」と「通知履歴」テーブルは作っておいたほうがいいんじゃないかな。とおもった。以上。

(どうやって実装すればいいのかはまだよくわかっていない

現在地共有サービス第2弾を作った。

https://user-images.githubusercontent.com/11763113/37872124-f2a6d84c-303b-11e8-9ce8-319e818eee96.png

 

以前、 imakaraでやりたかったことなど - YusukeIwakiのブログ で作っていたものをベースに、

  • Androidアプリ側はKotlinを使ってみよう
  • サーバーサイドはRailsじゃなくてFirebaseで済ませちゃおう

ということをやってみたく、作ってみた。

github.com

github.com

Firebase Realtime DatabaseとFirebase Functions/Hostingがあれば趣味のアプリは大抵作れてしまう

imakaraでは練習目的でRailsでサーバーサイドを実装していた。サーバーサイドがRailsだと当然、Androidアプリ側も、

  • APIクライアントを実装したり
  • 非同期処理をいい感じにやったり

などなど、わりとお決まりの骨が折れるコードをいっぱい書かないといけなかった。

しかし、今回、Firebase Realtime DB + Firebase Functions/Hosting でやってみて衝撃。APIクライアント(というかHTTPクライアント)を一切使うことなくWebアプリが作れてしまった。 ざっくりどんな構成になってるか説明してみようと思う。

Androidアプリ側でGCMトークンが取得できたタイミングで、FirebaseUserIDに紐づけてDBに保存

f:id:YusukeIwaki:20180326223618p:plain

実装的にはこのへん: imacoco-android/DeviceRegistrationManager.kt at 10d3d22bcba4a71e6dd973d700e70a43e28d8de9 · YusukeIwaki/imacoco-android · GitHub

アプリで「FirebaseUserIDに紐づけてDBに保存」されたのをFirebase Functionsでリモート監視して、そのレコードに共有用URL値を付け加える

f:id:YusukeIwaki:20180326224943p:plain

実装的にはこのへん: imacoco-firebase/index.js at fa330011380bc6047b3c22a11f1a727419ecbb83 · YusukeIwaki/imacoco-firebase · GitHub

Androidアプリ側で、共有URLをリアルタイムに表示する

f:id:YusukeIwaki:20180326225929p:plain

実装的にはこのへん: imacoco-android/OverviewActivity.kt at 10d3d22bcba4a71e6dd973d700e70a43e28d8de9 · YusukeIwaki/imacoco-android · GitHub

共有用URLにアクセスが来たら(中略)測位要求を送る

f:id:YusukeIwaki:20180326231558p:plain

Firebase HostingでGoogleMap1枚ぺらのページを返す。ついでに、Realtime DBの位置情報をリアルタイムにsetCenter。さらについでに、マップのロード時に裏でこっそりFCMプッシュを送ってて、測位して下さいねー!ってAndroidアプリ側に要求がいくようになっている。

実装的にはこのへん: imacoco-firebase/index.html at fa330011380bc6047b3c22a11f1a727419ecbb83 · YusukeIwaki/imacoco-firebase · GitHub

Androidアプリ側でプッシュを受けたら、ワンショット測位して結果をDBに書く

f:id:YusukeIwaki:20180326232403p:plain

実装的にはこのへん: imacoco-android/OneShotPositioningService.kt at 10d3d22bcba4a71e6dd973d700e70a43e28d8de9 · YusukeIwaki/imacoco-android · GitHub

 

 

 


・・・というかんじで、リアルタイムDBに必要な情報は全部載せてしまって、あとはFunctionsでボットスクリプトみたいなのを書いてHostingで入り口を作れば趣味のアプリはできてしまう。

もう趣味でRails書く気は全く無くなってしまった。困った。