TECH PLAY

キャディ株式会社

キャディ株式会社 の技術ブログ

61

はじめに こんにちは。2022年に誕生したAI Labというチームで、主に図面解析をしている中村遵介です。 趣味が料理と画像を4倍に拡大することなので、今日は最近読んだ「Revisiting l_1 Loss in Super-Resolution: A Probabilistic View and Beyond[1]」という、画像の拡大で利用される損失関数に関する論文を紹介したいと思います。 趣味以外の理由として、CADDiでは図面画像の解析を行なっておりノイズ除去や画像拡大などの分野に注目しているという点もあります。 畳み込みニューラルネットに関する知識は必要ですが、画像の拡大に関する知識は必要としないように書いたつもりです。 論文の概要 いったん細かい話を置いておいて、論文の概要をざっくりご説明します。 この論文が取り組んだ課題は以下の点になるかと思います。 入力された画像を拡大する超解像分野において、低画質画像と高画質画像を1対1で学習させる既存手法を拡大し、低画質画像から「対応する可能性のある高画質画像の分布」を学習する手法を提案した 提案した損失関数が既存手法で頻繁に使用される l_1 loss を下限に持ち、同時に不可能決定問題としてのランダム性も捉えられることを明らかにした 分布を学習することの副作用として、生成画像に対する画素ごとの不確実性も同時に予測できることを明らかにした それでは詳細についてみていきましょう。 前置き Single Image Super-Resolution について 論文のタイトルは「Super-Resolution」とだけありますが、報告されている内容は Single Image Super-Resolution(SISR) と呼ばれる分野の話になります。 SISRは、日本語で「単一画像超解像」と呼ばれ、 1枚の画像を入力とし、対応する1枚の拡大された画像を出力する タスクを指します。 SISRではバイリニアフィルタによるシンプルな手法もあれば、畳み込みニューラルネット(Convolutional Neural Network: CNN)を用いた手法もあり、画像処理分野では今も盛んに議論が行われる領域です。 これの一つの理由が、SISRが不可能決定問題であるというところにあると思います。つまり、ある低画質画像 x が与えられた際、縮小すると x になる画像、すなわち求めたい綺麗な高画質画像 y は複数存在します。そのため、SISRという問題設計においては正解の手法が存在せず、今後も既存手法より良い結果を得られる手法が登場する可能性が残り続けます。 SISRでよく用いられる損失関数と評価指標 最近では、CNNを使用した教師付き学習を利用した数多くのCNNが提案されています。学習方法やネットワーク構造など様々な点が議論されていますが、学習時に利用する損失関数に着目すると、大きく4つに分けられるかと思います。 生成画像と教師画像の l_2 loss 生成画像と教師画像の l_1 loss 生成画像と教師画像の perceptual loss 生成画像と教師画像の discriminator loss このうち l_2 loss と l_1 loss は非常に似ており、生成画像 y と 教師画像 \hat{y} に対して画素ごとに二乗誤差もしくは絶対誤差を計算して、その合計値を最小化することでより教師画像に近い画像を生成しようと試みます。 3番目のperceptual loss は特徴空間での比較を行います。具体的には、生成画像と教師画像をなんらかの学習済みCNNに通し、途中で得られる深層特徴について、その二乗誤差もしくは絶対誤差を最小化しようと試みます。 最後の discriminator loss はGenerative Adversarial Network(GAN)ベースの手法で利用される損失関数で、生成画像と教師画像を見分けるネットワークを同時に訓練し、生成ネットワークはなるべく見分けがつかないような画像を出力しようと学習していきます。 これらの損失関数は単独で使用されることもあれば、組み合わせて使用されることもあります。 一方で評価指標は大きく分けて3つ存在します。 Peak Signal-to-Noise Ratio(PSNR) 二乗誤差ベースで生成画像と教師画像の近さを比較します。値が大きいほど2つの画像が近いことを意味します Structural Similarity Index Measure(SSIM) パッチの統計情報ベースで生成画像と教師画像の近さを比較します。値が1に近いほど2つの画像が近いことを意味します MOS(Mean Opinion Score) 複数の評価者による5段階評価で画像の綺麗さ・自然さを評価します。5が理想的な高画質画像であることを意味します L1 loss の限界 SISRに対するCNNベースの教師付き学習では、 l_2 loss もしくは l_1 lossが使用されることが多いです。特に近年では l_1 lossがPSNR/SSIM/見た目の点で l_2 lossより優れているというのが実験的に言われ、 l_1 lossが使用される傾向が強まっています。 やや横道に逸れて歴史の話をすると、最初にCNNがSISRに適用されたのがSRCNN[2]という手法でした。これは l_2 lossを損失関数に利用した3層のネットワークで、当時の手法から大きくPSNRを向上させました。その後に登場するESPCN[3]やVDSR[4]・DRCN[5]では高速化やネットワークの層数の増加が行われましたが、やはり l_2 lossを使用する点は変わりませんでした。 PSNRが二乗誤差を元に計算されるため、この傾向は非常に自然な流れであったと思われます。 流れが変わったのが SRGAN[6] と呼ばれるGANベースの手法の登場です。SRGANは discriminator loss を採用することで、不可能決定問題に対してGANで「低画質画像 x に対応し、かつ高画質画像として自然な分布から生成されたかのような綺麗な画像 y 」を生成するという一つの解を提案しました。その中では学習の安定化のために、perceptual loss、そしてやはり l_2 loss が使用されていました。このSRGANの生成部分のネットワークはSRResNetと呼ばれ残差構造を積極的に取り入れた手法だったのですが、このネットワーク構造がSISRに向いていることがわかり、後の多くの手法がこのSRResNetを参考にしています。 その中でも注目を集めたのがEDSR[7]という手法で、EDSRはSRResNetを巨大化させたネットワークです。徒に巨大化させたわけではなく、例えばそれまで使用されてきたバッチ正規化を「実際に学習させてみた結果、ほぼ正規化能力が失われていた」ということで削除したり、より残差構造を取り入れたりといった工夫をしています。そして、はっきりと l_1 lossによる学習がPSNR/SSIMの両者において良い結果を示したことを報告しました。 We train our networks using L1 loss instead of L2. Minimizing L2 is generally preferred since it maximizes the PSNR. However, based on a series of experiments we empirically found that L1 loss provides better convergence than L2. EDSR[7]より引用 以降は多くの場合で l_2 lossの代わりに l_1 lossを損失関数に採用することが増えています。安定的に高パフォーマンスを出せるRCAN[8]も l_1 lossで学習を行なっています。 話を戻します。 l_1 loss が l_2 lossより実験的に優れていること自体は良いのですが、その本質はあまり変わりがありません。縮小したらある低画質画像 x になる高画質画像の集合を 仮に y とすると、 l_2 loss での学習は、これら y の平均値を学習することと等しく、 l_1 lossは中央値を学習することに等しいです。 つまり、 l_2 loss も l_1 loss も解空間のある一点だけを捉えようとする損失関数であり、解空間そのものを捉えるには不十分であると言えます。 そこで、論文ではパラメータ W と入力 x が与えられた時の高画質画像 y の事後分布を明示的にモデリングすることで、解空間を推定することに注力します。 確率的モデリング 多くの既存手法では以下の尤度関数の最大化を目的とします。 \max_{W}{L\left(W\mid\hat{y}\right)} = P\left(\hat{y}\mid x;W\right). W がパラメータで、 \left( x, \hat{y} \right) が学習データとして用意された低画質画像と高画質画像のペアです。論文の根幹は事後分布 P(y\mid x;W) の明示的な推定です。 ここでいう y は高画質画像として可能性のある複数の自然画像を意味しています。このとき、 \hat{y} は P(y\mid x;W) に従って高い確率で生成された画像のうちの1枚とみなすことができます。 つまり、最終的には以下の確率を最大化したいことになります。 P\left(\hat{y}, y\mid x;W\right) = P\left(\hat{y}\mid y\right)P\left(y\mid x;W\right). 日本語で言えば「入力 x とパラメータ W が与えられた条件での、高画質画像の確率分布に対し観測された \hat{y} がその分布から生成される確率」を最大にしたい、という感じでしょうか。 ここでは y が無数に存在するので扱いやすくするため期待値を考えることにしています。 \mathbb{E}_{y\sim P\left(y\mid x;W\right)}\left[P\left(\hat{y}\mid y\right)\right] あとはこれをいくつかの仮定をおきながら分解して考えるだけです。まず第一に「入力 x とパラメータ W が与えられた条件での、高画質画像の確率分布( P\left(y\mid x;W\right) )」を噛み砕いていみます。 論文では、ある画素は似た領域の加重平均で表せるという仮定と中心極限定理から、この分布は多変量正規分布に従うという仮定を置いています。 P\left(\hat{y}, y\mid x;W\right) \sim N\left(\mu_{\left(x;w\right)};\Sigma_{\left(x;w\right)}\right) ここで出てきた \mu と \Sigma はパラメータ W を用いてCNNで実際に推論するものになります。また、 P\left(\hat{y}, y\mid x;W\right) が多変量正規分布に従うという仮定を置きましたが、実際にこれを利用して学習する場合、分布からのサンプリングを行う必要があります。これは微分可能な操作ではないという問題がありますが、これに関してはすでに Variational Auto-Encoder[9] が Reparameterization Trick と呼ばれる方法での回避を提案しているので、そのまま利用することにしています。 すなわち、標準正規分布から生成される z を用いて \mathbb{E}_{y\sim P\left(y\mid x;W\right)}\left[P\left(\hat{y}\mid y\right)\right] = \mathbb{E}_{z\sim N\left(0, 1\right)}\left[P\left(\hat{y}\mid \mu + \sigma \ast z\right)\right]. のように y を捉え直すことで微分可能にします。 P\left(y\mid x;W\right) については考え終わったので、残っている P\left(\hat{y}\mid y\right) をみていきます。 ここでは、[10]に従ってこの分布をボルツマン分布に従うと仮定します。 P\left(\hat{y}\mid y\right) \propto \prod_{i}^{H\times W} \exp \left( – \frac{\|\hat{y}_i – y_i\|_1}{kT} \right) これを先程の式に代入することで最終的な目的関数を得ることができます。 \mathbb{E}_{z\sim N\left(0, 1\right)}\left[\prod_{i}^{H\times W} \exp \left( – \frac{\|\hat{y}_i – \left(\mu_i + \sigma_i \ast z\right)\|_1}{kT} \right)\right] あとは扱いやすいように負の対数尤度関数にして、最大化問題を最小化問題へと変更しています。 \min\mathbb{E}_{z}\left[\frac{1}{kT} \sum_{i}^{H\times W}{\|\hat{y}_i – \left(\mu_i + \sigma_i \ast z\right)\|_1}\right] kT は定数なので無視することにすると、結局のところは上の式を最小化する \mu と \sigma の2つををCNNで推定してあげれば良いことがわかります。その際に z がランダム性を与えてくれるイメージです。 ちなみに z は標準正規分布なのでその期待値は0になります。つまり、上の式は(Jensenの不等式にそのまま代入すると)下限が \hat{y} と \mu の l_1 lossになります。 こうして振り返ってみると、 l_1 lossが分布の1点だけしか捉えられていなかったことがよくわかります。 さて上式の最小化ですが1点だけ困った点があり、それはネットワークとしては \sigma を0としてしまう(ランダム性を排除して l_1 lossと同等にする)のが最も最小化できる戦略になる、ということです。 論文ではこれを実験的にも確かめ、 \sigma が0に落ちる様子をプロットしてくれています。 そこで、 \sigma を0にせず(ランダム性を失わず)良い感じに勾配降下法で常識を最小化するための設計を提案しています。 分散の実践的な設計 論文では、実際に学習させるための \sigma の設計を2パターン挙げており、それぞれを「入力データに依存しない \sigma 」と「入力データに依存する \sigma 」と呼んでいます。 最終的に選択しているのは後者の方ですが、せっかくですので両方見ていきます。 データに依存しない分散 データに依存しない分散 \sigma の設計は実に簡単で、「学習によって \sigma が0に収束してしまうなら、 \sigma を学習しなければいい」というものです。 すなわち、あらかじめ \sigma を非常に小さな定数 k として設定する、という方法です。この場合、最適化対象は以下のように読み替えられます(余計な定数は除外しました)。 \mathbb{E}_{z}\left[\sum_{i}^{H\times W}{\|\hat{y}_i – \left(\mu_i + \sigma_i \ast z\right)\|_1}\right] = \mathbb{E}_{z}\left[\sum_{i}^{H\times W}{\|\left(\hat{y}_i + k \cdot z\right) – \mu_i\|_1}\right]. これは、すなわち教師データである \hat{y} に対して、平均0、分散 k のガウシアンノイズをかけてから l_1 loss で学習させていることと同義です。 この考え方自体は Noise2Noise[11] と似ています。Noise2Noiseは、(ノイズ画像、綺麗な画像)というペアを用いでデノイジングを学習するのではなく、(ノイズ画像、ノイズ画像)のペアを用いても同等の性能が得られる、ということを示した論文です。ただし、事前条件としてガウシアンノイズであれば平均が0の分布に従う必要があります。また、理論的には l_2 lossでの精度を保証したものですが( l_2 lossが平均値を推定するため)、実験では l_1 lossでも良い性能を示すことを報告してくれています。今回は乗せるノイズは平均0のガウシアンノイズなので問題なしです。 しかし、この方法だとノイズによっては \mu が \hat{y} に対して遠ざかるような方向の勾配を産む可能性があります。これは先程の式を \mu で偏微分すれば明らかで、余計な定数項を除くと \frac{\partial \| \hat{y} + k\cdot z – \mu \|_1}{\partial \mu} となりますが、これは \hat{y} と \mu – k \cdot z との大小関係で符号が入れ替わります。 一方で、一般的な l_1 lossに関しては \frac{\partial \| \hat{y} – \mu \|_1}{\partial \mu} なので \hat{y} と \mu との大小関係で符号が入れ替わります。このため、 \mu – k \cdot z と \mu の間に \hat{y} が存在するような z では、 l_1 lossによる学習と、提案する学習では勾配の方向が逆転します。 この勾配の逆転による学習の不安定化を防ぐためには、十分な数の z のサンプリングが必要になりますが、これは学習コストが肥大化を意味し、論文では使用されませんでした。 データに依存する分散 やや天下り的なのですが、 \sigma を定数 k ではなく |\hat{y} – \mu| に設定してみます。 これによって、ネットワークが求めるべき \mu は以下であれば良いということになります。 \min_{\mu}\mathbb{E}_{z}\left[\frac{1}{kT} \sum_{i}^{H\times W}{\|\hat{y}_i – \left(\mu_i + |\hat{y}_i – \mu_i| \ast z\right)\|_1}\right] これを \mu で偏微分すると(余分な定数項は除外) \frac{\partial \mathbb{E}_{z}\left[\sum_{i}^{H\times W}{\|\hat{y}_i – \left(\mu_i + |\hat{y}_i – \mu_i| \ast z\right)\|_1}\right]}{\partial \mu_i} となります。これは \hat{y} と \mu との大小関係でのみ符号が入れ替わるので、勾配の符号だけ見れば l_1 lossで学習するのと同義です。また、 \sigma が0に収束するということは、 \mu と \hat{y} が完全に一致することを意味しますが、それはSISRの不可能決定性に反するので、過学習しない限りは起きないことになります。 論文ではこれを l_\mathbb{E} loss として呼んでいます。 ここで l_\mathbb{E} lossの意味合いを振り返ると、考え方としては y の分布の平均 \mu を予測しつつ、同時に \hat{y} と \mu の誤差も分散として使用することを意味します。 そこで、単に上式を最適化するだけでなく、ネットワーク上では1つのブランチで y の分布の平均 \mu を予測しつつ、同時に別のブランチで \hat{y} と \mu の誤差を予測し、それを \sigma として扱うマルチタスクラーニングとしても良さそうです。 \sigma ブランチでは \min_{\sigma} \| |\hat{y} – \mu| – \sigma \|_1 を求めれば良いことになります。論文ではこれをauxillary lossとして命名しています。 この \sigma を推論しておくメリットとして、 \sigma の値が予測の自信度として使用できる、という点を挙げています。 実験 理論が出来上がったので後は実験して評価をします。 論文では一般的なSISRの実験と同じ設定を用いていました。 学習にはDIV2Kデータセットのtraining setを使用し、評価データセットではSet5 / Set14 / BSD100 / Urban100 / Manga109データセットを使用します。Manga109は比較的新しいデータセットなので、3,4年より前のSISR論文では評価に使用していない場合もあります。 また、Set5 / Set14は慣習的に使用しますが、枚数がそれぞれ5枚 / 14枚なので評価値としてはブレが大きい印象があります。 拡大倍率については、2 / 3 / 4倍について実験を行なっています。最近だと8倍についても実験する論文を見かけることがあります。 評価指標は輝度に対するPSNRとSSIMを使用しています。 学習の詳細についてはあまり触れませんが、入力 x が48px x 48pxになるようにクロップしてリサイズしています。これは、SISRでは広範囲のコンテキストを必要としないという前提に基づくことが多く、他の多くの論文でもあまり大きな画像を入力に用いることはありません。 そのため、RCANのような大きいモデルであってもV100を1枚で十分学習が可能です。 最終的な推論結果としては \mu を使用しています。 提案手法は損失関数を調整し、また最後に \sigma 推論用のブランチを追加するだけで実現できるため、基本的にどのようなCNNにも適用できます。そこで、論文では、VDSR / SRResNet / EDSR / RCAN といった有名どころのCNNに適用し、 l_1 loss との比較を行なっています。 軽量なモデルに対して提案手法を適用した結果の比較(EDSR-baselineはEDSRの中で提案されている比較的軽量なモデルです) 重いモデルに対して提案手法を適用した結果の比較 PSNR/SSIMともに上がり幅としては僅かに見えますが、CNNベースのSISRの精度は年々上がり幅が小さくなっているので、それを踏まえて見ると悪くない結果のように思えます。 比較実験 提案手法の有効性を確認するために、以下の実験を行なっています。 損失を l_1 lossだけにして学習 損失を l_\mathbb{E} loss だけにして学習(論文ではEq. (8)として登場) 損失を auxillary loss だけにして学習(論文ではEq. (10)として登場) 損失を l_1 loss と auxillary loss で学習 損失を l_\mathbb{E} loss と auxillary loss で学習 それぞれについて、ベースネットワークとしてEDSRを使用し、Set14でPSNR/SSIMを評価しています。学習時間はやはり伸びますが、PSNR/SSIMとしては提案手法が最も良い結果を示していました。 予測の自信度の可視化 推測した \sigma を使用してモデルの自信度を画素ごとに表示させています。 当然と言えば当然なのですが、画像のエッジ部分(高周波部分)でモデルが自信をなくしているのが分かります。 感想 l_1 lossは近年のCNNベースのSISRで積極的に利用されてきましたが、きちんと焦点を当てている論文は中々限られているように思っていたので新鮮でした。 一方で、最終的な推論結果を \mu のみから導き出していた点がやや疑問点として残りました。結果として分布の平均値をピックアップせざるを得ないのかな、という印象です。 \sigma の結果を利用して、自信がない領域だけ生成的な手法で推測してあげる、みたいなことが出来たら面白いのかもしれないなぁ…というような未来を感じさせる論文でした。 引用 [1] He, Xiangyu, and Jian Cheng. “Revisiting L1 Loss in Super-Resolution: A Probabilistic View and Beyond.” arXiv preprint arXiv:2201.10084 (2022). [2] Dong, Chao, et al. “Image super-resolution using deep convolutional networks.” TPAMI 2015. [3] Shi, Wenzhe, et al. “Real-time single image and video super-resolution using an efficient sub-pixel convolutional neural network.” CVPR 2016. [4] Kim, Jiwon, Jung Kwon Lee, and Kyoung Mu Lee. “Accurate image super-resolution using very deep convolutional networks.” CVPR 2016. [5] Kim, Jiwon, Jung Kwon Lee, and Kyoung Mu Lee. “Deeply-recursive convolutional network for image super-resolution.” CVPR 2016. [6] Ledig, Christian, et al. “Photo-realistic single image super-resolution using a generative adversarial network.” CVPR 2017. [7] Lim, Bee, et al. “Enhanced deep residual networks for single image super-resolution.” CVPR workshops 2017. [8] Zhang, Yulun, et al. “Image super-resolution using very deep residual channel attention networks.” ECCV 2018. [9] Kingma, Diederik P., and Max Welling. “Auto-encoding variational bayes.” ICLR 2014. [10] Bruna, Joan, Pablo Sprechmann, and Yann LeCun. “Super-resolution with deep convolutional sufficient statistics.” ICLR 2016. [11] Lehtinen, Jaakko, et al. “Noise2Noise: Learning image restoration without clean data.” ICML2018 The post Revisiting L1 Loss in Super-Resolution: A Probabilistic View and Beyond を読んで appeared first on CADDi Tech Blog .
アバター
こんにちは。Quipu という原価計算システムの開発をしている山田です。 最近まで原価計算システムのバックエンドのアーキテクチャを変更するプロジェクトをチームで進めていて、その中で Python プロジェクトに導入してよかった OpenTelemetry について共有したいと思います。 原価計算システムのアーキテクチャ変更に伴うパフォーマンス懸念 OpenTelemetry について OpenTelemetry の導入 必要になったライブラリ API や SDK 周りのライブラリ トレース情報を任意のサービスに送信するライブラリ 各種ライブラリやモジュールの自動的な設定を行うライブラリ OpenTelemetry を実際に導入する OpenTelemetry を導入した結果 おわりに 参考 原価計算システムのアーキテクチャ変更に伴うパフォーマンス懸念 原価計算システムはいくつかのマイクロサービスによって動いていました。フロントエンド、フロントエンドから GraphQL のリクエストを受ける BFF、ビジネスドメインを扱う Rust の gRPC サーバ。今回この Rust の gRPC サーバのアーキテクチャ変更により、 Python で実装された価格計算システムと「見積」を管理する Rust の gRPC サーバというドメインの境界を設けてアプリケーションを分割しています。 今まで Rust で実装されていた原価計算処理を Python で実装しなおしました。Python で実装しなおした経緯としては、新しく原価計算処理を実装する人にとっても変更がしやすい設計にしたいというのがありました。加えて、開発者のみならず実際に原価計算の中身を変更するようなメンバーでも、実際システムとして動いているアプリケーションを変更できた方が良いという結論になったので学習しやすい Python の方が適していると判断した経緯もあります。 今までは原価計算処理についても Rust で実装されたシステムが動いていたのでサービスとしても一つでしたが、これを Rust と Python との分割されたマイクロサービスに変更したことで、ネットワーク越しに計算処理を実行することになります。新しい原価計算アプリケーションを導入するにあたってのパフォーマンスのボトルネック解消をチームで行っていました。1 つのサービスをデバッガやプロファイラなどを用いてパフォーマンスチューニングすることは十分できますが、マイクロサービス間のリクエストのパフォーマンスをチェックすることは現状難しかったのです。なのでこの部分に分散トレースシステムを導入して、より良いパフォーマンスを目指すための下地を作ることになりました。 OpenTelemetry について OpenTelemetry [1] は分散トレースを実現するためのフレームワークとそれらを提供するライブラリの名称で CNCF の incubating プロジェクトです。 OpenTracing と OpenCensus が合併して OpenTelemetry という名称で進められています。トレース情報やレイテンシなどアプリケーションの実行にまつわるデータの管理のために設計された API や SDK などのフレームワークになっていて、どんなベンダにも依存せずにデータを扱うことができるようになっています。現在僕らのチームではこれらのデータを Google が提供している Cloud Trace に送っています。 僕らのチームでは Python のアプリケーションを本格的に運用するのは初めてでしたが、Node.js で実装された BFF や見積を扱っている Rust で実装された gRPC サービスでは OpenTelemetry を導入してネットワーク間のリクエストの可視化ができていたのでその土台に Python のマイクロサービスも載せることで、一様にリクエストのレイテンシを可視化することができます。 OpenTelemetry の導入 必要になったライブラリ 今回 Python で実装された原価計算処理システムに実際導入するにあたって行ったことを紹介していきます。以降は OpenTelemetry の用語を使っているので、必要に応じて OpenTelemetry が用意している Glossary [2] を参照してください。 API や SDK 周りのライブラリ API や SDK 周りのライブラリを導入してトレース情報として取得したい単位でアプリケーションコードにトレース情報を取得する実装ができます。これらのモジュールにより Span を作成したり、トレース情報を送信し始めることができます。実際には opentelemetry-api や opentelemetry-sdk をインストールして利用します [3] 。 トレース情報を任意のサービスに送信するライブラリ トレース情報を送信するためにいくつかライブラリを導入します。開発の時にうまく送信できているかを確認するため Jaeger を開発環境で使用しているのでその exporter と、実際に本番環境などでトレース情報を送信するため、 Google Cloud Trace に export するためのライブラリ [4] を導入しています。 今回のプロジェクトでは opentelemetry-exporter-jaeger と opentelemetry-exporter-gcp-trace を利用して実現します。 各種ライブラリやモジュールの自動的な設定を行うライブラリ 今回のシステムではウェブアプリケーションフレームワークとして starlette を利用しているので、送られてきた trace context を自動的に解釈してくれるライブラリとして instrumentation ライブラリ opentelemetry-instrumentation-starlette を導入します。 今回は導入しなかったですがそのほかにもさまざまなライブラリに対応した instrumentation が存在します [5] 。 上記のライブラリ群を導入することで Python のアプリケーションでも OpenTelemetry が利用できます。 OpenTelemetry を実際に導入する まずは OpenTelemetry でトレースを始める部分のコードを書いてみましょう。 TracerProvider を使ってトレースを始めることができます。また、開始した TracerProvider を様々な箇所で API 経由で取得できるように trace.set_tracer_provider で設定します。 from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider tracer_provider = TracerProvider() trace.set_tracer_provider(tracer_provider=tracer_provider) トレース情報を全てのリクエストで取得する必要がないケースが存在します。例えば送られるデータ量が多くなってしまうので減らしたり、親のトレースに依存してトレース情報を取得するかどうか判断したいなどさまざまなケースでトレース情報をサンプリングできると嬉しいです。今回のアプリケーションは基本的にリクエストの末端に位置するアプリケーションになるので、サンプリングは親のトレースに任せる形にしました。その挙動がライブラリのデフォルトになっているので今回は指定しませんが、必要があれば sampler という引数を指定するか OTEL_TRACES_SAMPLER という環境変数を利用してトレースするかどうかを制御することができるようになっていました。 from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.samplers import TraceIdRatioBased # 1000 回に 1 回だけトレースする tracer_provider = TracerProvider(sampler=TraceIdRatioBased(1/1000)) アプリケーションがリクエストを受け取った地点からこの Python アプリケーションのトレース情報を取得できるように instrumentation を導入します。僕らは starlette を使っているので opentelemetry-instrumentation-starlette を導入します。このライブラリが提供する StarletteInstrumentor を利用することで自動的にトレース情報の収集ができます。 from opentelemetry.instrumentation.starlette import StarletteInstrumentor from starlette.applications import Starlette routes = [ // ルーティングの定義 ] app = Starlette(routes=routes) StarletteInstrumentor.instrument_app(app) アプリケーションとしてトレース情報が取得できるようになったので、これを Jaeger や Google Cloud Trace に送信するための設定を行いましょう。開発環境では標準出力にトレース情報を出力したり、Jaeger にトレース情報を export しつつ、本番環境では Google Cloud Trace にトレース情報を export します。以下のサンプルコードでは基本的にデフォルトの設定値を使うようになっています。 from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor # 標準出力にトレース情報を出力するための span processor tracer_provider.add_span_processor( span_processor=SimpleSpanProcessor(span_exporter=ConsoleSpanExporter()) ) # Jaeger にトレース情報を export するための span processor tracer_provider.add_span_processor( span_processor=BatchSpanProcessor(span_exporter=JaegerSpanExporter()) ) # Google Cloud Trace にトレース情報を export するための span processor tracer_provider.add_span_processor( span_processor=BatchSpanProcessor(span_exporter=CloudTraceSpanExporter()) ) 基本的には以上のことを行うだけで、アプリケーションに対して in-coming なリクエストがあった場合にそれがトレースされるようになります。しかしこれだけではリクエスト全体のレイテンシがわかるようになるだけで、全ての関数が自動的にトレースされるわけでも重たい処理がどこにあるかどうかなどがわかるわけでもありません。それ自体は自分でトレース情報を作成してあげる必要があります。 OpenTelemetry の API としてトレース情報を自分で作成して紐づける方法があるのでそれを利用して処理のトレース情報を増やしていきましょう。 tracer オブジェクトを取得して、その start_as_current_span メソッドを利用してトレース情報を作成します。このメソッドは @contextmanager デコレータが利用されているため with 文で使います。 例えば以下のようにして実際の処理のトレース情報を作成することができます。 from opentelemetry import trace tracer = trace.get_tracer_provider().get_tracer(__name__) def very_expensive_my_function() -> None: with tracer.start_as_current_span('foo'): # foo span が生成される with tracer.start_as_current_span('bar'): # foo span と同じレベルの bar span が生成される with tracerr.start_as_current_span('baz'): # bar span の子要素として baz span が生成される 上記のような方法でトレースできることがわかりました。実際トレースしたい箇所で毎回 tracer オブジェクトを生成していくのは少し大変なのと、ここまで詳細に処理を span として分割したいというよりもある関数全体でどれくらいのレイテンシなのかがわかるだけで十分なことが多いです。そこで関数のデコレータを用意して、そのデコレータが付与された関数は自動的に全体がトレースされるというようなものを作ってみましょう。ここでは @instrument というようなデコレータを作成します。 from functools import wraps from opentelemetry import trace tracer = trace.get_tracer_provider().get_tracer(__name__) def instrument(fn=None): def decorator(fn): @wraps(fn) def wrapper(*args, **kwargs): # デコレータを付与する関数名・メソッド名の qualified name name = fn.__qualname__ # デコレータが付与された関数名を span の名前として指定する with tracer.start_as_current_span(name=name): return fn(*args, **kwargs) return wrapper # @instrument でも @instrument() でも使えるようにするため if fn is None: return decorator return decorator(fn) @instrument def very_expensive_my_function() -> None: # 何らかの処理 この定義した @instrument デコレータを用いて大まかに関数全体のレイテンシを計測することができるようになりました。これを各種関数やクラスのメソッドに適用することでレイテンシが計測できていなかったもう少し詳しい部分までトレースできるようになりました。 OpenTelemetry を導入した結果 今回原価計算システムを Rust から Python に書き直したことでプロダクション環境に大きなパフォーマンス劣化の懸念が存在していました。今回 OpenTelemetry を導入して、 UI から発行されるリクエストベースで原価計算システムのトレース情報を取得することができるようになりました。これによりプロダクション環境に今回の変更を入れることでパフォーマンスにどう影響があるかが可視化されました。 当初実装していた API ではパフォーマンスに大きな劣化が生じてしまうことが事前にわかり、それを解決するために API の設計を変更し API の粒度を小さくして並列に API を呼び出すように変更しました。非同期で複数の計算結果を処理する形になり、結果としてパフォーマンスは小さな影響だけで済む形でリリース可能だと判断することができました。また Google Cloud Trace ではトレース情報をもとに日次や週次でレポートを作成することができるので、パフォーマンス改善を行ったときに前と比べてベースラインがどのように変化しているのかということも確認できるようになったのが導入した利点として大きかったです。 おわりに Python のプロジェクトで OpenTelemetry の導入をしてみましたが、しっかりライブラリ群は作り込まれていて自分達が利用しているライブラリのトレース情報を自動的に取れるようなライブラリもコミュニティで開発されていたこともありスムーズに導入が完了できました。 僕たちのチームでは Node.js や Rust でも OpenTelemetry が導入されていた背景もあり、サービス全体で分散トレーシングができるようになりました。発行されたリクエスト一つをとってみてシステム全体でどのような処理が行われているのかが確認できるようになったので、これらの資産を活かして開発効率を向上させていければと思っています。 OpenTelemetry の specification が v1.0 になってまだ 1 年です。これからもっと発展させていけるようにコミュニティに還元できることがないか利用しながら調査を進めていければと思っています。 参考 OpenTelemetry: https://opentelemetry.io/ Glossary: https://opentelemetry.io/docs/concepts/glossary/ OpenTelemetry Python API and SDK: https://github.com/open-telemetry/opentelemetry-python OpenTelemetry instrumentation for Python modules: https://github.com/open-telemetry/opentelemetry-python-contrib OpenTelemetry Python exporters for Google Cloud Monitoring and Trace: https://github.com/GoogleCloudPlatform/opentelemetry-operations-python
アバター
こんにちは。ソフトウェアエンジニアの江良です。 普段は Web アプリケーションのコードをせっせと書いて暮らしているのですが、AI Lab の誕生に伴い、 機械学習 を専門とするエンジニアと協業する機会も増えてきました。 今回は、 機械学習 の研究開発プロジェクトで導入した Streamlit という フレームワーク について紹介しようと思います。 Streamlit とは Streamlit は Python で Web アプリケーションを作成するための フレームワーク です。 機械学習 エンジニアやデータサイエンティスト向けに設計されており、 Python のコードを数行書くだけで、可視化のためのカスタムアプリケーションを簡単に構築することができます。 streamlit/streamlit: Streamlit — The fastest way to build data apps in Python 「 機械学習 のモデルを評価するためのデモ用のアプリケーションを作りたい」「あくまでデモ用なので、労力はできるだけかけずに済ませたい」という今回の ユースケース にぴったりのツールだったため、導入してみることになりました。 Streamlit を触ってみよう Streamlit は pip でインストールすることで使えます。 pip install streamlit 詳細は 公式ドキュメント に譲りますが、ほんの数行のコードを書くだけで簡単にグラフィカルなアプリケーションが実装できます。 import streamlit as st x = st.slider('Select a value') st.write(x, 'squared is', x * x) 作成したアプリケーションは以下のコマンドで起動できます。 streamlit run main.py チュートリアル に載っている 30 行ほどのコードを書くだけでこんなアプリケーションも作れます。 Streamlit の威力をなんとなく感じていただけたでしょうか? Streamlit アプリケーションを公開しよう Streamlit Cloud の紹介 Streamlit で書いたアプリケーションは Streamlit Cloud というサービスを使うことで簡単に ホスティング できます。 作成したアプリケーションを全世界に公開することもできますし、有料の Teams プランに加入すれば社内だけに限定して公開することもできます。 公式ドキュメント を参考に Streamlit Cloud にサインアップし、 GitHub リポジトリ 、デプロイしたいブランチ名、main ファイルのファイルパスを入力して少し待つだけで、簡単に Streamlit アプリケーションをデプロイできます。 アプリケーション、インフラ構成の紹介 次に、今回作成したアプリケーションの構成について触れていきます。 構成については、ざっくり以下の通りです。 訓練済みのモデルは GCS に配置し、推論時に取ってくる 推論に使うデー タセット は BigQuery から取ってくる GCP の各サービスにアクセスできるよう Service Account を作成し、credential を参照させる Streamlit の Secrets Management という機能を使うと、credential のようなセンシティブな情報を安全に保存し、Streamlit のアプリケーションから 環境変数 越しにアクセスさせることができます。 Secrets management - Streamlit Docs Connect Streamlit to Google BigQuery - Streamlit Docs st.secrets を参照するようにアプリケーションのコードを書き換え、 def get_credentials(): if "gcp_service_account" in st.secrets: return service_account.Credentials.from_service_account_info( st.secrets["gcp_service_account"] ) else: return None Streamlit Cloud の「Advanced settings」に指定することで Secrets を使用できるようになります。 Streamlit Cloud のハマりどころ ここまで Streamlit、Streamlit Cloud の良いところを紹介してきました。 この勢いのまま Cloud 上にさくっと持っていけると良いのですが、 Streamlit は2018 年に公開されたばかりの比較的新しい フレームワーク のため、実務で利用するにあたってはいくつか落とし穴も存在します。 メモリが足りない Streamlit Cloud は使用可能なメモリに制限があります。 (2022/02/14 現在で、Free プランなら 1G、Teams プランなら 3G が上限となります。) Troubleshooting - Streamlit Docs この制限を超えてしまうと、 Oh no. のメッセージとともに Streamlit アプリがクラッシュしてしまいます。 この問題を解決するには、Streamlit のキャッシュ機能を正しく活用することが鍵になります。 キャッシュ機能は、キャッシュしたい処理の関数に @st.cache を指定することで設定できます。 @st.cache def load_data(nrows): data = pd.read_csv(DATA_URL, nrows=nrows) data[DATE_COLUMN] = pd.to_datetime(data[DATE_COLUMN]) return data Optimize performance with st.cache - Streamlit Docs キャッシュが効かない ところが、この @st.cache を使用するだけでは問題が解決しないケースがあります(つらい)。 Experimental cache primitives - Streamlit Docs によると、 @st.cache の単一 API であまりにも多くの ユースケース をカバーしようとした結果、処理が遅く複雑になってしまったとのこと。 この問題を解決するために、streamlit v1.0 から @st.experimental_singleton と @st.experimental_memo という API が追加されました。 クレデンシャルオブジェクトなど、一回だけ初期化したいもの(かつセッション間で共有されて問題ないもの)については @st.experimental_singleton が使用できます。 @st.experimental_singleton def get_credentials(): if "gcp_service_account" in st.secrets: return service_account.Credentials.from_service_account_info( st.secrets["gcp_service_account"] ) else: return None データを返す関数には @st.experimental_memo が使用できます。 @st.experimental_memo def load_data(nrows): data = pd.read_csv(DATA_URL, nrows=nrows) data[DATE_COLUMN] = pd.to_datetime(data[DATE_COLUMN]) return data これらの問題を解決したところ、無事デモ用のアプリケーションが Streamlit Cloud 上で動作するようになりました :tada: おわりに 以上、Streamlit と Streamlit Cloud の紹介でした。 Streamlit をうまく活用できたおかげで、 機械学習 のモデルの評価も順調に進み、現在は「 機械学習 を実際のアプリケーションにどう組み込むか」を議論するフェーズにたどり着くことができました。 (ぼくが担当したのは Streamlit Cloud を使えるようにするための設定と若干のコード変更だけでしたが)研究開発の成果がきちんと「次」につながっていく場に立ち会えるのは嬉しいですね。 We're hiring キャディでは、 機械学習 のタスクを実装し、結果を可視化して高速に改善することで、モノづくり産業のポテンシャルを解放するプロダクトを開発するエンジニアを募集しています。 ところで、ぼくの専門はバックエンドエンジニアです。こちらのご応募もお待ちしております。
アバター
物理のモノづくりと比べたソフトウェア開発 物理的なモノだからこそ難しい事: ここまでは 品質の難しさや取り組みの重要性 に関して語ってきましたが、物理的なモノを扱っているからこそ発生するチャレンジを紹介します。 スケールの難しさ: ソフトウェアの世界では処理能力を上げるためにサーバを追加してHorizontal Scalingしたり、マシンスペックを上げてVertical Scalingしたりできます。 ユーザ数やシステム負荷に応じ動的に調整ができます。 しかし、製造するのには材料を購入したり物理的に加工する時間が必要です。 そのため当然加工機も必要ですし在庫する倉庫も必要です。 クラウドと違い簡単に固定費を下げる仕組みが無いため、需要予測が非常に大切になってきます。 リカバリーの難しさ: サーバの障害アラートが上がるとログを遡って事実確認に入るのと同じく、キャディでお客様から不良の連絡があると事実確認を行います。 遠隔で見れるサーバログと違い、事実確認だけのために実物の配送が必要な場合もあります。 製品が大きすぎて検査員を動かす方が製品を動かすより経済的な場面もあります。 修正もサーバに新しくデプロイするのではなく、物理的に追加加工をしたり、出来ない場合は再制作になります。 ソフトウェアの再制作はファイルコピーで終わるものの、物理的なモノを複製するのにはお金と時間が必要です。 そして当然ですがロールバックも出来ないため非常に神経を使います。 アジリティ向上の難しさ: ソフトウェア開発ではロールバックを可能にしたり、git branch毎にコンパイルする技術を導入する事で、よりリスクを小さく早く取りやすくなってきました。 設計変更も小さくデリバリーできるようになりました。 しかし、CI/CD同等の技術はスケールの難しさの関係で経済的に実現しにくく、リカバリーの難しさもあり小さくデリバリーする事自体が難しいです。 品質は物理もソフトも同じに扱える ソフトウェア開発も昔は簡単にロールバックできない、スケールしにくい、アジリティ上げにくい、と数多くの課題に悩まされていました。 ロールバックの仕組みが無いため「最後のチェック」を強化する傾向は製造業と同じくありましたし、デプロイ作業する度に本番環境に直接SSHして汗かきながらコマンドを打っていた時代もありました。 でも、Cloud Native時代の今となっては全く事情が違い、スケールもデプロイの再現性もロールバックも全部実現できる世界になってきました。 物理的なモノづくりとソフトウェアは具体のプロセス観点では違いがあるものの、品質観点から見ると意外と似ている部分もあるかなと思います。 物理のモノとソフトウェアを飛行機や空飛ぶ乗り物の開発文脈で紹介します。 飛行機といえば安全性を意識しますよね。 機内の席の広さや窓の大きさも重要ですが、安全に目的地までたどり着けることが大前提となっています。 実際は法律上数多くの安全性規定があるわけですが、どのように「安全性」という品質要件を考えられているのか深ぼってみましょう。 我々が乗る飛行機は加工部品、電子部品、ソフトウェア、パイロットが組み合わさって初めて機能する仕組みです。 全て重要な要素ですが乗客からしたらそこの詳細は関係なく、安全な空の旅が出来るかが重要で、総合的な結果が一番大切。 仮にジェットエンジンが故障しても嬉しくはないけれども、安全にたどり着けることに越したことはないです。 全ての要素がコントローラブルでは無い中でも、最終的な安全性指標として事故の確率が設けられているのが航空宇宙業界が成熟しているという主張かもしれません。 半世紀以上品質と向き合ってきた業界から、我々純粋なソフトウェア開発者としても参考にできる要素はいくつかあるのではないでしょうか。 モジュール分解しているのはソフトウェアも似ている 安全性というのは確率論なので数値化しやすく見えますが、事故要因は無限大に想像できますよね。 ジェットエンジンに鳥が巻き込まれたり、部品が劣化して壊れたり、そもそも寸法違いの部品が組み込まれていたり。 マニアックな例だと宇宙からの放射能で組み込みソフトの変数が稼働中に変わってしまうとか。 あらゆるリスクを洗い出して発生確率を計算します。 さて、ここで2つのモジュールが同時不具合起こして初めて乗客にインパクトがある事故になる場合、事故率を以下のように表現できるでしょう。(厳密には独立変数前提で記載しています) P(overall) = P(A and B) = P(A) x P(B) 一方で、いずれかのモジュールの不具合により乗客インパクトがある場合は: P(overall) = P(A or B) = P(A) + P(B) – P(A) x P(B) なるべく安全性を上げる上では両方のモジュールが不具合を起こして初めて事故につながるようにレバレッジしたくなり、モジュールに分解して極力独立変数になるように設計する事を意識する方向性になるかなと思います。 書いてみれば当たり前ですが、飛行機の品質を総合的に評価する上で全体を複数の独立したモジュールに分解し、モジュール単位でリスク分析をする事で全体の確率を計算しやすくしています。 機内のビデオ配信システムとエンジン制御のシステムが完全に独立している設計になっているからこそ、ビデオ配信システムはランタイムエラーを起こしても機体の安全性には影響与えない保証があります。 重要なシステムの冗長性を担保するために複数台バックアップ用のシステムを設けることもあります。 ソフトウェア開発する上でもモジュール同士を粗結合にすべきか判断する上で、エラーのトレーサビリティやリスク分析のしやすさも考慮すべきなのかもしれません。 品質考える上でユーザもシステムである 飛行機を設計する上で、全てが広い意味でのシステムとしてモデリングされます。 エンジンも、構造物も、ソフトウェアも、パイロットもシステムです。 何らかのインプットを元にアウトプットを出すものは全てシステム。 そしてシステム毎に不具合の確率や不具合時の挙動を定義していくと、機体だけではなく空を飛ぶ仕組み全体としての不具合の確率が出せます。 ここでパイロットが全体の一部であり、システムでもあるというポイントが特徴的かと思います。 パーフェクトな人間はいないのでパイロットというシステムを定義する上で「◯◯レベルまで訓練したパイロットならX%の確率で操作ミスをする」という前提を設けて、飛行機全体の不具合の確率を計算します。 安全に到着する目的に向けてパイロットのトレーニングを強化する施策もあれば、より操縦を自動化する方法もあります。 ウェブアプリのユーザ幅はパイロットよりは広く定義も曖昧ですが、ユーザに価値提供出来るかはソフトウェアの技術的要件だけではなく、ユーザの思考にも左右されます。 ユーザをトレーニングすべきかそもそも対象外にすべきかはプロダクト次第ですが、ユーザのペルソナや前提知識をしっかりと言語化する事で初めてユーザという「システム」を品質の指標計算に入れられます。 純粋なソフトウェアの世界でもユーザを全体システムの一部だと考えると、最終的に提供したい価値がより言語化できるのかもしれませんね。 品質は継続的に改善する事で単発の何かではない. DevOpsもまさにこれ 航空業界では 「the laws are written in blood」 と言われることもあります。 全ての事件に対して調査が行われ、根本原因が特定され、再発防止対策実施までサイクルが徹底されています。 法律や規定の数が凄まじい事になっていますが、良い意味で改善が続けられてきた証拠とも言えるでしょう。 過去の失敗を元にPDCAを回し続けてきたからこそ、飛行機の事故率は交通事故より低い実績があります。 キャディのソフトウェア品質組織 今の体制: 2022年2月現在、いわゆる「品質」や「QA」や「QC」の専任人材も組織も無いです。 だからといって製造業でいう検査工程が無いわけではないですが、逆に開発者が自らプロセス改善含めて改善に投資する文化になっています。 専任がいないため劣後されるものも多く、最終的なあるべき姿とはいえないかもしれませんが、実は創業から四年間QA組織を意図的に作って来なかった背景があります。 早く専任QA組織を作ると機能を開発する人間として「一旦QAに見てもらう」事が頭を横切ってしまいますよね。 この気持ちは分からなくはないです。 しかし、この思考が根付いてしまうとQAが「テスター部隊」と暗黙知かもしれないが認識されてしまい、本質的にユーザ価値を最大化するための改善活動にリソースが避けられなくなります。 ある意味、開発組織が小さいうちに、皆で多少の障害を起こしたり転びながら成長していくフェーズかなと思っていました。 もちろんテスターのロールを担うリソースは確保していますし、現時点では開発組織も視座高く価値は出せていると思いますが、ぼちぼち限界に近づいています。 開発組織も50人超えて、今後さらに拡大していく必要があるためキャディの開発組織を一段レベルアップさせる施策が必要だと感じています。 ここまで自ら品質と向き合ってきた開発組織だからこそ、最強の品質組織をこれから作れるのではないかと思います。 テスト自体は滑り止めでしか無い 本質的には「検査」でミスに気づくのではなく、そもそもミスが起きないような仕組み作りやプロセス改善が組織の積み上がる資産になると信じています。 最後に意図的にこぼしているものを「検査」で拾うのは健全ですが、意図せぬものがそこで検知されると上流工程の方に目を向けるべきです。 このためにはDevOps的な動きも必要ですし、テストやプロセスの自動化も必要ですし、開発者の目線揃えも重要です。 車輪の再発明も減らして共通化できる部分は横断組織で持つ事で、機能の品質を向上する事もできます。 また価値を最大化する上ではより大胆な施策に挑戦する事も重要で、そこの仕組み作りという意味で、7月にプラットフォームエンジニアングチームを作りました。 開発方針の判断材料として品質指標が重要かも 現状ではSLI・SLO運用はしていません。 厳密に価値や安定性を数値化出来ていないというのもあるのですが、何らかの形で今の品質を表現する判断の軸を設ける事で皆の目線を揃えやすくなるかなと思います。 価値自体を分解し、中間指標にする事で。 今後に向けて 価値提供を一緒に考える仲間を探しています 正直、専属の人を置くことが重要なのかも分からないです。 明確に品質という領域に注力するリソースは必要ですが、それがどういう形でどういうロールなのか、日々の動き方がどうあるべきかまだ分かっていないです。 色々な事例を参考にさせて頂いていますが、一緒に挑戦してみたいなと思う方は TwitterのDM または 応募ページ からぜひカジュアル面談をでも設定下さい。 現状では専任のテスター部隊を求めているのではなくて、ユーザ価値を届ける上でのコスパを考慮し、プロセス改善からテストまでお任せできる人です。 全工程のステークホルダーが価値提供にフォーカスして、コスパやリスクトレードオフの判断ができる組織を目指しています。 ドメインエキスパートの仲間も探しています パフォーマンスやセキュリティ等、普段非機能要件とまとめられる領域も暗黙知の機能要件です。 DevSecOpsという表現がバズる理由も分からなくは無いのですが、これも品質基準の言語化の一種であると考えています。 このような新たなムーブメントを社内で作ったり、専門知識を活かしたい方も是非お声がけ下さい。 絶賛仲間を探しております。 The post 製造業とソフトウェアの品質 Part 2/2 appeared first on CADDi Tech Blog .
アバター
こんにちは。桐生です。久々の投稿となりました。 最近 Next.js + urql + chakra-ui で環境を構築する機会があったのですが、Deno上にも同じような環境が作れないかと思い、Aleph.jsを使っても同じようにやれるのか試してみたので、その内容を共有したいと思います。 そもそも Deno とは?については、以前 ブログ を書きましたので、合わせてご覧ください。 Aleph.jsとは Doc: https://alephjs.org/ GitHub: https://github.com/alephjs/aleph.js Aleph.js is a fullstack framework in Deno, inspired by Next.js. Aleph.js とは、公式ドキュメントにある通り、Next.jsに着想を得たDeno上で動くReactフレームワークです。 公開されてから既に1年以上経っており、いろいろな方々がAleph.jsを試して記事にされていたりするので存在を知っている方も多いのではないでしょうか。 使い方はシンプルで、 # Aleph.jsのインストール deno run -A https://deno.land/x/aleph/install.ts # 新規アプリケーション作成 aleph init # `development` mode で実行 aleph dev # `production` mode で実行 aleph start などのコマンドが用意されています。 aleph dev を実行したときに、 esm.sh のロードの挙動にハマったので後述します。 esm.shとは Doc: https://esm.sh/ GitHub: https://github.com/alephjs/esm.sh A fast, global content delivery network to transform NPM packages to standard ES Modules by esbuild. esm.sh とはCDNの一つで、npmパッケージにあるモジュールをesbuildを使ってESMに変換して配信しています。Denoでサードパーティライブラリを使用する際によく使われるCDNで、他にも skypack などがあります。 ただし、もともとnpmパッケージはDenoで動かすことを想定していないので、Denoで動かない、Denoでそもそもインポート時にエラーになるといったこともよくあります。が、 esm.sh のバージョンが上がるにつれて解消されていろいろ使えるようになっていっています。 Github Issue を覗いてみると Failed to import - <package name> というタイトルのIssueが多く立っているので、自分の使いたいnpmパッケージでエラーが出るようなことがある場合は、同じようにIssueを立てておくと、今後Fixしてくれるかもしれません。 また Aleph.js のメンバー自体が esm.sh の開発にも携わっていることもあり、 Aleph.sh でも esm.sh 固有の処理があったりします。 今回の環境 > deno --version deno 1.17.0 (release, x86_64-apple-darwin) v8 9.7.106.15 typescript 4.5.2 > aleph -v aleph.js v0.3.0-beta.19 また、検証時の esm.sh の最新 v61 を使用しました。 Aleph.jsでurqlを使う これまで Apollo Client を使っていましたが、他のGraphQLクライアントの知見も貯めておきたいと思い urql を使うことにしました。 https://formidable.com/open-source/urql/docs/comparison/#framework-bindings にある通り React Suspense に対応しているのがいいですね。 Suspense との組み合わせによりロード中の状態を宣言的に記述できるようになって、コンポーネント実装がシンプルになることが期待できます。 ただし、 Aleph.js はデフォルトではSSRモードで動くため、そのままでは Suspense が使えません。そこで aleph.config.ts というファイルを作って( aleph init では作られません)でSSRをオフにセットしておく必要があります。 // aleph.config.ts import { Config } from 'https://deno.land/x/aleph@v0.3.0-beta.19/types.d.ts'; export default <Config>{ ssr: false, }; それでは urql を使っていきましょう。 まずは esm.sh からimportするため、 import_map.json に追加します。 // import_map.json { "imports": { ... "urql": "https://esm.sh/urql" }, } 実は Aleph.js 特有の処理はこれくらいで、あとは普通に実装していくだけです。 続いて、Clientの作成とProviderの設定です。フリーのGraphQLエンドポイントとして SpaceX Land API を使っています(このAPIでSpaceXの打ち上げデータなどを取得できる)。また、Suspenseを有効にするため suspense: true をセットします。 // app.tsx import React, { FC } from 'react'; import { createClient, Provider } from 'urql'; const client = createClient({ url: 'https://api.spacex.land/graphql/', // enable suspense suspense: true, }); export default function App({ Page, pageProps }: { Page: FC; pageProps: Record<string, unknown> }) { return ( <Provider value={client}> <main> <head> <meta name="viewport" content="width=device-width" /> </head> <Page {...pageProps} /> </main> </Provider> ); } Queryする側の実装です。 SpaceX コンポーネントを作り useQuery を使ってデータ取得する実装を行います。ローディング中状態は Suspense に任せることにし、ここで実装はしません。コンポーネント内から分岐処理がなくなりとてもシンプルになりました。素敵ですね。 // components/SpaceX.tsx import React from 'react'; import { useQuery } from 'urql'; const LaunchesPastQuery = ` { launchesPast(limit: 10) { mission_name launch_date_local links { video_link article_link } rocket { rocket_name } details } } `; export function SpaceX() { const [result] = useQuery({ query: LaunchesPastQuery, }); return ( <> {result.data?.launchesPast?.map(({ mission_name, launch_date_local, links, rocket, details }) => { return ( <article key={mission_name}> <h2>Mission: {mission_name}</h2> <section> <p> {new Date(launch_date_local).toLocaleDateString()} | <strong>{rocket?.rocket_name}</strong> </p> <p>{details}</p> <div> <a href="{links.video_link}" target="_blank" rel="noopener"> video </a>{' '} <a href="{links.article_link}" target="_blank" rel="noopener"> article </a> </div> </section> <hr /> </article> ); })} </> ); } 最後に SpaceX コンポーネントの組み込みです。 Suspense でラップしてローディング中の状態を実装します。 // pages/index.tsx import React, { Suspense } from 'react'; import { SpaceX } from '../components/SpaceX.tsx'; export default function Home() { return ( <Suspense fallback={<p>loading...</p>}> <SpaceX /> </Suspense> ); } aleph dev で実行してみると、 loading... としばらく表示されたあと、SpaceXの打ち上げ情報がリスト表示されました。無事 Aleph.js 上で urql (と Suspense )が動いているのを確認できました。 余談1 実は最初、importするURLを https ではなく http と記述していたために、なぜか useQuery の実行時にエラーになる、という事象に陥りました。 // import_map.json { "imports": { ... "react": "https://esm.sh/react@17.0.2", "react-dom": "https://esm.sh/react-dom@17.0.2", "urql": "http://esm.sh/urql" // http にしてしまっていた }, } Aleph.js の実装を追っかけてみると、 esm.sh 経由でimportしたモジュールについては、 aleph dev(dev mode) と aleph start(production mode) )とで、ロードするモジュールのモードを切り替えている、ということがわかりました。 https://github.com/alephjs/aleph.js/blob/v0.3.0-beta.19/server/aleph.ts#L993-L1001 の実装を見てみてください。 // append `dev` query for development mode if (this.isDev && specifier.startsWith('https://esm.sh/')) { const u = new URL(specifier) if (!u.searchParams.has('dev')) { u.searchParams.set('dev', '') u.search = u.search.replace('dev=', 'dev') specifier = u.toString() } } aleph dev で実行している場合、 https://esm.sh/ から始まるimport urlについては Aleph.js が ?dev というクエリストリングを付与するようになっています。 一方 esm.sh は、urlに dev クエリストリングが含まれている場合、Development modeのモジュールを返すという機能があるので、 aleph dev で実行した場合はDevelopment modeのモジュールがロードされるようになっています。 import_map.json をもう一度確認すると、 // import_map.json { "react": "https://esm.sh/react@17.0.2", "react-dom": "https://esm.sh/react-dom@17.0.2", "urql": "http://esm.sh/urql" } react は https://esm.sh にマッチするので、 aleph dev でDevelopment modeのモジュールがロードされます。 一方で、 urql は http://esm.sh だったので上記条件にマッチせず、Production modeの urql がロードされるようになっていました。さらに、 urql は react を依存モジュールとして持っていたので、同じくProduction modeの react がロードされることになりました(ここがややこしかった)。 これにより、Aleph.js本体は dev mode の react で実行されているにもかかわらず、 urql およびその依存モジュールであるreactは prod mode で同居する形になり、その結果 Context が共有されなくなり useQuery をコールしたタイミングで実行時エラーが出ていた、というわけでした。 本当につまらないミスで、エラー解消までに多大な時間と労力を消費してしまいました。とはいえ、これがきっかけで Aleph.js の内部処理を知ることができたので、良しとしましょう。 Aleph.jsでchakra-uiを使う 気を取り直して chakra-ui を入れてみましょう。まずは import_map.json に以下のように追加します。 // import_map.json { "imports": { ... "chakra-ui": "https://esm.sh/@chakra-ui/react", "emotion/react": "https://esm.sh/@emotion/react", "emotion/styled": "https://esm.sh/@emotion/styled", "framer-motion": "https://esm.sh/framer-motion" }, } Aleph.js固有の処理はこれだけで、あとは通常通り実装していくだけです。 続いて ChakraProvider の設定です。特別なことはありません。 // app.tsx import React, { FC } from 'react'; import { createClient, Provider } from 'urql'; import { ChakraProvider } from 'chakra-ui'; ... export default function App({ Page, pageProps }: { Page: FC; pageProps: Record<string, unknown> }) { return ( <Provider value={client}> <ChakraProvider> <main> <head> <meta name="viewport" content="width=device-width" /> </head> <Page {...pageProps} /> </main> </ChakraProvider> </Provider> ); } 最後に chakra-ui を使って SpaceX コンポーネントをスタイリングしていきます。こちらも特別なことはなし。 import React from 'react'; import { useQuery } from 'urql'; import { Badge, Flex, Heading, HStack, Link, Text, VStack } from 'chakra-ui'; ... export function SpaceX() { const [result] = useQuery({ query: LaunchesPastQuery, }); return ( <VStack spacing={4} align="stretch" p={4}> {result.data?.launchesPast?.map(({ mission_name, launch_date_local, links, rocket, details }) => { return ( <Flex as="article" direction="column" gap={2} p="4" borderWidth="1px" borderRadius="lg" key={mission_name}> <Heading as="h2" size="lg"> {mission_name} </Heading> <Flex direction="column" gap={2}> <HStack spacing={2}> <Text fontSize="sm">{new Date(launch_date_local).toLocaleDateString()}</Text> <Badge colorScheme="blue" borderRadius="full"> {rocket?.rocket_name} </Badge> </HStack> <Text>{details}</Text> <HStack spacing={2}> <Link href={links.video_link} isExternal color="blue"> video </Link> <Link href={links.article_link} isExternal color="blue"> article </Link> </HStack> </Flex> </Flex> ); })} </VStack> ); } 以上で実装終わりで、 aleph dev で実行してみると、きちんとスタイリングされた状態でUIが表示されました。素晴らしい。 余談2 実は年末に何度か chakra-ui の適用にチャレンジしていたのですが、断念していました。その時は、当時の最新 esm.sh v58 の chakra-ui を使っていたのですが、どうやってもうまくいかずでした。 Aleph.js には Plugin という機能があり、 Aleph.js の各ライフサイクルのタイミングで処理をHookすることができるので、 chakra-ui 用のPluginを書けばうまくいくのかも、なんてぼんやりと思っていたのですが、今年に入って esm.sh の v61 が出たのでそちらで改めてトライしたら、見事動くようになっていました。 Denoで esm.sh や skypack 経由のモジュールを使ってエラーが発生した場合は、まずそれらCDNのIssueを確認してみたり、該当するものがなければIssueを登録するなどしていくのが良さそうですね。 また、使うCDNによっても結果は違ってきたりするので、諦めずに別のCDNからインポートしてみると良さそうです(ちなみに年末は skypack からのインポートも試しましたがダメでした、そういうこともありますよね)。 おわりに 今回やってみて、 urql と chakra-ui が割とすんなり使えることがわかりました。特に言及していませんでしたが、ビルドも速くHMRなども効いており、そこまでストレスなく開発できる感触を得ました。 今回の検証Repoは以下のリンクから見れますので、興味ある方は覗いてみてください。 https://github.com/tkiryu/evaluate-aleph ところで、 Aleph.js の将来性はどうなんでしょうか? 実は、GitHub の最終コミットが 20 Oct 2021 となっており、3ヶ月近く更新がない状態です。開発がアクティブでなければ安心して使っていくのは難しいところですが、どうやらリデザイン中のようで、GitHub Issue でコメントされていました。 https://github.com/alephjs/aleph.js/issues/429#issuecomment-967794820 at alephjs side, i decided to re-design the framework, the new system will be powdered by wasm that can run any edge network, for example deno deploy, and it will support any UI frameworks like react/vue/sevlte… i almost finish the compiler layer MVP, will publish it soon. https://github.com/alephjs/aleph.js/issues/409#issuecomment-979803656 i am redesigning the framework to support deno deploy, in fact it will support any edge worker for example cloudflear 今後大きく変わる可能性があるため、今すぐ実践投入するのはやめておいたほうがよさそうですが、個人的には今後の動向に注目していきたいフレームワークです。何かアップデートがあれば、またブログにしたためようかと思います。 今回は以上です。 The post Aleph.js + urql + chakra-ui appeared first on CADDi Tech Blog .
アバター
はじめに こんにちは、小橋です。前回は シリーズBの調達後のキャディの進化 について書かせて頂きました。その中で開発組織のアジリティ向上やイノベーション推進のためのプラットフォームチームに関して触れましたが、今回は新たな組織横断課題についてお話出来ればと思います。 品質です。 ここ数年で沢山の用語や職種が飛び交うようになりましたね。QAとかQCとかSETとか。クラウドやインターネットの浸透でSaaS等の継続的な価値を提供するビジネスモデルが可能になったからこそ、ソフトウェアの品質保証組織も進化している証拠かもしれません。品質の概念と長年向き合ってきたモノづくり産業のポテンシャルを解放するキャディとして、物理のモノづくりとソフトウェアを比較しようと思います。 物理的なモノの品質 オーダーメイドの洋服を購入したりする時に、皆さん「品質」をどう評価していますか?オプションも色々ありますし、メーカーによって生地、機能性、見た目も違います。意識しやすい品質もありますが、使ってみないと分からない機能性もあります。 その洋服を高品質で低価格ですばやくお届け出来る事が理想であり、QCD (Quality, Cost, Delivery) という表現も聞いたことがあるかもしれません。品質良く製造する事も重要ですが、この洋服は消費者の自宅に配達されて初めて価値になります。配送中に汚れると製造された商品の品質は変わらなくても消費者が感じる品質は下がります。カスタマーサクセスとかユーザエクスペリエンスとか、色々な用語がありますが物理的なモノを提供する事業においては最終的に納品して初めて顧客価値に繋がるため、QCDの概念には製造から梱包、輸送まで含まれます。 キャディは産業装置業界を含む多品種少量から中量産のモノづくりをしております。部品点数が多く、加工の種類も多い中で、調達の課題を解決しています。ペットボトルを作る装置から半導体設備まで幅広く調達支援をさせていただいています。大半の部品が通販やカタログで購入出来るものではなく特注品です。設計図があり部品ごとに寸法や表面処理が違います。キャディでは各部品が用途に適した品質で作られることを心がけており、そのために社内で製造業のドメイン知見を蓄積しています。 この品質というものをキャディで担保するために検査拠点も関西と関東に設けており、お客様が求めている品質に製品が出来上がったか確認しているわけですが、これが非常に大きなチャレンジです。 製造業の「検査」工程 普段出荷直前に設計図通りに製造されたかを確認する検査工程が設けられます。産業や会社によって検査のしかたは違いますが、なんらか「ユーザに届く前の最後の確認」をする認識はどこも共通でしょう。このステップで「穴の直径が間違っている」とか、「表面にキズがある」とか、「塗装が剥がれている」等と、色々アラートが上がってきます。 面白いことに穴の直径が違っても、キズが付いていても、必ずしもダメな訳ではない。ユーザが穴の直径を気にしないなら良いわけです。誰の目にも付かずキズだらけでも良い部品なら、キズがあっても良いです。ソフトウェアの世界でもアイコンの位置が数ピクセルズレていても、本番へのリリースを続行する事もある。全てコンテキスト次第でややこしいですね。肉眼でキズが無くても顕微鏡で見ればいくらでもキズは見つかります。あらゆる品質の基準に強弱を付けて製造の現場に伝える事が重要で、そのための標準として日本国内ではJIS規格が設けられています。 家具の金具を作りたいのにジェットエンジンと同じ過剰な品質を求めると価格に大きな影響が出てしまう。だからといって、「キズだらけに作ってOK」とか「寸法は適当でOK」という設計者はいないでしょう。大体設計部門は品質を良くしたく、購買部門は購入価格を抑えたいため、機能要件と価格インパクトを上手くバランスするのが製造業の難しさともいえるでしょう。 そのせめぎ合いで部品が調達される状況下で検査では合否判断が求められます。検査というコンテキストだけでは判断しきれなく担当者に確認が必要になる場面も少なくないです。ソフトウェアの世界でもリリース直前に不具合に気付き、リリース続行判断をプロダクトマネージャに求める場面に似ています。一定不確実性が検査工程まで残ってしまうのは仕方がないですが、それをラーニングとして次回に活かし、上流にフィードバック出来る事が最終的な姿かもしれません。 出荷直前の検査工程でミスに気が付かずにお客様に迷惑かけるよりはマシですが、根本的には何も解決していない。不良の検出単体はその場の事実確認だけであり、継続的な改善に向かうには更に原因追跡や再発防止対策が必要。何もしないで再発のリスクを飲み込むのも全然ありですが、その共通認識を随時アップデートするきっかけとなるのが検査のあるべき姿なのではないでしょうか。 品質の継続的改善 結局正解は無いし、完全に白黒の世界は作れないので如何にユーザや業界に合わせ続けられるかが肝になります。品質は一回確認して「完了」するものではなくて、何が求められているのかを把握した上で、継続的にそれをお届け出来るようにコミットする事です。ミスの無い世界は存在しないですし、不確実性がゼロの世界も存在しないですが、トレードオフを常に意識して改善する事は出来ます。 求められている基準を定期的にアップデートして、それに向けて改善し続ける事を言語化したのが国際基準である ISO9001 です。こちらは「品質バッチリ!」の認定ではなく、「継続的にPDCAを回す」体制と実態があり、それにコミットする経営からの意思表明です。キャディでもISO9001認定を受けており、QMS (Quality Management System)の構築と継続的な改善に取り組んでいます。検査の結果をフィードバックのメカニズムとして活用する事を記述させて頂いています。 加工品の品質の継続改善と同時に、テクノロジー本部ではソフトウェアの継続的な改善にも取り組んでおります。物理的な加工品とパソコンの中にしか存在しないソフトウェアとで基質は違いますが、似ているところも複数あります。キャディの受発注事業部とは全く別ですがSaaSの事業部ではISO27001認定を受けており、ISMS (Information security management system)の構築を運用をしております。この世に100%の保証は無いものの、極力お客様に寄り添っていき、変化し続ける要求に対して適切なプロダクトを提供し続けるための意思表明とも言えます。 今回ご紹介させていただいた物理の品質を踏まえて、次回 Part 2 ではセキュリティに限らず物理のモノづくりとソフトウェアのモノづくりを比較出来ればと思います。 The post 製造業とソフトウェアの品質 Part 1/2 appeared first on CADDi Tech Blog .
アバター
こんにちは、キャディでソフトウェアエンジニアをしている小倉です。今はフロントエンドを主に触っています。 いきなりまとめ(TL;DR) 本記事は、AgGridのセルの値のReadとWriteについての機構をまとめた記事になります。 記事が長くなってしまったので、触れる内容をまとめた図を先に持ってきました。 やり方が複数あるものについては以下のように考えておくと良いです。 valueSetter,valueGetterは使わなくて良いなら使わないようにして、なるべくfield指定だけですむようにrowDataを設計する。 文字列処理だけで住むときはvalueFormatter, それ以上の処理( CSS で飾り付ける、リッチな機能をつける、etc...)が必要ならcellRenderer プロローグ キャディで作っているTechプロダクトの半数は、人力で回していたオペレーションを代替する立ち位置のものです。どうすればうまくいくのかわからない製造業の課題に対してまずは人力で試して、手応えがあったら、効率化や規模拡大のためにTechプロダクト化する、というよくある流れですね。 さて、このようなプロダクト開発に携わっている方が必ずといって良いほどユーザーから聞くフィードバックがあります。 「 Excel ・ スプレッドシート の方が便利 」 人力で回すオペレーションはUIとDBの部分を 表計算 ソフトで間に合わせていることが多いです。プロダクト化しても良さそうだぞとなるオペレーションはだいたい強力な Excel ・スプシが出来上がっていることでしょう。それと比較されて出てくるフィードバックです。 表計算 ソフトはUIとデザインを一緒くたにして使ってしまうと甚大な アンチパターン を踏んでしまいがちですが、そこをちゃんとすれば、洗練された迅速なオペーレーションを回す手助けをしてくれます。とくに 表計算 UIは大量のデータを扱うのに強かったりします。 表計算 ソフトの アンチパターン を踏まれないようにFEエンジニアがうまくUIを設計すれば強力なツールとなるでしょう。 そういうわけで、私達のチームでは 表計算 コンポーネント をプロダクト内に導入することにしました。 表計算 っぽい コンポーネント を提供するライブラリには WijmoのFlexGrid , Handsortable , cheetahGrid など様々ありますが、私達は AgGrid を使うことにしました。 この記事はなに? AgGridは機能が多く、同じことを実現するにしても様々な書き方ができてしまいます。とくにセルへの入出力まわりはその傾向が顕著です。 公式ページをよく読めばどういうときにどの機能を使うのかのポリシーが書いてあるのですが、開発のたびに参照しに行くのも面倒なので、一覧性を重視したまとめドキュメントを作ることにしました。この記事はそのドキュメントをブログ向けにアレンジしたものです。 用語 Grid:表全体 Row:行 Cell:マス一つ 行は「どのオブジェクトか」、列は「オブジェクトをどう料理するか」 AgGirdでは各行(Row)は1つのオブジェクトと対応します。つまりGrid全体としてはオブジェクトの配列になります。その配列がしばしばrowDataと呼ばれるものです。 各列(Column)は、対応するオブジェクトをどう料理するかに対応します。ここでいう「料理」は大抵の場合は「オブジェクトのこのフィールドの値をtoStringした値を表示」になります。それ以外にも、複数フィールドの合計値を計算して黄色い背景色で表示、などの複雑なパターンにも対応可能です。 つまり、$i$行目$j$列目の場所にあるCellの値は rowData[i] をj列目共通の料理の仕方で得られた値になります。(UI上で列や行の並びを変えられるので厳密な説明ではありませんが、、、) コンポーネント の例 基本的には AgGridReact コンポーネント に行データのArray(rowData)をPropsで与えると、Gridが出来上がります。 AgGridReact のchildrenとして AgGridColumn を追加すると列に関する情報を入れることができます。 AgGridColumn の field 属性にフィールド名を入れておくと i 行目の当該列は rowData[i] のそのフィールドの値を toString した値が表示されます。 ▼素朴なAgGrid コンポーネント import { AgGridColumn, AgGridReact } from "ag-grid-react"; import "ag-grid-community/dist/styles/ag-grid.css"; import "ag-grid-community/dist/styles/ag-theme-alpine.css"; const rowData = [ { firstName: "Taro", lastName: "Tanaka", age: 10 }, { firstName: "Hanako", lastName: "Yamada", age: 25 }, { firstName: "Jiro", lastName: "Suzuki", age: 32 }, ]; export const MyGrid: React.VFC = () => { return ( <div className="ag-theme-alpine" > <AgGridReact rowData={rowData}> <AgGridColumn field="firstName" editable={true} /> <AgGridColumn field="lastName" editable={true} /> <AgGridColumn field="age" /> </AgGridReact> </div> ); }; ▼見え方 表示 上で説明した「料理」は、おおまかに以下の2パートに分かれます。 - オブジェクト(rowData)から値を計算する部分 - 値を表示する部分 オブジェクトから値を計算する部分 オブジェクトから値を計算する部分は以下の2種類のパターンがあります。 ①フィールドを指定する <AgGridColumn field="lastName" /> のようにGridCloumnのfield属性を指定すると、その列のセルの値は、オブジェクトのそのフィールドの値になります。次に紹介するvalueGetterが特に指定されていないときは、フィールド指定だと思って値を取ってこようとします。たとえfield属性が指定されていなかったり、存在しないフィールド名が指定されていたとしても、そのフィールドにアクセスして値を取得しようとするので、undefinedがそのセルの値になります。AgGridは全体的にフィールド指定で済むならばその方がコードがスッキリするようになっていますから、 なるべくフィールド指定で済むようにrowDataの設計をするとよいでしょう。 ②valueGetterを指定する <AgGridColumn valueGetter={myValueGetter} /> のようにGridCloumnのvalueGetter属性に関数を指定すると、その列のセルの値は、その関数が返す値になります。ここでvalueGetter関数には、引数としてその行のオブジェクト(rowDataの要素)が渡されるだけでなく、Grid全体を操作できる API などいろいろおまけがついてきます。そのため実質Grid全体の情報を使ってなんでも計算できることにります。詳細な引数は 公式ドキュメント を参照してください。 どうしても複数フィールドにアクセスしないといけない場合や、複数のセルで同じ値を共通して参照しなければならない場合にvalueGetterを使うとよいでしょう。 値を表示する部分 値を表示する部分は以下の3種類のパターンがあります。 ①toStringして得られる文字列を表示する とくに何も指定がない場合は、その列のセルは、セルの値を Object.toString して得られる文字列が表示されます。 値そのものがテキストや数値で、そのまま表示していいときは、このパターンで実装するとよいでしょう。 ②valueFormatter関数によってフォーマットした文字列を表示する <AgGridColumn valueFormatter={myValueFormatter} /> のようにGridCloumnのvalueFormatterk属性に関数を指定すると、その列のセルは、その関数が返す文字列を表示するようになります。なお、次に説明するcellRendererが指定されている場合はそちらが優先されます。 ValueFormatterr関数に渡される引数の詳細は 公式ドキュメント を参照してください。 値そのものがテキストや数値で、フォーマットし文字列を表示するだけでよい場合にこのパターンで実装するとよいでしょう。 ③cellRendererを指定してJSX/ TSX を描画する <AgGridColumn cellRenderer={myCellRenderer} /> のようにGridCloumnのcellRenderer属性に関数などを指定すると、その列のセルは、それによって計算されるJSXやHTMLを表示するようになります。cellRendererはJSX/HTMLを返す関数、その関数名(string)、レンダラーの コンポーネント 、を指定することができます。 もしReactのFunctionalComponentを渡したい場合はcellRenderer属性ではなくcellRendererFramework属性にFunctionalComponentを渡してあげなければならないことに注意してください。 cellRenderer関数に渡される引数の詳細は 公式ドキュメント を参照してください。 値そのものがテキストや数値で、フォーマットし文字列を表示するだけでよい場合にこのパターンで実装するとよいでしょう。 編集 <AgGridColumn editable={true} /> のようにGridColumnのeditable属性をtrueにするとその列のセルは編集可能になります。 編集は以下の3パートに分かれます - Editorが編集後の値を返す部分 - 編集後の値をパースしてセルの値を更新する部分 - 更新されたセルの値に応じて、オブジェクト(rowData)の値を更新する部分 Editorが編集後の値を返す部分 <AgGridColumn cellEditor="agSelectCellEditor"> のようにGridColumnのcellEditor属性を指定すると、その列のセルを指定されたEditor コンポーネント を通じて編集できるようになります。このEditorはgetValueという メンバ関数 を持っている必要があります。これは編集終了時に呼び出される関数であり、編集後の値を返すことが期待されます。 AgGridは公式に 何種類かのcellEditor を用意していますが、自前でEditorを実装することも可能です。そのときはgetValueメソッドを実装してあげることを忘れないように、、、 もしReactのFunctionalComponentを渡したい場合はcellEditor属性ではなくcellEditorFramework属性にFunctionalComponentを渡してあげなければならないことに注意してください。 また筆者は今回AgGridを使って初めて知ったのですが、FunctionalComponentに メンバ関数 を追加したい場合はuseImerativeHandleフックを使うとよいです。 編集後の値をパースしてセルの値を更新する部分 EditorのgetValueメソッドを通じて得られた値は通常そのままセルの値としてみなされます。しかし、何かしらパースしたい場合は <AgGridColumn valueParser={myValueParser}> のようにGridColumnのvalueParser属性にパース関数を指定することで、パース処理をはさむことができます。valueParser関数に渡される引数の詳細は 公式ドキュメント を参照してください。 更新されたセルの値に応じて、オブジェクト(rowData)の値を更新する部分 更新されたセルの値に応じて、オブジェクト(rowData)の値を更新する部分には2種類のパターンがあります。 ①フィールドを指定する <AgGridColumn field="lastName" /> のようにGridCloumnのfield属性を指定すると、その列のセルの値は、オブジェクトのそのフィールドの値を上書きします。次に紹介するvaluSetterが指定されていないときは、フィールド指定だと思って値を書き込もうとします。 ②valueGetterを指定する <AgGridColumn valueSetter={myValueSetter} /> のようにGridCloumnのvalueSetter属性に関数を指定すると、その列のセルの値(編集後の値)が引数として関数に渡されセルの値が更新する処理が走ります。valueSetterの詳細な引数は 公式ドキュメント を参照してください。 有償プランの機能 AgGridの有償版にはたくさんの便利機能がついています。この記事ではそのうちコピーペーストと補完について触れます。ここで扱う機能は有償ライセンスがなくても試すことは可能です(ただし、 ウォーターマーク が入る)。 どの機能もファイルの先頭に import "ag-grid-enterprise"; を追加しないと有効にならないので注意です。 コピー コピー時の挙動はカラムではなくAgGrid全体に共通のコールバック関数を指定する必要があります。 <AgGridReact processCellForClipboard={myCallBack}/> のようにGridのprocessCellForClipboard属性に関数を指定すると、選択したセルがコピーされたときに、その関数の返り値(string)が クリップボード にコピーされるようになります。複数セル選択でコピーしたときは各セルごとにこの関数が計算した文字列がTSV形式で クリップボード にコピーされます( \t 以外のデリミタを指定することも可能 )。 この関数はどの列も共通なので、どの列から呼び出されたかは関数内で判定しないといけません。以下のようにcolIdで判定するとよいでしょう。 const processCellForClipboard = (params: ProcessCellForExportParams) => { const {column} = params; const colId = column.getColId(); if(colId === 'lastName')return "LastName" + params.value; return params.value; }; ペースト ペースト時の挙動もカラムではなくAgGrid全体に共通のコールバック関数を指定する必要があります。 <AgGridReact processCellFromClipboard={myCallBack}/> のようにGridのprocessCellFromClipboard属性に関数を指定すると、選択したセルにペーストされたときに、 クリップボード の文字列がその関数に渡され、返り値をセルの新しい値として更新します。複数セル選択でペーストしたときは クリップボード の文字列をTSVだと思って、よしなに文字列を分割してくれます。 この関数はどの列も共通なので、どの列から呼び出されたかは関数内で判定しないといけません。以下のようにcolIdで判定するとよいでしょう。 const processCellFromClipboard = (params: ProcessCellForExportParams) => { const {column} = params; const colId = column.getColId(); if(colId === 'lastName')return "LastName" + params.value; return params.value; }; 補完 補完はセルの選択範囲の右下の小さな四角をドラッグしながら選択範囲を広げることで、よしなに値を産めてくれる機能です。言葉で説明するより以下のGIFを見たほうが早いかもしれません。 この機能は <AgGridReact enableRangeSelection={true} enableFillHandle={true}/> のようにenableFillHandleをtrueにすると有効になります。 デフォルトだと、数値の場合は 多項式 補完、そうでない場合は周期的に補完してくれるようです。もしそれ以外の方法で補完したい場合は <AgGridReact fillOperation={myFillOperation}/> のようにGridのfillOperation属性に関数を指定するとよいでしょう。 この関数はどの列も共通なので、どの列から呼び出されたかは関数内で判定しないといけません。以下のようにcolIdで判定するとよいでしょう。 const fillOperation = (params: FillOperationParams) => { const { column, initialValues } = params; const colId = column.getColId(); if (colId === "age") return initialValues.reduce((accum, val) => accum + val, 0); return ""; }; まとめ(再掲) 本記事で触れた内容をまとめた図です。 やり方が複数あるものについては以下のように考えておくと良いです。 - valueSetter,valueGetterは使わなくて良いなら使わないようにして、なるべくfield指定だけですむようにrowDataを設計する。 - 文字列処理だけで住むときはvalueFormatter, それ以上の処理( CSS で飾り付ける、リッチな機能をつける、etc...)が必要ならcellRenderer 最後に AGGridにはほかにもたくさんの機能があります。 表計算 っぽいUIが欲しくなったらぜひ一度使用を検討してい見てください!
アバター
はじめに 先日行われた atmaCup #12 にて、「CADDiチーム立ち上げ期MLE・DS積極採用中」チームが 245チーム中 9位 になりました。 惜しくも入賞は逃してしまいましたが、 コンペティション 内でチームとして参加していた中では最も良い成績を残す事ができました。 コンペティション の詳細な内容には触れる事はできないのですが、1週間という短期コンペにチームで参加した経験を振り返り、今後についての考察をまとめて何かの学びになればと思い、本記事を執筆しています。 atmaCup × Sansan戦略 今回は社内から猿田( @srt_taka )と竹原( @myaunraitau )、社外から河合( @vaaaaanquish )を加え、3人での参加しました。 前提として3者とも、 機械学習 エンジニアとしての業務経験、 コンペティション 参加経験を持っていたため、最初の数日はいくつかの特徴量 + LightGBM + 5fold CVで各々が進んでいました。 いくつか特徴量に関する知見を交換しながらも、各々submitする形でした。 中間日辺りから、LightGBMが伸び悩みはじめ、互いの特徴量作りも手数が減ってきたため、河合がtabtransfomer、テー ブルデー タを画像化した上でのCNN、竹原がtransformer等を試しはじめました。 最終日2日前にそれらをマージした上で最終スコアを出し、post processの処理を書き始め最適化を行う事で最終スコアとなりました。 良かったこと 今回短期の コンペティション にチームで参加するにあたって、手数が他チームより多かったと思います。 特に短期のチーム戦では、各々の知見を集約しながらも、「手数を増やす」ことが重要だったと感じます。 今回上位陣は、多くがDeep Neural Networkに関連した手法を用いており、私達もCNN、Transformerを試す事で大きくスコアを向上させる事ができました。 仕事や家庭もある中、短期間の コンペティション では、多くの論文を サーベイ したり、新しいライブラリをガツガツ導入するといった事は単独では難しい部分も多くあります。 その点において、今回チームメンバーの知見を集約し、様々なツール、手法に取り組めた事が、互いの知見向上の意味でも非常に良い体験でした。 利用したツール 具体的な手製の特徴量については触れられませんが、特徴量生成、特徴量選択、 モデリング においては、LightGBMやsklearn、PyTorchに加えて以下のツールを利用しました。 https://github.com/Ynakatsuka/kaggle_utils KaggleMasterである Yuki Nakatsuka( @ynktk1 )さんが公開しているkaggle_utilsです。 前処理で有用な2次特徴量変換が多く実装されており、過去のatmaCupでもKaggle Grand Masterである @takuoko さんがソリューションで利用していました。 以下のように、pandas.DataFrameを入力とし、簡易にCategoryEmbeddingやTargetEncoding、Aggregationを実装できます。 # pip install https://github.com/Ynakatsuka/kaggle_utils from kaggle_utils.kaggle_utils.features.groupby import GroupbyTransformer from kaggle_utils.kaggle_utils.features.groupby import DiffGroupbyTransformer param_dict = [ {'key': ['id'], 'var': box_feat_for_agg, 'agg': ['mean', 'max', 'min', 'std', 'nunique', 'rank']}, ... ] gt = GroupbyTransformer(param_dict = param_dict) gt.fit(df) df = gt.transform(df) gt = DiffGroupbyTransformer(param_dict = param_dict) gt.fit(df) df = gt.transform(df) ... https://github.com/pfnet-research/xfeat 特徴量が増えてくると、Aggregateの実行時間がかかるため、PFNさんが公開しているxfeatを利用しました。 こちらもkaggle_utils同様に、TargetEncodingやAggregationを簡易に実装できるものですが、いくつかのFeatureSelectionの アルゴリズム が実装されているだけでなく、入力をcudfにする事で高速に前処理を行う事ができます。 事前に作った1次特徴量をcudfに変換し、AggregationやFeatureSelectionを行いました。 import cudf from xfeat import aggregation from xfeat.pipeline import Pipeline from xfeat.selector import (ConstantFeatureEliminator, DuplicatedFeatureEliminator, SpearmanCorrelationEliminator) cdf = cudf.DataFrame.from_pandas(df) cdf, agg_cols = aggregation(cdf, group_key=key, group_values=cols, agg_methods=['mean', 'max', 'min', 'sum', 'std', 'rank']) selector = Pipeline([ DuplicatedFeatureEliminator(), ConstantFeatureEliminator(), SpearmanCorrelationEliminator(threshold=0.9), ]) cdf = selector.fit_transform(cdf[cols]) GPU に乗せる事で、半日程掛かっていた前処理が15分前後で終わるようになり、 コンペティション 中の作業効率促進に繋がりました。 https://github.com/awslabs/autogluon 竹原がLightGBM単体でsubmitを繰り返し様々な特徴量の実験を行っている間に、河合が AWS Labsが公開しているAutoGlounを用いてstacking、bagging、Out of Folds(oof)の生成を実行していました。 from autogluon.tabular import TabularPredictor # 利用するmodelの指定 models = ['GBM', 'CAT', 'XGB', 'NN', 'LR', 'KNN'] predictor = TabularPredictor( label=label, eval_metric='f1_macro', # 今回のコンペティションに合わせて problem_type='multiclass', groups=groups # group fold ) predictor = predictor.fit( train_df, # validationを自前で設定する場合は tuning_data=val_df time_limit=time_limit, # N sec以内にfitが終わるようモデル選択が行われる (default=0) presets='best_quality', num_bag_folds=num_bag_folds, num_bag_sets=num_bag_sets, num_stack_levels=num_stack_levels, hyperparameters={x:{} for x in models} ) # oofの取得 oof = predictor.get_oof_pred_proba(train_data=train_df) # predの取得 pred = predictor.predict_proba(df) # 各modelファイルはfit毎に別ディレクトリにあり # predictor自体のsave/loadはpickleで行える import pickle with open('./predictor.pkl', 'wb') as f: pickle.dump(predictor, f) 今回の コンペティション では、周囲のデータのoofを用いた特徴量が順位に大きく響きました。 AutoGlounは、modelごとのoof抽出などの コンペティション に必須な機能を備えているだけでなく、 GPU を自動検知してLightGBM等を GPU ベースで処理したり、ハイパパラーメタチューニングや、timeoutを指定して「指定時間内に終わるようにstacking、emsembleモデルを作る」ことを自動で行ってくれるため、特徴量の追加、削除などの側面でかなり PDCA を回しやすかったです。 今回は自前で特徴量加工を行いましたが、自動での特徴量加工も実装されているようです。 また、LightGBMやCatBoost、XGBoost、NeuralNetなど、実際によく使われる アルゴリズム を利用する実装になっているため、AutoMLとしての信頼度も高かったと言えます。 https://github.com/optuna/optuna コンペティション 終盤では、Optunaによる複合ルールベースのpost process最適化を実施しました。 今回の コンペティション では、あるclass Xの処理を行うかどうかで大きく順位が変動したのですが、河合がtestとtarin間のXの検出量の差の最小化問題として捉えたpost processを実装し、大きく順位を上昇させました。 最小化問題が目の前にある時、Optunaが非常に使い勝手が良く、これが最後のTop10争いに効きました。 次はこうしたい 良い点が多かった反面、最終的に入賞できなかったのが非常に残念な所です。 短期の 機械学習 コンペティション をチームで戦う上での反省点として、以下のように振り返りました。 誰かがリーダーシップを取り役割を割り振る 利用するパイプラインを決めておく データのやり取り等のフォーマットを固めておく 今回各々が簡易コードで特徴量加工部などを共有するのみで終始参加していたため、お互いの進捗が見えづらいだけでなく、前半同じような特徴量生成を全員が書くことに時間を使ってしまいました。 個々がデータをよく見る時間としては有用だったかもしれませんが、全員でディスカッションしていればすぐ出たもので、実装も1つで済んだ所です。 3人のうち1人を挑戦的なモデルチューニングに割り当てる事で、入賞が狙えた位置にいると思うと、実際悔しい限りです。 また、短い コンペティション かつ、互いの開発のバックグラウンドが分かっていない状況だったため、パイプラインツールの統一やデータのやり取りも雑多に行っており、途中特徴量データの受け渡しに失敗して時間をロスしたりしていました。 時間が限られた コンペティション では、事前にチームを組むメンバーで共同開発の練習をしておくと良さそうです。 全体振り返り チーム名の通り、現在CADDiでは 機械学習 ・データサイエンスのチームを立ち上げようとしている所です。 既に数人メンバーが社内で活躍していますが、まだまだプロダクトを推し進めてくれるメンバー探しをしています。 そこで、業界内でのプレゼンスを高め実績を残す意味でも「絶対優勝するぞ!」と応募しました。 atmaCup #12は、一週間という非常に短い期間かつオンラインで開かれました。 タスクもテー ブルデー タを扱うもので、長期間の コンペティション に比べ、立ち上げ期の我々にとっては非常に参加しやすいものでした。 実際参加してみて、運営からのベースライン、 チュートリアル などのヒントや、guruguru *1 上でのディスカッションの盛り上がりが、一週間で行われるものとは思えない程濃密で質が高く、非常に楽しかったです。 チームメンバーが互いに信頼する意味合いでも、同一のタスクをオンラインかつ短期間という制約のある状態で解く事自体に、価値があったと感じています。 各々仕事の隙間を縫っての参加となりましたが、特徴量や手法に関するディスカッションは普段の業務以上に行う事ができました。 この経験を活かし、次回はより良い結果を残したい所です。 おわりに 今回はSansan様のホストする コンペティション で、名刺に関連した内容でした。 CADDiでは、図面を多く扱うプロダクトを作っていますが、今回の コンペティション の課題に似たテー ブルデー タ、画像認識の課題が山積みとなっています。 是非、新しい 機械学習 ・データサイエンスチームへジョインして、より強い力で一緒に前に進んでくれるメンバーを募集しています。 We are hiring! *1 : atmaCupがホストされているWebサイト
アバター
こんにちは。Platform チームの飯迫 ( @minato128 )です。 CADDi ではこれまで Hosted Redash(app.redash.io) を利用していたのですが、残念ながら 2021/11/30 に End of Life になるので、10 月末に Self-Hosted Redash 環境を構築して移行しました。今回はそのときやったことを紹介します。 移行の流れ 新しい Redash 環境を v10 で構築する 公式の移行ツールを利用してデータ移行する 監視を追加する 新しい Redash 環境を v10 で構築する まず、 移行ツールは移行先として v10 を前提としている ので、新しい環境は v10 である必要があります。ちなみに、 v10 は 10/2 にリリースされた現時点の最新版 です。 v10 であればどんな方法で構築しても問題ないのですが、今回は社内用 GKE Cluster に入れることにしました。監視もリソースも集約でき、Helm Chart を使うことで初期導入コストも低そうだったからです。 移行対象 GKE Cluster は、このような構成となっており、 基幹システム(社内システム)のほとんどをマルチテナントで運用している ArgoCD で GitOps している Secret は External Secrets + GCP Secret Manager で管理している Datadog で監視、ログを参照できるようにしている 次のように進めていきました。 (1) Terraform に必要な GCP リソースを追加してデプロイ GKE に新しい Node pool の追加 基幹システムに出来る限り影響を与えないようにしたかったので、いったん Node pool ごと分離 移行後のリソース状況をみながら見直す想定 CloudSQL( PostgreSQL ) の追加 DB の管理はできるだけしたくなかったため バックアップもおまかせ (2) Redash Helm Chart の ArgoCD Application Manifest を追加してデプロイ 現在の Chart version は 2.3.1 で Redash version は v8 となっています。ほしいのは v10 だったため、まず v10 に対応する PR を作成しました。(Redash ほど有名なツールの Chart が最新バージョンに対応してないとは思っておらず、これは想定外の作業でした) https://github.com/getredash/contrib-helm-chart/pull/102 追加したファイルの一覧はこれらで、 ├── applications │   ├── redash-assets │      └── overlays │      └── prod │      ├── kustomization.yaml │      ├── redash-postgres-secret.yaml │      └── redash-secret.yaml ├── argocd │   ├── overlays │      ├── prod │         ├── pj-name │            ├── helm-redash.yaml 以下が helm-redash.yaml のイメージです。(実際のものではなくいろいろ端折っています) apiVersion : argoproj.io/v1alpha1 kind : Application metadata : name : redash namespace : argocd finalizers : - resources-finalizer.argocd.argoproj.io spec : project : project-name source : repoURL : "https://minato128.com/contrib-helm-chart/" chart : redash targetRevision : 2.4.1 helm : valueFiles : - values.yaml values : |- # env -- Redash global envrionment variables - applied to both server and worker containers. env : PYTHONUNBUFFERED : 0 REDASH_RATELIMIT_ENABLED : "false" REDASH_MAIL_SERVER : "smtp.sendgrid.net" REDASH_MAIL_PORT : "587" REDASH_MAIL_USE_TLS : "true" REDASH_MAIL_USERNAME : "apikey" REDASH_MAIL_DEFAULT_SENDER : "redash@caddi.jp" ## Redash application configuration redash : # -- REQUIRED `REDASH_SECRET_KEY` value. Secret key used for data encryption. Stored as a Secret value. # helm template 通らないので入れているが、実際はexistingSecretが採用される secretKey : "DUMMY" # -- `REDASH_STATSD_HOST` value. # @default -- 127.0.0.1 statsdHost : "datadog-statsd-service.datadog" # -- `REDASH_GOOGLE_CLIENT_ID` value. googleClientId : "XXXXXXXXXX" # -- REQUIRED `REDASH_COOKIE_SECRET` value. Stored as a Secret value. # helm template 通らないので入れているが、実際はexistingSecretが採用される cookieSecret : "DUMMY" # redash.existingSecret -- Name of existing secret to use instead of either the values above ## This secret must contain keys matching the items marked "Stored as a Secret value" above. existingSecret : "redash-secret" # we dont use ingress ingress : # ingress.enabled -- Enable ingress controller resource enabled : false # CloudSQL # externalPostgreSQLSecret -- Read external PostgreSQL configuration from a secret. This should point at a secret file with a single key which specifyies the connection string. externalPostgreSQLSecret : name : redash-postgres-secret key : connectionString ## we dont use this postgresql postgresql : # postgresql.enabled -- Whether to deploy a PostgreSQL server to satisfy the applications database requirements. To use an external PostgreSQL set this to false and configure the externalPostgreSQL parameter. enabled : false # helm template 通らないので入れているが、実際はexternalPostgreSQLSecretが採用される postgresqlPassword : "DUMMY" ## Configuration values for the redis dependency. This Redis instance is used by default for caching and temporary storage [ref](https://github.com/kubernetes/charts/blob/master/stable/redis/README.md) redis : # redis.enabled -- Whether to deploy a Redis server to satisfy the applications database requirements. To use an external Redis set this to false and configure the externalRedis parameter. enabled : true destination : server : "https://kubernetes.default.svc" namespace : redash syncPolicy : syncOptions : - CreateNamespace= true v10 対応の PR はまだマージできていないので、fork した Repository の Package を参照している v10 の スキーマ に対応した DB で運用していれば、あとで Chart を入れ替えても問題ない 移行のブロック要因にはならない 移行データが多いと API 制限に当たってしまうので REDASH_RATELIMIT_ENABLED を false にする Secret は existingSecret と externalPostgreSQLSecret で指定する redash-assets という名前で、ArgoCD Application を追加して External Secrets をデプロイ REQUIRED な設定には、実際には使わなくても空でない値を入れておかないと helm template コマンドが通らない Redash は Statsd に対応しているが、現在の Chart では REDASH_STATSD_HOST に status.hostIP を指定できないので、Proxy Service を作って指定している 公式の移行ツールを利用してデータ移行する 概要は @ariarijp さんのスライドがわかりやすいです。 https://speakerdeck.com/ariarijp/you-should-know-about-hosted-redash-eol-and-redash-migrate 基本的には 公式ドキュメント に書いてある通りにやっただけです。 redash-toolbelt をインストールする redash-migrate init 移行に必要な設定が meta.json として生成される redash-migrate --help の結果のコマンドを上から順に実行していく データに依存関係があるため 移行の from/to 情報が meta.json に蓄積される こちらが実行後の meta.json の一部抜粋で、データタイプごとに移行元と移行先の id が状態管理されていることがわかります。 " queries ": { " 233300 ": 1 , " 233440 ": 2 , " 234065 ": 3 , } 以下がポイントです。 redash-migrate は最新バージョンを使う 初期バージョンだと group の重複など致命的なバグがある 現時点でも細かいバグは残っている https://github.com/getredash/redash-toolbelt/issues セキュリティ上の都合で、Data Source の Secret は移行されない Redash UI 上で再設定が必要 冪等性があり何度でも実行可能 リソースの id 単位で移行するため、移行済みリソースを origin 側で変更しても再同期はできない Origin 側も Destination 側も Redash 本体の Web API でアクセスする 接続周りに気を使う必要がない 移行量が多い場合は、 Destination 側の API Limit を変えておく 環境変数 REDASH_RATELIMIT_ENABLED 監視を追加する モニタリングのためにこれらを追加しました。 Datadog Logs の Pipeline 追加 一部 Parse しづらいログがあるので手動で Parser を書いてレベル(status)を正しく Remap する Datadog Monitor の追加 GKE や CloudSQL は既存の監視を利用 Terraform で管理しており、Module を参照 Error log 数 Synthetics(外形監視) https ://[REDASH_HOST]/ ping を監視 Queue 監視 前提として、Redash v10 は RQ(Redis Queue) を利用しており、job(message)を処理できているか監視する必要がある 個人的に Queue は Message Age で監視する派ですが、既存 metrics にはないので、message がたまり続けてないか観測することにした redash.rq.jobs.created が enqueue 時、 redash.rq.jobs.started が処理開始時に 記録されている ので、 created - started が n (正の整数)より大きい状態が一定期間続くと worker が message をさばけていないことになる n は traffic や worker count に依るが 0 に近いほうがよい Datadog Dashboard の追加 俯瞰して状況把握できるように まとめとポイント 新しい環境は v10 で作る 移行ツールが v10 しかサポートしていないため Redash Helm Chart は現時点では Redash v8 データ移行は、公式の移行ツールで簡単にできる 移行データに依存関係があるので、help コマンドの順番通りに実行していく セキュリティ上の都合で、Data Source の Secret は移行されないので UI 上で再設定が必要 冪等性があり何度でも実行可能 Origin 側も Destination 側も Redash 本体の Web API でアクセスするので、接続周りに気を使う必要がない 移行量が多い場合は、 Destination 側の API Limit を変えておく
アバター
Summary This post is my hobby and has nothing to do with work. I have wanted Extensible Records (a library in Haskell ) for a long time. The time has finally come. The language features we need to implement it are there in C++ 20! Therefore, this post will show you how to emulate row polymorphism in C++ 20. The latest, complete code can be found in this repository . Row Polymorphism Row polymorphism is a kind of polymorphism that allows one to write programs that are polymorphic on record field types (also known as rows, hence row polymorphism). Here is a TypeScript example: type foo = { first: string, last: string }; const o = { first: "Foo", last: "Oof", age: 30 }; const p = { first: "Bar", last: "Rab", age: 45 }; const q = { first: "Baz", last: "Zab", gender: "m" }; const main = <T extends foo>(o: T) => (p: T) => o.first + o.last main(o) (p); // type checks main(o) (q); // type error Mitama.Data.Extensible.Record In TypeScript, it is implemented as a language feature, but in C++ 20, there is no such feature, so we need to emulate it somehow. The reference is the famous Haskell library extensible which emulates the same feature. In the end I succeeded in making a library which allows the following syntax. import Mitama.Data.Extensible.Record; #include <iostream> #include <format> using namespace mitama::literals; using namespace std::literals; void print(mitama::has<"name"_, "age"_> auto person) { std::cout << std::format("name = {}, age = {}\n", person["name"_], person["age"_]); } int main() { using mitama::as; // declare record type using Person = mitama::record < mitama::named<"name"_, std::string> , mitama::named<"age"_, int> >; // make record Person john = Person{ "name"_v = "John"s, "age"_v = 42, }; // access to rows john["name"_]; // "John" john["age"_]; // 42 print(john); // OK auto tom = mitama::empty += as<"name"_>("Tom"s) ; print(tom); // ERROR: constraints not satisfied } Guide-level explanation mitama::named The syntax "name" and "age" is a UDL (User-Defined Literal). These literals create a type mitama::static_string which is a structual type (non-type template enabled class). Thus, mitama::named<"age"_, int> is a wrapped type of int named with mitama::static_string . You can construct mitama::named<_, T> by applying operator%(static_string, T) . mitama::named age = "age"_v = 42; mitama::named has some interfaces like std::optional . mitama::named age = "age"_v = 42; age.value(); // 42 mitama::named name = "name"_v = "Mitama"s; name->length(); // calls std::string::length and returns 6 mitama::record mitama::record<Rows... > is constrained to only take mitama::named in Rows... and strings in mitama::named must be distinct. When initializing a particular record type, the order of the initializers is free, because the Row strings are distinct. using Person = mitama::record < mitama::named<"name"_, std::string> , mitama::named<"age"_, int> >; // OK Person john = Person { "name"_v = "John"s, "age"_v = 42, }; // Also OK Person tom = Person { "age"_v = 42, "name"_v = "Tom"s, }; We can make mitama::record with CTAD (Class template argument deduction). In this case, Rows... of the record is inferred in the order of the initializers, so different record types are inferred depending on the order of the initializers. // john: record< named<"name"_, std::string>, named<"age"_, int> > auto john = mitama::record { "name"_v = "John"s, "age"_v = 42, }; // tom: record< named<"age"_, int>, named<"name"_, std::string> > auto tom = mitama::record { "age"_v = 42, "name"_v = "Tom"s, }; Use mitama::shrink to convert between records that have the same rows but in a different order. // decltype(john) _ = tom; // ERROR decltype(john) _ = mitama::shrink(tom); // OK In fact, a = shrink(b); converts b: B to a: A where A ⊆ B . using Person = mitama::record < mitama::named<"name"_, std::string> , mitama::named<"age"_, int> >; auto tom = mitama::record { "name"_v = "Tom"s, "age"_v = 42, "gender"_v = "m", // an extra row }; Person tom2 = mitama::shrink(tom); // OK Reference-level explanation How to specify string literals as a non-type template parameter Basic idea In order to specify string literals as a non-type template parameter, we first create a structural type class that holds const CharT [N] . CharT is a structural, and an array of a structural type is also structural. And the class such that all base classes and non-static data members are public and non-mutable and the types of all bases classes and non-static data members are structural types or (possibly multi-dimensional) array thereof is structural. Thus, fixed_string below is a structural type. template<std::size_t N, class CharT> struct fixed_string { static constexpr std::size_t size = N; using char_type = CharT; constexpr fixed_string(CharT const (&s)[N]) : fixed_string(s, std::make_index_sequence<N>{}) {} template<std::size_t ...Indices> constexpr fixed_string(CharT const (&s)[N], std::index_sequence<Indices...>) : s{ s[Indices]... } {} CharT const s[N]; }; We can use fixed_string as a non-type template parameter, and CTAD will automatically infer CharT and N from string literals. template <fixed_string S> struct static_string { using char_type = typename decltype(S)::char_type; static constexpr auto size = decltype(S)::size; }; int main() { using ss1 = static_string<"test">; static_assert(std::same_as<char, typename ss1::char_type>); using ss2 = static_string<u"test">; static_assert(std::same_as<char16_t, typename ss2::char_type>); using ss3 = static_string<U"test">; static_assert(std::same_as<char32_t, typename ss3::char_type>); } Complete idea In order to be able to handle std::string_view at compile time, static_string should have static constexpr std::string_view . template<fixed_string S> struct static_string { using char_type = typename decltype(S)::char_type; static constexpr std::basic_string_view<char_type> const value = { S.s, decltype(S)::size }; }; Furthermore, creating a static_string with UDL gives an appearance like "name" . The operator "" () allows us to pass string literals directly to the template parameter, so that we can construct a static_string using the fixed_string deduced by CATD. namespace mitama:: inline literals:: inline static_string_literals{ template <fixed_string S> inline constexpr auto operator ""_() noexcept { return static_string<S>{}; } } How to access to rows in a record mitama::named has a protected member operator[] for record. template <static_string Tag, class T> class named { public: static constexpr std::string_view str = decltype(Tag)::value; // ... protected: template <auto S> requires (static_string<S>::value == str) constexpr delctype(auto) operator[](static_string<S>) const noexcept { return storage::deref(); } }; Rows... in mitama::record<Rows... > should all be mitama::named . mitama::record inherits Rows... and make operator[] visible by using declaration. template <named_any ...Rows> class record : protected Rows... { public: // ... using Rows::operator[]...; }; This will enable to access rows through operator[] by overload resolution. How to check the equivalence of rows in two records In C++ 20, - lots of features can be used at compile time, - lots of classes and functions can be used at compile time, - template syntax for generic lambdas is available and - consteval is available. However, due to lot of compiler bugs, some lack of implementation and some Core Issues, it has become difficult to deliver accurate and elegant code to you. So I leave you with the challenge of the modern metaprogramming techniques in C++ 20. The code, with workarounds everywhere, can be found here . Appendix A: development environment Visual Studio 2022 Version 17.0.0 Preview 5.0 References ISO/IEC 14882:2020 Programming Languages -- C++ extensible
アバター
こんにちは😉 @ryokotmng です。 今日は社内ドキュメントの、Rust初心者向けのクックブックを公開しようと思います。 私自身コードを書くのに四苦八苦していた頃にとても助けられたので、Rustをはじめたばかりの方の参考になれば嬉しいです。 目次 [ toc ] はじめに この記事では、 The Book に記載されている知識を前提としています。 Rustを全く書いたことがない方は、先に読んでみることをお勧めします。 サンプルコードが結構長いこと、実行環境があった方が良い内容も多いことから、サンプルコードは大体Rust Playgroundのリンクとなっています。 ぜひご自身で修正して遊んでみてください。 単位つきの計算を型で厳格に縛る 例えば複数の長さの単位 (mm, cm, mなど) を扱う場合に、単位が合っていない長さ同士の計算をする場合、単位を揃える必要がありますね。 この時、最終的に欲しいのは1つの「長さ」、つまりプリミティブな数字のデータになるでしょう。 計算を行うとき、最終的に得たい「長さ」の単位は1つになるので、コード上では単位を比較して異なる場合はエラーを返す、もしくは、異なる単位の長さ同士の計算の場合はある単位に換算したうえで計算できる状態にする、のどちらかの処理が必要になります。 しかし、単位を型として定義すると、計算の実装をする際に単位をチェックするようなコードを書くことなく、異なる単位の長さ同士の計算を実装しようとしたら コンパイル エラーを出すことで、意図しない挙動を防いでくれます。 また、下記のサンプルコードのように、traitを使って単位の変換処理を実装することもできます。 サンプルコード 上記のサンプルコードには、型の書き方の他にも、以下のような多くの知識が詰め込まれています。 Rustの enum が、 JavaScript でいうUnion型のような使い方もできること ジェネリクス PhantomData (幽霊型) これらの概念を知らなくてもさらっと読んで雰囲気を掴むこともできますが、よく使うテクニックのはずなので、慣れていない方はそのような概念をひとつずつ調べながら読むことをお勧めします。 The Book にも記載されているので、ぜひ読んでみてください。 参考 (The Book): Enumを定義する 、 ジェネリクス 、 幽霊型パラメータ 参考 (外部ブログ): Rust で Phantom Type (幽霊型) なお、RustベースのWebエンジンである Servoの内部実装 でも、このパターンが使われています。よければ参考にしてみてください。 エラーハンドリング Rustは基本的に、 f() -> Result<T, E> 型でエラーを伴う処理を表します。 T が成功した場合の値, E がエラーだった場合の型です。 Result<T, E> 型は、パターンマッチで T と E のどちらに値が入っているのか判定できるので、以下のように使うことができます。 match f() { Err(e) => //エラー処理(eはE型の値), Ok(r) => // 成功したときの処理(rはT型の値), } これを利用してエラーハンドリングすると、以下のような処理を書くことが出来ます。 サンプルコード 1 更にRustでは、 ? オペレーターを利用して以下のように書くことも出来ます。 サンプルコード 2 ? を利用するために下準備で必要となるコード量が多いため、実際には以下のcrateを利用したりして使いやすくすることができます。弊社では errer crateを利用しています。 errer errer_derive OptionとResultに対する処理にcombinatorを使う Option と Result について処理を行う場合、match式で場合分けを行いながら処理を進めることも出来ますが、combinatorを使うと短く書くことができて便利です。 なお、Productionでは、 unwrap() , expect() は処理が失敗した場合にpanicを返すので原則使わない方が良いでしょう。 (テストやサンプルコードで使うのは問題ありません。 また紛らわしいですが、 unwrap_or***() 系のpanicを出さないものは問題なく使えます。) サンプルコード Productionコードでは、 Result を返す小さい関数を、 and_then() などのcombinatorを使って合成し、大きい処理を表したりするのに使います。 また単純なエラーハンドリングの場合は、combinatorで頑張らなくても、上記に挙げた ? オペレーターで処理もできるので、読みやすいように適宜調整すると良さそうです。 Rustでは厳密には Monad はありませんが、考え方は使えるので以下の記事などで少しでも理解しておくと理解しやすいでしょう。 参考: 箱で考えるFunctor、ApplicativeそしてMonad なお弊社では、 anyhow クレート を使用しています。エラーにコンテキスト情報を含める機能 ( with_context ) や便利なマクロ ( bail! など),独自のエラー型などを提供しています。 collect() で Vec<Result > と Result<Vec > を相互に変換できる イテレータ の処理で collect() を実行し、返り値として Result<Vec< >> がほしいと仮定します。 単純に collect() を実行した結果、 Vec<Result< >> が返り値となった場合でも、その逆の形に簡単に変換することができます。 以下引用 fn main() { // 全てSomeならSome(配列)を返し、どれかがNoneなら全体もNoneになる assert_eq!([Some(1), Some(2)].iter().cloned().collect::<Option<Vec<_>>>(), Some(vec![1, 2])); assert_eq!([None, Some(2)].iter().cloned().collect::<Option<Vec<_>>>(), None); } 引用元: RustでOptionやResultの配列ができてしまったときの一般的なテク4つ このテクニックを知らないままVecの複雑な処理に直面すると絶望的な気持ちになるので、すぐには使う場面がなくても、「こんなことができるんだな」くらいに覚えておく価値はあると思います。 _ の表す意味 変数名に使う => 変数が未使用であることを宣言する 型の一部として使う => 型推論 してねとRustにお願いする サンプルコード コードの公開範囲(public/private) Rustで定義したものはデフォルトでprivateで定義されます。定義されたモジュールの外で利用する場合は pub キーワードで公開することを宣言する必要があります。 サンプルコード また、pub(crate)などと指定することにより、公開する範囲を限定することができます。 参考: Visibility and Privacy 注意点 タプルも同様に、デフォルトの公開範囲はprivateです。 // Sampleタプルは外に公開されている // この場合、タプル内部のStringは非公開 pub Sample(String); // このように内部にpubをつけることで公開すことができる pub Sample(pub String); PRを出す前にやっておきたいcargoコマンド プッシュする前に、下記のコマンドを実行し、エラーがないことを確認しておきましょう。 cargo build アプリケーションをビルド cargo test テストコードを実行 cargo clippy Linterで構文チェック rust-lang/rust-clippy cargo fmt formatterにかける rust-lang/rustfmt 副作用のある処理をMock化して、実装を切り替えられるようにする 弊社では、 ビジネスロジック ( ドメイン 層など)などは、クリーン アーキテクチャ で言う外側の層に影響されないように記述しています。 例えば、 ビジネスロジック の 単体テスト を行うのに、DBや API など外部のシステムと連携したテストを作成するのは、環境構築等色々な前工程を行う必要が生じるため辛いことになります。 このため、外部リソースを使って計算を行うロジックはロジック部分と外部の連携部分を切り分けたくなります。 以下のサンプルコードでは、実際の ビジネスロジック は、その外部リソースを扱う処理に直接依存するのではなく、「外部リソースの扱い方を定義したtraitに依存するように記述する」ことで処理の分離を実現しています。 サンプルコード turbofish(::<>) turbofishとは型注釈の一種で、型を引数のように関数に対して与える表現方法です。 例えば、strに対する parse() メソッドの型定義は以下の通りです。 pub fn parse<F>(&self) -> Result<F, <F as FromStr>::Err> where F: FromStr, 出所: Primitive Type str ここで F は、 FromStr を実装している型となるように抽象化されています。 つまり、実際に使う時には コンパイラ が F の型を推定できないと、 str 型を何に変換すればいいか特定できないでしょう。 この時、推定できるように記述する方法が2つあります。 // 1. 型が決まるように束縛するxに対して型注釈をつける // なお、xの型注釈は Result<i32, _> のように省略可能 // => 理由は以下のturbofishの例に記述 let x: Result<i32, ParseIntError> = "10".parse() // 2. turbofishを使う // この際turbofishとしてはi32になることが特定できれば // エラー型はFromStrの定義からError型の具体型が特定できる let x = "10".parse::<i32>() 参考: Rustのturbofishを理解する (おまけ) cargoの独自コマンドを作る cargo bookに、「$PATHに cargo-XXX というバイナリが入っていたら cargo XXX でcargoのサブコマンドのように実行できる」との記述がありますが、これはバイナリに限った話ではなく実行権限がついていれば大丈夫です。なので、簡単なshellを登録しておいてcargoから実行することも可能です。 (例) #!/bin/sh rg -l todo: 上記のshellを$PATHの通る場所に置いておくと、 cargo todolist で todo: のあるファイルを探してくれます。出力がPATHになるので、弊社では、 VSCode のターミナルで実行 -> PathをCtrl+クリックで該当ファイルに飛ぶなどに利用している人もいます。 shell自体をPATHにあるところに置いて呼び出せばいいじゃんという声が聞こえてきそうですが、 cargo --list で利用できるサブコマンドの一覧を取れるところが強みです。 いかがでしたでしょうか? Rustはすらすら書けるようになるまでが難しい言語だと思います。私の場合は、Rustを書き始めて2ヶ月くらいの間は、他の言語ではスラスラと書けたようなロジックでも全然書けなくて、とても悲しい気持ちになりました。 ですが、書けるようになってみるとやっぱり良いところもたくさんありますし、勉強すればするほどその強力さがわかって楽しくなってくるなと感じています。 最後に、弊社でRustを長く書いているエンジニアに勉強のコツを聞いてみたのですが、「 The Book は論理から非常によくまとまっているため、何度も読むと良い」というアド バイス をいただきました。本記事のようなテクニック的なところではなく、Rustのコード自体読んでもよくわからないと言う方は、 The Book を読み直すと良いかもしれません。何度読んでも学びがある内容なので、損はないと思います。 この記事を読んでいる皆様が、Rustを楽しんでくれることを願っております! We’re hiring!!! キャディでは、エンジニアを含め全職種積極採用中です! Rustを使って開発がしたい方、会社に興味を持ってくださった方、気になるから話を聞いてみたいという方、ぜひ面談にお越しください。 弊社がRustを採用している背景や、実際に開発してみてのメリットデメリットなどは、 「Rust についてカジュアル面談で頻繁に訊かれる質問と、それに対する個人的な回答」 をご参考ください。 ご応募は こちら カジュアル面談のお申し込みは こちら 募集職種一覧 長文お読みいただき、ありがとうございました!
アバター
エンジニアリングマネージャーの村上 (@mura_mi) です。採用関連で面談に出ることが多いのですが、大体7割くらいの確率で 「なんで Rust 使ってるのですか?」「Rust 使っててどうですか」と聞かれるので先回りして書いておこうと思った記事です。 なんで Rust を選んだの Rust をエンジニアリングチームの武器の中心に据える意思決定がされたのは私の入社前ですが、伝え聞いている話しと自分の解釈を混ぜ合わせた話を書きます。 「データ指向アプリケーションを堅牢に作るのに必要な型システムを求めたこと」と、「キャディがもともと C++ の会社だったこと」の2つが、キャディが Rust を使っていることの背景にあったのだと理解しています。 後述しますが、キャディが 原価計算システム やサプライチェーン・マネジメントシステム を Rust を使って開発しはじめたのは、2019年の中頃だったと伝え聞いています。これらのシステムは、「キャディがどんなものを、いくら費やして、どのように製作し、いくらで販売するか」という、キャディのビジネスの根幹となるデータを扱った データ指向アプリケーション です。 扱うデータの複雑さや、常に変化の可能性の下にあるビジネスルールに立ち向かう手段はいくつか考えられるでしょうが、データストアを担う物理層だけでなく、アプリケーション層でデータ形式の制限をすることは常套手段のひとつでしょう。 静的型付けのないプログラミング言語を否定するつもりは毛頭ありませんが、具体的な技術選定に口出しをしない CTO も、”テストをたくさん書くことに時間を使わず、型を書いてバリエーションを自明にしてラクをしよう” とは, しばしば口にします。 ではなぜ、そこで Rust なのか?選択肢に挙がった言語たちをなぜ見送ったのかの具体的な理由を記述することは割愛しますが、選択肢の中に Rust があった背景には、CADDi がもともと C++ の会社だったことが挙げられます。 「CADDi」という社名の由来のひとつには “CAD から Direct” という意味がありました。この記事の執筆時点では、キャディがお客様からいただく設計図の情報は二次元図面がほとんどですが、創業当初のキャディのビジネスの中心にはCADデータの自動解析アルゴリズムがありました。この頃 CAD データの解析に使っていたのが C++ で、江添亮さんには一時期テクニカルアドバイザーになってもらっていた事もありました。 C++ のプログラマから見て、パフォーマンスを犠牲にせずに安全性を手に入れることができるのが Rust です。当時社内で Rust を推した一人である いなむさんのnote を読むと、少しだけ当時の雰囲気が垣間見えるかもしれません。 数多のテック企業が採用候補者を奪いあう戦国時代の中で、採用市場で「え、Rust を業務システムに使っているの?」でアテンションを惹けることは、キャディにとって良い副産物 でした。そもそも C++ と一口に言っても C++17 を 2018 年の段階で実戦投入したりしていた というのもあり、最新の技術を使っている組織だというイメージは強化されたのかなと思います。そういえば、筆者自身も「は?Rust でサービス作るの?」と釣り針に引っかかってカジュアル面談の話を聞きに行った一人でした。 Rust の用途とメリット 主に3つのエリアで使っていたり、使おうとしています。 1つは前述した、原価計算システムやサプライチェーン管理を行う「基幹システム」の開発です。 tonic (一部、まだ tower-grpc を使ってる部分もあります…😫 ) を用いて gRPC API サーバーを建てています。リレーショナルデータベースを接続してのデータ CRUD には、 diesel 上に構築した、データレコードの更新履歴も保存するフレームワークを独自に開発して利用しています。 ブラウザからユーザーがアクセスしたり、RDB のみならず Redis やメッセージキューなど多種多様なミドルウェアと接続されたシステムなので日々様々なトラブルは起きるのですが、Rust 自体が不明瞭な挙動を引き起こしたり、パフォーマンス劣化に悩まされる事象は見た記憶がありません。 (ビジネスルールの設計や実装に起因する問題だったり、データベースに異常な負荷を掛けてしまうケースが多い) 2つ目は、現在のキャディのビジネスの中心を流れる 「二次元図面」の解析を行うアルゴリズム の開発です。これに関しては、図面解析チーム (orama) のテックリードを務める寺田の記事 がとても良いので読んでほしい… のですが、Rust に関して彼の言っていることの抜粋が以下です。 Rust は目新しさがあるかもしれません。Rust は安全性とスピードを兼ね備えているので、パフォーマンスが求められるアルゴリズムの開発には本当に適していると実感しています。Python + OpenCV だけで賄えない理由は、ベクターデータの処理が必要になるからです。上で紹介した表の罫線認識では、まず画像をベクターデータに変換してから様々なアルゴリズムを組み込んでいます。ベクター化アルゴリズムを含めた各種のアルゴリズムの実装に、Rust が活躍しています それ以外に、まだ実戦投入していませんが、WASM を用いたブラウザアプリケーションの開発に利用できないかとフロントエンドエンジニア陣が試行錯誤をしています。ブラウザ上で2次元図面のデータを表示したり、その図面への注記の追加をブラウザ上でできるようにする (社内では 図面版Figma と呼ばれていたりします) ようなアプリケーションの開発が視野にあるのですが、このようなアプリケーションの開発にはどうしてもパフォーマンスを追求したくなるケースが出てくると思っており、そのときに備えて様々な技術調査をしています。 Rust を仕事で使って、「頑張らないといけない」ところ 現場で Rust をこれからも使い続けていくに際し、組織的に頑張らないといけない点もいくつかあります。 まず思い当たるのが ビルドや CI に時間が掛かる点。開発者にパワフルなマシンを貸与するという “札束勝負” もしつつ、並行ビルドが効くような工夫 も頑張っています。 独特な書き味であったり、ライフタイム、所有権など他の言語では馴染みのない概念も多く、新規参入メンバーに各種知識のキャッチアップをしてもらうのはどうしても大変です。 しかし、私達のチームは「誰もが Rust のキャッチアップを頑張る道を通った経験がある」ことが強みであると思っていて、より一層「学習の高速道路」を整備しなきゃいけないなと思っています。 言語仕様や性質から来る制約という観点では、外部通信のような比較的大きい副作用が絡むテストに於いて テストダブルを差し込むことは可能なのですが、かなりの労力が必要になる印象を持っています。これは、Rust の場合コンパイル時に Dependency Injection される具体的なデータ型が指定されている必要があることに由来しています。 (キャディでは CakePattern を使った DI を実装しています) 同様の理由で、The Clean Architecture の「domain のレイヤをピュアに保つ」という方針を徹底しきれないな、と思うこともしばしばあります。 とはいえ、長所と短所 を天秤に乗せても、ビジネスの基幹となる情報システムを、硬く・速く動くようにするという意味では良い選択肢だと思っているので、もっとうまく使いたいと思っているところです。 キャディの人って元から Rust 書ける人ばっかりなんですか? 否。 入社前から Rust を経験していたメンバーもいますが、 「がっつりチームで Rust 使った開発してた!」「ウェブアプリケーションの API サーバーを Rust で開発・運用していた」という人はいない はずです。皆それぞれのバックグラウンドをもって入社しています。先述したように、誰もがキャディに入社してから Rust 周辺についてキャッチアップをしてきた経験値を持っていることが、チームの強みなんじゃないかと思っています。 実際、 採用活動の中で候補者の方とお話する際には、Rust の経験があればもちろん良いのですが、それ以外にどういう経験してきたかを重視しています。サーバーサイドアプリケーションの開発をメインの領域とする自分からすると、しっかりとドメインモデリングに向き合ってきた経験や、他の言語で Clean Architecture をイチから書いたり、理解して使ってきた経験の方が重要だなーと思っています。 一方で、赤裸々なことを書くと、実際に社内の Rust のコードを読んでいても、API の設計をする際に参照と実態の区別をしっかりつけられていない (よって無用な .clone() の必要が生じる) ケースも結構見かけ、まだまだ Rustacean の集団として成熟しないといけないなぁと思うこともあります。 We’re hiring てなわけで、社内で書くコードが Rust オンリーというわけではないけど、キャディは「書きたい」と言えば十中八九 Rust を書ける環境だと思います。そんなCADDi の仲間になってくれる方を募集しています。「すぐ転職する感じじゃないんだけど Rust の話は聞きたい!」「Rust 書いたことないけど興味あります!」みたいな方でもお気軽にカジュアル面談からお申し込みください。 応募はコチラ https://caddi-careers.studio.site/jobs-tech-backend からどうぞ。 The post Rust についてカジュアル面談で頻繁に訊かれる質問と、それに対する個人的な回答 appeared first on CADDi Tech Blog.
アバター
はじめに こんにちは。キャディで原価計算システムの開発を担当しております、高橋です。 この記事は キャディ Advent Calendar 2020 の23日目です。前日は朱さんの 「【開発カルチャー発信 vol.1】原価計算システム開発チームの開発理念を大公開!」でした! さて本日は掲題の通り、私がスキルアップを兼ねて趣味的に取り組んでいる、コストモデル可視化システムの開発について紹介させていただきます。 目次 課題意識 弊社のビジネスの核は、コストモデル コストモデルは、名前の通り「コスト」の計算を「モデル」化したことで、原価計算という作業を弊社内で民主化しました。「正しい原価」を誰でもすばやく計算できるということです。 これが無くては弊社のビジネスがスケールアウトすることは不可能であり、スケールアウトしなければ受発注プラットフォームは作れません。従って、弊社のビジネスの核は1にも2にもコストモデルなのです。 コストモデルは生き物 しかしこの「正しい原価」というのが曲者です。正しいの定義は時々刻々と変わります。従って、コストモデルはこの正しさに常に追従する必要があります。 例えば材料費が高騰したら、原価は変わります。また、弊社が今まで対応していなかった新しい加工方法をコストモデルで取り扱えるように拡張しないと正しい原価が出せない場合もあります。このような事情で、コストモデルは常に改訂・拡張を繰り返しながら、あるべき原価を求めてさまよい続けています。 しかし、コストモデルは見えにくい このように、弊社ビジネスの核でありながらも常に動き続けるコストモデルですが、現状はRustのソースコードで実装されています。従って、コストモデルを常日頃からメンテナンスしている立場でない限り、具体的にコストモデルの定義がどうなっているのか把握しにくいというデメリットがあります。 コストモデルを作るのではなく扱う立場であっても、コストモデルが前提とするコストの構造・概念を理解していることは重要なのですが、ここで「コストモデルが見えにくい」ということが障壁になっていると私は考えています。 コストモデルをどうやって可視化するか 可視化したいものがコードの中にしか無い 上記の通り、現状ではコストモデルの定義はRustでハードコーディングされています。 こんな感じのものがたくさんコーディングされています。 コストモデルの定義の例(体積計算): 原価 = 加工時間 × 時間当たり原価 加工時間 = 加工工程 XXX の時間 + 加工工程 YYY の時間 時間辺り原価 = とある定数 加工工程 XXX の時間 = XXX加工単体の時間 × XXX加工の数 加工工程 YYY の時間 = XXX加工単体の時間 × XXX加工の数の2倍 抽象的に書いていますが、例えば穴あけ加工が1個1分かかってそれが2箇所、1分あたり100円なら、概算で200円、みたいなことを考えるとわかると思います(実際にはこんな単純ではなく、もっと複雑です)。 ここから可視化しようと思うと、Rustのコードをパースしてコストモデルの定義を抽出してくるような仕組みが必要ですが、あまり現実的ではありません。 コストモデルの定義をデータとして分離する コストモデルは数式のグラフ 上記の例から分かるように、コストモデルは数式の集合であり、それらは依存関係を持つことから、数式や定数(引数を持たない数式)をノードとする非循環有向グラフ(DAG)を考えることができます。これを数式グラフと呼ぶことにしましょう。 上記の定義を数式グラフとして表現した例: document.write("graph TD;\n原価-->加工時間;\n原価-->時間当たり原価;\n加工時間-->加工工程XXXの時間;\n加工時間-->加工工程YYYの時間;\n加工工程XXXの時間-->XXX加工単体の時間;\n加工工程XXXの時間-->XXX加工の数;\n加工工程YYYの時間-->XXX加工単体の時間;\n加工工程YYYの時間-->XXX加工の数;\n"); 例えば上図のように表現された数式グラフを計算する場合は、依存の階層の一番下から順番に計算していくと、最終的に原価を求めることができます。コストモデルの定義がどのようなものであれ、計算可能な関数と依存関係の集合であれば可能なことです。 グラフ構造をデータとして計算処理から分離できる さてこのように考えていくと、コストモデルの定義は数式グラフの中にしか登場せず、数式グラフの計算処理には登場しません。したがって、現状のようにプログラム中にコストモデルの定義をハードコーディングせずに、どこかにデータとして保存された数式グラフを計算実行処理に外側から注入して結果を得る、というやり方ができそうです。要は、コストモデルの定義を数式のグラフ構造のデータとして、計算処理から切り離すということです。 このやり方であれば、グラフ構造の保存・編集・可視化ができれば、コストモデルを可視化できると言えそうです。Rustのプログラムをパースしてコストモデルの定義を解析するよりも、大分現実味があります。 技術選定とシステム構成 上記のやり方を試すために、Frontend, backend, db を直列につないだ極めてシンプルな構成でシステムを組んでみることにしました。 component 仕様技術、ライブラリなど 役割 Frontend React + Typescript , G6.js, typed-rest-client 数式グラフと計算結果の表示 Backend Java + Spring boot + Spring Data Neo4j, mxParser 数式グラフのロードと計算 DB Neo4j 数式グラフの保存 まず、数式グラフを保存する手立てとして、グラフ構造をそのまま扱えるグラフ指向データベースを使うことにしました。とりあえず今回は、一番有名っぽいNeo4jにしました。 すると、Neo4jとエンティティ定義のマッパー(ObjectGraphMapping)に対応したフレームワークであるSpring Data Neo4j を使うのが一番楽につくれそうなので、バックエンドはJavaに決定。 バックエンドでは数式グラフを計算するので、Neo4jに文字列として保存された数式(簡単のために算術演算に限定)を動的にパースして計算する処理が必要になるのですが、そのようなライブラリをJavaから探した結果、mxParserというのがあるので使ってみました。「キャディのエンジニアなんだからパーサーくらい自分で書け」と言われそうですが、今はサクサク作って動かしたいので、あるものは最大限活用します。 UIは、業務上のスキルアップも兼ねてReact + Typescript を使うことにしました。グラフ構造を描画するUIライブラリとしては、G6.js を使ってみました。弊社で使用実績はなさそうなものの、MITライセンス・機能が豊富・公式ドキュメントが充実の3点で決めました。バックエンドのAPIを叩くクライアントは、本家が作ってて信頼できそうなのでtyped-rest-clientに(適当)。 実装 今回は簡単のため、UIからのコストモデルへのパラメータの入力は受け付けないものとします。 また、コストモデルの編集は実装が多いので、ここでは割愛します。 データベースに予め数式の定義と入力が保存されている状況から、計算と表示ができるところまでを紹介します。 特に難しいことはしていないので、同じものはこの記事を読みながらどなたでも作れると思います。 Backend ここでは、この記事のために「数式グラフのロード」「数式グラフの計算」の2つのAPIを用意してみます。 Entity定義 ノードに数式をもたせて、ノード間のエッジに、数式同士の依存関係と、依存先の数式が対応する依存元の数式中の変数名を保持させます。 SpringDataNeo4j のおかげで、クラスにアノテーションを付けるだけでグラフの構成要素として設定できます。 @NodeEntity public class ExprNode { @Id @GeneratedValue public Long id; public String name; public String expr; @Relationship(type = "SUBEXPR", direction = Relationship.OUTGOING) public ArrayList subExpressions = new ArrayList (); // 依存先の数式を定義する関数 // 第二引数で、数式中のどの変数に第一引数の数式の評価結果を割り当てるかを // 指定している public boolean setSubexpr(ExprNode node, String token) { Expression expr = new Expression(this.expr); this.subExpressions.add(new Edge(this, node, token)); } // 自身の値を計算する関数。 // 関数の引数の値が別の関数で求まる場合、再帰的に潜っていって計算する。 // Expression は mxParser が提供する数式型。 public Double evaluate() { // ここで文字列の数式をパースして関数を生成すると同時に、関数の引数に依存先の関数の評価結果を割り当てる Expression expression = new Expression(this.expr, this.subExpressions.stream() .map(edge -> new Argument(edge.startToken, edge.end.evaluate())).toArray(Argument[]::new)); return expression.calculate(); } } @RelationshipEntity(type = "SUBEXPR") public class Edge { @Id @GeneratedValue public Long id; @StartNode public ExprNode start; @EndNode public ExprNode end; // startnode の数式のどの 変数名に endnodeが対応するかを保存するフィールド public String startToken; } Repository Neo4jにアクセスするインターフェースを定義し、数式グラフをロードしてくる関数を定義します。 これもSpring DataNeo4jの恩恵を受けることが出来て、interface さえ定義すれば実装はSpringが勝手に作ってくれます。 public interface ExpressionRepository extends Neo4jRepository { // cyper query を直接書くこともできる @Query("MATCH p=(n:ExprNode)-[:SUBEXPR *]->(:ExprNode) RETURN nodes(p), relationships(p)") ArrayList getAllExprNodesWithEdges(); // query を書かない場合、実装は関数名から自動で定義される // デフォルトではグラフの深さ1までしか取ってきてくれないので、アノテーションで指定する @Depth(value=4) Option findByName(String name); } Service 作りたいAPIには、DBへの数式グラフのシード、数式グラフの取得、数式グラフ計算の3つの処理が必要なので、それをここで用意します。 @Service @Transactional @EnableNeo4jRepositories(basePackageClasses=ExpressionRepository.class) public class ExpressionService{ @Autowired ExpressionRepository expressionRepository; // 数式全体の取得 // 戻り値は適当に用意したResponse型 public ExpressionsResponse getAllExpressions() { ArrayList nodes = expressionRepository.getAllExprNodesWithEdges(); ArrayList nodeResponses = nodes .stream() .map(node -> { return new NodeResponse(node.id, node.name, node.expr); }).collect(Collectors.toCollection(ArrayList::new)); ArrayList edgeResponses = nodes .stream() .flatMap(node -> { return node .subExpressions .stream() .map(edge->{ return new EdgeResponse(edge.id, edge.start.id, edge.end.id, edge.startToken); }); }).collect(Collectors.toCollection(ArrayList::new)); return new ExpressionsResponse(nodeResponses, edgeResponses); } // 架空の体積計算を表す数式グラフを保存してみる public String seedExpressions() { // 数式ノード定義 ExprNode volume = new ExprNode("volume", "x * y * z"); ExprNode x = new ExprNode("x", "10"); ExprNode y = new ExprNode("y", "10"); ExprNode z = new ExprNode("z", "a + b"); ExprNode a = new ExprNode("a", "20"); // 定数 ExprNode b = new ExprNode("b", "30"); // 定数 // 数式中の変数に別の数式を割り当てる。 volume.setSubexpr(x, "x"); volume.setSubexpr(y, "y"); volume.setSubexpr(z, "z"); z.setSubexpr(a, "a"); z.setSubexpr(b, "b"); expressionRepository.save(volume); return volume.name; } // 数式グラフ中の指定した数式ノードの値を計算する public double calculateExpression(String name){ ExprNode rootNode = expressionRepository .findByName(name) .orElseThrow(() -> new RuntimeException()); return rootNode.evaluate(); } } Controller RestAPI経由でそれぞれのビジネスロジックを呼んでResponseを返すようにします。 @RestController @RequestMapping("/") // UI は yarn start で立てるので、そのアドレスをcorsに設定 @CrossOrigin(origins = "http://localhost:3000") @ResponseBody public class Controller { @Autowired ExpressionService expressionService; // 数式グラフをシードしてロード @RequestMapping(value = "/expressions", method = RequestMethod.GET) public ExpressionsResponse readExpressions(@PathVariable String version) { expressionService.seedExpressions(version); return expressionService.getAllExpressions(version); } // 数式グラフの計算 @RequestMapping(value = "/calculate", method = RequestMethod.GET) public String calculateSeededNode(@PathVariable String version) { // DB初期化 String rootNodeName = expressionService.seedExpressions(version); return String.format("calclation finished: %f", expressionService.calculateExpression(rootNodeName, version)); } }; UI こんな感じで、Reactのコンポーネント内でG6.jsのグラフオブジェクトを初期化した後、バックエンドからグラフ取得したグラフのデータを流し込んで描画します。(長いので一部省略しています) G6.js は pureJS ライブラリなので、Reactに組み込むのがちょっと手間です。公式のサンプルを元に実装しましたが、そのままだと警告が出るので一部手を加えました。 function GraphView() { const ref = React.useRef(null); const graph = React.useRef (null); useEffect(() => { if (!graph.current) { graph.current = new G6.Graph({ container: ReactDOM.findDOMNode(ref.current) as HTMLElement, layout: { type: 'dagre', // 有向グラフを階層的にレイアウトするためのアルゴリズム rankdir: 'LR', // レイアウトの向きを指定 ranksep: 70, // レイアウト方向のノード間隔を指定 }, // グラフ上で描画されるノードのデフォルト設定 defaultNode: { type: 'modelRect', anchorPoints: [ [0, 0.5], // source [1, 0.5], // target ], // ...その他設定は省略 }, // グラフ上で描画されるエッジのデフォルト設定 defaultEdge: { // ノードのアンカーポイントのどれとどれをつなぐかを // defaultNode の anchorPointsのindex指定で設定 sourceAnchor: 1, targetAnchor: 0, }, }); } let rest: trc.RestClient = new trc.RestClient('test', 'http://localhost:8080/'); rest .get (`expressions`) .then((res: trc.IRestResponse ) => { let data: GraphData = { nodes: res.result!.nodes.map(/* G6.js のノードの形式に変換*/), edges: res.result!.edges.map(/* G6.js のエッジの形式に変換*/), }; graph.current!.data(data); graph.current!.render(); }); return () => { graph.current!.destroy(); graph.current = null; } }, []); return ; } 起動 以上作ったものをローカルで起動させてみました。 まず、DBは公式ドキュメントに従うとDockerコマンド一発で立ち上がります。 $ docker run -p7474:7474 -p7687:7687 -e NEO4J_AUTH=neo4j/s3cr3t neo4j backend と Frontend はそれぞれ、gradle bootRun, yarn start として起動しました。 起動してみるとこんな感じです。簡素な画面ではありますが、数式のグラフを可視化できています。 上のコードでは省略していますが、G6.jsのプラグインで、画面左上に計算処理を叩くボタンを用意して、押すと計算結果を alertするようにしてみました。 volume = 10 * 10 * (20 + 30) = 5000 なので、確かにちゃんと計算できています。 これで一応、数式グラフの保存・ロード・表示まではできたことになります。 今後はもう少し作り込んで、あわよくば仕事につながったら面白そうに思っています。 おわりに 今回は、コストモデルの背景や課題に加え、それをReactとNeo4jを用いて可視化する試みをご紹介しました。 私は元々CADアルゴリズムグループとして採用されたので、Webエンジニアは初めてまだ1年程です。しかし、この記事のような小さなシステムを自分で一から一通り作ってみると、中々いい勉強になりました。 また、ここに書いた実装は私1人では達成し得なかったことで、色々な方のアドバイスを経ながらできたものです。弊社にはこのように一緒に技術を楽しんでくれるメンバーが揃っておりますので、興味を持っていただいた方は、ぜひご連絡いただければと思います。 The post React + Neo4j によるコストモデル可視化の取り組み紹介 appeared first on CADDi Tech Blog.
アバター
こんにちは。CADDi でバックエンドエンジニアをしている 高藤 です。 この記事は CADDi Advent Calendar 21日目の記事です。昨日は、寺田さんによる RustでRAMの動作原理をシミュレートする でした! 今回はRustのtracintg crateについて紹介したいと思います。 目次 はじめに キャディではバックエンドのAPIをgRPCを使って実装しています。 実装にはtonicというRustでは比較的新しいcrateを使っています。使いやすいこともあり比較的使ってみたなどの記事は散見されるのですが、今回は本番環境で運用するのに大事なloggingの観点で説明をしたいと思っています。 gRPCサーバの実装 まずはtonicのサンプルを紹介します。以下のコードはtonic にあるexamples/src/helloworld/server.rsをもとに説明を行います。 まずコードを見てみましょう。非常にシンプルなサーバです。Requestに名前を含めると返事をしてくれるそれだけのサーバですが、まずはこのコードを使っていくつか検証をして行こうと思います。 use tonic::{transport::Server, Request, Response, Status}; use hello_world::greeter_server::{Greeter, GreeterServer}; use hello_world::{HelloReply, HelloRequest}; pub mod hello_world { tonic::include_proto!("helloworld"); } #[derive(Default)] pub struct MyGreeter {} #[tonic::async_trait] impl Greeter for MyGreeter { async fn say_hello( &self, request: Request , ) -> Result , Status> { println!("Got a request from {:?}", request.remote_addr()); let reply = hello_world::HelloReply { message: format!("Hello {}!", request.into_inner().name), }; Ok(Response::new(reply)) } } #[tokio::main] async fn main() -> Result > { let addr = "[::1]:50051".parse().unwrap(); let greeter = MyGreeter::default(); println!("GreeterServer listening on {}", addr); Server::builder() .add_service(GreeterServer::new(greeter)) .serve(addr) .await?; Ok(()) } 挙動を確認するためにこちらのServerをまずは実行してみます。 ❯ cargo run --bin helloworld-server` サーバを起動すると GreeterServer listening on [::1]:50051 このように表示されてサーバが起動します。 このままRequestを送るためgrpcurlを使って見ます ❯ grpcurl -plaintext -d '{"name": "foo"}' -proto proto/helloworld/helloworld.proto -import-path ./proto localhost:50051 helloworld.Greeter/SayHello { "message": "Hello foo!" } サーバ側 Got a request from Some([::1]:58752) ちゃんと動いていますね。 あくまでサンプルですが、このコードにDBとの接続処理やロジックを記述していくことでサービスを提供できそうです。ですが、本番でちゃんと運用するにはログをちゃんと出力しないと難しいです。 上記の例では標準出力にprintln!を使ってRequestが来たことは出力されていますがtimestampもなくいつ処理されたものなのかもわかりません。 本番環境での運用を考えてgRPCサーバのloggingについて考えて見ようと思います。 env_loggerの利用 Rustではログの出力を行うためログ出力機能が抽象化されたlogcrateとその実装crateが存在しています。crates.ioでも上位にあるenv_loggerを使ってログの出力を行ってみます。 まず、Cargo.tomlに対して依存するcrateの追加を行います。dependenciesに以下の2つのcrateを追加します。 env_logger = "0.8.2" log = "0.4.11" env_loggerの初期化 main関数部分でenv_loggerの初期化処理を追加します。あわせてprintln!を使って標準出力を行っている部分をlog::info!に書き換えて見ましょう #[tokio::main] async fn main() -> Result > { // ここを追加 env_logger::init(); let addr = "[::1]:50051".parse().unwrap(); let greeter = MyGreeter::default(); log::info!!("GreeterServer listening on {}", addr); Server::builder() .add_service(GreeterServer::new(greeter)) .serve(addr) .await?; Ok(()) } こちらを改めて起動して確認をします。 起動直後 [2020-11-06T16:25:56Z INFO helloworld_server] GreeterServer listening on [::1]:50051 Requestの送信時 [2020-11-06T16:26:43Z INFO helloworld_server] Got a request from Some([::1]:34238) 無事timestampやlogレベルをあわせて出力することが出来ました。 ただし、これだけだと処理の内容がわからずlogを出力する意味があまりない状態なので処理の終了時にRequestの内容と処理が終わった旨を出力するように修正してみます。 #[tonic::async_trait] impl Greeter for MyGreeter { async fn say_hello( &self, request: Request , ) -> Result , Status> { let reply = hello_world::HelloReply { message: format!("Hello {}!", request.get_ref().name), }; log::info!("Request: {:?}, Done", request); Ok(Response::new(reply)) } } 実行結果 [2020-11-06T16:35:39Z INFO helloworld_server] GreeterServer listening on [::1]:50051 [2020-11-06T16:35:40Z INFO helloworld_server] Request: Request { metadata: MetadataMap { headers: {"content-type": "application/grpc", "user-agent": "grpc-go/1.30.0", "te": "trailers"} }, message: HelloRequest { name: "foo" }, extensions: Extensions }, Done これで意図したとおりに処理が終わった旨の出力とRequestの内容が表示されるようになりました。 もう少し実用的なアプリケーションを想定して処理を追加してみます 冒頭のimport宣言にCodeを追加します。 use tonic::{transport::Server, Code, Request, Response, Status}; 何かしらの処理を行う関数some_logic()の追加とそれを利用するようにsay_hello()メソッドの修正を行います。 #[tonic::async_trait] impl Greeter for MyGreeter { async fn say_hello( &self, request: Request , ) -> Result , Status> { let reply = hello_world::HelloReply { message: format!("Hello {}!", some_logic(&request.get_ref().name).await?), }; log::info!("Request: {:?}, Done", request); Ok(Response::new(reply)) } } async fn some_logic(name: &str) -> Result { log::info!("run some logic"); match name { "foo" => { log::error!("Failed some_logic"); Err(Status::new(Code::InvalidArgument, "who is foo")) } _ => Ok(name.to_string()), } } Requestに含む名前によってはエラーを出力するように修正を行いました。サーバを起動し、先程と同様にgrpcurlでRequestを投げると以下のような出力を得ることが出来ます。 [2020-11-07T00:39:25Z INFO helloworld_server] GreeterServer listening on [::1]:50051 [2020-11-07T00:39:36Z INFO helloworld_server] run some logic [2020-11-07T00:39:36Z ERROR helloworld_server] Failed some_logic 想定している通り失敗した時にERRORログが出力されることが確認できました。 しかしこの方法だと問題があります。 ERRORログにRequestの情報がないので複数のRequestを受けている時にどのRequestがエラーになったのか判断出来ない 今回の処理は全てasync fnにより非同期に実行されるため、ログの出力に1つのRequestからなる処理の内容がが混ざって表示される 愚直に問題を解決させるなら、RequestやRequestヘッダーにRequesを識別できるIdを含めてそれをsome_logic()関数に渡すことで解消はできます。 async fn some_logic(request_id: RequestId, name: &str) -> Result ただしこのやり方では更にlogicが複雑になったときなどに全てのlogicに対してRequestや識別子を持ち回す事を行わないと実現することが出来ません。 このような問題を解決するためにtracing crateを利用することが出来ます。 tracing crateの利用 Cargo.tomlにはすでにtracingの依存が含まれている状態なので、修正はserver.rsのみとなります。 #[derive(Default, Debug)] pub struct MyGreeter {} #[tonic::async_trait] impl Greeter for MyGreeter { #[tracing::instrument] async fn say_hello( &self, request: Request , ) -> Result , Status> { let reply = hello_world::HelloReply { message: format!("Hello {}!", some_logic(&request.get_ref().name).await?), }; tracing::info!("Done"); Ok(Response::new(reply)) } } async fn some_logic(name: &str) -> Result { log::info!("run some logic"); match name { "foo" => { tracing::error!("Failed some_logic"); Err(Status::new(Code::InvalidArgument, "who is foo")) } _ => Ok(name.to_string()), } } #[tokio::main] async fn main() -> Result > { tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) .init(); let addr = "[::1]:50051".parse().unwrap(); let greeter = MyGreeter::default(); log::info!("GreeterServer listening on {}", addr); Server::builder() .trace_fn(|_| tracing::info_span!("gRPC server")) .add_service(GreeterServer::new(greeter)) .serve(addr) .await?; Ok(()) } いくつかの修正を行っているため、修正点を列挙します。 MyGreeterにDebug traitを実装 say_helloメソッドに#[tracing::instrument]を追加 log::info, log::errorとしている部分をそれぞれtracing::info, tracing::errorとなるように修正 main関数で初期化していたenv_loggerの初期化処理を削除し、tracing_subscriberの初期化処理を追加 このコードを実行し先程のエラーが起きるRequestを送信するとログの出力が以下のようになります Nov 07 09:57:42.891 INFO helloworld_server: GreeterServer listening on [::1]:50051 Nov 07 10:02:51.585 INFO gRPC server:say_hello{self=MyGreeter request=Request { metadata: MetadataMap { headers: {"content-type": "application/grpc", "user-agent": "grpc-go/1.30.0", "te": "trailers"} }, message: HelloRequest { name: "foo" }, extensions: Extensions }}: helloworld_server: run some logic Nov 07 10:02:51.585 ERROR gRPC server:say_hello{self=MyGreeter request=Request { metadata: MetadataMap { headers: {"content-type": "application/grpc", "user-agent": "grpc-go/1.30.0", "te": "trailers"} }, message: HelloRequest { name: "foo" }, extensions: Extensions }}: helloworld_server: Failed some_logic 実際にログを出力しているsome_logic()関数内にはRequestの情報は渡していないにも関わらずログの出力にRequestの情報など付与されるようになりました。 どのような仕組みになっているのか少し説明をします。 tracing crateは In−Process Tracing機能を提供するcrateとなります。Microservice等の分散処理システムの文脈ではJaeger、Zipkinを始めとする分散トレーシングという技術を利用してどこのサービスからどのサービスへ通信がされたか、その処理時間はなどメトリクスを取得することが出来ます。tracing crateも同様にプロセス内部の処理を追跡できるような形で記録する仕組みを提供しています。 仕組みを理解する上で重要になるのが以下の3つの要素となります。 Span 処理を記録する期間を表します 名前やあわせて記録しておきたい情報を保持することができる Event Spanに記録するトレースしたい事象を表します 発生した事象を記録したい情報とあわせて保持することが出来ます Subscriber Spanや紐付いたEventを収集するための処理を表します 今回の例を上記3つの要素を明確に使ってsay_hello()メソッドの部分を書き直すと以下のようになります。 async fn say_hello( &self, request: Request , ) -> Result , Status> { let args = format!("{:?}", request); let span = tracing::span!(tracing::Level::INFO, "say_hello", request = args.as_str()); let _enter = span.enter(); let reply = hello_world::HelloReply { message: format!("Hello {}!", some_logic(&request.get_ref().name).await?), }; tracing::event!(tracing::Level::INFO, "Done"); Ok(Response::new(reply)) } 処理の冒頭でSpanを定義します 定義内容 名前: say_hello Spanに含める情報 = RequestをDebug traitを使って文字列にした情報 Span.enter()を行いSpanの中に入る事を表す。(enter()はRAIIガードオブジェクトを返し、DropされたタイミングでSpanを閉じます) event!()マクロを使って記録する内容を記述します。 上記の例からわかるとおり、#[tracing::instrument]の処理ではSpanの定義とSpan::enter()の処理を自動的に生成しています。また、tracing::info!()やtracing::error!()はEventの生成をlog crateと同様のI/Fで定義できるように作られています。 注意公式のドキュメントにも記述されていますが非同期処理内でのSpan::enter()の処理は慎重に利用するか避けることが明記されています。非同期関数の場合は#[tracing::instrument]を使った場合にただしく生成できるとドキュメントに書かれているように#[tracing::instrument]を利用することを推奨します。 tonicとの統合 すでに実装例で示していますが、tonicのServer::Builderにはtrace_fn()メソッドが用意されており、ここでRequest毎のSpanを生成しています。次の例ではRequest全体の情報をSpanに含めず、Request Headerにtrace_idという文字列の情報を出力するように変更しています。(もちろんClient側でRequestする際にIdをヘッダーに入れる必要があります) #[derive(Default, Debug)] pub struct MyGreeter {} #[tonic::async_trait] impl Greeter for MyGreeter { #[tracing::instrument(skip(self, request))] async fn say_hello( &self, request: Request , ) -> Result , Status> { let reply = hello_world::HelloReply { message: format!("Hello {}!", some_logic(&request.get_ref().name).await?), }; tracing::info!("Done"); Ok(Response::new(reply)) } } async fn some_logic(name: &str) -> Result { tracing::info!("run some logic"); match name { "foo" => { tracing::error!("Failed some_logic"); Err(Status::new(Code::InvalidArgument, "who is foo")) } _ => Ok(name.to_string()), } } #[tokio::main] async fn main() -> Result > { tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) .init(); let addr = "[::1]:50051".parse().unwrap(); let greeter = MyGreeter::default(); log::info!("GreeterServer listening on {}", addr); Server::builder() .trace_fn(|header| { let trace_id = header .get("trace_id") .map(|value| value.to_str().unwrap_or("Unknown")) .unwrap_or("Unknown"); tracing::info_span!("gRPC server", trace_id = trace_id) }) .add_service(GreeterServer::new(greeter)) .serve(addr) .await?; Ok(()) } 修正点 #[tracing::instrument(skip(self, request))] instrumentを使ってSpanを生成する場合、引数を全てSpanに含める挙動になりますが、今回はtraice_idのみを出力するために引数をSpanに含めないようにしています trace_fn(|header| ....) trace_fn()メソッドは引数にHeaderMap型をとり、Requestに含まれるHeaderの情報を取得することができます 実行結果 Nov 07 12:02:03.233 INFO helloworld_server: GreeterServer listening on [::1]:50051 Nov 07 12:02:06.875 INFO gRPC server{trace_id="Unknown"}:say_hello: helloworld_server: run some logic Nov 07 12:02:06.875 ERROR gRPC server{trace_id="Unknown"}:say_hello: helloworld_server: Failed some_logic Nov 07 12:02:12.284 INFO gRPC server{trace_id="xxxxxxxxxxxxxxxxxxxx"}:say_hello: helloworld_server: run some logic Nov 07 12:02:12.284 ERROR gRPC server{trace_id="xxxxxxxxxxxxxxxxxxxx"}:say_hello: helloworld_server: Failed some_logic 出力結果にHeaderから取得したtrace_idを含めることができました。 このようにtracing crateを利用することで非同期に実行される処理に対してContextを含めたログの出力を行うことが出来ます。 おわりに 今回取り上げたtracingにはこの処理機構を使って様々な処理を拡張するためのcrateが存在しており1つのecosystemが形成されて来ています。今回は単純にログを出力するだけのFmtSubscriberを利用しましたが、tracing-opentelemetry crateなどを利用すると前述した分散トレーシングシステムに対して出力することも可能です。 こちらは依存するopentelemetry crateの変更が激しく、今回割愛していますが興味がある方は試してみると面白いと思います。私が検証した内容では tracing-opentelemetry = "0.7" opentelemetry-jaeger = "0.7.0" opentelemetry = "0.8" 上記のような依存関係だとうまく実装が出来ましたが、すでにopentelemetry crateは0.10.0がリリースされている状態なので、本番への適用はもう少し様子を見たほうが良いかもしれません。 この記事がどこかの誰かの役に立つ日がくれば幸いです。 The post tracing crateを利用したRustのlogging方法について appeared first on CADDi Tech Blog.
アバター
頭おかしいタイトルですね。何を言っているんだお前は。 本記事は CADDi とは何の関係もありませんし、実用的価値も一切ありません。その点はご了承を。 あ、Rust が分からないからといって帰る必要はありません。この記事はほとんどRustと無関係です。なんらかのプログラム言語に親しんでいる方であれば雰囲気で読める程度の機能しか使っていないのでご安心ください。 nand2tetris 先日、こちらの記事が話題になっていました。Nand2Tetris(コンピュータシステムの理論と実装)でCPUからOSまで一気通貫で作るのが最高に楽しかった話 この記事にあるように、O’Reilly Japan – コンピュータシステムの理論と実装 、またの名を nand2tetris と呼ばれる本があります。NAND素子を出発点として簡単なゲームを作るまで(何故か作るのは名前に反してテトリスではない…)を一気通貫に説明してくれる本です。 上の記事の方は完走されたそうで、すごいですね。私は根気が続かず、途中でやめてしまいました…。お恥ずかしい。 しかしながら、やはりこの本が最高に楽しいのは前半のハードウェアのところではないかと思っています。私は本書に沿って、ハードウェアの動作をシミュレートするプログラムをRustで書いてみました。 やったのはずいぶん昔なのですが、上記の記事で思い出したので紹介してみます。キッカケを作ってくれた記事に感謝です。 NANDとフリップフロップ NAND pub fn nand(a: bool, b: bool) -> bool { !(a && b) } 言わずとしれたNAND素子です。 論理ゲートの入出力は電圧による 0/1 ですから、これを bool 型でシミュレートすることにします。この形式の関数で、2つの入力と1つの出力を持つ論理ゲートがシミュレートできることがわかると思います。 実装には && や ! といった演算子が使われていますね。こういった「高度な」演算子を使うのはここだけで、他の箇所では一切使いません。NAND素子を最もプリミティブな要素として、それを組み合わせて && のような論理演算をシミュレートしていくというのが目的なのですから、こういった演算子を使ってしまっては意味がありません。一方、nand() 関数だけはブラックボックスとして与えられるプリミティブな素子ですから、この実装だけはズルをするしかない、というわけです。 フリップフロップ nand2tetrisの紹介で「NAND素子を出発点として」と書きましたが、実はもうひとつ「フリップフロップ」も所与のものとして与えます。 コンピュータの状態遷移はクロック信号によって駆動されます。従って、コンピュータやそれを構成する部品は、次のようなクロック信号のループに駆動されて動くというモデルで考えていきましょう。 loop { hardware.clock(...); } これを踏まえて、フリップフロップを次のようなコードでモデル化します。 pub struct Flipflop { bit: bool } impl Flipflop { pub fn new() -> Self { Self { bit: false } } pub fn out(&self) -> bool { self.bit } pub fn clock(&mut self, a: bool) { self.bit = a; } } フリップフロップは1bitの状態を持っています。 入力 in が変化しても、すぐには出力 out には反映されず、内部で持っている bit の値を出力し続けます。そしてカシャッと clock が入力されたタイミングで、入力の値が内部に取り込まれます。 これをRustでシミュレートしたものが上記のコードです。out() 関数は単に self.bit を返す関数であり、clock() 関数によって内部状態を入力値に置き換えます。 clock() の実装において、値(状態)の代入という「高度な」操作が使われています。しかしこれ以降、Flipflop の内部以外では一切、mutable な状態変数への代入という操作は行いません。コンピュータは状態遷移機械であり、その「状態」を保持するための最もプリミティブな機構がこのフリップフロップです。それをシミュレートするのが目的ですから、Rust言語が備えている状態保持の機能を使ってしまっては意味がありません。フリップロップだけは、NAND素子と同様にブラックボックスとして与えられるものですから、その実装では「ズル」をしています。しかしこれ以降は、「状態」はすべてフリップフロップを組み合わせて表現していくことになります。 1bit レジスタを作る ではいよいよ、レジスタを作っていきましょう。まずは最も簡単な、1bitだけを保持するレジスタです。こんな形をしています。 フリップフロップと比較すると、load という入力が増えていることが分かります。clock のタイミングで内部の状態が遷移するという点はフリップフロップと同じです。 この構造をRustのコードにすると、次のようになります。 pub struct BitRegister { flipflop: Flipflop } impl BitRegister { pub fn new() -> Self { Self { flipflop: Flipflop::new() } } pub fn out(&self) -> bool { self.flipflop.out() } pub fn clock(&mut self, input: bool, load: bool) { ... } } BitRegister 型は、内部にフリップフロップを1つ保持しています。そして out() はフリップフロップの out() をそのまま返しています。 問題は clock() の実装です。この関数で、input と load という2つの入力に応じて内部の状態が遷移します。 実現したいのは次のような動きです。 impl BitRegister { pub fn clock(&mut self, input: bool, load: bool) { self.flipflop.clock(if load { input } else { self.out() }) } } 要するに load が true の場合のみ input が取り込まれて、load が false の時には状態は遷移しない、というわけですね。 しかし、上記は if 式を利用しています。これはズルです。物理デバイスに if を直接実現するものはありません。ですから、NANDを組み合わせて if に相当する回路を組まなくてはなりません。if すら使ってはいけないプログラミング、相当頭おかしい感じがしますが、やっていきましょう。 1bit レジスタを実現する回路は、下図のようなものです。(※ DFF と書かれているのは Data Flipflop の略で、要するに上で定義した Flipflop 型です。) Mux という素子が登場しています。これは multiplexor と呼ばれる素子で、if に相当する機能を担うものです。Rust で表現すると次のような動作をします。 pub fn mux(a: bool, b: bool, sel: bool) -> bool { if sel { b } else { a } } Mux は a, b, sel の3つの入力を持ち、sel の値に応じて a または b を出力します。上のコードは if を使ってズルをした実装になっていますが、これはあとで直すとして、まずはこの mux() を使って BitRegister::clock() の実装を書き換えてみましょう。 impl BitRegister { pub fn clock(&mut self, input: bool, load: bool) { // self.flipflop.clock(if load { input } else { self.out() }) self.flipflop.clock(mux(self.out(), input, load)) } } 上の回路図と見比べると、きちんと対応していることが分かるでしょう? これで BitRegister から if を取り除くことが出来ました。あとは mux() の実装のズルを取り除いて、全てNANDの組み合わせで実現できれば完了です。 mux() は次のように書き換えることが出来ます。 pub fn mux(a: bool, b: bool, sel: bool) -> bool { // if sel { b } else { a } (a && !sel) || (b && sel) } あとは &&, ||, ! という3つの論理演算子を nand() で表現できればOKです。 どん!答えは下記のとおりです。 pub fn not(a: bool) -> bool { nand(a, a) } pub fn and(a: bool, b: bool) -> bool { not(nand(a, b)) } pub fn or(a: bool, b: bool) -> bool { nand(not(a), not(b)) } pub fn mux(a: bool, b: bool, sel: bool) -> bool { or(and(a, not(sel)), and(b, sel)) } これで1bitのレジスタの完成です。 それにしても load という入力はどう役に立つのでしょうか? それは後ほどのお楽しみ。 16bit レジスタを作る 16bit を 1 word とするレジスタを作りましょう。まず Word を次のように定義しておきます。 pub type Word = [bool; 16]; [bool; 16] というのは長さ16(固定長)のboolの配列型を意味しています。 ところで、「配列」を使うのはズルではないのでしょうか。我々は if や && すら使ってはいけないプログラミングに取り組んでいます。「配列」は使ってはいけない「高度な」機能ではないのでしょうか。 心配は無用です。16本の導線を束にすれば、ハードウェアで [bool; 16] を実現することが出来ます。もちろん可変長の配列を使うことは出来ませんが(ハードウェアで動的に導線が増減したら怖い)、固定長なら問題ありません。 というわけで、16bit レジスタは下記のコードになります。 pub struct Register { bits: [BitRegister; 16] } impl Register { pub fn new() -> Self { Self { bits: [ BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), BitRegister::new(), ] } } pub fn out(&self) -> Word { [ self.bits[ 0].out(), self.bits[ 1].out(), self.bits[ 2].out(), self.bits[ 3].out(), self.bits[ 4].out(), self.bits[ 5].out(), self.bits[ 6].out(), self.bits[ 7].out(), self.bits[ 8].out(), self.bits[ 9].out(), self.bits[10].out(), self.bits[11].out(), self.bits[12].out(), self.bits[13].out(), self.bits[14].out(), self.bits[15].out(), ] } pub fn clock(&mut self, input: Word, load: bool) { self.bits[ 0].clock(input[ 0], load); self.bits[ 1].clock(input[ 1], load); self.bits[ 2].clock(input[ 2], load); self.bits[ 3].clock(input[ 3], load); self.bits[ 4].clock(input[ 4], load); self.bits[ 5].clock(input[ 5], load); self.bits[ 6].clock(input[ 6], load); self.bits[ 7].clock(input[ 7], load); self.bits[ 8].clock(input[ 8], load); self.bits[ 9].clock(input[ 9], load); self.bits[10].clock(input[10], load); self.bits[11].clock(input[11], load); self.bits[12].clock(input[12], load); self.bits[13].clock(input[13], load); self.bits[14].clock(input[14], load); self.bits[15].clock(input[15], load); } } 単に BitRegister を16個並べたものが Register です。動作は上のコードを読めばすぐに分かるでしょう。 それにしても Register::clock() の実装、これはひどいですね。for ループ使えや!と言いたくなります。 が、「ハードウェアに for ループはない!」という強い信念の元(?)、あえてループは使わずに実装しました。こうやってベタッと書いたほうが、回路図が透けて見える気がしませんか? 8ワード(16バイト)のRAMを作る Register を8個並べてRAMを作りましょう。骨組みは下記のようなコードになります。 pub struct RAM8 { registers: [Register; 8] } impl RAM8 { pub fn new() -> Self { Self { registers: [ Register::new(), Register::new(), Register::new(), Register::new(), Register::new(), Register::new(), Register::new(), Register::new(), ]} } pub fn out(&self, address: [bool; 3]) -> Word { ... } pub fn clock(&mut self, address: [bool; 3], input: Word, load: bool) { ... } } Register とよく似ていますが、よく見ると out() と clock() に address: [bool; 3] という引数が新たに加わっています。address は要するに、ポインタです。レジスタは8個ですから、3bit のアドレスで一意に指定することが出来ます。out() は address で指定されたアドレスのレジスタを読み取りますし、clock() は address で指定されたレジスタの値を書き換えるというわけです。 RAM8::out() 実現したいのはこういう動作です。 impl RAM8 { pub fn out(&self, address: [bool; 3]) -> Word { match address { [false, false, false] => self.registers[0].out(), [true, false, false] => self.registers[1].out(), [false, true, false] => self.registers[2].out(), ... [true, true, true ] => self.registers[7].out(), } } } もちろん match 式は「ズル」ですから、これを使わずに論理回路で分岐を実現しなくてはなりません。 1bit レジスタのときには、if 式を mux() に置き換えたのでした。ここでも mux() を組み合わせて拡張していきます。 まず次のような動作をする mux16() というものを作ります。 pub fn mux16(a: Word, b: Word, sel: bool) -> Word { if sel { b } else { a } } ほとんど mux() と同じに見えますが、入出力が bool から Word (16bit)に拡張されていることに注意してください。 これは、次のように mux() をひたすら16個ならべることで実装できます。 pub fn mux16(a: Word, b: Word, sel: bool) -> Word { [ mux(a[ 0], b[ 0], sel), mux(a[ 1], b[ 1], sel), mux(a[ 2], b[ 2], sel), mux(a[ 3], b[ 3], sel), mux(a[ 4], b[ 4], sel), mux(a[ 5], b[ 5], sel), mux(a[ 6], b[ 6], sel), mux(a[ 7], b[ 7], sel), mux(a[ 8], b[ 8], sel), mux(a[ 9], b[ 9], sel), mux(a[10], b[10], sel), mux(a[11], b[11], sel), mux(a[12], b[12], sel), mux(a[13], b[13], sel), mux(a[14], b[14], sel), mux(a[15], b[15], sel), ] } 続いて、次のような動作をする mux4way16() というものを作ります。 pub fn mux4way16(a: Word, b: Word, c: Word, d: Word, sel: [bool; 2]) -> Word { if sel[1] { mux16(c, d, sel[0]) } else { mux16(a, b, sel[0]) } /* 次のコードと等価 match sel { [false, false] => a, [true, false] => b, [false, true ] => c, [true, true ] => d, } */ } これは下記の実装で実現できることがすぐ分かるでしょう。 pub fn mux4way16(a: Word, b: Word, c: Word, d: Word, sel: [bool; 2]) -> Word { mux16(mux16(a, b, sel[0]), mux16(c, d, sel[0]), sel[1]) } 同様にして mux8way16() を作ることが出来ます。 pub fn mux8way16( a: Word, b: Word, c: Word, d: Word, e: Word, f: Word, g: Word, h: Word, sel: [bool; 3] ) -> Word { mux16( mux4way16(a, b, c, d, [sel[0], sel[1]]), mux4way16(e, f, g, h, [sel[0], sel[1]]), sel[2] ) } これを使って、RAM8::out() は次のように実装できます。 impl RAM8 { pub fn out(&self, address: [bool; 3]) -> Word { mux8way16( self.registers[0].out(), self.registers[1].out(), self.registers[2].out(), self.registers[3].out(), self.registers[4].out(), self.registers[5].out(), self.registers[6].out(), self.registers[7].out(), address) } } 以上で、指定された address のレジスタを読み取る回路が作れました。 RAM8::clock() address からの読み取りは出来ましたから、今度は address への書き込みを実装しましょう。実現したいのはこういう動作です。 impl RAM8 { pub fn clock(&mut self, address: [bool; 3], input:Word, load: bool) -> Word { match address { [false, false, false] => self.registers[0].clock(address, input, load), [true, false, false] => self.registers[1].clock(address, input, load), [false, true, false] => self.registers[2].clock(address, input, load), ... [true, true, true ] => self.registers[7].clock(address, input, load), } } } しかし、ちょっとこれは無理があります。このコードは address の値に応じてクロック信号を入力するレジスタを切り替える書き方になっていますが、クロック信号は常に全ての素子に入力し続けなくてはなりません。 ですので、こんなふうな方針に切り替えます。 impl RAM8 { pub fn clock(&mut self, address: [bool; 3], input:Word, load: bool) -> Word { let load8: [bool; 8] = match address { [false, false, false] => [load, false, false, false, false, false, false, false], [true, false, false] => [false, load, false, false, false, false, false, false], [false, true, false] => [false, false, load, false, false, false, false, false], ... [true, true, true ] => [false, false, false, false, false, false, false, load], }; self.registers[0].clock(input, load8[0]); self.registers[1].clock(input, load8[1]); self.registers[2].clock(input, load8[2]); self.registers[3].clock(input, load8[3]); self.registers[4].clock(input, load8[4]); self.registers[5].clock(input, load8[5]); self.registers[6].clock(input, load8[6]); self.registers[7].clock(input, load8[7]); } } 常に全てのレジスタにクロック信号が入力されていることが一目瞭然ですね。input も常に全てのレジスタに入力されています。 ではどうやって指定された address だけに書き込む制御をしているかというと、ここで load ビットが活躍します。指定された address のレジスタだけ load に true を入力することで、この制御をしています。BitRegister で仕込んだ load 入力の伏線を、ようやくここで回収することが出来ました。 では今までと同様に、if や match を使っている箇所(load8 を求めている箇所)を論理回路に置き換えていきましょう。 まず DMux (Demultiplexor)という素子を作ります。これは次のような動作をするものです。 pub fn dmux(input: bool, sel: bool) -> [bool; 2] { match sel { false => [input, false], true => [false, input], } } これは次のような論理回路で実現できます。 pub fn dmux(input: bool, sel: bool) -> [bool; 2] { [and(input, not(sel)), and(input, sel)] } これを組み合わせて、次の動作仕様の dmux4way() dmux8way() を作ります。 pub fn dmux4way(input: bool, sel: [bool; 2]) -> [bool; 4] { match sel { [false, false] => [input, false, false, false], [true, false] => [false, input, false, false], [false, true ] => [false, false, input, false], [true, true ] => [false, false, false, input], } } pub fn dmux8way(input: bool, sel: [bool; 3]) -> [bool; 8] { match sel { [false, false, false] => [input, false, false, false, false, false, false, false], [true, false, false] => [false, input, false, false, false, false, false, false], [false, true, false] => [false, false, input, false, false, false, false, false], ... [true, true, true ] => [false, false, false, false, false, false, false, input], } } これらから match 式を除去して論理回路としてどう実装できるか、考えてみて下さい。 これを使うと、RAM8::clock() は次のように実装できます。 impl RAM8 { pub fn clock(&mut self, address: [bool; 3], input: Word, load: bool) { let load = dmux8way(load, address); self.registers[0].clock(input, load[0]); self.registers[1].clock(input, load[1]); self.registers[2].clock(input, load[2]); self.registers[3].clock(input, load[3]); self.registers[4].clock(input, load[4]); self.registers[5].clock(input, load[5]); self.registers[6].clock(input, load[6]); self.registers[7].clock(input, load[7]); } } おわりに このあとは、RAM8 を8個並べて組み合わせて RAM64 を作り、RAM64 を8個並べて RAM512 を作り…、と続けてRAMを大きくしていきます。 そして、CPUを論理ゲートとレジスタの組み合わせから構成し、CPUとRAMを繋げて、ROMから機械語コードを読み出して実行するようにしていきます。 これが組み上がって動いたとき、何とも言えない感動を覚えたものです。特にレジスタやRAM周りの仕組みにワクワクしました。コンピュータというのは、クロック信号でカチカチと動いていく壮大なピタゴラ装置なんだということが実感できました。 書いたコードはここに置いてあります。ドヤァ!https://github.com/u1roh/nand2tetris …と思ったら、あれ?これ動かないっすね…。 ディスプレイをシミュレートするところを glium という OpenGL ラッパーで作ったのですが、久しぶりに動かそうとしたら動かない…。 今ちょっと原因を調べる時間も取れないので、すんません、ダサい感じの終わり方になりましたが、以上です。 The post RustでRAMの動作原理をシミュレートする appeared first on CADDi Tech Blog.
アバター
こんにちは。テクノロジー本部バックエンド開発グループの江良です。 この記事は CADDi Advent Calendar 19 日目の記事です。昨日は、狭間さんによる「GraphQL PaginationのNestJSでの実装」でした! 「バックエンド開発グループの〜」と自己紹介したばかりで恐縮なのですが、今日はフロントエンドの話をします。 目次 はじめに これはなに Apollo Client の 3.0 で追加されたキャッシュ周りの新機能を試してみた記事です offsetLimitPagination と relayStylePagination について触れています 実際に手元で動かせるコードを使って、ステップ・バイ・ステップで説明します 能書きはいいからコードを見せてくれ、という人はこちらをご覧ください。gushernobindsme/apollo-client-v3-practice まえがき 私が所属する原価計算システムの開発チームでは、 バックエンド BFF フロントエンド という構成でシステムを提供しています。 バックエンド・BFF 間は gRPC、BFF・フロントエンド間は GraphQL で通信しています。 フロントエンドから BFF の GraphQL サーバにアクセスする際に使用しているのが、今回お話する Apollo Client というライブラリです。 弊チームでは、現在 Apollo Client のバージョン 2.6.9 を使用しているのですが、3.0 以降で登場したキャッシュ周りの機能がなかなか便利そうだったので、今後のバージョンアップに備えて試してみたことをまとめてみます。 使用したライブラリのバージョン 検証には以下のバージョンを使用しました。 @apollo/client: 3.3.4 graphql: 15.4.0 Apollo Client について Apollo Client のキャッシュとは Apollo Client は、GraphQL クエリの結果をインメモリのキャッシュに保存します。 クエリの結果は正規化して保存され、 InMemoryCache というクラスから簡単に操作できます。 InMemoryCache は 公式ガイド にも記載の通り、簡単に使い始められます。 import { InMemoryCache, ApolloClient } from '@apollo/client'; const client = new ApolloClient({ // ...other arguments... cache: new InMemoryCache(options) }); 保存されたキャッシュには InMemoryCache の以下のメソッドを使うことでアクセスできます。 readQuery readFragment writeQuery writeFragment また、Apollo Client 3.0 からはキャッシュ内の個々のフィールドを更新するために modify というメソッドが追加されています。「mutation を実行した後、その結果をキャッシュに書き戻したい」といったユースケースで便利です。 詳細は 公式ガイド のほか、弊社フロントエンドエンジニアの 桐生さんの記事 にも詳しく書かれていますので、気になる方は読んでみてください。 Apollo Client 3.0 の新機能 Apollo Client 3.0 ではいくつもの新機能が追加されています。 詳細は Apollo の公式ブログ と マイグレーションガイド に譲りますが、その中でも特にパワフルなのが Pagination helpers の追加です。 これは文字通りページネーションの実装を助ける便利なヘルパ機能になります。 ページネーションの設計 さて、ここでちょっと脱線してページネーションを実現する API の設計方針について考えてみましょう。 ページネーションの設計は数あれど、大まかなパターンとしては以下の二種類に整理できるかと思います。 オフセットベース(Offset-based pagination) カーソルベース(Cursor-based pagination) オフセットベース オフセットベースはいわゆる offset と limit を使ってページングを行うやり方です。 offset にデータの取得開始位置を指定し、 limit に取得するデータ件数を指定します(SQL を書いたことのある人には馴染みのあるアレですね)。 例えばこんな風に指定すると、 SELECT * FROM transactions LIMIT 10 OFFSET 20; 先頭の 20 行目から 10 件のデータを取得してください、という意味になります。 カーソルベース カーソルベースはデータの取得を開始する位置をインデックスではなく、トークンで指定するやり方です。 この方式では first にデータ件数、 after にデータの取得開始位置を表す base64 エンコードされたカーソルを指定します。 { user { id name friends(first: 10, after: "opaqueCursor") { edges { cursor node { id name } } pageInfo { hasNextPage } } } } この方式は GraphQL のサイトにてベストプラクティス として紹介されているほか、GraphQL クライアントの Relay でも紹介されています。 GraphQL Cursor Connections Specification 閑話休題 さて、話を Apollo Client に戻します。 Apollo Client 3.0 では、上述した二種類の API のページング処理をいい感じにしてくれる便利な機能を提供しています。 Pagination helpers は InMemoryCache に対するオプションとして設定できます。 先ほど紹介した InMemoryCache のインスタンスを生成するコードを思い出してみましょう。 公式ガイド によると、ここに offsetLimitPagination を指定するとオフセットベースの API のページング処理がいい感じになります。 const cache = new InMemoryCache({ typePolicies: { Query: { fields: { comments: offsetLimitPagination(), }, }, }, }); また公式ガイドの このページ によれば、ここに relayStylePagination を指定するとカーソルベースの API のページング処理もいい感じになるそうです。 const cache = new InMemoryCache({ typePolicies: { Query: { fields: { comments: relayStylePagination(), }, }, }, }); 本当に、そんなうまい話があるのでしょうか? サンプルコードで学ぶ Apollo Client 3.0 導入 ということで、早速コードを書いて検証してみます。 やってみたことは以下の通りです。 オフセットベースのレスポンスを返す GraphQL のエンドポイントを実装する カーソルベースのレスポンスを返す GraphQL のエンドポイントを実装する Apollo Client 3.0 を組み込んだフロントエンドを実装し、Pagination helpers を設定する ここでは、ページングの動作を検証するためのシンプルな CRUD アプリケーションを実装してみます。 概要 ということで完成したのがこちらのリポジトリです。gushernobindsme/apollo-client-v3-practice backend ディレクトリに NestJS 製の Graph サーバを実装 frontend ディレクトリに React 製のフロントエンドを実装 という構成になっています。 ページネーション以外の話題については、本記事では省略します。NestJS を使ったバックエンドの実装については、前日の狭間さんの記事に詳しく書いてありますので、是非読んでみてください! Offset-based なページネーションを実装する まず、オフセットベースの GraphQL の定義を用意します。 (バックエンド側の実装については割愛します。) type Query { sharks(offset: Int, limit: Int): [Shark] } type Shark { id: Int originalTitle: String japaneseTitle: String rate: Int } 次に、 offsetLimitPagination を設定した InMemoryCache を用意します。 const cache = new InMemoryCache({ typePolicies: { Query: { fields: { sharks: offsetLimitPagination(), } } } }) const client = new ApolloClient({ // other settings cache }); GraphQL のドキュメント定義を用意して、 const GET_SHARKS = gql` query getSharks(offset: Int,limit: Int) { sharks(offset: offset, limit:limit) { id originalTitle japaneseTitle rate } } `; 戻り値の型を用意して、 interface SharksModel { sharks: Shark[]; } useQuery の hooks を実装します。 const { loading, error, data, fetchMore } = useQuery ( GET_SHARKS, { variables: { offset: 0, limit: 10 }, }, ); 最後に hooks を呼び出す component を実装して完成です。 // ... 略 // ... 略 {data && data.sharks.map((shark) => { return ( {shark.id} {shark.originalTitle} {shark.japaneseTitle} {shark.id && ( )} ); })} // ... 略 次の 10 件をフェッチするためのボタンも設置します。 次のデータの取得は fetchMore メソッドを呼ぶことで簡単に実装できます。Core pagination API – Client (React) – Apollo GraphQL Docs { await fetchMore({ variables: { offset: data?.sharks.length, }, }); }} > fetch more Cursor-based なページネーションを実装する 次にカーソルベースの GraphQL の定義を用意します。 お作法にしたがって connection に edges と pageInfo を、 edges に node を定義してみます。 type Query { sharks(first: Int!, after: String): SharkConnection } type SharkConnection { edges: [SharkEdge] pageInfo: PageInfo } type SharkEdge { node: Shark cursor: String } type PageInfo { endCursor: String hasNextPage: Boolean } 次に、 relayStylePagination を設定した InMemoryCache を用意します。 const cache = new InMemoryCache({ typePolicies: { Query: { fields: { sharks: relayStylePagination(), } } } }) const client = new ApolloClient({ // other settings cache }); GraphQL のドキュメント定義を用意して、 export const GET_SHARKS = gql` query getSharks(cursor: String) { sharks(first: 10, after:cursor) { edges { cursor node { id originalTitle japaneseTitle rate } } pageInfo { endCursor hasNextPage } } } `; 戻り値の型を用意して、 interface SharksModel { sharks: SharkConnection; } useQuery の hooks を実装します。 const { loading, error, data, fetchMore } = useQuery ( GET_SHARKS, { variables: { cursor: '' }, }, ); 最後に hooks を呼び出す component を実装して完成です。 // ... 略 // ... 略 {data && data.sharks && data.sharks.edges && data.sharks.edges.map((shark) => { const node = shark.node; return ( {node?.id} {node?.originalTitle} {node?.japaneseTitle} {node?.id && ( )} ); })} // ... 略 次の 10 件をフェッチするためのボタンはこんな感じです。 {data && data.sharks.pageInfo?.hasNextPage && ( { await fetchMore({ variables: { cursor: data?.sharks?.pageInfo?.endCursor, }, }); }} > fetch more )} 動作確認 実装が一通り書けたのでさっそく動かしてみましょう。 「fetch more」ボタンを押すと次の 10 件が表示されます。 さっそくデータを追加してみましょう。 追加できました。 ☆アイコンを押して評価をつけることもできます。 ( フランケンジョーズ は CG が本当にひどいので☆ 1 つです。) こちらも無事更新できました。 (ちょっとわかりにくいのですが)実際にうまくキャッシュが動作している様子は、先ほどご紹介したリポジトリをクローンして起動することでも検証できます。是非お手元で動かしてみてください。 おわりに ということで Apollo Client 3.0 で追加された新機能 Pagination helpers のご紹介でした。 明日は、寺田さんによる「RustでRAMの動作原理をシミュレートする」です。お楽しみに! The post Apollo Client 3.0 ではじめる快適キャッシュ生活 appeared first on CADDi Tech Blog.
アバター
こんにちは! @ryokotmng です。本記事は、 キャディ Advent Calendar 2020 – Qiita の4日目の記事です。昨日の記事はagate-prisさんの Orphan Ruleよありがとう ~Rustを採用したおかげでリファクタリングが捗った話~ でした。 キャディのエンジニアがどんな開発環境で仕事をしているのかについて、アンケートをとってみました。その結果を (私の心の声を挟みつつ) まとめてみましたので、本日は弊社エンジニアチームの雰囲気を感じてもらえるとうれしいなと思っています💁 回答してくれたエンジニアの経歴と今のお仕事 合計18人のエンジニアが回答してくれました!まずは、回答してくれたエンジニアたちのバックグラウンドと今のお仕事について、ざっくり見ていきたいと思います。 エンジニア経験は何年くらいですか? ※ 一部未回答の質問もあるため、グラフの数字の合計は必ずしも全回答者の合計人数と一致しません。 ※ 縦軸は人数です。 一番経験が長い言語は? ※ 縦軸は人数です。 経験年数、言語共に、バックグラウンドはかなり多様なようです! 今ざっくり言うとどんな開発をしている? 今一番よく使う言語は? ※ 横軸は人数です。 最もよく使われているのはRustでした。 キャディではほぼ全てのサービスでバックエンドをRustで書いており、フロントエンドやBFFではTypeScriptを使っています。また、フロントエンドやバックエンドの担当と言っても境界ははっきりしているわけではなく、バックエンドエンジニアがBFFやフロントを書いたり、その逆もよくあります。 お待ちかね、開発環境について 使っているPCは? ※ 以下円グラフ内に記載された数は回答者数です。 Macを使っている人が多いですね。 個人的には、自作PCを使っている人が4人もいることに驚きました!Rustを用いた開発では、コンパイルに時間がかかることやメモリが足りなくなりがちなこともあり、高スペックなPCを求めて自作しようと思うようになるのかもしれません。 自作PCの詳細を教えてくれた人たちは、Ryzen 9 3900X + GEFORCE RTX 2080 SUPERを使っているそうです。「コンパイル速度もゲームもいい感じ」とのこと。(Ryzen、私も使ってみたいです!めっちゃ速いんだろうなぁ。) バックエンドエンジニアの @kuwana_kb_ さんより、自慢の自作PCの写真をいただきました🧑🏻💻もはやSFっぽい写真ですね!かっこよすぎて、ウェブから適当にイケてる写真を引っ張ってきたんじゃないかとつい疑ってしまいました (ご本人が作成したものです)w 他にも、「ノートPCながらGTX1070搭載はなかなか熱いと思ってます!(排熱的にも)」という意見もありました。 使っているOSは? 使っているシェルは? zshを使っている人が7割と大多数でした。 fishを使っている人は、「履歴補完が便利で他のshellを使う気にならない」とのこと (そんなにすごいのか、ちょっと使ってみたいな...)。 Nushellは知らなかったのですが、Rust製なのですね!2019年9月頃公開された新しいシェルで、fishのように入力補完機能が強力なようです。 使っているエディタは? ※ 複数回答が含まれます。 VSCodeが最も多く、VimとIntelliJ系がそれに続きました。「VSCodeはデファクトスタンダード」という声や、「IntelliJ系の補完、リファクタリング機能、文字列選択機能が優秀」、「Vimは思考のスピードで編集できる」などの感想もあり、なかなかどれが良いとは一概に言えなそうですね。複数使い分ける派の人も数人いました。 なかには、同じJetBrains製品を複数個使い分けている人も。「Rust は CLion、TypeScript は WebStrom、インフラコードは IntelliJ、という風に使い分けています。どのコードを書く時にも同じ操作感で使える点が気に入っています。CLion は Rust の静的解析がとにかく速いので助かってます」とのことです。 VSCodeにはTabNineというAIによるオートコンプリート機能のプラグインがあり、これをおすすめするエンジニアもいました。 これは開発に必須!と思われる便利なツールは? fzf + ghq、zsh-autosuggestions、fishの履歴補完機能など、コマンドを補完するためのツールが多く挙げられていました。 Vimの場合はLSPのcocを使用している人が多いようです。ほかには、ripgrep、iTerm、tmuxなどを使っている人が多いみたいです。 個人にインタビューする機会があれば、この辺は実際の作業を見ながら細かく突っ込みたいところです!(今回の企画が好評だったらやりたい!) 使っているキーボードは? ※ 複数回答が含まれます。 この結果には含まれていませんが、私も今年のクリスマスにHHKBデビューする予定です。HHKBは大人気な一方で、打鍵音がやや大きいため、「奥さんに不評なので、Apple公式のものと使い分けている」という意見も。 普段使うぶんには気になりませんが、ご家庭がある方は、特に音の問題は難しいですよね。 Nizを使っている人からは、「キーが軽くて深くてしっかり戻ってくるのでかなり打ちやすいです。キーバインドがないので Vim & tmux を使う方でも衝突しなくてよい」という声もありました。 こちらはミートアップ等でおなじみ、エンジニアリング・マネージャー @mura_mi さんのキーボード。Realforceの変荷重だそう。配色が可愛いです✨英字配列であることには拘っているとのことです。 自宅、見せてください!! さて、ここで数人のエンジニアの自宅のデスクを写真でお届けしちゃいます💁🏻 まずはアルゴリズムエンジニア、 @ngtkana さんのデスク。キーボードはNiZですね⌨️左手にペンがありますが、メモを取る時にはiPadのGoodNoteを使っているそうです。左手にある書籍スタンドが便利そうすぎて、これを見てつい私もポチってしまいました❣️ アルゴリズムエンジニアいなむさんのデスク。手元でメモを取れるようにiPadを置いていて、Notabilityというアプリを使っているそうです。奥で虹色に光る自作PCがきれいですね🤤こだわりは、「IDEの背景が可愛いキャラクターであることが重要だと思っています」とのこと。 バックエンドエンジニア、 @gushernobindsme さんのデスク。 スッキリしているなかにもオーディオ機器へのこだわりがみられますね。ディスプレイの左手に立てかけてあるのは、プライベート用のPCでしょうか?「 サウナイキタイ 」のステッカーが貼ってありますね🧖‍♂️ 次にバックエンドエンジニアの木村さんです。 ドラマに出てきそうなかっこいい空間に仕上がっています😍 左側のディスプレイの上にある丸いものは、カメラと女優ライト的なやつでしょうか!?これなら登壇や動画配信もバッチリですね💪🏻 こちらは @mura_mi さんのデスク。 ディスプレイやキーボードスライダーはリモートワークの影響で整備したそう😃 メモ用の紙・ノート類は、「1週間のタスクを書き続けるスケジュール手帳」「1on1記録ノート」「ブレスト用のA4コピー用紙」3種類を使っているそうです。 ついテンションが上がってしまいました💦 私の家の環境はしょぼすぎて残念ながら公開できないのですが、みなさんの写真を見ると環境を整えたくなりますね! 仕事に欠かせないものは? 下記3つは、どれも10人以上から回答されていました。 - ディスプレイ - イヤフォン (含ネックスピーカー) - 良い椅子 他にも、お菓子や音楽、ノートは多くの人が挙げていました。 リモートワークの影響でイヤフォンの長時間利用から耳を痛める人も出てきていて、ネックスピーカーを購入した人も数人いました。私も最近愛用しているのですが、かなり軽くてちょっと席を離れる時もつけてて気にならないので (何より耳が痛くないの最高) 個人的には重宝しています。 一方で、「イヤフォンにノイズキャンセリングは必須」という声や、「FocalのStellia(ヘッドホン)は最高に良い音だぞ...」との意見もありました。こちらの方が没入感があって集中力を上げやすそうなので、使い分けるのも良さそうですね。 @kuwana_kb_ さんより、Focalのヘッドホン。またまた写真がイケてますね!悔しいけどかっこいいです! 椅子については、「思い切ってコンテッサの椅子を買ったら腰痛が一気に楽になった」との意見も。また、電動昇降机を使用している人や、座椅子で仕事をしているという人もいました。キャディも基本的にはリモートワークなので、家にいながら生産性を上げる環境は大切ですね。(ぐぬぬ〜〜、椅子高いけど、いいやつ欲しいな〜〜〜〜。) また、「マイクはAKGのC451という楽器用のものを使用しています~」、「毎シーズン良いダージリンを取り揃えております」というこだわり派も。 木村さんのマイク。本格的すぎる😯 いなむさんこだわりのダージリン。収穫年やグレードまで書いてあります! 他にも、確かに!と思ってしまったものに、このような意見もありました→「最近は、開発する人間の環境(近くの飯屋とか)のほうが重要度が高い気がします」。家の近くのランチにはもう飽きた...なんて方も世の中には多いのかもしれません。 最近気になるものは? 下記のようなものが複数人から挙げられていました。 - M1搭載Mac mini - キーボード - 4Kディスプレイ - 椅子 - 自作PC 自作PCについては、私も年末年始を利用して取り組んでみたいと思っています!社用PCは特に問題ないのですが、Rustを始めてから、自分のMac book pro (ケチって4コア😇) ではなかなか開発に苦労するようになってきてしまいました💦 いかがでしたでしょうか、楽しんでいただけましたか?これまで開発環境について話したりする機会は少なかったので、書いている私としてもとても勉強になりました。 キャディでは、楽しいエンジニアたちが日々業界を変えるために頑張っています!今回は仕事の話は全然していませんが、キャディでの開発や技術に興味を持ってくださった方は、ぜひ カジュアル面談に申し込んでみてください ☺️ お読みいただきありがとうございました!寒い日が続きますが、健康に気をつけてくださいね〜!
アバター
業務でRustのコードを書いていて、 rustfmt が失敗する事象に遭遇した。 少し調べたところ、 MatchArms の後にカンマを含むコメントがあると、うまく動かないことが分かった。 以下は2つの連続した改行が1つの改行に詰められることを期待したコードである。 rustfmt はマッチ式全体のフォーマットを諦めてしまう。 fn f() { let x = 0; match x { 0 => {} 1 => {} _ => {} // foo // bar, } } 尚、マッチ式の外のフォーマットは継続される。ファイル全体がフォーマットされなくなったりはしない。 おそらく、コメント中のカンマと MatchArms 中の Expression に対応するカンマの区別が出来ず、混乱していると思われる。 いくらか恐ろしいのは、 rustfmt は上記のコードについてフォーマットが失敗したことを一切エラーとして報告しないことだ。フォーマットに失敗するコードがCIの際に検知されず、デプロイまで素通りしてしまう恐れがある。 マッチ式の途中に意図的に空白だけの行を配置する等の手段でエラーとして報告させることはできる。その場合は error[internal]: left behind trailing whitespace として報告される。フォーマット後のバリデーションチェックで( rustfmt 自身の)エラーチェックを行っているものと思われる。 この事象は GitHub でIssueとして報告した。 rustfmt fail to format if there is a multi-line comment at the end of the match expression and it is terminated by a comma · Issue #4037 · rust-lang/rustfmt
アバター
キャディのバックエンドエンジニアをして働いている高藤です。 キャディではRustを使った API サーバを開発しています。今回はその開発の過程で導入した cargo workspace を使ったプロジェクト構成についてまとめました。 今回のアプリケーションについて Rustで記述 ドメイン 駆動設計を用いて設計をしており、 ドメイン 層を明確に分離している アプリケーションの役割はgRPCで API を提供したり、MessageQueueからくるメッセージの処理を行う 実装しているアプリケーションで使っている技術や設計手法などは弊社エンジニアが書いた別の記事もご参照下さい。 DDDのパターンをRustで表現する ~ Value Object編 ~ TypeScriptにおけるgRPC関連ライブラリの比較とプロダクト開発で採用した方法の紹介 workspaceを使うようになるまでの経緯 開発初期、 cargo new コマンドで生成されたプロジェクトを以下のような構造にして実装していました。 application_name ├─ app │ └─ main.rs ├─ src │ ├─ domain/ │ │ ├─ aaa.rs │ │ └─ ...etc │ ├─ usecase/ │ │ ├─ bbb.rs │ │ └─ ...etc │ └─ infrastructure/ │ ├─ grpc/ │ │ └─ ...etc │ └─ mq/ │ │ └─ ...etc ├─ Cargo.toml ドメイン 層などを ディレクト リを使い階層構造でmoduleを配置しています。処理をどこに記述すべきかを理解しやすくするためこのような構成にしていました。この構造でプロジェクトが進むにつれ、各 ディレクト リ内のmoduleは増え続けると共にビルド時間が増大し、開発の効率を悪化させる事象が発生しました。 cargo workspace の利用 上記の問題を解決するため、 cargo workspace という機能でプロジェクトを複数のcrateに分離しました。 workspaceを使うメリット crateを分割するメリットとしては保守性や再利用性の向上ももちろんありますが、今回のケースとしてはビルド時間を少しでも短縮することが当初の目的でした。 なぜならRustのビルドツール cargo では依存関係のない crate は並列に コンパイル する事が出来ます。 上記のケースでは infrastructure の中にあるコードは domain , usecase に依存しています。他方で infrastructure 内部の grpc , mq などの処理はお互いに依存はないため、分割することで コンパイル 速度を向上させることが可能です。 workspaceを使ったプロジェクト構成 application_name ├─ app │ ├─ src/main.rs │ └─ Cargo.toml ├─ domain │ ├─ src/...etc │ └─ Cargo.toml ├─ usecase │ ├─ src/...etc │ └─ Cargo.toml ├─ grpc │ ├─ src/...etc │ └─ Cargo.toml ├─ mq │ ├─ src/...etc │ └─ Cargo.toml ├─ Cargo.toml 上記の構成では5つの crate に分割しています。 workspaceの作り方 application_name/Cargo.toml を以下のように定義します。 [workspace] members = [ "app", "domain", "usecase", "grpc", "mq", ] workspace 配下に配置するcrateを上記の様に members として記述をします。 それぞれの crate の中には Cargo.toml を用意する必要があります。 なお、 members に記述のは path になるため、必ずしも同一階層に全ての crate を配置しなくても定義可能です。 例: ./infrastructure/grpc , ./infrastructure/mq のように定義することも可能。 workspace適用後の効果 今回のケースの場合、下記グラフの通り最終的に10分前後かかっていたビルド時間が、2分弱の時間で実行できるようになりました。 workspaceの使い方メモ workspace に関する詳細は各ドキュメント等を参考にしてください。簡単な説明となってしまいますが箇条書きでいくつか利用方法等をご紹介させてもらいます。 workspace の中では コンパイル 成果物が格納される target ディレクト リは workspace 直下に配置されます。(上記例だと application_name/target ) Cargo.lock も同様に workspace 直下に配置されます。これにより workspace 配下の crate が依存する crate のバージョンを保証しています。 workspace を利用しているときも通常のプロジェクトと同様に cargo コマンドでビルドを行うことが出来ます( cargo check , cargo build , cargo run ...etc) workspace 配下の crate にカレント ディレクト リを変更してビルドを行った場合その crate を対象にビルドができます。 カレント ディレクト リを変更したくない場合は --package オプションを使ってビルドも可能です( cargo check --package domain ) The Rust Programming Language ch14-03 The Cargo Book 最後に 私達が開発するアプリケーションは現在16 crateまで分割しています。正直まだ分離させられる余地もあり、成長と共にビルド時間が増えたり、保守観点から分離すべきタイミングで分けるべきだと考えています。 また、参考までにRust製のservice meshである linkerd2-proxy を確認すると55のcrateから構成されています。 このようにアプリケーションが成長し規模や複雑さに応じて簡単に workspace を使って分離できるのはかなり有用かと思っています。 ある程度の成長が予測されるアプリケーションなどは最初から workspace の構成を考えておくなどしておくと良いと思います。 参考: linkerd2-proxy
アバター
はじめに はじめまして、キャディでバックエンドエンジニアとして働いている高藤です。 キャディではRustを使ったバックエンド API を実装しています。業務ではgRPCサーバを実装していますが、今回はRustを利用した簡単なWebアプリケーションを作成し意外と簡単に API サーバが作れる事を紹介させていただきます。 今回はまだRustを触ったことない方でも記事を読み、ちょっとRustやってみようかなと思ってもらえたら幸いです。 前提 Rustの言語仕様など基本的な説明は省略させていただきます。Rust未経験であれば、是非公式のドキュメントを読んでください。 https://doc.rust-lang.org/book/ 有志による日本語訳 https://doc.rust-jp.rs/ 作るもの 今回はまず単純にHTTP Requestをすると JSON を返すサーバを実装を行います。 環境 ❯ rustc --version rustc 1.41.0 (5e1a79984 2020-01-27) プロジェクトを作成する ❯ cargo new sample-web-app Created binary (application) `sample-web-app` package ❯ cd sample-web-app 依存するcrateの定義 今回のサンプルには warp という crate を使って実装を行います。 warp は Github の冒頭に A super-easy と明記されているようにRustを触ったばかりでも比較的導入が楽だと思っています。 https://github.com/seanmonstar/warp まずは依存関係を定義します。 sample-web-app/Cargo.toml [package] name = "sample_web_app" version = "0.1.0" authors = ["nrskt <norisuke_takafuji@caddi.jp>"] edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] tokio = { version = "0.2", features = ["macros"] } warp = "0.2" [dependencies] 配下に2行追加しました。1つは今回メインとなる warp ,もう1つは warp が依存する tokio という crate です。 まずは Github のREADMEどおりに実装 sample-web-app/src/main.rs // 今回のサンプルが必要とする`warp.Filter` traitをimportします。 use warp::Filter; // 今回tokioのランタイムを利用する // 非同期ランタイムの上で実行されるためmain関数はasyncをつけて定義します #[tokio::main] async fn main() { // GET /hello/warp => 200 OK with body "Hello, warp!" let hello = warp::path!("hello" / String).map(|name| format!("Hello, {}!", name)); // Serverの起動 warp::serve(hello).run(([127, 0, 0, 1], 3030)).await; } 処理内容 warp::path!("hello" / String) の箇所で URL パスを定義し、 /hello/ 以下を String 型で受け取ることを宣言します。 map(|name| format!("Hello, {}!", name)) の箇所で前述のURLからString型で受け取った値と format! する処理をつなぐように宣言しています。 起動してみる ❯ cargo run ❯ curl localhost:3030/hello/nrskt Hello, nrskt! URLの末尾にある文字列を利用したResponseが返る事を確認できました。 Filter を理解する 今回利用している warp は Filter traitを実装したFilterと呼ばれる部品を組み合わせて1つの処理を作り上げる仕組みとなっています。 これらの Filter を使っていくつかサンプルを作ってみます。 #[tokio::main] async fn main() { let hello = hello().and(name()).and_then(greet_handler); warp::serve(hello).run(([127, 0, 0, 1], 3030)).await; } fn hello() -> warp::filters::BoxedFilter<()> { warp::path("hello").boxed() } fn name() -> warp::filters::BoxedFilter<(String,)> { warp::path::param().boxed() } async fn greet_handler(name: String) -> Result<impl Reply, Rejection> { let reply = format!("hello {}", name); Ok(warp::reply::html(reply)) } 先程の path! マクロで表現していた path の処理を、 hello() , name() Filterに分解し、組み合わせられる部品としました。 また最終的に処理を行うhandlerも関数をして表す事が可能です。 上記の例ではあまりメリットはありませんが、複雑な処理を小さく分解された部品を組み合わせて組み立てる仕組みが強く意識されています。 型安全 先程の例で 名前 を受け取る部分では String 型のパラメータを受け取るように処理を書いていました( fn name() -> warp::filters::BoxedFilter<(String,)> )。 このままだとどのような文字列が来ても処理を進めることが出来てしまうためhandler内で受け取った値が想定している値かValidationをする必要が発生します。 Rustでは独自の型を定義することが容易にできるため、名前を表す型を用意し、意図しない値がそもそもhandlerに渡ることを防ぐ事が出来ます。 ここでは例として名前の仕様を以下のように定義してみました。 [A-Za-z]の文字種を使い、10文字以内で表される 型の定義 /// 名前を表す型の定義 #[derive(Clone, Debug)] struct Name(String); impl Name { /// 値のチェックを行った上でNameを作成する /// 今回はサンプルのため作成の失敗をString型で表現している pub fn new(name: &str) -> Result<Self, String> { let size = name.chars().count(); if size < 1 || size > 10 { return Err("名前は10文字以内です".to_string()); } if name.chars().any(|c| !c.is_ascii_alphabetic()) { return Err("名前が使用できる文字種はA-Z, a-zです".to_string()); } Ok(Name(name.to_string())) } } /// 文字列からの変換を表す /// このtraitの実装をwarp::path::params()関数が要求する impl std::str::FromStr for Name { type Err = String; fn from_str(s: &str) -> Result<Self, Self::Err> { Name::new(s) } } /// handlerでformatを行うために要求される impl std::fmt::Display for Name { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } #[test] fn test_name() { let ok_value = "Nrskt"; assert!(Name::new(ok_value).is_ok()); let ok_value = "N"; assert!(Name::new(ok_value).is_ok()); let ok_value = "NrsktNrskt"; assert!(Name::new(ok_value).is_ok()); let ng_value = "0"; assert!(Name::new(ng_value).is_err()); let ng_value = ""; assert!(Name::new(ng_value).is_err()); let ng_value = "NrsktNrsktN"; assert!(Name::new(ng_value).is_err()); } これで新しく Name 型の定義が終わりました。 先程のコードを修正します。 fn name() -> warp::filters::BoxedFilter<(Name,)> { warp::path::param().boxed() } async fn greet_handler(name: Name) -> Result<impl Reply, Rejection> { let reply = format!("hello {}", name); Ok(warp::reply::html(reply)) } Pathのパラメータを受け取る部分の戻り値の型を String -> Name に変更します。 greet_handler の引数の型を String -> Name に変更します これによりパラメータ部分から受け取った値が Name 型の範囲になることが保証されます。 ❯ curl -D - localhost:3030/hello/0 HTTP/1.1 404 Not Found 上記の例のように Name 型で利用できない文字種が使われた際にエラーを返すようになりました。 Userを取得,保存する API を書いてみる ここからはもう少し実用的な例 としてユーザの取得と保存を行う API を実装します。 今回はRESTでよく使われる JSON を利用してRequest値とResponse値を表します。 なお、データの保存については HashMap を利用して実装を行います。 (メモリ上にデータが残るためサーバを停止するとデータは消えます。) 最終的にサンプルコードは以下の リポジトリ に公開しているので併せて確認をして下さい。 https://github.com/nrskt/sample-web-app 依存関係の修正 JSON を扱うため依存するcrateを追加するため Cargo.toml の dependencies に以下を追加します。 serde = { version ="1.0.104", features = ["derive"] } [package] name = "sample_web_app" version = "0.1.0" authors = ["nrskt <norisuke_takafuji@caddi.jp>"] edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] tokio = { version = "0.2", features = ["macros"] } warp = "0.2.1" serde = { version ="1.0.104", features = ["derive"] } Userの定義 models.rs #[derive(Clone, Debug)] struct User { id: u64, name: Name, } このUser型は JSON として入出力できなければならないため、 Serialize , Deserialize の特性を導出します。 まずUser型の構成要素である Name 型に Serialize , Deserialize の実装を行います。 models.rs // Serializeを追加 #[derive(Clone, Debug, Serialize)] struct Name(String); // Deserializeの実装を行う impl<'de> de::Deserialize<'de> for Name { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: de::Deserializer<'de>, { let s = String::deserialize(deserializer)?; Name::new(&s).map_err(de::Error::custom) } } #[derive] で Deserialize を自動導出しなかったのは、型の制約が記述されている Name::new() を呼び出す必要があったためです。 #[derive(Deserialize)] としてしまうとどのような文字列でも Name 型に変換できてしまうためこのような実装としています。 同様に User 型に対して Serialize , Deserialize の実装を行います。 models.rs #[derive(Clone, Debug, Serialize, Deserialize)] struct User { id: u64, name: Name, } Database(HashMap)の定義 今回のサンプルではUserの情報を HashMap に残すように実装します。併せてDBの初期化を行う関数 init_db を定義します。 db.rs use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; use crate::User; pub type Database = Arc<Mutex<HashMap<u64, User>>>; pub fn init_db() -> Database { Arc::new(Mutex::new(HashMap::new())) } Handlerの実装 3つのHandlerを実装します。 ユーザを全件取得する処理 ユーザIdを指定して特定のユーザを取得する処理 ユーザを新規登録、更新する処理 handlers.rs use warp::{Rejection, Reply}; use crate::{Database, User}; pub async fn list_users_handler(db: Database) -> Result<impl Reply, Rejection> { let db = db.lock().await; let users = db .clone() .into_iter() .map(|(_, v)| v) .collect::<Vec<User>>(); Ok(warp::reply::json(&users)) } pub async fn get_user_handler(db: Database, id: u64) -> Result<impl Reply, Rejection> { let db = db.lock().await; let user = db.get(&id); match user { None => Err(warp::reject::not_found()), Some(u) => Ok(warp::reply::json(&u)), } } pub async fn put_user_handler(db: Database, id: u64, user: User) -> Result<impl Reply, Rejection> { if id != user.id() { return Ok(warp::reply::with_status( warp::reply::json(&()), warp::http::StatusCode::BAD_REQUEST, )); } let mut db = db.lock().await; db.insert(user.id(), user.clone()); Ok(warp::reply::with_status( warp::reply::json(&user), warp::http::StatusCode::OK, )) } Reply を作成する際に warp::reply::json 関数を使っています。 pub fn json<T>(val: &T) -> Json where T: Serialize, 型定義の示すとおり、引数の型 T が serde::Serialize を実装していれば与えた T 型の値を JSON に変換した Reply を作成する関数です。 今回の実装では JSON での入出力を行うために利用しています。 Filterの定義 続いてFilterの定義を行います。 今回は各Handlerへのルーティングを表すFIlterを用意し、作成した3つのFilterをまとめた users_api というFilterを定義しました。 filters.rs use warp::{Filter, Rejection, Reply}; use crate::{get_user_handler, list_users_handler, put_user_handler, Database}; /// 最終的に公開するFilter /// 用意した部品を組み合わせて表現する pub fn users_api(db: Database) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { get_user(db.clone()).or(list(db.clone())).or(put_user(db)) } /// Path "users" を表す部品 fn users() -> warp::filters::BoxedFilter<()> { warp::path("users").boxed() } /// PathからUserIdを取り出す部品 fn user_id() -> warp::filters::BoxedFilter<(u64,)> { warp::path::param().boxed() } /// list_users_handlerを呼び出すための部品 fn list(db: Database) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { users() .and(warp::get()) // HTTP GETメソッドを指定 .and_then(move || list_users_handler(db.clone())) // Handlerを呼び出す } /// get_user_handlerを呼び出すための部品 fn get_user(db: Database) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { users() .and(user_id()) // User IdをPathから取得 .and(warp::get()) // HTTP GETメソッドを指定 .and_then(move |id| get_user_handler(db.clone(), id)) // Handlerを呼び出す } /// put_user_handlerを呼び出すための部品 fn put_user(db: Database) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { users() .and(user_id()) // User IdをPathから取得 .and(warp::put()) // HTTP PUTメソッドを指定 .and(warp::body::json()) // Request Bodyに含まれたJSONを取り出しUser型へ変換 .and_then(move |id, body| put_user_handler(db.clone(), id, body)) // Handlerを呼び出す } かなりややこしい型になりますが、やっている処理自体は Path のマッチ、 id を取り出す、Request Bodyから JSON を取り出す事を行っています。 warp::body::json() 関数はRequest Bodyに含まれる JSON から Deserialize を実装した特定の型への変換を行っています。どの型へ変換するかの指定を行う必要があります。 型推論 が正しく動かない場合は warp::body::json::<User>() のように User 型への変換を明示する必要があります。 今回の例では put_user_handler の引数で明示的に User 型を要求しているため省略して記述が可能です。 main関数の実装 最後に実装した部品をmain関数にまとめます。 main.rs use sample_web_app::{init_db, users_api}; #[tokio::main] async fn main() { // Database(HashMap)の初期化 let database = init_db(); // users_api filterにdatabaseを代入してサーバを起動 warp::serve(users_api(database)) .run(([127, 0, 0, 1], 3030)) .await; } 動作確認 実際に cargo run でサーバを起動して、いくつかテストを行います。 何も登録されていないことを確認する ❯ curl localhost:3030/users [] ユーザの登録 ❯ curl -X PUT -H 'Content-Type:application/json' -D - localhost:3030/users/1 -d '{"id": 1, "name": "nrskt"}' HTTP/1.1 200 OK content-type: application/json content-length: 23 date: Mon, 24 Feb 2020 09:10:20 GMT {"id":1,"name":"nrskt"} ❯ curl -X PUT -H 'Content-Type:application/json' -D - localhost:3030/users/2 -d '{"id": 2, "name": "neko"}' HTTP/1.1 200 OK content-type: application/json content-length: 22 date: Mon, 24 Feb 2020 09:12:48 GMT {"id":2,"name":"neko"} 登録ユーザの取得 ❯ curl -D - localhost:3030/users HTTP/1.1 200 OK content-type: application/json content-length: 48 date: Mon, 24 Feb 2020 09:14:03 GMT [{"id":1,"name":"nrskt"},{"id":2,"name":"neko"}] 登録した全ユーザを取得することが確認できました。 IDを指定したユーザの取得 ❯ curl -D - localhost:3030/users/1 HTTP/1.1 200 OK content-type: application/json content-length: 23 date: Mon, 24 Feb 2020 09:19:22 GMT {"id":1,"name":"nrskt"} 指定したIDのユーザを取得することを確認できました。 誤ったデータの登録 ❯ curl -X PUT -H 'Content-Type:application/json' -D - localhost:3030/users/3 -d '{"id": 2, "name": 1}' HTTP/1.1 400 Bad Request content-type: text/plain; charset=utf-8 content-length: 96 date: Mon, 24 Feb 2020 09:20:52 GMT Request body deserialize error: invalid type: integer `1`, expected a string at line 1 column 20 文字列を期待している部分に数値型を入れた場合、正しく 400 Bad Request が返る事を確認できました。 ❯ curl -X PUT -H 'Content-Type:application/json' -D - localhost:3030/users/3 -d '{"id": 2, "name": "0"}' HTTP/1.1 400 Bad Request content-type: text/plain; charset=utf-8 content-length: 102 date: Mon, 24 Feb 2020 09:21:33 GMT Request body deserialize error: 名前が使用できる文字種はA-Z, a-zです at line 1 column 22 Name 型の範囲外の値が指定された場合も正しく 400 Bad Request が返る事を確認できました。 まとめ 簡単な説明となってしまいましたが、 warp を利用してRustでWebアプリケーションを実装する例を紹介させていただきました。もちろん warp 以外にも様々なライブラリ、 フレームワーク が存在するので、そちらも試していただければと思います。
アバター