sbt pluginのつくりかた ~実装から公開まで~


世間ではsbtは**S**imple **B**uild **T**oolの略だという噂もありますが，ぶっちゃけ全然simpleじゃないです笑[^1]

[^1]: あくまで個人の感想です


今回は試しに[sbt-gcs](https://github.com/saint1991/sbt-gcs)というsbtのpluginを自作してみたので，これをベースに(私の思う)sbtのややこしい部分を解説しつつ，sbt pluginの開発から公開までの手順を紹介しようと思います!

# こんな人にオヌヌメ
- sbtのpluginを作って見たい人!
- sbtを使ったことはあるが次のコードがようわからんっていう人

```scala
gcsUrls in gcsDelete := Seq(
  "gs://bucket_name/path/to/object/to/delete.json"
)
```

# 前提
とりあえず筆者の環境は以下の通りでした．

- sbt 1.1.0
- Scala 2.12.4

# はじめに

sbtは全然simpleではありませんが，ドキュメントはかなりしっかりしています．
公式ドキュメント厨の方は，ここ2つを読めば基本プラグイン開発には困らないはずです!

- [Plugins](https://www.scala-sbt.org/1.x/docs/Plugins.html)
- [Best Practice](https://www.scala-sbt.org/1.x/docs/Plugins-Best-Practices.html)



# Plugin開発
sbt pluginの開発に当たって最低限必要なのは，以下のみです．
順に説明していきます．

1. build.sbtで `sbtPlugin := true` を設定
2. keyの定義
3. AutoPluginを継承したobjectを実装

## 1. build.sbtの設定
ここは特筆すべきところはないです．
`sbtPlugin := true` を設定しておきましょう．
自分は以下のように書きました．

```scala
lazy val root = (project in file("."))
  .settings(
    name := "sbt-greet",
    organization := "com.github.saint1991",
    version := "0.1.0",
    scalaVersion := "2.12.4",
    sbtPlugin := true,
    sbtVersion := "1.1.0"
  )

```

## 2. keyの定義
これがsbtの基礎でありながらも，Simpleならざるものにしている要因でしょう．便利な機能なんですけどね...
ここではsbtの各種keyについて説明したいと思います．

sbtにおけるkeyはMapのkeyと近いものではあるのですが，大きく違うのはkey毎にscopeという概念をもっていることです．なので，**keyとscopeの組み合わせ**で初めて値が定まります．

簡単な例を以下に示します．

```scala
// keyの定義
val whom = SettingKey[String]("whom", "the name of person whom greet")
val greet = TaskKey[Unit]("greet", "greet to a person")

// greetというtaskのscopeにおけるwhomの値を"saint1991"に設定
whom in greet := "saint1991"
```

### Keyについて
まずkeyについてですが，sbtでは

| key | description |
|---|-----------|
|[SettingKey](https://www.scala-sbt.org/1.1.1/api/sbt/SettingKey.html)| projectがロードされる際に一度だけ評価される |
|[TaskKey](https://www.scala-sbt.org/1.1.1/api/sbt/TaskKey.html)| 実行毎に毎回評価される |
|[InputKey](https://www.scala-sbt.org/1.1.1/api/sbt/InputKey.html)| コマンドラインから入力をとる以外はTaskKeyと同様 |

の3つがあります．

基本的には何らかの処理をsbtのtaskとして実装したい場合はTaskKey，
CLIから何か引数を渡して処理を変えたい場合はInputKeyを使います．

SettingKeyは上記2つのタスク内で利用する設定値を定義するのに使います．
これがPluginを利用する側の`build.sbt`における設定項目になります．

多くの場合はSettingKey + TaskKeyの組み合わせで実装できるでしょう．


### Scopeについて
こちらがややこしいのですが，sbtの各keyにはscopeの概念があります．
すなわち，同じkeyでも，scopeによって異なる値を持つことが可能です．

scopeにはざっくり言うと以下のようなものがあり，この粒度ごとに違う設定を持つことができるようになっています[^2]．

- Build全体
- サブプロジェクト毎
- Task毎  

ちなみに関係を図にすると下の図のようになるはずです．(やや自信なし)

<img width="400" src="https://qiita-image-store.s3.amazonaws.com/0/74604/20b5b36d-7306-e007-dc23-615a05edb98d.png">

上の方が細かい粒度になりますが，その粒度のscopeに設定値がない場合は一つずつ大きいscopeにフォールバックします．つまりTaskを実行する際は，まずそのTaskのscopeにおける設定値が使われますが，それが未設定の場合，Project, Buildのscopeの値が順に探索され，最初に見つかった値が使われます．

[^2]: 厳密にはConfigurationという別軸のscopeも持つことができますが，ここでは紹介しません．詳細は[こちら](https://www.scala-sbt.org/1.x-beta/docs/Scopes.html)を参照．おそらくConfigurationの出番はそこまで多くはないはずです．

keyを特定のscopeに関連づける場合，下記のように`in`メソッドを使います.

```scala

// task scope
whom in greet := "saint1991"

// project scope
whom in ThisProject := "saint1991"

// build scope
whom in ThisBuild : = "saint1991"

// 省略されている場合は設定を記述している場所に合わせて自動でscopeが決まる
// topレベルだとbuild scope
whom := "saint1991"

// subprojectの設定に書くとproject scope
lazy val root = (project in file("."))
  .settings(
    whom := "saint1991"
  )
```

これらを踏まえると，最初のこの例は，`gcsDelete`というTaskのscopeにおける`gcsUrls`の値を設定しているコードだったということがお分かりいただけたのではないでしょうか．

```scala
gcsUrls in gcsDelete := Seq(
  "gs://bucket_name/path/to/object/to/delete.json"
)
```

### taskの定義
これらを踏まえて，標準出力に `Hello $whom` を出力するTaskを定義してみます．

```scala
val whom = SettingKey[String]("whom", "the name of person whom greet")
val greet = TaskKey[Unit]("greet", "greet to a person")

greet := Def.task {
  val name = (whom in greet).value // greetタスクに関連づけられたnameの値を取得
  println(s"Hello $name")
}.value
```

上記はとても簡単な例ですが，自作Pluginを作る場合も基本は同じです．
各種Keyを定義し，TaskKey, InputKeyの中身を実装していくだけです．

ちなみに上記では`greet`というタスクが作られるので，次のように起動できます．
結局sbtコマンドも特定のkeyを評価しているだけです．

```bash
$ sbt greet
Hello saint1991
```

## 3. AutoPluginを継承したobjectを実装
このAutoPluginがsbtプラグインの実体です．sbtにどのようにロードするかをここで規定します．
コードは下記のようになります． 下記では少し端折っていますがこちらのコードは[gist](https://gist.github.com/saint1991/e19c3c63dfcd09d7250e551738f861a9)にも上げてあります．

```scala
package com.github.saint1991

import sbt._
import sbt.Keys._

object SamplePlugin extends AutoPlugin {

  // (1) autoImport内のフィールドはobjectはimportしなくても読み込まれる
  object autoImport {
    val whom = SettingKey[String]("whom", "the name of person whom greet")
    val greet = TaskKey[Unit]("greet", "greet to a person")
  }
  import autoImport._

  // (2) どういう条件でPluginがロードされるかを規定する
  // allRequirementsにしておくと依存Plugin(今回はない)が全て読まれていれば勝手にロードされるようになる
  override def trigger = allRequirements

  // (3) settingsにいちいち記述するのは面倒なので，Pluginがロードされたらprojectに勝手にkeyが定義されるようにしている
  override def projectSettings: Seq[Def.Setting[_]] = super.projectSettings ++ Seq(
    whom in greet := "saint1991",
    greet := Def.task {
      println(s"Hello ${(whom in greet).value}")
    }.value
  )
}
```

コード中にもコメントで記載してありますが，簡単にポイントを説明しておくと

まず(1)について，keyはautoImportという名前のobjectに定義するとよいです．
autoImport中のフィールド(ここではwhomとgreetというkey)は，Plugin利用側のbuild.sbtでimport文なしで参照することができます[^3]．

[^3]: 通常なら `import com.github.saint1991.autoImport._` を書かないと`whom`や`greet`が`build.sbt`で参照できないところですが，autoImportに書いておくとこれが不要になります．

(2)(3)がAutoPluginのメソッドで，Pluginとしての機能を規定します．

(2)のtriggerメソッドでは，どのような条件下でこのPluginを有効にするかを規定します．
`allRequirements`を設定しておくと，依存するPlugin (上記のサンプルだと特にないですが)が有効な場合に，このPluginも自動で有効化されるようになります．

(3)が利用側の`build.sbt`内で規定される各種設定項目にあたるものですが，ここでは`projectSettings`に各種keyの実装が追加されるようにoverrideしてあります．
このようにしておくことで，Pluginが読み込まれた際に，利用側の`build.sbt`にこれらの実装が自動で追加されるようになります．

ここまででsbt pluginをつくるのに十分な知識がおそらく身についたはずです．
より具体的な実装を知りたい場合は，ぜひこちらの[sbt-gcs](https://github.com/saint1991/sbt-gcs)のリポジトリを参考にしてみてください!

# Pluginを公開する
出来上がったPluginはやっぱり公開して使ってもらいたいですよね!
sbtは親切なのでこの辺りもしっかり[ドキュメント](https://www.scala-sbt.org/1.0/docs/Bintray-For-Plugins.html)があります．

## 1. Bintrayにrepositoryを作成

基本的にsbt pluginは[Bintray](https://bintray.com/)でホストしているようです．
なのでPluginをリリースする際はこちらにpublishするのが良さそうです．

上記のドキュメント通りに操作していくだけですが，まずはサインアップしましょう!
次にAdd New Repositoryを選択し， `sbt-plugins`というGeneric repositoryを作成します[^4]．

[^4]: 厳密には名前はなんでもいいようです．

<img width="800" alt="bintray.png" src="https://qiita-image-store.s3.amazonaws.com/0/74604/f21e8479-815b-48c1-55cc-85f85d1bc095.png">

<img width="680" alt="bintray.png" src="https://qiita-image-store.s3.amazonaws.com/0/74604/8f802650-dad4-739c-348b-04d3008dd857.png">

これでPluginの置き場はできたので，あとはPublishするだけです．


## 2. リリース
次に1.で作ったRepositoryにPluginをPublishしてリリースします．
ここでは次の2つのsbt pluginを使うと良さそうです．

|name|description|
|:--|:--|
|[sbt-bintray](https://github.com/sbt/sbt-bintray)|BintrayにjarをPublishするためのPlugin|
| [sbt-git](https://github.com/sbt/sbt-git) (Optional) | Gitと連携してLibraryのバージョン管理を楽にしてくれる神Plugin |

自分はこんな風に書いてます．

```project/plugins.sbt
addSbtPlugin("org.foundweekends" % "sbt-bintray" % "0.5.2") 
addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "0.9.3") |
```
2番目の`sbt-git`は厳密には必要ないのですが，LibraryのversionにGitのcommit hashやtagを使ってくれるようになるので，Pluginのバージョン管理に非常に便利です!
自分はリリース時にGitでtagを打ってpublishするという運用にしています．

sbt-sonatypeを使って先ほど作成したBintray repositoryにpublishするために，`build.sbt`に下記を追加します．下記では部分的にしか記載していませんが，全容を見たい場合は[こちら](https://github.com/saint1991/sbt-gcs/blob/master/build.sbt)を参照してください．

```build.sbt
settings(
    publishMavenStyle := false,
    startYear := Some(2018),
    licenses += ("Apache-2.0", new URL("http://www.apache.org/licenses/LICENSE-2.0")), // Licenseは適宜選んでください
    bintrayRepository := "sbt-plugins",    // ここに1.で作成したrepository名を入れる
    bintrayOrganization in bintray := None // こちらは1でorganizationを指定して作った場合のみ
)
```

次に`bintrayChangeCredentials`タスクでBintrayのクレデンシャル情報を設定します．

```bash
$ sbt bintrayChangeCredentials
Enter bintray username: saint1991
Enter bintray API key (under https://bintray.com/profile/edit): ********************************
```

ユーザ名とAPI Keyが必要です． API Keyは[こちら](https://bintray.com/profile/edit)から確認できます．

<img width="700" alt="bintray.png" src="https://qiita-image-store.s3.amazonaws.com/0/74604/539e6014-79eb-a010-f73d-2dc9cb6aa6be.png">

ここまで設定が終われば準備は完了です． Publishしましょう!

```bash
$ sbt publish
```

これでリリース完了です! 


# sbt公式Pluginにする (おまけ)

ここまでで，世界中のユーザから使ってもらえる状態にはなっていますが，今のままだと使ってもらう際に下記のように自身のrepositoryをresolverに追加してもらう必要があり，ユーザからすると若干めんどうです...

```project/plugins.sbt
resolvers += Resolver.bintrayIvyRepo("saint1991", "sbt-plugins") // Bintrayユーザ名， repository名
```

sbt公式のPluginにすることで，sbtのデフォルトresolverに設定されているrepositoryに関連づけることができるため，上記の設定なしでPluginを使うことができるようになります．

[こちら](https://bintray.com/sbt/sbt-plugin-releases)にsbt pluginの公式organizationがあるので，自分の作ったPluginを追加してもらえるように申請します．

<img width="800" alt="bintray.png" src="https://qiita-image-store.s3.amazonaws.com/0/74604/1a0d0f7b-7da2-3626-0b23-575f88b3414e.png">

ここで作成したPluginの名前を入力し，下記のようにPluginの概要を記載し申請を出します．

<img width="800" src="https://qiita-image-store.s3.amazonaws.com/0/74604/6ff066e9-7778-0605-bee1-27fcc4c7aff7.png">

これを送信すれば，メンテナーのレビューを経てsbtの公式organizationに取り込まれ，resolverの追加設定なしでPluginを使えるようになります!


# Pluginを周知する (おまけ2)

[ベストプラクティス](https://www.scala-sbt.org/1.x/docs/Plugins-Best-Practices.html)に記載されている通り下記をやっておきましょう!

1. Twitterで周知する，
2. [公式リファレンス](https://www.scala-sbt.org/1.x/docs/Community-Plugins.html)に載っける．

## 1. Twitterで周知する．
 `@scala_sbt`にメンションをつけてツイートします．自分はこんな感じにしました．
https://twitter.com/saintech1991/status/961633397251612673

## 公式リファレンスに載っける
[こちら](https://github.com/sbt/website/blob/1.x/src/reference/01-General-Info/02-Community-Plugins.md)を編集してPRを出しましょう!


# 最後に
sbt pluginの作り方からリリースまでを紹介しました! 
Scala/sbt界隈は強い人がたくさんいらっしゃるので間違っていればすぐ指摘が入るでしょう笑
マサカリぶん投げお待ちしております．すぐ直します．

[sbt-gcs](https://github.com/saint1991/sbt-gcs)もScala×sbt×GCPというニッチな需要にお応えできる一品になっています[^5]．
ぜひ使ってやってください． PRもお待ちしております!

[^5]: 自分たちはDataProcでSparkMLをぶん回すために，[sbt-assembly](https://github.com/sbt/sbt-assembly)で作ったfatJarをpublishするのに使っています．多分用途はそれぐらいしかないでしょう笑