Featured image of post コードガーデナー【Decorator】品種と機能、掛け算で増えていくクラスの数〜台木は一本、必要な性質だけを接ぐ〜

コードガーデナー【Decorator】品種と機能、掛け算で増えていくクラスの数〜台木は一本、必要な性質だけを接ぐ〜

元SIerの僕が温室トマットの栽培管理ツールで、機能を足すたび構造体が倍増する「そっくり株」にはまり、Selfを返すtraitがdyn非互換で弾かれる。庭師が見せてくれたBox<dyn Trait>で外側から包む接ぎ木の手入れ。

真夏の午後、温室の中は外よりもさらに蒸し暑い。ビニール越しの日差しが、トマトの葉の緑を白く飛ばして見せています。僕は今日の水やり当番を終えて、額の汗をタオルで拭いながら、隅に置いた折りたたみ椅子に腰を下ろし、休憩がてらノートパソコンを開きました。防虫ネットをつけた株の記録も、そろそろ管理ツールに追加しよう——そう思って、いつものようにコピペ元にする構造体を探して、自分のコードをスクロールします。

TomatoTomatoColdGuardTomatoAutoWaterTomatoColdGuardAutoWater

指を止めて、画面を見つめました。もう4つある。防虫ネットの有無を掛け合わせたら、8つになる。頭の中で勝手に計算が始まります。2の3乗で8。品種名はnameの中身を変えるだけで済むようにしてあるから、そこだけは増えずに済んでいるはずだ——せめてもの救いだと思いながらも、機能の掛け算だけで、もう十分すぎるほど増えています。前職なら、こんな増え方をする設計は、レビューで真っ先に突き返していたはずでした。自分のコードだと、なぜか目を逸らし続けてしまいます。

前職の癖で、僕はすぐに直そうとしました。「traitを使えば、構造体を増やさずに機能だけ足せるはずだ」。その場でGreenhousePlantというtraitを書き、with_cold_guardwith_auto_waterというメソッドを、selfを受け取ってselfを返す形でチェーンできるようにしました。試しにTomato::new("桃太郎".to_string()).with_cold_guard()と書いてみると、ちゃんと動きます。これで解決した、と一瞬思いました。前の現場で散々書いてきた、設定をメソッドチェーンで組み立てるやり方——あれをそのまま持ち込めばいい、と。

ところが、複数の株をまとめて世話するリストをVec<Box<dyn GreenhousePlant>>として書こうとした瞬間、見たことのないエラーが赤く並びました。

1
error[E0038]: the trait `GreenhousePlant` is not dyn compatible

意味が分かりません。何度か書き直してみましたが、同じ場所で同じように弾かれます。with_cold_guardを消してみる、with_auto_waterだけにしてみる——それでも変わりません。手当たり次第に触っているだけで、何が悪いのか、見当すらつきませんでした。

途方に暮れて、隣の畝で作業していた先輩研修生に声をかけました。「これ、なんかコンパイラに怒られてて……」。画面を覗いた先輩は、コードの意味はよく分からないながらも、「ああ、それ多分、庭師さんに見てもらった方が早いよ」と笑いました。「植物の相談だけじゃなくて、たまにコードの面倒まで見てくれるらしいよ。工房、知ってる?」。僕は半信半疑のまま、自転車にノートパソコンを積んで、教えられた道を走り出しました。

訪れ ── 自分で直したはずが、また赤い文字

西日がきつい。ペダルを漕ぐたびに、額から流れる汗が目に染みます。前の現場だったら、こんな時間に外を自転車で走ることなんてまず無かった、と埒もないことを考えながら、教えられた坂道を上っていきました。坂を上りきったところに、木立に囲まれた小さな庭と、その奥の工房の屋根が覗きました。入口の脇に、古い木が一本立っています。木陰に、思わず自転車を寄せてしまいました。少しだけ涼んでから、僕は工房の戸を叩きます。

戸の向こうから、無言のまま人影が現れました。年格好は、正直つかめません。無口な職人肌か、前の現場にもいたな、と僕は思います。

「すみません、コードのことでご相談したくて。自分でも直そうとしたんですけど、これがどうしても……」

言い終わる前に、庭師さんの脇をすり抜けるようにして、若い女性が顔を覗かせました。ヒバリさんというらしい。

「まあまあ、立ち話もなんですし、こちらへどうぞ」

戸をくぐると、外の熱気が嘘のように、ひんやりとした空気に包まれました。土間の隅には接ぎ木用のナイフや台木の切れ端が積まれ、蛍光灯もサーバーの唸りもない静けさが、耳の奥にじんわり響きます。僕は勧められた作業台に座り、開いたままのノートパソコンを差し出します。

「構造体が増えすぎて困ってたんですけど、traitで直そうとしたら、今度は別のエラーが出て……自分では、もうお手上げです」

僕が差し出した画面には、4つの構造体と、赤いエラーメッセージの両方が開いたままになっています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
struct Tomato {
    name: String,
}

impl Tomato {
    fn tend(&self) -> String {
        format!("{}の世話をする", self.name)
    }
}

struct TomatoColdGuard {
    name: String,
}

impl TomatoColdGuard {
    fn tend(&self) -> String {
        format!("{}の世話をする。耐寒カバーの霜を確認する", self.name)
    }
}

struct TomatoAutoWater {
    name: String,
}

impl TomatoAutoWater {
    fn tend(&self) -> String {
        format!("{}の世話をする。自動潅水のタンク残量を確認する", self.name)
    }
}

struct TomatoColdGuardAutoWater {
    name: String,
}

impl TomatoColdGuardAutoWater {
    fn tend(&self) -> String {
        format!(
            "{}の世話をする。耐寒カバーの霜を確認する。自動潅水のタンク残量を確認する",
            self.name
        )
    }
}

症状観察 ── 庭師の視線が、Selfの一語で止まる

庭師さんは、僕が差し出したノートパソコンの画面に目を落としました。何も言いません。まず4つの構造体を、上から下へゆっくりと目で追います。それから、下に書いたGreenhousePlantトレイトのコードに視線を移し、fn with_cold_guard(self) -> Self;という行のあたりで、視線がぴたりと動かなくなりました。

1
2
3
4
5
trait GreenhousePlant {
    fn tend(&self) -> String;
    fn with_cold_guard(self) -> Self;
    fn with_auto_water(self) -> Self;
}

僕は、その沈黙にいたたまれなくなって、聞いてしまいます。

「あの、traitの書き方、何か間違ってますよね……?」

庭師さんは答えません。ただ、画面の-> Selfという部分を、指先で軽く示すだけでした。僕は、自分の書いたコードの、その部分をもう一度見返します。何がまずいのか、まだ分かりません。

庭師さんは、赤いエラーメッセージにも目を落としました。「the trait GreenhousePlant is not dyn compatible」。何度か読み返しているようでしたが、表情は変わりません——ように見えました。それから、また上の4つの構造体に視線を戻し、それぞれのtend()の中身を、順に指でなぞっていきます。3つ目、4つ目と進むにつれ、指の動きがだんだん重なって見える文章をなぞっているのが分かりました。

見立てと翻訳 ── 「そっくり株」という名前

庭師さんの視線が、ようやくヒバリさんの方へ動きました。

「そっくり株、増えたな」

ヒバリさんが、僕の方を向きます。

「これ、“そっくり株"って呼んでるんですけど」

自分から、そう切り出しました。

「機能を1つ足すたびに、ほとんど同じ中身の構造体を、コピーして増やしていってる状態のことです」

言われて画面に目を落とすと、確かにそうでした。「あ……TomatoColdGuardAutoWaterの中身、TomatoTomatoColdGuardTomatoAutoWaterを手でくっつけて書いたやつです」

「それで、traitで直そうとしたのは、悪い発想じゃなかったんです」とヒバリさんが続けます。「ただ、書き方が一つ、引っかかってます」

ヒバリさんはfn with_cold_guard(self) -> Self;を指します。

「このメソッド、Selfを返してますよね。トレイト(複数の型に共通のふるまい——メソッド——を約束させる仕組みです。この約束を満たしていれば、違う型でも同じ操作で扱えます)をdyn——さっきのエラーで出てきた"dyn compatible"のdynです——として、複数の型をまとめて箱に入れて扱いたいとき、トレイトのメソッドがSelfを戻り値に使ってると、それができなくなるんです。selfを引数として受け取る分には、実は問題ないんですよ。問題になるのは、戻り値の型としてSelfを使ってるところだけです」

僕は、すぐには言葉が出ませんでした。画面のwith_cold_guardの行と、さっき動いたはずの呼び出しを、頭の中で何度か見比べます。

「……でも」

ようやく口を開きました。

「コンパイルは一旦通ったんですよ。Tomato::new(...).with_cold_guard()って、単体では動きました」

ヒバリさんが頷きます。「単体で使う分には、確かに動きます。壊れるのは、Vec<Box<dyn GreenhousePlant>>って、色んな型をまとめて1つのリストに入れようとした瞬間だけなんです」

「でも、なんでそこで急に壊れるんですか」と僕は食い下がりました。「型は分かってるはずなのに」

Box<dyn GreenhousePlant>にした時点で、コンパイラは中身がTomatoなのかColdGuardなのか、具体的な型を忘れてしまうんです」とヒバリさんが答えます。「覚えてるのは”GreenhousePlantを満たしてる何か"ってことだけ。だから、呼び出せるメソッドの一覧——実行時に参照する早見表(この仕組みは"vtable"と呼ばれています)——を、トレイトの約束だけを頼りに作らないといけません。もしwith_cold_guardSelfを返すとしたら、その早見表には『戻り値の型は、呼び出した時点ではまだ分からない具体的な型』って書くしかなくなるんです。それでは早見表として機能しません」

僕は、まとめて扱おうとしなければ壊れなかったのか、と考え込みます。単体では動く。まとめると壊れる——同じコードのはずなのに、扱い方一つでこうも違うのかと、腑に落ちるような落ちないような感覚でした。

「これ、そもそもいつ頃からこうなったんですか」とヒバリさんが聞きます。

「その都度、それで済ませてきたんです」と僕は経緯を語ります。

「最初はTomato一つで足りてました。研修が始まったばかりの頃です。冬が近づいて、耐寒カバーをつけた株のログを分けたいって話になって、TomatoColdGuardを作って。次に自動潅水を入れた株も分けたくなって、TomatoAutoWater。両方つけた株も出てきて、TomatoColdGuardAutoWater……そのたびに、コピーが一番早いと思ってました」

ヒバリさんが「最初はそれで十分でしたよね、誰も悪くないです」と言います。庭師さんは、何も言わずに一度だけ首を縦に動かしました。

「品種の方は、実は構造体を分けてないんです」と僕は付け加えます。「大玉もミニも、nameの中身が違うだけで、struct自体は同じ形なので。爆発してるのは、あくまで機能の掛け合わせの方でした」

「traitを使おうと思ったのは、なんでですか」とヒバリさんが聞きます。僕は少し前のめりになって答えました。

「前の現場でも、設定をメソッドチェーンで組み立てるやり方、よく使ってたので……型を1つにまとめて、機能を足すメソッドを繋げれば、構造体を増やさずに済むはずだと思ったんです」

それまで手も口も動かさなかった庭師さんの口から、短い一語が漏れました。

「積み方が違う」

僕は「え」と聞き返します。ヒバリさんが噛み砕きました。

「機能を追加するたびに、“同じ型のまま作り変える"んじゃなくて、“元の株はそのままにして、外側から包む"必要があるんです。それが接ぎ木——Decoratorです」

手入れ ── 外側から、接ぐ

庭師さんがノートパソコンに手を伸ばします。僕が場所を譲ると、庭師さんはまずGreenhousePlantトレイトを書き直し始めました。tendメソッドだけを残し、with_cold_guardwith_auto_waterを削ります。ヒバリさんが実況してくれました。

「機能を追加するメソッドを、トレイト自体からは無くします。かわりに、機能ごとに専用の構造体を作って、そっちに任せます」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
trait GreenhousePlant {
    fn tend(&self) -> String;
}

struct Tomato {
    name: String,
}

impl Tomato {
    fn new(name: String) -> Self {
        Tomato { name }
    }
}

impl GreenhousePlant for Tomato {
    fn tend(&self) -> String {
        format!("{}の世話をする", self.name)
    }
}

庭師さんがColdGuardという構造体を書きます。中はplant: Box<dyn GreenhousePlant>というフィールド1つだけ。

「これは何ですか。dynは分かりましたけど、なんでそのままplant: dyn GreenhousePlantじゃダメなんですか」と僕が聞くと、ヒバリさんが答えます。

「トレイトを実装する型ごとに、大きさがバラバラだからです。TomatoColdGuard自身、フィールドの数も違いますよね。フィールドに直接dyn GreenhousePlantを置こうとすると、コンパイラは『このフィールド、何バイト分の場所を確保すればいいのか』を決められません。Boxは中身をヒープに置いて、ColdGuard側にはポインタ1個分の大きさだけを持たせます。中身がどんな型でも、ポインタの大きさは変わらないので、これなら場所を確保できるんです」

「元の株を、そのまま内側に持っておくための場所です。tend()を呼ばれたら、まず内側の株に世話をさせて、その結果に耐寒カバーの確認を1行足す。これだけです」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct ColdGuard {
    plant: Box<dyn GreenhousePlant>,
}

impl ColdGuard {
    fn new(plant: Box<dyn GreenhousePlant>) -> Self {
        ColdGuard { plant }
    }
}

impl GreenhousePlant for ColdGuard {
    fn tend(&self) -> String {
        format!("{}。耐寒カバーの霜を確認する", self.plant.tend())
    }
}

僕はimpl GreenhousePlant for ColdGuardのコードを見ます。self.plant.tend()の結果に、文字列を1行足しているだけです。

「あ、Tomato自体は、何も書き換えてないんですね」

「そうです」とヒバリさん。「品種を増やしても、Tomatoは変わりません」

僕は、ふと引っかかりを覚えてColdGuard::newの行を指しました。

「あの、これ-> Selfって書いてありますよね。さっき、Selfを返すのがダメだって言ってませんでした?」

「いいところに気づきましたね」とヒバリさんが笑います。「with_cold_guardnew、似てるようで実は違うんです。with_cold_guard(self) -> Selfは、&selfを取る——トレイトのメソッドでした。でもnew(plant: Box<dyn GreenhousePlant>) -> Self&selfselfも受け取ってません。トレイトに属するメソッドじゃなくて、ColdGuardという構造体に紐づく、ただの関数なんです。dyn互換性の規則が縛るのは、トレイトのメソッドだけ。構造体固有の関数は、最初から規則の外なんです」

僕は、頭の中で線を引き直しました。「機能を追加するメソッド」をトレイトの中に生やしたのが、自分の失敗だったのだと分かります。庭師さんたちは、それをトレイトの外——構造体ごとのnew——に置いていました。

庭師さんは、AutoWaterも同じ形で書いてから、実際に積み重ねる書き方を見せてくれました。

1
2
3
let plant = Tomato::new("桃太郎".to_string());
let plant = ColdGuard::new(Box::new(plant));
let plant = AutoWater::new(Box::new(plant));

「これでTomatoColdGuardAutoWaterと同じ株になる」とヒバリさんが言います。

「じゃあ、今日追加したかった防虫ネットは……」

僕が言いかけると、庭師さんはもうNetGuardという構造体を書き始めていました。plantフィールドもnewも、ColdGuardと一字一句同じ形。違うのはtend()の中で足す文言だけで、"耐寒カバーの霜を確認する""防虫ネットの破れを確認する"に変わっています。書き終えると、plantにもう一段重ねます。

1
let plant = NetGuard::new(Box::new(plant));

「構造体、1個も増えてない……いや、増えてますけど、TomatoにもColdGuardにもAutoWaterにも、一切手を入れてない」

さっきまで、防虫ネットを足すには4つの構造体が8つに膨れ上がる覚悟をしていました。それが、たった1つの新しい構造体を、外側に重ねるだけで済んでいます。拍子抜けするくらい、あっけない手つきでした。

「機能が3つなら、Beforeのやり方だと組み合わせは2の3乗で8個でした」とヒバリさんが言葉にしてくれます。「Decoratorだと、機能ごとに構造体が1つ——3機能なら3個です。組み合わせの数は、型の数としては増えません。tend()を呼ぶときにどれとどれを重ねるか、実行時に選んでるだけなので」

構造体の数は機能の数に比例するだけで、組み合わせの数だけ増えるわけじゃない——僕はその一言を、頭の中で自分の言葉に置き換えてみました。

僕がまだ半分ぴんときていない顔をしていたからか、ヒバリさんは作業台の隅のメモ用紙を引き寄せて、鉛筆でさらさらと図を描き始めました。左側には、中身だけ変えて量産された札が四枚。右側には、trait札を真ん中に置いて、そこへ台木の札と機能の札を麻紐で結んだ図です。

「左が今までのやり方、右がDecoratorです」とヒバリさんが鉛筆の先で示します。「左は機能が増えるたび、札そのものが掛け算で増えていきます。右は、台木の札は一枚のまま、機能の札を紐で結んで足していくだけです」

Decoratorパターンの比較図。左側はBefore「機能を足すたびに複製」で、Tomato・TomatoColdGuard・TomatoAutoWater・TomatoColdGuardAutoWaterの4枚の種子袋カードが並び、防虫ネットを足すと2の3乗で8個に増えると記されている。右側はAfter「台木は一本、接ぎ木で増やす」で、GreenhousePlant(トレイト)の札を頂点に、ColdGuard・AutoWater・NetGuardの3枚の札が点線(実装)でトレイト札へ、実線の麻紐(内側に保持)でTomato(台木)札へ結ばれている

「これって、Vec<Box<dyn GreenhousePlant>>にも、ちゃんと入りますよね」と僕が聞きます。ヒバリさんが頷きました。

「入ります。ColdGuardAutoWaterNetGuardも、tend(&self)しか持ってないので」

僕は少し考えて、「型ごとに分けて別々の関数を書く方法もあったと思うんですけど、なんでこう、いろんな型を一つの箱にまとめられるようにしてるんですか」と聞きました。

庭師さんが短く答えます。

「まとめて運ぶなら、箱がいる」

「品種も機能もバラバラな株を1つのリストで扱いたいときに向いてる書き方なんです」とヒバリさんが続けます。「型ごとに分ける書き方は、扱う型が最初から決まってるときの方が得意なんです。今回みたいに、依頼人ごとに組み合わせが違う株を、後からどれだけ増えても同じVecにまとめたい——っていう場面だと、Box<dyn Trait>(ヒープに置いた、トレイトオブジェクト——具体的な型を指定せず"このトレイトを満たす何か"として扱うための入れ物です——への、所有権付きの入れ物)の方が向いてます」。庭師さんたちは、このBox<dyn Trait>を「接ぎ木した株をまとめて運べる鉢」と呼んでいるそうです。

僕はさらに聞きます。「これで、もう構造体を増やさずに、機能をいくつでも足せるってことですよね」

しばらく黙っていた庭師さんが、ぼそりと返しました。

「重ねる順は、選べ」

僕は意味を掴みかねて、ヒバリさんを見ます。

「さっきのNetGuard、一番外側に重ねましたよね」とヒバリさんが説明を始めました。「この状態なら、防虫ネットだけ外して耐寒カバーと自動潅水は残す、っていうのも簡単です。一番外側の一枚を剥がすだけなので。でも、もし防虫ネットをColdGuardAutoWaterの間に挟んでたら——防虫ネットだけを外すのに、一番外のAutoWaterも一度剥がして、また組み直すことになります」

僕は、少し黙って考えます。

「……外すときは、掛けた順の逆でしか、外せないってことですか」

ヒバリさんが頷きます。「そうです。重ねる順番、何も考えずに決めていいわけじゃないんです」

「どのDecoratorも、まず内側のtend()を呼んでから、自分の一行を後ろに足す作りになってます」とヒバリさんが仕組みを言葉にします。「だから、一番外側に重ねたものの一行が、いちばん最後にくっつくんです。外側が先に自分の分を書いてから内側を呼ぶわけじゃないので、重ねた順がそのまま出力の順に効いてきます」

実際、ColdGuardAutoWaterを逆の順で重ねると、tend()が返す文言の並びも入れ替わります。

1
2
3
4
5
6
// ColdGuardが外側、AutoWaterが内側
let plant = Tomato::new("桃太郎".to_string());
let plant = AutoWater::new(Box::new(plant));
let plant = ColdGuard::new(Box::new(plant));
// -> "桃太郎の世話をする。自動潅水のタンク残量を確認する。耐寒カバーの霜を確認する"
// (先の重ね方とは、耐寒カバーと自動潅水の順番が入れ替わっている)

庭師さんが、作業台の隅にあった素焼きの小さな鉢を三つ、黙って重ねてみせました。一番奥にトマトの苗、その上にひとまわり大きな鉢、さらにもうひとまわり大きな鉢。

「中から、順に」

とだけ言って、鉢の底を指先で軽くつつきます。

tend()を呼ぶと、まず一番内側のTomatoが答えを返して、それを一つ外側のColdGuardが受け取って一文足す。さらに外側のAutoWaterが受け取ってまた一文——最後にNetGuardが受け取って、防虫ネットの一文を足して返します」とヒバリさんが言葉を継ぎました。「中から外へ、答えが鉢を一つずつくぐって出てくるイメージです」

Decoratorの積み重ね順とtend()出力順の対応図。左から台木のTomato(桃太郎の世話をする)、1枚目の鉢ColdGuard(耐寒カバーの霜を確認する)、2枚目の鉢AutoWater(自動潅水のタンク残量を確認する)、3枚目の鉢NetGuard・一番外側(防虫ネットの破れを確認する)と鉢が順に大きくなりながら並び、tend呼び出しの矢印でつながって、最終出力として4つの確認文が積み重なった木札が右端に描かれている

見送り ── 帰り道、幹に残る継ぎ目

画面に映った書き直し後のコードを自分のノートパソコンへ書き写しながら、僕は手を止めずに尋ねました。

「今日追加しようとしてた防虫ネットも、これでちゃんと組み込めますよね」

庭師さんは頷きます。「ああ」

ヒバリさんが「traitでチェーンを組もうとした発想自体は、悪くなかったですよ。積み方の向きが、ちょっと違っただけです」と笑いました。肩の力がすっと抜けるのを感じながら、僕はノートパソコンを鞄にしまいます。

工房を辞し、自転車を押しながら坂道を戻り始めます。入口の脇、あの木のところに差しかかって、足が止まりました。さっきは木陰に助けられただけで、木そのものはよく見ていなかったのです。今度は、幹に目をやります。根元から少し上がったあたりに、うっすらと継ぎ目のようなものが見えました。接いだ跡だろうか、と思いましたが、聞くほどのことでもない気がして、僕は自転車のペダルに足を戻しました。

自転車を漕ぎながら、僕は独りごちます。

「型を1つにまとめたい、っていう発想自体は、間違ってなかったんだよな……ただ、まとめ方を間違えた」

前職で身につけたやり方が、そのまま通用する場面もあれば、向きを変えないと刺さらない場面もある——農業でもコードでも、それは同じなのかもしれません。今日のうちに、記録ツールを直してしまおう。そして、次にまた機能を足したくなったときは——構造体を複製する前に、一度「これは外側から包めないか」を考えよう、と思いました。


手入れ記録

こんな症状が出たら入れるべき手入れ(パターン)まだ様子見でいい
機能を1つ増やすたびに、構造体(型)の数が掛け算式に増えている✓ Decorator(共通トレイト+Box<dyn Trait>の委譲)
種類の異なる株を1つのリストにまとめて、統一的に処理したい✓ Decorator(トレイトオブジェクトでまとめて扱う)
機能が高々1〜2個で、今後増える予定が無い✓ そのままの実装で様子見

手入れの手順

  1. 似た構造体を洗い出し、共通のふるまい(トレイト)を1つに抜き出す
  2. トレイトのメソッドがSelfを戻り値に使っていないか確認する(dyn互換性を妨げる)
  3. 基本の型(ConcreteComponent)を1つに絞り、以後は書き換えない
  4. 機能ごとにラッパー構造体(Decorator)を作り、内部にBox<dyn Trait>を保持して委譲する
  5. 必要な機能だけ、重ねる順番を決めて組み立てる(順番は結果に影響するので、何も考えずに決めない)

庭師の一言

足すときの手つきが、外すときの手順を決める。

見習いの手控え

師匠の言葉、要は「重ねる順番も込みで設計してください」ってことです。今日は、traitでチェーンを組もうとした発想自体は良かったんですよね。向きを変えるだけで、ちゃんと接ぎ木になりました。

comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource
Hugo で構築されています。
テーマ StackJimmy によって設計されています。