【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
をした時、手続き的な方法で上書きした設定もマージされ残るのだろうか?
基本的に、マージは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の第一章にも出ているのを知り、久しぶりに読み返してみた。
SICPでは、ある数の階乗 n!
を求める再帰関数を以下のように書いている。
(define (factorial n) (if (= n 1) 1 (* n (factorial (- n 1)))))
schemeの読み方はここでは詳しく書かないが、 (define (factorial n) (<body>))
という形式で、引数 n
を取る factorial
という名前の関数を定義している。また、*
は2つの引数の積を返す関数で、 (* a b)
と書くと a
と b
の積が返ってくる。
例えば 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-iter
は product
, counter
, max-count
の3つの引数を取っている。
注目すべきは、 fact-iter
が自身を呼び出す際、それ以外のデータを一時的に保存していないことだ。 counter
が max-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が起動した。
【CircleCI】Pull Requestを出した時のみビルドを動かす
先日チームでのリリースフローが変わり、それに合わせてCircleCIの設定を変えようとしていた。やりたいことは、Pull Requestを作成・更新した場合と、masterブランチにタグを打ったときのみビルドを走らせることだったのだが、公式ドキュメントを見てworkflowのfilterに指定できる要素を見てもPull Requestがなかった。
GitHub Actionsにしないとだめだろうかとも思ったが、別の場所にオプションがあった。
Only Build Pull Request
オプションを有効にすると、Pull RequestがOpenされた時のみビルドするようになる。このオプションは config.yml
ではなく、コンソール内のAdvanced Settingからしか切り替えることができないようだ。
デフォルトではCircleCIは全ブランチの全プッシュをトリガーとしてビルドを走らせるが、このオプションを有効にすることで、ビルド回数を抑えることができ、料金の節約にもつながる。
実際に個人用レポジトリで設定をオンにしてみたところ、PRをOpenしたタイミングでビルドされるようになった。その後はPRに追加でコミットをプッシュしてもビルドが行われる。
なお、設定画面にも書いてある通り、defaultとなっているブランチにおいては全てのプッシュをトリガーとしてビルドが走る。defaultがmasterブランチであれば、これで本番環境へのデプロイも問題ないだろう。ただし、Git flowを使用するなどしてdefaultをdevelopブランチにしている場合、masterブランチにマージしてもビルドが走らなくなる。この場合はCircleCIのデフォルト通り、全ブランチの全プッシュでビルドを走らせるしかない。
【Git】過去のコミットのメールアドレスを全て変える
先日、開発に使う用のメールアドレスを新しく作ったのだが、今コミットしている自分の個人用レポジトリを git log
で見ると、プライベート用のメールアドレスも混じってしまっていた。なんとなく気持ち悪いというだけなのだが、統一する方法はないかと調べてみたところ filter-branch
というコマンドが使えそうだった。
filter-branchとは
filter-branch
は、大量のコミットを機械的に書き換えるためのコマンドである。もちろん非常に危険なコマンドで、ドキュメントの最初にWARNINGが書かれているだけでなく、実行すると最初の数秒間は処理が開始されずWARNINGメッセージが表示されるほどである。
非常に容量が大きいファイルを誤ってコミットしてしまったり、かなり前のコミットを変えたいがそれ以降の多くのコミットに影響してしまうという時に使われることが多い。
メールアドレスを変える
今回はコミットの中身は変えず、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_DATE
を GIT_AUTHOR_DATE
に合わせないと、コミットの日付が今日になり、草が変わってしまう。なお、 git log --verbose
で GIT_COMMITER_DATE
も表示させることができる。
【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
型に基づいた記載となる。