Introducing TLA+ Intellij plugin
Motivation
Nowadays, it's getting popular to use formal methods to verify complex systems e.g. distributed storages.
TLA+ is a widely-adopted formal specification language created by Leslie Lamport.
The standard way to write and verify TLA+ specification is to use official TLA+ toolbox which is built on top of Eclipse.
Though the toolbox is a full-featured and easy-to-use basically, it was bit frustrating that I cannot use code editing environment I'm familiar with.
For VS code users, there is already a great vscode-tlaplus plugin.
But I'm a fan Intellij IDEA and there seems to be no plugin exists as of now. Then why not creating it?
That's why I developed TLA+ Intellij plugin.
Features
The plugin is heavily inspired by vscode-tlaplus
and it has only subset of features of vscode plugin for now.
Please refer README for the features currently implemented.
But there are several unique points in the intellij plugin.
In this article, I gonna introduce one of them, fine-grained reference resolution.
Reference resolution (i.e. Find usages / Go to definition)
In tlaplus-intellij-plugin
, TLA+ files are parsed by similar grammar definition as SANY (TLA+'s syntactic analyzer) which is written in Grammar-Kit's BNF.
tlaplus-intellij-plugin/tlaplus.bnf at v0.4.6 · ocadaruma/tlaplus-intellij-plugin · GitHub
Find usage / Go to definition are implement on top of it.
It means that the correctness of the reference resolution is almost same level as SANY (as well as Toolbox).
For example:
- Local definitions introduced by
LET
are visible only inside ofIN
block - Definitions prefixed by
LOCAL
are visible only inside the module - Modules can be instantiated with different name by
Bar == INSTANCE Foo
This greatly helps to read complex TLA specs which often separated to multiple sub modules and require us to find the definition across multiple files.
Conclusion
Though still there are points of improvement in tlaplus-intellij-plugin
, it will provide better editing experience for Intellij IDEA users.
Please try it out if you're interested, and give me your feedbacks!
Does Kafka bootstrap server have biased connections from clients?
Kafka clientは以下のようなフローでbrokerに接続します。
- まずbootstrap.serversに指定されたいずれかのbrokerに接続し、metadata(partitionのleader)を得る
- partitionのleaderに対してconnectionを開く
- produce/consumeを開始する
最初に最低1台に繋げればmetadataを取得できるので、通常bootstrap.serversにはクラスターの全台ではなく数台のみ指定します。
ここでもしmetadataを取得したあと最初に接続したbrokerへのコネクションを閉じる仕組みが無いと、bootstrap.serversはほとんど全clientに対するソケットを保持することになり、いくらクラスターを拡張してもclientが増えればbootstrap.serversのコネクション数がボトルネックとなってしまいます。
これを避ける仕組みはちゃんとあって、connections.max.idle.ms
過ぎると必要のないコネクションは閉じるようになってます。
Thoughts about oslo.messaging Kafka driver
(OpenStackについては素人なので間違ったことをいろいろ書いてるかもしれない)
OpenStackはクラウド環境を構築するためのオープンソースプロジェクトで、VMなどユーザー向けの計算資源を管理するサービス(Nova)や認証を提供するサービス(Keystone)etcで構成される。
それぞれのサービス、例えばNova自体も、APIエンドポイントであるnova-apiや、VMをホストする各物理マシン上で動作するnova-computeといった複数のコンポーネントで構成される。
nova-apiからnova-computeへのVMの作成要求といった各コンポーネント間のRPCは、MQを経由することで疎結合性を担保する。
refs:
各コンポーネントからMQへのアクセスはoslo.messagingとよばれるライブラリとして抽象化されていて、特定のMQに依存しない形になっている。
oslo.messagingは、上記に述べたRPCの他に、Notificationを行うAPIも提供する。
たとえばnova-computeで特定のアクションを実行した際にnotificationをMQに送ることで、外部のモニタリングツール等がMQ経由でnova-computeの状態変化を観測する、といったユースケースが想定されているようだ。
oslo.messagingにおいて、MQごとの具体的な処理を実装するレイヤーはdriverと呼ばれる。
OpenStackのMQとして広く利用されているのはRabbitMQだが、Kafkaも利用可能のようだ。
そしてWallaby現在、Kafka driverでサポートするのはnotificationのみで、RPCは対象外とある。
Kafkaはstream processingに最適化されたミドルウェアなので、これは妥当な判断に見える。
(もしKafka driverでRPCをサポートしようとした場合)たとえば全nova-computeノードにmessageをブロードキャストするようなケースを考えてみる。
Kafkaでこれを実現しようとするなら、あるtopic-partitionを用意して、全nova-computeがそのpartitionをsubscribeするという形になる。
Produce/consumeはpartition leaderに対してのみ可能だが、leaderは単一のKafka brokerなので、ここがscalability limitになるのは容易に想像できる。
A pitfall in Kafka partition splitting when auto.offset.reset = latest
Kafka 2.7.0現在、consumerのauto.offset.reset configはlatestがデフォルトとなっています。
これは、consumer groupがあるpartitionをsubscribeするとき、commit済みoffsetが存在しない場合(consumerの初回デプロイ時や、offsets.retention.minutes以上の期間consumerを起動していなかった場合などが該当します)、consume開始位置をlog end offsetにセットするというものです。
通常は1) consumer 2) producerの手順でデプロイしますので、これで問題ありません。(逆の場合、consumerが起動するまでの間にproduceされたmessageは処理されないことになります)
ここで、サービスを開始してしばらくしてpartitionを途中で増やしたくなったとしましょう。
これによりkeyのpartitionへの割り当てが変わるため、一時的にkey単位のmessage localityが崩れることはよく知られていますが、consumerをauto.offset.reset = latestに設定している場合、さらに一部messageのconsumeが漏れる可能性があるという落とし穴があります。
これは以下のようにして起こります。
- topic-Xのpartition countを1 -> 2にsplit
- producerがmetadataをrefreshし、partitionが追加されたことを知る。新しく追加されたpartition-1へのproduceを開始
- consumerがmetadataをrefreshし、consumer rebalanceが必要であることを知る。新しく追加されたpartition-1をconsume開始
- partition-1にcommitされたoffsetはまだ存在しないため、開始位置をlog end offsetにセットする。
2と3のどちらが先に発生するかはタイミング依存です。(metadata.max.age.msや、ちょうどmetadataが更新されるような事象(brokerが死ぬとか)が起きたとしてどちらが先に検出するか、など)
もし2 => 3の間にproducerがある程度のmessageをpartition-1にproduceしていた場合、いくつかのmessageはconsumerに届かないことになります。
Demo
さて、実際にpartition splitするタイミングでdelivery lostするかどうか試してみましょう。
これは、以下のような仕様の小さなproducer/consumer applicationです。
- producerは、1秒間隔でmessageをproduceする。
- messageにはmonotonic integerをidとして与える。produce完了したら、標準出力にidを出す。
- consumerは、consumeしたら標準出力にidを出す。
partitionを一つだけ持ったdemo-topic
というtopicを作って、アプリケーションを動かしてみます。
% ./gradlew run [ID=0] sent. tp=demo-topic-0[offset=0] [ID=0] received. [ID=1] sent. tp=demo-topic-0[offset=1] [ID=1] received. [ID=2] sent. tp=demo-topic-0[offset=2] [ID=2] received. [ID=3] sent. tp=demo-topic-0[offset=3] [ID=3] received. [ID=4] sent. tp=demo-topic-0[offset=4] [ID=4] received. [ID=5] sent. tp=demo-topic-0[offset=5] [ID=5] received. ...
messageが正しく届いている限り、このようにsentとreceiveがセットで出力されます。
さて、途中でpartitionを1 -> 2に増やしてみましょう。
... [ID=16] sent. tp=demo-topic-1[offset=0] [ID=17] sent. tp=demo-topic-0[offset=16] [ID=17] received. [ID=18] sent. tp=demo-topic-1[offset=1] [ID=19] sent. tp=demo-topic-1[offset=2] [ID=20] sent. tp=demo-topic-0[offset=17] [ID=20] received. ...
ある時点からproducerはpartition-1へのproduceを開始しましたが、consumerに届いていないことがわかります。 もう少し待つと、
... [ID=28] sent. tp=demo-topic-1[offset=5] [ID=28] received. [ID=29] sent. tp=demo-topic-1[offset=6] [ID=29] received. [ID=30] sent. tp=demo-topic-0[offset=23] [ID=30] received. ...
offset=5からconsumeされるようになりましたが、offset=4まではdelivery lostしてしまいました。
今回の検証コードではconsumerに長いmetadata.max.age.msを設定して容易に事象が発生するようにしたのですが、たとえ同じあるいはconsumerのほうを短くしていたとしても、metadataの更新には様々な契機が存在するため、auto.offset.reset = latestで有る限り同様の事象は起こり得ます。
Conclusion
consumerのauto.offset.resetをlatestにしている場合、partitionをsplitするタイミングでdelivery lostするケースがあることを確認しました。
後からpartitionを増やす可能性があり、かつdelivery lossが許容できないようなサービスの場合はauto.offset.reset = earliestに設定しておく必要がありそうです。
histogram_quantileはどのようにquantileを計算しているか
Prometheusには、quantileを計測する方法としてSummaryとHistogramの2種類があります。
上記公式documentに記載がある通り、Summaryはclient sideでquantileを計測するのに対し、Histogramではprometheus sideでqueryを打つ際にアドホックに算出します。
Summaryはqueryの際にaggregationすることはできないため、たとえば「複数台のAPIサーバーがあり、それら全体での99th percentile response latencyをモニタリングしたい」といった場合は必然的にHistogramを選択することになります。(「1台1台からreportされた99th percentileの平均」などは、それはそれでモニタリングする価値のある指標ではあるかもしれませんが、統計的に意味をもつ値ではありません)
Histogramの使い方は次のようになります。(prometheus clientを使う場合はbuilderで簡単にセットアップできると思いますが)
- histogram bucketをどのように取るか決める。たとえば次のような具合。
[0, 100ms]
[0, 200ms]
[0, 300ms]
[0, 400ms]
[0, +∞]
- event発生時、対応するbucketの値をincrementする
- たとえばresponse latencyが256msだった場合、
[0, 300ms]
,[0, 400ms]
,[0, +∞]
の3つをincrementする
- たとえばresponse latencyが256msだった場合、
- それぞれのbucketについて、
le=<upper bound of bucket>
というlabelをつけてcountをexportする histogram_quantile
関数を使ってquantileを計算する。たとえば直近10minにおける99th percentileを計算したい場合は以下のようになる。histogram_quantile(0.99, sum(rate(api_latency_seconds_bucket[10m])) by (le))
How histogram_quantile calculates quantile from buckets
さて、histogram_quantile
はどのようにquantileを算出しているのでしょうか。
上記公式documentには
it applies linear interpolation
とあります。
さきほどの、0から400msまで100msごとに刻んでいる例を使って図示してみます。
直近10minで合計20回のAPIリクエストがあり、latencyの分布が以下のようになったとします。(もちろんhistogramに記録した時点で個々の点のlatencyは消滅し、実際に取れるのはbucketごとの4,8,12,...というcountだけです)
ここで75th-percentileを計算することを考えます。これは全体を小さい方から並べたときに75%のところに位置する値ですから、今回の場合小さいほうから数えて15番目の値ということです。
これより75th percentileが[300ms, 400ms]のどこかに位置することは分かりますが、個々の点の情報はすでに消滅してhistogramになってるため、近似するしかありません。
bucket内に一様にlatencyが分布していると仮定して線形補間すれば、75th percentile = 375msが得られます。
Prometheusのコードを見ると、まさにこのような計算を行なっていることが確認できます。
prometheus/quantile.go at v2.20.1 · prometheus/prometheus · GitHub
もちろん次のように分布が一様でない場合は近似の精度が下がることになりますが、それでもbucketの[lower bound, uppder bound]内に正確な値があることは保証できます。
そして、histogramはただのbucketごとのcounterですので、それぞれのserverからreportされた値をaggregateして全体でのquantileを計算することも容易です。
Conclusion
KIP-392 Follower fetchingによるfetch requestの増加について
Kafka 2.4.0でfollower fetchingと呼ばれる機能が入りました。
これにより、consumerはleader replicaのみではなくfollower replicaからデータを読み出すことが可能となります。
Follower fetchingは、たとえばデータセンターをまたいでreplicaを配置している場合に有効で、consumerと同一DCにあるreplicaから読み出すようにすれば高コストなDC間通信を削減することができます。
Consumerに対するreplicaの割当ては、ReplicaSelector
interfaceを実装することで行います。
replica.selector.class
broker configurationで実装を指定でき、デフォルトはLeaderSelector
です。(無条件にleader replicaを選ぶ。2.4以前の挙動と同一)
Consumerのrack情報を利用してreplicaを決定する、RackAwareReplicaSelector
もビルトインで提供されます。
High watermark propagation
Partitionのhigh watermark(以下HW)とは、全てのin-sync replicaにreplicationが完了している最新のoffsetを指します。
Consumerが読み出すことができるのはHWまでとなります。(Leaderがもっと新しいmessageを受け取っていても、replicationが完了するまでconsumerには見えない)
もし未replicateのmessageを読み出せた場合、以下のような不整合が起こるためです。
- replication factor = 3, isr = [0, 1, 2], leader = 0 - consumer group A, Bがconsumeしている
として、
timestamp | event |
---|---|
t0 | offset 55301のmessageをproduce |
t1 | - consumer group Aがoffset 55301のmessageをconsume - follower replicaおよびconsumer group Bはまだ55301をfetchしていないとする |
t2 | - broker 0がクラッシュ - broker 1が新たなleaderになる |
=> 55301はlostするにもかかわらずAによってだけ処理された状態となる。
したがってfollower fetchingにおいても同様に、consume可能なmessageをHWまでに制限する必要があります。
ここで、followerへのHWの伝搬はfetch responseを通して行われるため、そのままだとproduceしたmessageをconsumeするまでのlatencyに最大でfetch response latencyが加算されることになります。
どういうことかというと、
- partitionのproduction rateは低い(1 request / 1 sec) - replication factor = 3, isr = [0, 1, 2], leader = 0 - replica.fetch.max.wait.ms = 500, replica.fetch.min.bytes = 1 - consumerはleader(0)でなく1からconsumeしている
としたとき、
timestamp (millisec) | event |
---|---|
- | broker 1, 2はleaderにfetch requestを送信済みでpurgatoryに入っており、条件の満足を待っているとする |
t0 | - offset 55301のmessageをproduce - fetch条件の満足により、broker 1, 2にfetch responseが返る - 同時に、HWが55301に更新される |
t0 + 1 | - broker 1, 2は次のfetch requestを送信するが、この時点では条件を満足しないためpurgatoryに入る |
t0 + 501 | - max.waitの経過により、fetch responseが返る。これによりHWが伝搬し、broker 1から55301をconsume可能となる |
Leaderからconsumeしていたらt0
の時点でmessageが取得可能だったのに対し、followerへのHWの伝搬を待つため500 millisecのlatencyが加算されます。
これに対処するため、KIP-392の実装にともない、fetch responseの完了条件に「followerの持っているHW情報がstale」であることを含むようになりました。
これにより、上記の例でいうとt0 + 1
の時点で即座にbroker 1にfetch responseが返り、HWが伝搬するようになります。
KAFKA-9731
問題は、この方法だとfetch requestが倍増してしまうことです。
上記の例で、stale HWをfetch response条件に含めた時に何回のfetch requestが発行されるか数えてみましょう。
timestamp (millisec) | event |
---|---|
t0 | - offset 55301のmessageをproduce - fetch条件の満足により、broker 1, 2にfetch responseが返る - 同時に、HWが55301に更新される |
t0 + 1 | - broker 1, 2は次のfetch requestを送信する - HWがstaleであるため即座にresponseが返る |
t0 + 2 | - broker 1, 2は再度fetch requestを送る - これは条件を満足しないためblockする |
一度のproduce requestに対し、2倍のfetch requestが発生していることがわかります。
この問題はKAFKA-9731として報告され、以下のように修正されました。
- これは1つ目のパッチで、replica selectorが指定されている場合のみ即座にHW propagationを行うようにする変更です。
- しかしその後、最終的にHW propagationそのものが取り除かれました。
- KAFKA-9952; Remove immediate fetch completion logic on high watermark updates by hachikuji · Pull Request #8709 · apache/kafka · GitHub
- 1つ目のパッチでは、依然としてreplica selectorを有効にするとfetch requestが倍増するリスクが残ります。
- そもそも十分なtrafficがある場合fetch responseもある程度速く返るため、即座のHW propagationはtrafficが少ない場合のみ有用です。
- follower fetchにおけるlatencyの上限は、max waitの調整によってコントロールできます。
Conclusion
- Kafka 2.4.0, 2.4.1では、fetch requestが大幅に増加します。(2.5.0もっぽい?)
- Kafka 2.6.0で修正されるが、follower fetchを使う際はleaderから読む場合に対してdelivery latencyが増えます
Arduinoで電子オーボエを作る
はじめに
3月の終わりに、響け!ユーフォニアム1期〜2期, リズと青い鳥, 誓いのフィナーレを一気見して「特別になりたい」気持ちを再確認し、オーボエを始めようと思い立ちました。
「昨今みんな在宅環境に投資してるし、いいイス買うようなものだし」と謎の論理を免罪符にしてMarigaux Lemaireの中古美品を買ったはいいものの、自宅は防音では無いので気軽に音は出せず、スタジオに練習に行くわけにもいきません。
自分はトランペットを長いことやっていますが木管楽器はリコーダーを除けば初めてで、オーボエの複雑な運指は問題でした。(トランペットはボタンが3つのみで音程の規則性も明確なので運指は簡単です。対してオーボエ(Lemaire)は22個キーがあり、音域によってはほぼ運指に規則性が無く丸暗記を必要とする上、左手人差し指ではハーフオープンを含め3つの状態を管理しなくてはなりません。)
今の練習状況では「第三楽章『愛ゆえの決断』」のオーボエソロを吹けるようになるまで何年かかるかわかりません。
そこで、家で手軽に運指を練習できるソリューションとしての電子オーボエの検討を始めました。
既存製品
ざっと調べた感じでは「電子オーボエ」そのものズバリな製品は見当たりませんでした。
AKAI EWIではオーボエに似た運指に切り替えられますが、そもそもキーの数も配置も異なるため再現度には限界がありそうです。
PCキーボードにオーボエのキーをマッピングする手も考えましたが、やってもかなり無理矢理な配置になるし、N-Key Roll Overに対応したキーボードを持っていませんでした。
DIY
したがって自前で製作することにしました。
おおまかに考えて、以下のような構成で電子オーボエが作れるはずです。
- マイコンボード
- 筐体
- 3Dプリント
- 自作キーボード用キースイッチ
- Kailhロープロファイルスイッチがちょうどよさそう
- ブレスコントローラー
以下、製作の過程を紹介していきます。
作った3DモデルやArduinoスケッチはこちらで公開しています。
ブレスコントローラー
ArduinoでUSB MIDIブレスコントローラーを作る、まさに求めていたことをやっている先例がありました。
同型番のセンサー(MPVZ4006GW7U)をDigiKeyで注文し、Melodion 唄口も入手してブレッドボードで動作確認します。
以下のように直でArduinoとセンサーをつなぎ、
MPVZ4006GW7U | Arduino Uno R3 |
---|---|
Vs | 5V |
Gnd | GND |
Vout | A0 |
スケッチを書き込んでシリアルモニターで確認します。
int intensity; int reading; int sensorIni; void setup() { delay(200); Serial.begin(9600); sensorIni = analogRead(A0); } void loop() { reading = constrain(analogRead(A0) - sensorIni, 0, 1014 - sensorIni); intensity = map(reading, 0, 1014 - sensorIni, 0, 127); Serial.println(intensity); }
記事でOptionalとして言及されていたdecoupling circuitは確かに省略しても動作に問題なさそうだったので、今回は無しで行きます。
筐体設計
電子オーボエの筐体をどんな形にするか、実際のオーボエを参考に穴の位置を決めつつ、ざっくり考えていきます。
もっと本物のオーボエのような見た目にできればかっこいいでしょうが、造形が難しそうなのでこのへんを落とし所とします。
完全に直方体だとキーを押さえるのに邪魔になるため、若干流線型にしています。
3Dモデル作成
Blenderで作っていきます。。
ブレスコントローラーやArduinoの取り付け位置まで緻密に設計していると無限に時間がかかりそうだったので、ある程度の配置だけイメージしつつ、残りは後で糸ノコで調整する前提で出力してしまいます。
3Dモデル出力
DMM.makeを利用しました。
素材は、キャンペーンで安くなっていたのでPA12(MJF)を選択。
上部と底部の2モデル合わせて15,000円でした。サイズ大きいので結構かかりますね。。
もっと小さくパーツ分けてあとでつなぎ合わせるほうがいいのかな。そうすれば家庭用プリンターでも出力できそうです。(持ってないけど)
筐体加工
無事出力できたはいいのですが、なんと第二オクターブキーをはめる穴を開け忘れていました。。ここは糸ノコで開けます。
また、以下はモデル出力時点ではTBDにしていたので、寸法を図りつつ加工していきます。
- 上部と底部をナットで止めるための穴
- Arduinoを固定するための穴
- ブレスセンサーを配置したユニバーサル基板を固定するための穴
- 唄口を露出させるための穴
キースイッチとPCB配置
キースイッチをはめ込んで、そこに無限の可能性 アルタナを載っけていきます。
寸法通りに出力したので当たり前といえばそうですが、きれいにハマってちょっと感動。
キーマトリックス設計
Arduino Uno R3のGPIOは14本で、オーボエのキーは23個(今回、左手人差し指ハーフオープンを別個のキーとして実装します)あるためダイレクト配線ではピンが足りません。 したがってキーマトリックスを組みます。
23個のキーに番号を振ったのち、
キーマトリックスへ割り当てます。data[0-4]がINPUT_PULLUP、sel[0-4]がOUTPUTモードです。
任意キーの同時押しは必須要件なのですべてのキーにダイオードも入れます。
実装
表面実装ダイオード1N4148Wを各無限の可能性にはんだ付けして、キーマトリックスを配線していきます。。
次に、ブレスコントローラーを取り付けます。
中身は最終的にこうなります。
上部と底部を閉じてナットを締めればハードウェアの完成です。
スケッチ実装
ロジック的に難しいことは無いですが、運指表を見ながら音との対応づけを根気よくコードに落としていきます。。
https://github.com/ocadaruma/roboe/blob/v0.1/oboe.cpp#L645
Pitch OboeKeys::toPitch() const { switch (this->bitFlags()) { case B_FLAT_3_0: return { 3, PitchName::B, Accidental::FLAT }; case B_3_0: return { 3, PitchName::B, Accidental::NATURAL }; case C_4_0: return PITCH_MIDDLE_C; case C_SHARP_4_0: case C_SHARP_4_TRILL_0: ....
C++11準拠でスケッチを書けて便利です。
ArduinoをUSB MIDI controller化
Serial.printデバッグである程度動作確認できたら、ArduinoをMoco LUFAでUSB MIDI controller化していきます。
手順はこちらに詳しいです: Arduino Moco LUFA
Putting together
一通り出来上がったので手持ちのDAWで鳴らしてみます。
。。。おおむね問題ないのですが、タンギングが効きません。(タンギングとは、舌を歯やリードに当てて息の流れを遮断し、音を止めることです)
それはそうで、ブレスコントローラーは下記のように実装していました。
- センサーはホース内の圧力に応じて値が変化する
- 息を吹き込むとホース内の圧力が上がる
- 唄口 -> ホース -> センサーは密閉されており、息の通り道は無い
舌をどこに当てたところで圧力が変化しないためタンギングができないのです。
そこで、唄口に小さな穴を開けます。
これにより、継続的に息を吹き込んでいる限り圧力がかかってセンサーが反応するが、息を遮断すると穴から息が逃げて圧力が下がるようになり、タンギングが効くようになります。
というわけで最終的にできたものを演奏した動画がこちらです。(ビブラートは音源が勝手にかけてくれている。。)
まとめ
これで実際オーボエの運指の練習になるのかはさておき、ほぼ想定通りのハードウェアが出来上がりました。(なおこの記事ではすべてがスムーズにいったかのように書いていますが、実際は試行錯誤やパーツ到着のリードタイムなどもあり、着手から完成まで1ヶ月くらいかかってます)
ただし改善したい点もいくつか出てきました。
- キーの配置を実際のオーボエにもっと近づけたい
- 特に小指キーがかなり苦しい
- 穴の配置を斜めにしたりすればマシになりそう
- キーキャップを取り付けたい
- キースイッチ同士をかなりタイトに配置したので普通のキーキャップがハマらない
- そもそも、キーボード用キースイッチよりもっと適したスイッチがあるのでは?
- 3Dモデルの完成度を上げて、印刷後の追加工作を不要にしたい
- 糸ノコとかドリルは極力使いたくない
- 専用PCBで配線の手間を減らしたい
- 唄口は、衛生面から交換を容易にしたい
- 手軽にはずして水で洗えるとうれしい。現状は唄口とホースがきつめに噛んでるので筐体のネジ外してホースを引っ張り出して押さえながらグリグリ抜く必要がある
次に作るとしたらこのあたりが課題です。