おじゃまぷよ系エンジニアメモ

主にスマホネイティブ関連Tips。たまにWebも書きたい。お腹はぷよぷよ

社内でGoogle Home使ってハッカソンしました

タイトル通り1日エンジニアの業務をストップしてハッカソンしました
作ったものは大したことないんですが、3人チームでメンバーの一人がGoogle Homeを持っていたのでそれを使っての開発が楽しかったので振り返りを

作ったもの

「我々には仕事より大切なものがある」をテーマにGoogle Homeでアイドルやアーティストのライブに遅刻しないように、逐一Google Homeに喋ってもらおうと頑張りました。
ひたすらググって出てきた参考ソースをほぼコピペしてだけなのですが…

喋ってもらうもの

  • 公式ブログ
  • 公式アカウントのツイート
  • 特定ワードのツイート
  • 「残り時間教えて!」と聞くと、ライブまでの残り時間教えてくれる
  • etc…

使用したライブラリなど

Google Homeに指定した文言を喋ってもらうのには

github.com ツイートの取得には

www.npmjs.com

以上2つお世話になりました。 また独自の音声コマンド「残り時間教えて」を実行するのにIFTTTを使いました
これらの3つはGoogle Homeでググるといっぱい先人の経験があるのでおかげさまで楽できました

ifttt.com

それらを組み合わせてこんな感じになりました f:id:masahide318:20171216005632p:plain

基本的にツイートを喋ってもらう分には問題無い作りです。StreamingAPIで流れてくるテキストをgoogle-home-notifierにcurlを投げつければいいだけなので
ただ独自の音声コマンド追加して何かさせるというのがGoogle Homeあまり向いてないんですかね…こんなめんどくさいことするとは思わなかったです。

  1. 「残り時間教えて」って言うとGoogleHomeが反応
  2. IFTTTのApplet実行し、特定のワードをTwitterに投稿
  3. 特定のワードがStreamingAPIで流れてきて、ローカルPCで取得
  4. 残り時間計算して、google-home-notifierにcurlなげて計算結果しゃべってもらう

なんじゃこりゃ…ハッカソンなので許してください

ハマったところ

google-home-notifierを使うためにGoogle Homeの固定IPを取得する必要があったのですが、スマホの端末とGoogle Homeとの接続が同一ネットワーク内にあるにも関わらずうまく接続できないということがありました。
結果社内のwifiが悪いということが判明して手持ちのiPhoneテザリングして繋いだらあっさりセットアップできたのでなんとかなりました。

改善点

  • やはりgoogle-home-notifierを常駐しておく用のラズパイなり何かが欲しかった
  • もっと別のいいやり方や連携サービス使えたかもしれないが知識が足りなかった

まとめ

  • 初めて使う言語やデバイスに触れる機会ができて楽しかった
  • 接続うまくいかないときはテザリングする
  • Google Home実際に使ってみておもったより色々なことできそうなのでおもちゃとしてほしくなった
  • もう少し普段からサービス間の連携を考えたり、何ができるかを意識しておく必要がある
  • 修行がまだまだたりないと改めて実感
  • AmazonEcho早く届いてほしい

CloundFirestoreの基本的なCRUD使い方メモ

Android開発においていわゆるRepository層にcloud firestoreを使うときのメモです。
昨日のNANAMemoのときのデータストアに活躍してもらいました。 その時の使用した様子を特に詳しい説明もなく書いてます。 RxJavaと使うと相性がよさそうですね。

gradleにfirestore追加

AndroidStudioのToolメニューからfirebaseの連携をした後にgradleにfiresotreを追加します。
だいたい以下のような感じになります

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
    implementation 'com.android.support:appcompat-v7:26.1.0'
    implementation 'com.android.support.constraint:constraint-layout:1.0.2'
    implementation "com.google.firebase:firebase-firestore:11.6.0" //firestore追加
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.1'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1'
}

apply plugin: 'com.google.gms.google-services' 

CRUDサンプル

data class User(
        val name: String = "",
        val age: Long,
        val email: String = "",
        val createdAt: Date = Date()
)
class UsersRepository(val db: FirebaseFirestore = FirebaseFirestore.getInstance()) {

    private val path = "users"

    fun findAll(onSuccess: (List<User>) -> Unit, onError: (Exception?) -> Unit) {
        db.collection(path).get().addOnCompleteListener {
            if (it.isSuccessful) {
                onSuccess(it.result.toObjects(User::class.java))
            } else {
                onError(it.exception)
            }
        }
    }

    fun findOne(id: String, onSuccess: (User) -> Unit, onError: (Exception?) -> Unit) {
        db.collection(path).document(id).get().addOnCompleteListener {
            if (it.isSuccessful) {
                onSuccess(it.result.toObject(User::class.java))
            } else {
                onError(it.exception)
            }
        }
    }

    fun create(user: User, onResult: (Boolean) -> Unit?) {
        db.collection(path).add(user).addOnCompleteListener {
            onResult(it.isSuccessful)
        }
    }

    fun update(id: String, user: User, onResult: (Boolean) -> Unit?) {
        db.collection(path).document(id).set(user).addOnCompleteListener {
            onResult(it.isSuccessful)
        }
    }

    fun delete(id: String, onResult: (Boolean) -> Unit?) {
        db.collection(path).document(id).delete().addOnCompleteListener {
            onResult(it.isSuccessful)
        }
    }
}

上記のやりたかというか、firestoreのtoObjectメソッドは結構融通が利かず、key名と変数名が一致していないといけません。 また型にも厳しく、Intをもたせることが出来ません
さらにidの情報はaddOnCompleteListenerのDocumentSnapShotが持ってるので、idがわかりません。
なので独自のマッピングメソッドを用意してあげてもいいかもしれません
Userのentityにidをもたせると、次はfirestoreにaddしたときにkeyのid情報と、userのドキュメントidとで情報が重複しますがあまり気にしない。

data class User(
        val id: String? = null,
        val name: String = "",
        val age: Int,
        val email: String = "",
        val createdAt: Date = Date()
) {
    companion object {
        fun mapping(result: DocumentSnapshot): User {
            return User(
                    result.id,
                    result.getString("name"),
                    result.getLong("age").toInt(),
                    result.getString("email"),
                    result.getDate("createdAt")
            )
        }

        fun mapping(result: QuerySnapshot): List<User> {
            val users = mutableListOf<User>()
            result.forEach {
                users.add(mapping(it))
            }
            return users.toList()
        }
    }
}
    fun findAll(onSuccess: (List<User>) -> Unit, onError: (Exception?) -> Unit) {
        db.collection(path).get().addOnCompleteListener {
            if (it.isSuccessful) {
                onSuccess(User.mapping(it.result)) //こんな感じで置き換える
            } else {
                onError(it.exception)
            }
        }
    }

Presenterから使う

class SamplePresenter(val mainView: MainView,val usersRepository: UsersRepository = UsersRepository()){

    fun loadUsers(){
        usersRepository.findAll({
            mainView.showUser(it)
        },{
            mainView.showError()
        })
    }
}

RxJavaを使うVersion

class UsersRepository(val db: FirebaseFirestore = FirebaseFirestore.getInstance()) {

    private val path = "users"

    fun findAll(): Single<List<User>> {
        return Single.create<List<User>> { emitter ->
            db.collection(path).get().addOnCompleteListener({
                if (it.isSuccessful) {
                    emitter.onSuccess(User.mapping(it.result))
                } else {
                    emitter.onError(Throwable(it.exception))
                }
            })
        }
    }

    fun findOne(id: String): Single<User> {
        return Single.create<User> { emitter ->
            db.collection(path).document(id).get().addOnCompleteListener({
                if (it.isSuccessful) {
                    emitter.onSuccess(User.mapping(it.result))
                } else {
                    emitter.onError(Throwable(it.exception))
                }
            })
        }
    }
}
class SamplePresenter(val mainView: MainView, val usersRepository: UsersRepository = UsersRepository()) {

    fun loadUsers() {
        usersRepository.findAll().observeOn(AndroidSchedulers.mainThread()).subscribeOn(Schedulers.io()).doOnSuccess {
            mainView.showUser(it)
        }.doOnError {
            mainView.showError()
        }.subscribe()
    }
}

水樹奈々の思い出を記録するNANAMemoアプリ公開しました

アプリはこちらです
play.google.com

なぜつくったのか?

ファンクラブイベント7においてセトリハンターというコーナーが話題になりました。
その場で抽選で選ばれた人に次のライブで歌ってもらいたい歌をリクエストできるという企画で自分が歌ってほしい歌を確定で歌ってもらえる。ファンにとってこれほど嬉しいものはありません。
その時みんな自分が好きな歌を選んでくれ−っと思ったはずです。私もそうです。願わくば誰か「Independent Love Song」か「宝物」か「あの日夢見た願い」を選んでくれ−って思いました。
そしてひらめきました。みんなの思い入れのある歌の思い入れを記録してみんなで見れればいいのではないかと。自分の好きな歌が他の人がどんな思い入れがあるか知れたら楽しいではないかと
ということで開発期間約1週間で、とりあえずガーッと作りました。

ざっくりした構成

基本的にデータストアはfirebaseのCloud Firestoreにもたせてます。初めてNoSQLをがっつり使ったのでどういうデータ構成にするか悩みました。そのことについてはまた後日書きたいと思います。
ニュース記事は自分のsakuraサーバーでphpによるスクレイピングをしてMySQLにデータを格納。その後JavaのバッチがFirebaseにデータを送るという形になってます。
ログイン機構もFirebaseに任せていますしTwitterログインしかさせてないのでメールアドレスなど個人情報も一切持ってないので安心して使ってください。

今後の開発予定

  • CDやアルバム情報に画像を載せたかったのですがやはり著作権的にNGなので載せれなかったのでなんとかして華やかさをもたせたい。
  • iOS版を開発する
  • コメント数など表示する
  • ライブBDの情報も追加して、ライブの思い出も残せる場を作る
  • ニュース記事を増やす

とりあえず直近でこの辺りを計画してますが、果たしてどうなることやら…とりあえず頑張ります…
何かご意見ご感想があればsarahahやtwitterで言っていただければ頑張ります

masahide318.sarahah.com

AndroidでMVPするときのPresenterの責務

Androidのクラス設計でMVPやMVVMが流行っています。
自分もMVPのクラス設計が導入しやすく好きなのですが、実際にやろうとすると何をPresenterに書き何をView(ActivityやFragment)に書くべきなのか指針がなくて
なんとなくMVPになることが多いです。そこで個人的にどういう風にMVPに分けるべきかメモしておきます
個人的に注意するポイントは以下の三点です

  • PresenterにViewの具象クラスを引き数として与えない
  • PresenterにAndroid特有のクラスを引き数として与えない
  • Presenterはテスタブルな状態にする

PresenterにViewの具象クラスを引き数として与えない

個人的にNGなコードは以下のようなPresenterが出てきたときです

class MainViewPresenter(val mainView: MainActivity) {
    fun doSomething() {

    }
}

PresenterのコンストラクタでActivityやFragmentをそのまま受け取るようなパターンです。
PresenterがガッツリViewと依存してしまいますし、AcitivityやFragmentを受け取ることでテストコードでmockにしにくいし、これをやってしまうとView側でできることとPresenter側で出来ることの境目がなくなってしまいます FragmentからfindViewByIdすればレイアウトは操作できてしまうし、Contextを取り出せばやりたいほうだいです。すべてのロジックをPresenterに書けてしまいまうためチームメンバーによって何を書くかバラバラになってしまいます。
ViewとPresenterはInterfaceを通して余計なことができないように縛りましょう

interface MainViewable{
    fun bindUserData(user:User)
}
//MainViewableのInterfaceを渡すことで余計なことをさせない
class MainViewPresenter(val mainView:MainViewable) {
    fun doSomething() {
           
    }
}
class MainActivity : AppCompatActivity(),MainViewable{
  val presenter:MainViewPresenter by lazy { 
        MainViewPresenter(this)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        presenter.doSomething()
    }

    override fun bindUserData(user:User) {
          //Viewにデータを反映させる
    }
}

PresenterにAndroid特有のクラスを引き数として与えない

例えばこういうPresenterです。

class MainViewPresenter(val context: Context,val pref:SharedPreferences) {
    fun doSomething() {

    }
}

ContextやSharedPreferencesなどAndroid特有のクラスを渡してしまうものです。
これをやってしまうとActivityやFragmentを引き数として渡すのと同じでUnitテスト時にmockを差し込みにくくなります。 またContextを渡してしまうと上記で書いた通りPresenter側でほぼなんでも出来るようになってしまうのでViewとPresenterの責務が曖昧になりがちです。 SharedPreferencesなどはそれをラップしたクラスを作ってそれを渡すといいでしょう。Contextはそもそも渡さないようにしましょう。
Contextが必要なクラスが多くあると思いますが、それは上記のSharedPreferenceServiceのようにContextを扱えるView側でクラスを生成しそれをPresenterへ引き数として渡すようにしましょう。

class SharedPreferencesService(private val context: Context) {
    val pref: SharedPreferences by lazy {
        context.getSharedPreferences("sample", Context.MODE_PRIVATE)
    }

    fun apply(key: String, value: String) {
        pref.edit().putString(key, value).apply()
    }
}
//ラップしたクラスにすることで簡単にmockにかえJUnitを回しやすくなる
class MainViewPresenter(val context: Context,val pref:SharedPreferencesService) {
    fun doSomething() {
    }
}

Presenterはテスタブルな状態にする

基本的にPresenterにAndroid特有の関心事を持ち込まなければJUnitによるテストが実行可能になっているはずです。
Presenterは高速にテスト回せる状態にし、ActivityやFragmentはAndroidUnitTestによる少し重めのテストという風に住み分けします。
PresenterがJUnitによるテストコードを回せなくなったとき、一度立ち止まって本当にPresenterでやるべき処理か立ち止まるいいきっかけになります。
ソースレビューするときもレビュー観点としておくことで、メンバー間によるプログラムの差異を減らせると思います。

最後に

そもそもMVPってAndroidのコードがFragmentやActivityにビジネスロジックとViewの操作が集中してマッチョになってしまうのでビジネスロジック部分を分けたいという欲求から生まれたものなはずなのに
そのビジネスロジック部分にFragmentやContextが出てきてマッチョになっていくのは何か違う気がすると思います。なんのためのPresenterなのか?それを一度考えましょう
ちなみにadapterの操作をどこで行うか?というのが悩みどころですが、あれもAndroid特有のクラスなので自分は「View側で操作する」でいいと思います。
sharedPreference同様にapdaterをラップしたクラスを用意しそこで操作するようにすればPresenterに渡してもいいかもしれません。
ものすごく雑にまとめたので実際のプロダクトでやるともしかしたらうまくいかない部分が出てくるかもしれませんが、基本方針としてPresenterをJUnitでテスタブルな状態にするということを守っていれば
クラス間の責務は適切にわけられているのではないでしょうか?
その他にMVPの責務の分け方の考えなど意見がある方は是非教えてください。自分もこれが最適とは思っていないので……
Androidのクラス設計ってなんでこんなに難しいんでしょうかね。

capistranoでcomposer installしてからrsync

capistranophpのプロジェクトをdeployしようとして
通常のdeploy方式だとdeploy先のサーバーでgit pullしてからcomposer installになってしまうがそうではなくて
deployサーバーでgit pullしてcomposer installしてそれをdeploy先のサーバーにrsyncでファイルを送りたかった
ググったらすぐに見つかるかなぁと思っていたが思いの外ピンポイントな情報がなかったのでメモ

github.com

capistrano-bundle_rsyncを使います GemFileの内容としてはだいたいこんな感じ

source "https://rubygems.org"

gem 'capistrano', '3.8.1'
gem 'capistrano-rbenv'
gem 'capistrano-bundle_rsync'

bundle install –path vendor_bundle でbundle installしましょう。pathを指定しないとデフォルトでvendorになるので、phpのcomposerのvendorと被って余計なものがデプロイされてしまいます。なのでbundleではインストールパスを指定して後でそのパスはrsyncから外すようにします。 後はcapistrano-bundle_rsyncのREADMEに従ってCapfileやconfigファイルを設定します

コピペですがCapfileは

# Load DSL and Setup Up Stages
require 'capistrano/setup'

# Includes default deployment tasks
require 'capistrano/deploy'

# capistrano-3.3.3 - 3.6.1
require 'capistrano/bundle_rsync'

# capistrano-3.7+
require 'capistrano/bundle_rsync/plugin'
install_plugin Capistrano::BundleRsync::Plugin

# Loads custom tasks from `lib/capistrano/tasks' if you have any defined.
Dir.glob('lib/capistrano/tasks/*.rake').each { |r| import r }

config/deployにvagrantにdeployする想定のlocal.rbを作ります

server "vagrant"
set :stage, :local
role :web, "vagrant"

問題のdeploy.rbです

# config valid only for current version of Capistrano
lock "3.8.1"

set :branch, ENV['BRANCH'] || 'master'
set :keep_releases, 2
set :scm, :bundle_rsync
set :application, "sample"
set :repo_url, "gitrepo"
set :rsync_options, '-az --delete --delete-excluded --exclude=vendor_bundle'
set :deploy_to, "/var/www/html/example"

# rsync前にcomposer installする
task :pre_rsync_action do
  config = Capistrano::BundleRsync::Config
  run_locally do
       execute ("cd #{config.local_release_path} && php composer.phar install --no-dev")
  end
end

before "bundle_rsync:rsync_release", "pre_rsync_action"

rsync_optionでbundle installしたディレクトリはrsyncから外しています。 capistrano-bundle_rsyncはいろいろなタイミングでフックポイントがあるのでrsyncが実行される前にcomposer installを実行するようにしています。
rsyncされるディレクトリーはcapistranoがカレントディレクトリの下に「.local_repo」というディレクトリを作り、さらにその下にできる「releases/{datetime}」ディレクトリがrsyncされるディレクトリです。 そのrsyncされるパスを取得するのがdeploy.rbの「config.local_release_path」の部分です
ここまでできたら後はgitにGemfile.lockをPushして「bundle exec cap local deploy」を実行するだけです。
今回はcomposer installしただけですが、その他にも設定ファイルなどをこのタイミングでそのstageに合わせてコピーするなど色々できます。
ごちゃごちゃ書きましたが結果としてはcapistrano-bundle_rsyncのREADME通りに書いてdeploy.rbだけ少し改良するだけで良いという話です

kotlinのjavascirptで非同期で取得したデータをDOMにバインドするサンプル

kotlinがAndroidに公式サポートされて盛り上がっておりますね。
一応Androidエンジニアやっている?いた?のでkotlinは少し追っていたのですが、今ではWebエンジニアとしての方が強くなってきたので
あえてkotlinでjavascriptやってみよう思い立ってDelegate.Observableを使って、非同期で取得したデータをhtmlのDOMにバインドできないかなぁと思ってやってみたら楽しくなってきたのでメモ

kotlinでjavascriptの準備

チュートリアルにそって準備しました kotlinlang.org

適当にhtmlを用意

<!DOCTYPE html>
<html lang="en">
<head>
    <script src="../kjs/out/production/kjs/lib/kotlin.js"></script>
    <script src="../kjs/out/production/kjs/kjs.js"></script>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div>
    <input id="sampleText" type="text" value="text" title="sampleText">
    <input id="sampleCheckBox" type="checkbox" title="checkBox">
    <button id="sampleButton">sampleButton</button>
</div>
<script>
    new kjs.SampleRequest().request();
</script>
</body>
</html>

divタグの中のDOMのvalueやらcheckboxのdisableの切り替えをAPIを叩いて帰ってきたJSONレスポンスをdata classに変換してバインドする
みたいなものを想定しています。無邪気にid指定しているのは許してください。nameでもclassでも取得できるのでそこは後でお好みで変換してください。 scriptタグ内のkjs.SampleRequest().request()で非同期でGETリクエストを投げてJSONを受けってデータバインドします

バインドするdata class

data class SampleEntity(val inputText: String, val disabled: Boolean, val buttonText: String)

inputTextをDOMのtextのvalueに
buttonTextをDOMのButtonのtextContentに
disableをcheckBoxのdisableに バインドする想定のdata class

バインドするDOMのテンプレートクラス

object SampleTemplate {
    var entity by Delegates.observable<SampleEntity?>(null) {
        _, _, new ->
        new?.let {
            inputText.value = new.inputText
            checkBox.disabled = new.disabled
            button.textContent = new.buttonText
        }
    }
    val inputText: HTMLInputElement = document.getElementById("sampleText") as HTMLInputElement
    val checkBox: HTMLInputElement = document.getElementById("sampleCheckBox") as HTMLInputElement
    val button: HTMLButtonElement = document.getElementById("sampleButton") as HTMLButtonElement
}

それぞれのDOMの情報をメンバ変数に持って
Delegate.observableを利用してSampleEntityがsetされたらDOMに値を入れるようにしています

GETのRequestを投げるクラス

class SampleRequest {
    fun request(){
        val req = XMLHttpRequest()
        req.onloadend = fun(event:Event){
            SampleTemplate.entity = JSON.parse<SampleEntity>(req.responseText)
        }
        req.open("GET","http://localhost:63342/kjs/json_text.txt",true)
        req.send()
    }
}

XMLHttpRequestで非同期でGETリクエストを投げて、取得したJSONをSampleEntityに変換してSampleTemplateのentityにセットしています。

適当なJSONのファイル用意します

{"inputText":"テキストはいる","disabled":true,"buttonText":"hoge"}

buildしてブラウザでチェックします

ディレクトリ構成はこんな感じですごく適当においてます f:id:masahide318:20170606235740p:plain

IntelliJからindex.htmlをopenすると「http://localhost:63342/kjs/index.html」みたいなURLで開きます

すると
f:id:masahide318:20170606234207p:plain
から
f:id:masahide318:20170606234312p:plain

にtextの内容やcheckboxがdisable状態になったりと、データが反映されます。
今回は恐らく最小の構成?でバーっと書いてみましたが、案外使えそうな気がしています。

おまけ

f:id:masahide318:20170606235313p:plain

チュートリアルにあるようにdebugの設定をすると、kotlinのソース上でちゃんとブレイクポイント貼れるのでかなりデバッグが助かります。 vue.jsやほかのjsのライブラリとうまく組み合わせたパターンなど色々と試してみたいですね。

phpのSlim3をコマンドラインから実行して使う

月1ペースを目標に更新したかったけど、全然更新してなかった ということでphpの軽量フレームワークSlim3を最近使うことが多く、以前コマンドラインから実行するバッチ専用のシステムとして作ったのでその時の情報をメモです。

github.com

Slim3でプロジェクトを作る

www.slimframework.com

公式ドキュメントを元に空のプロジェクトを作ります。
今回は「batch-sample」というプロジェクト名で作成します。

index.phpファイルを修正する

batch-sample/public/index.php

<?php

require __DIR__ . '/../vendor/autoload.php';

$settings = require __DIR__ . '/../src/settings.php';

$commandName = $GLOBALS['argv'][1];


$settings['environment'] = \Slim\Http\Environment::mock(
    [
        'REQUEST_URI' => '/' . $commandName,
    ]
);
$app = new \Slim\App($settings);

require __DIR__ . '/../src/dependencies.php';

require __DIR__ . '/../src/middleware.php';

require __DIR__ . '/../src/routes.php';

$app->run();

ここが恐らく一番のポイントです。 \Slim\Http\Environment::mockでコマンドラインから受け取ったコマンド名をURIに設定して、それに対応するルーティングの処理を呼び出すようにします。 Enviroment:mockはUnitTestのときに、Httpリクエストのmockとして使われます。それをそのまま流用してコマンドラインから実行したときにコマンド名からmockのリクエストを作って実行するという流れです。

バッチの主処理を記述するクラスを作る

batch-sample/src/Action/SampleAction

<?php

use Psr\Container\ContainerInterface;
use Slim\Http\Request;
use Slim\Http\Response;

class SampleAction {

    private $container;

    public function __construct(ContainerInterface $container)
    {
        $this->container = $container;
    }

    public function __invoke(Request $request, Response $response, $args)
    {
        $response->write("hello world");
        return $response;
    }
}

ルーティングでこのActionをバインドする

batch-sample/src/route.php

<?php

require_once __DIR__.'/Action/SampleAction.php';

$app->get("/sample_command", SampleAction::class);

とりあえず実行する

ここまで作ったらとりあえず実行してみます。 実行方法はbatch-sampleディレクトリで「php public/index sample_command」です。 実行してみるとコンソール上に「hello world」が出力されます。
非常に簡単ですね。

もう少し改良する

例えばWebからもフックできるようにindex.phpをいじります。 WebApplicationとCliApplicationクラスのようなものを作って、PHP_SAPIを見てどちらのApplicationクラスを実行するか分岐させます

batch-sample/src/Application/CliApplication.php

<?php
//コマンドラインから起動されたとき甩のクラス
class CliApplication
{
    public function run()
    {
        $settings = require __DIR__ . '/../../src/settings.php';

        array_shift($GLOBALS['argv']);
        $commandName = $GLOBALS['argv'][0];

        $settings['environment'] = \Slim\Http\Environment::mock(
            [
                'REQUEST_URI' => '/' . $commandName,
            ]
        );
        $app = new \Slim\App($settings);

        require __DIR__ . '/../../src/dependencies.php';

        require __DIR__ . '/../../src/middleware.php';

        require __DIR__ . '/../../src/routes.php';

        $app->run();
    }
}

batch-sample/src/Application/WebApplication.php

//Webからフックされたとき用のクラス
<?php
class WebApplication
{
    public function run()
    {
        if (PHP_SAPI == 'cli-server') {
            // To help the built-in PHP dev server, check if the request was actually for
            // something which should probably be served as a static file
            $url = parse_url($_SERVER['REQUEST_URI']);
            $file = __DIR__ . $url['path'];
            if (is_file($file)) {
                return false;
            }
        }

        $settings = require __DIR__ . '/../../src/settings.php';
        $app = new \Slim\App($settings);

        require __DIR__ . '/../../src/dependencies.php';

        require __DIR__ . '/../../src/middleware.php';

        require __DIR__ . '/../../src/routes.php';

        $app->run();
    }
}

batch-sample/public/index.php

<?php

date_default_timezone_set('Asia/Tokyo');
require __DIR__ . '/../vendor/autoload.php';

use Batch\Sample\Application\CliApplication;
use Batch\Sample\Application\WebApplication;


if(PHP_SAPI == "cli"){
    (new CliApplication())->run();
}else{
    (new WebApplication())->run();
}

これでビルトインサーバーを起動して「http://localhost:8090/sample_command」にアクセスすれば同じバッチ処理が起動します。

さいごに

さらっとした説明はここまでにして、その他にも多重起動防止や実際にDBを操作する処理も含めたものはgithubにあげておきます。 サブシステムとして特定のバッチ処理をしたいというときにもしかしたら使えるかもしれませんね。

github.com

githubに上げてる方はクラス設計がここのサンプルとはだいぶ変わっています。といってもActionクラスがServiceクラスを決定し、Serviceクラスはバッチ処理を行うための複数のModelを使用する…ってだけですが、適度に分割されてると思いますしテストコードも問題なく書けるはずです。 Slim3は軽量なフレームワークなだけあってクラス設計がフルスタックよりも楽しいです。
APIサーバー用のフレームワークとしてもかなりアリだと思うので今作ってるアプリのサーバー側はSlim3にしようかと思っているところ。
Slim3は最近すごくお気に入りなので、APIサーバーを作ってまた何か知見を得られれば何か書きたいです。