【Scala】パターンマッチングでの "unbound placeholder parameter" エラー

Scala関数型デザイン&プログラミング」を進めている。今は3章でデータ型を定義しているところなのだが、パターンマッチングを書いたときに凡ミスでエラーを起こしたのでメモ。

sealed trait List[+A]

case object Nil extends List[Nothing]
case class Cons[+A](head: A, tail: List[A]) extends List[A]

object List {
    def apply[A](as: A*): List[A] = {
        if (as.isEmpty) Nil
        else Cons(as.head, apply(as.tail: _*))
    }
}

このようなList型の定義がある時、このコンパニオンオブジェクトの中に、任意の個数の要素をListの先頭から消す drop メソッドを追加したい。そこで以下のように書いたところエラーが発生した。

def drop[A](as: List[A], n: Int): List[A] = (as, n) match {
    case (Nil, _) => Nil // n個消す前に要素が全部消えたらNil
    case (_, 0) => _ // n個消し終わったら残った要素のListを返す
    case (Cons(_, xs), i) => drop(xs, i-1) // 先頭を一つ消したリストを使い再帰を行う
}

// Main.scala
val x = List(1, 2, 3, 4, 5)
println(List.drop(x, 4)) // unbound placeholder parameter case (_, 0) => _

エラーの原因は、2番目のcaseで返される値が _ になっていること。ついcase(_, 0) で指定されている _ と同じ変数である感覚で書いてしまったが、 _Javaのswitch文で言う default に相当するものであり、特定の値を格納する変数ではない。 そのため、返される値の _ が何にも束縛されておらず、 unbound placeholder parameter のエラーとなった。

正しく書くには、条件部分で _ ではなく変数を使い、それをそのまま返す値として渡すようにすれば良い。

def drop[A](as: List[A], n: Int): List[A] = (as, n) match {
    case (Nil, _) => Nil
    case (l, 0) => l
    case (Cons(_, xs), i) => drop(xs, i-1)
}

ただし、これだと2番目のcaseは受け取った as 引数をそのまま返しているだけなので冗長になる。このようなcaseは、if文としてパターンマッチングの外に出してしまった方が良い。

def drop[A](as: List[A], n: Int): List[A] = {
  if (n <= 0) as
  else as match {
    case Nil => Nil
    case Cons(_, xs) => drop(xs, n-1)
  }
}

【kubernetes】last-applied-configurationの役割とConfigMapのマージ戦略

先日、テスト環境上のKubernetesで、アプリケーションがDBに急に接続できなくなってしまった。GitHubのmasterブランチで管理している設定ファイルには全く変更がなかったので、原因をどう調べようか迷っていたところ、まずConfigmapのlast-applied-configurationを見てみると良いと教えてもらった。

last-applied-configurationとは

last-applied-configurationはConfigmapのフィールドの一つであり、 kubectl apply コマンドによって最後に適用された設定が記録されている。逆に言うと、それ以外の方法で設定が更新された場合はlast-applied-configurationに記録されない。last-applied-configurationは、内容が変更された設定ファイルを kubectl apply で適用した際に、変更点を洗い出して設定をマージするために使われる。
今回調べてみたところ、last-applied-configurationには正しい接続情報が記載されており、今適用されている設定とは異なっていた。すなわち、誰かが接続情報を手続き的な方法(例えば kubectl replace 等)で直接上書きしたということがわかる。

kubectl apply時のマージ方法

last-applied-configurationの役割は分かったが、ここで一つ疑問が浮かんだ。例えば、 kubectl apply で設定ファイルを更新したが、その後手続き的な方法で設定を変えたために、設定ファイルとlive configuration(実際に適用されている設定)に差が生まれているとする。その場合、次に kubectl apply をした時、手続き的な方法で上書きした設定もマージされ残るのだろうか?

kubernetes.io

基本的に、マージはlast-applied-configurationと新たにapplyされた設定ファイルのみを比較して行われる。もし特定のフィールドが手続き的に変更されていた場合、そのフィールドが新しくapplyされた設定ファイルに含まれていれば、値が更新される。逆に、そのフィールドが新しくapplyされた設定ファイルに含まれていなければ、マージの際の比較対象に含まれず、設定がそのまま残る。

デフォルト値のベストプラクティス

設定変更がマージされる際、上記の挙動は問題を引き起こしうる。デフォルト値を取るフィールドAがあり、その値が設定ファイルに明示されていない場合、当然そのフィールドはlast-applied-configurationに含まれない。その状態で、当該のフィールドを設定ファイルに追加してapplyすると、デフォルト値のフィールドに依拠していた別のフィールドがあったときに予期せぬ挙動となってしまう。
このため、ドキュメントではフィールドのデフォルト値をそのまま使う場合でも、最初から設定ファイルに明記するよう推奨している。例えば、各resourceのselectorとtemplate、それにRollout Strategyなどのフィールドがそれにあたる。

【Scala】再帰処理を末尾最適化するための考え方をSICPに学ぶ

Scala関数型デザイン&プログラミングScala関数型プログラミングに慣れようとしている。最初の練習問題はn番目のフィボナッチ数を出力する関数を作るというもの。
はじめは以下のように書いた。

object Main {
  //@annotation.tailrec
  def fib(n: Int): Int = {
    n match {
      case m if m <= 0 => throw new IndexOutOfBoundsException("fibonacci index should be positive number")
      case 1 => 0
      case 2 => 1
      case _ => fib(n - 2) + fib(n - 1)
    }
  }

  def main(args: Array[String]): Unit = {
    println(fib(12))
  }
}

正常に動くことは動くが、 @annotation.tailrec をつけるとコンパイルエラーになる。 この関数が末尾再帰になっていないためだ。

末尾再帰とは

末尾再帰とは、Scala関数型プログラミングに限った概念ではない。Scala関数型デザイン&プログラミングの説明だけではいまいち理解できなかったが、昔読んだSICPの第一章にも出ているのを知り、久しぶりに読み返してみた。

mitpress.mit.edu

SICPでは、ある数の階乗 n! を求める再帰関数を以下のように書いている。

(define (factorial n)
  (if (= n 1)
      1
      (* n (factorial (- n 1)))))

schemeの読み方はここでは詳しく書かないが、 (define (factorial n) (<body>)) という形式で、引数 n を取る factorial という名前の関数を定義している。また、* は2つの引数の積を返す関数で、 (* a b) と書くと ab の積が返ってくる。

例えば factorial(6) のように実行すると、この関数の中では

  • (* (6 factorial(5)))
  • (* (6 *(5 factorial(4))))
  • (* (6 *(5 * (4 factorial(3))))) ...

のように計算が進んでいく。この関数は文法的には問題ないが、最終行で (* n (factorial (- n 1))))) という計算処理を行いながら自身を呼び出している。これにより、* の第一引数である n の値を、再帰でループする回数分だけスタックに保存しておかなければいけない。もしループする回数が巨大になると、スタックに入り切らずStackOverFlowが起こりうる。

巨大な回数分ループできるようにするために、SICPでは以下のように関数を書き換えている。

(define (factorial n)
  (fact-iter 1 1 n))

(define (fact-iter product counter max-count)
  (if (> counter max-count)
      product
      (fact-iter (* counter product)
                 (+ counter 1)
                 max-count)))

factorial 関数の中に fact-iter という別の関数を定義し、その fact-iter再帰関数になっている。fact-iterproduct, counter, max-count の3つの引数を取っている。
注目すべきは、 fact-iter が自身を呼び出す際、それ以外のデータを一時的に保存していないことだ。 countermax-count より大きくなるまでは、 fact-iter は引数を更新して自身を呼び直すことしかしていない。このため、 factorial(6) の計算は

のように進んでいく。fact-oterの呼び出し以外に一時的に保存されているデータはない。

重要なのは、factorial 関数の中にもうひとつ fact-iter という関数を定義していること。しかも fact-iter 関数はコードの書き方の上では再帰と言えるが、そのプロセス自体はforループやwhileループと同じ反復プロセスになっているということにある。最初の factorial 関数の実装では、計算はせずに再帰呼び出しを最後まで終え、その後計算が遅延して行われる。一方で2番目の例では、1回のループの中で1度ずつ計算が行われ、 product 引数の値が更新されていくのがわかる。

この2番目の例のような再帰を末尾再帰と言い、処理の最後のステップで、自身を呼び出す以外の計算処理を一切していないような再帰関数を指す。これにより、反復プロセスと同様に巨大な回数のループでもスタックを食い潰さずに処理できる。

fib関数を末尾最適化する

Scalaにおける末尾最適とは、再帰関数を末尾再帰であるように書くことでコンパイラがそれを検知し、whileループと同様になるようにコンパイルすることを言う。最初のfib関数の例では、自身を呼び出す処理が fib(n - 2) + fib(n - 1) となっている。これは末尾再帰とはいえない。
以下のように書き直すことで、末尾最適化することができる。

object Main {
  def fib(n: Int): Int = {
    @annotation.tailrec
    def sumPrevElems(i: Int, j: Int, count: Int): Int = {
      if (count == 1) i
      else sumPrevElems(j, i+j, count-1)
    }
    n match {
      case m if m <= 0 => throw new IndexOutOfBoundsException("fibonacci index should be positive number")
      case _ => sumPrevElems(0, 1, n)
    }
  }

  def main(args: Array[String]): Unit = {
    println(fib(1))
  }
}

fib 関数の中に sumPrevElems 関数を定義し、その中で連続する2つのフィボナッチ数を合計している。合計した値は sumPrevElems 関数を再帰呼び出しする際に引数に含めており、指定した回数ループしたら再帰をやめるようにしてある。
末尾再帰を実現させるには、最後にまとめて計算するのではなく、 再帰呼び出しが一度行われるたびに計算を行ってその値を渡していくことがテクニックの1つかもしれない。

【Kubernetes】nodeSelectorでPodがassignされるNodeを指定する

会社のアプリケーションをローカルの開発環境で走らせようとして、manifestファイルを開発環境のClusterにapplyしたのだが、いつまで待ってもPendingのまま。 kubectl describe で調べてみると、Event欄に以下の表示が出ていた。

0/9 nodes are available: 3 Insufficient memory, 9 node(s) didn't match node selector.

メモリの方は単にmanifestファイルの resource 欄を修正すれば良かったが、node selectorの方は触ったことがなかったので調べてみた。

Nodeとは

NodeはPodを動作させるCluster内のワーカーマシンを指し、VMであったり物理的なマシンだったりする。Namespaceとは異なり、NodeはCluster内のPodの場所を物理的に区分けしている。NodeはNamespaceよりもさらに低レベルに位置するResourceなので、特定のNamespaceには属していない。

NodeSelectorとは

Kubernetesでは、Podをassignする先のNodeを意識しなければいけないことはあまりない。なぜならKubernetesにはSchedulerがあり、NodeにassignされていないPodがあれば、各Nodeに残された利用可能なResourceを考慮して最適なNodeに割り振ってくれるからだ。
しかし、例えばフロントエンドとバックエンドのアプリケーションがそれぞれあり、バックエンドの方は大量のデータ処理を伴うため性能の良いEC2インスタンスで動かしたいというような場合、Nodeによってマシンスペックが異なることもありうる。そのような場合、PodがassignされるNodeを指定したいということもあるだろう。NodeSelectorを使えば、個々のNode名を入力することなく、条件に合致するNodeを簡単に絞れる。各Nodeには、他のResourceと同様にk-v形式のLabelを付与することができる。NodeSelector内でそのk-vを指定すれば、assignされるNodeを指定できる。

今回の場合、まずStatefulSetのmanifestファイルを確認すると、 nodeSelector として nodegroup: backend と指定されていた。次にnamespaceを検索してみる。

$ kubectl get namespace -L nodegroup
NAME    STATUS   ...  NODEGROUP
docker-desktop   Ready      ... backend-dev
docker-desktop   Ready      ... backend-dev
...

-L オプションで、表示させるlabelのキーを指定することができる。用意してあった開発環境には nodegroup=backend のLabelが付与されたnodeがなかったため、Podのassignができなかったようだ。
manifestファイル内でnodegroupを backend-dev に修正して再度applyすることで、無事podが起動した。

kubernetes.io

【CircleCI】Pull Requestを出した時のみビルドを動かす

先日チームでのリリースフローが変わり、それに合わせてCircleCIの設定を変えようとしていた。やりたいことは、Pull Requestを作成・更新した場合と、masterブランチにタグを打ったときのみビルドを走らせることだったのだが、公式ドキュメントを見てworkflowのfilterに指定できる要素を見てもPull Requestがなかった。
GitHub Actionsにしないとだめだろうかとも思ったが、別の場所にオプションがあった。

circleci.com

Only Build Pull Request オプションを有効にすると、Pull RequestがOpenされた時のみビルドするようになる。このオプションは config.yml ではなく、コンソール内のAdvanced Settingからしか切り替えることができないようだ。
デフォルトではCircleCIは全ブランチの全プッシュをトリガーとしてビルドを走らせるが、このオプションを有効にすることで、ビルド回数を抑えることができ、料金の節約にもつながる。

f:id:Udomomo:20200308231651p:plain

実際に個人用レポジトリで設定をオンにしてみたところ、PRをOpenしたタイミングでビルドされるようになった。その後はPRに追加でコミットをプッシュしてもビルドが行われる。

なお、設定画面にも書いてある通り、defaultとなっているブランチにおいては全てのプッシュをトリガーとしてビルドが走る。defaultがmasterブランチであれば、これで本番環境へのデプロイも問題ないだろう。ただし、Git flowを使用するなどしてdefaultをdevelopブランチにしている場合、masterブランチにマージしてもビルドが走らなくなる。この場合はCircleCIのデフォルト通り、全ブランチの全プッシュでビルドを走らせるしかない。

【Git】過去のコミットのメールアドレスを全て変える

先日、開発に使う用のメールアドレスを新しく作ったのだが、今コミットしている自分の個人用レポジトリを git log で見ると、プライベート用のメールアドレスも混じってしまっていた。なんとなく気持ち悪いというだけなのだが、統一する方法はないかと調べてみたところ filter-branch というコマンドが使えそうだった。

filter-branchとは

filter-branch は、大量のコミットを機械的に書き換えるためのコマンドである。もちろん非常に危険なコマンドで、ドキュメントの最初にWARNINGが書かれているだけでなく、実行すると最初の数秒間は処理が開始されずWARNINGメッセージが表示されるほどである。
非常に容量が大きいファイルを誤ってコミットしてしまったり、かなり前のコミットを変えたいがそれ以降の多くのコミットに影響してしまうという時に使われることが多い。

git-scm.com

メールアドレスを変える

今回はコミットの中身は変えず、author/commiter情報を書き換えたいだけなので、 --env-filter オプションを使う。注意点として、今回のレポジトリは自分一人しかコミットしていないが、もし他人のコミットも混じっている場合、 --commit-filter オプションの中でif文などを書き、コミットを絞り込む必要がある。

git filter-branch -f --env-filter \
'GIT_AUTHOR_EMAIL="udomomo@example.com"; \
GIT_COMMITTER_EMAIL="udomomo@example.com"; \
export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"' HEAD

--env-filter オプションの中で、author/commiterそれぞれの新しいメールアドレスを指定している。末尾の HEAD は、対象となるコミットの範囲を指定するもの。今回の場合、initial commitからの全てのコミットが対象となる。
また、最後の行の export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE" は、コミットの日付を変えないためのもの。Gitにはタイムスタンプが2つあり、 git log で表示される日付は GIT_AUTHOR_DATE だが、GitHub等で参照される日付は GIT_COMMITER_DATE の方。
そのため、GIT_COMMITER_DATEGIT_AUTHOR_DATE に合わせないと、コミットの日付が今日になり、草が変わってしまう。なお、 git log --verboseGIT_COMMITER_DATE も表示させることができる。

git-scm.com

【Spring Boot】OpenAPI GeneratorのType Mappingで自作のドメインオブジェクトを使用する

Spring Boot + OpenAPI GeneratorでTodoリストのAPIサーバを作っている。OpenAPIでエンドポイントやリクエスト・レスポンスを定義するところは楽だったが、 OpenAPIで定義したリクエスト・レスポンスの型は自動で生成されたコードの中に定義されることがわかった。そのため、Spring Boot側でドメインオブジェクトを定義しても、それをそのままリクエストやレスポンスの型として使うことができない。
自分はGeneration Gapパターンを使って開発しているので、自動生成されたコードには手を加えたくないし、gitの管理下にも入れたくない。そのためSpring Bootの側でConverterを用意するしかないかと思っていたのだが、調べてみるとOpenAPI GeneratorのType Mappingという機能で賄えそうだった。

Type Mappingとは

OpenAPIで定義した型と、実装コード側で定義した型を変換することができる機能。設定項目は以下の2つがある。

  • --type-mappings : 変換先の型を指定する。
  • --import-mappings : 変換に伴いインポートする型のテンプレートを指定する。

たいていの場合、両方を指定する必要がある。

Type Mappingを使う

今回作っているのは簡単なTodoリストのRESTful APIであるため、SpringBoot側で定義したドメインを、そのままCREATE時のレスポンスの型として使いたい。
OpenAPI側では、レスポンスを Task 型として以下のように定義した。

paths:
  /tasks:
    post:
      summary: add a new task
      operationId: addTask
      requestBody:
        description: task to create
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TaskRequest'
      responses:
        '201':
          description: created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Task'
        default:
          description: unexpected error
          content: 
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  schemas:
    Task:
      description: one task object
      type: object
      allOf:
        - $ref: '#/components/schemas/TaskRequest'
        - required:
          - id
          - done
          properties:
            id:
              type: integer
              format: int64
            done:
              type: boolean
    TaskRequest:
      description: object to create or edit a new task
      type: object
      required:
        - content
        - urgency
        - importance
      properties:
        content:
          type: string
        urgency:
          type: integer
          format: int32
        importance:
          type: integer
          format: int32

この結果、OpenAPI Generatorによって以下のようなメソッドが生成される。 ResponseEntity<Task> が戻り値となっており、この Task 型も Task.java として生成されている。

default ResponseEntity<Task> addTask(@ApiParam(value = "task to create"  )  @Valid @RequestBody(required = false) TaskRequest taskRequest) {
...
}

一方、Spring Boot側では、以下のようなドメインオブジェクトを定義した。

@Entity
@Table(name="task")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TaskEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private int id;

    private String content;
    private int urgency;
    private int importance;
    private boolean isDone;

    public TaskEntity(String content, Integer urgency, Integer importance, boolean isDone) {
        this.content = content;
        this.urgency = urgency;
        this.importance = importance;
        this.isDone = isDone;
    }
}

始めは自動生成された Task 型のみでサーバサイドの処理を行えないかと思ったが、Entityとして管理されているドメインオブジェクトを使わなければJpaをうまく使うことができない。
そこで、Type Mappingで変換をかける。 build.gradle に以下の設定を追加する。

openApiGenerate {
    typeMappings = [
            Task: 'com.example.springboottodo.TaskEntity'
    ]
    importMappings = [
            Task: 'com.example.springboottodo.TaskEntity'
    ]
{

この状態で再度OpenAPI Generatorでコードを生成してみると、 addTask メソッドの返り値の型が変わった。

default ResponseEntity<com.example.springboottodo.TaskEntity> addTask(@ApiParam(value = "task to create"  )  @Valid @RequestBody(required = false) TaskRequest taskRequest) {
...
}

これで、直接 TaskEntity 型を使ってレスポンスを作れるようになる。
なお、最初に生成された Task 型はコード内では使われなくなるが、OpenAPIからドキュメントを生成するときは Task 型に基づいた記載となる。