【よくわかる】Amazon Managed Blockchainによるプライベートネットワークの構築-Part 2チェーンコード編

記事タイトルとURLをコピーする

こんにちは、ディベロップメントサービス1課の山本です。

今回の記事では、AMB(Amazon Managed Blockchain) の利用方法について解説します。

特に、プライベートネットワークの構築について、AWS が提供しているサンプルを用いて詳しく説明します。

このサンプルは 9 つのパートに分かれていますので、1 つのパート1 つのブログ記事として扱い、それぞれ詳しく解説していきます。

皆様の理解を深めるために、図や詳細な説明を多く取り入れる予定です。

ブロックチェーンや AMB の基本的な内容が知りたい方は、過去のブログを参考ください。

関連過去ブログ

blog.serverworks.co.jp

blog.serverworks.co.jp

blog.serverworks.co.jp

この記事の対象者は?

この記事は以下の方々に向けて書かれています:

  • Amazon Managed Blockchain を使用してプライベートネットワークを構築することを検討している開発者の方々
  • Hyperledger Fabric のチェーンコードの実装を検討している方々

進め方

AMB(Amazon Managed Blockchain)のプライベートネットワークについての理解を深めるため、AWS が提供しているサンプルを用いて具体的な実装を進めていきます。

この過程で、各ステップの説明を挟みつつ、実際の操作を行っていきます。

サンプルの詳細は以下のリンクからご覧いただけます:

https://github.com/aws-samples/non-profit-blockchain/blob/master/README.md

サンプルの概要

このサンプルでは、架空の非営利団体(NGO 団体)向けに、寄付金の出納をブロックチェーンで管理するシステムを構築します。

このシステムを通じて、ブロックチェーンの透明性と信頼性を活用し、寄付金の管理をより効率的かつ安全に行う方法を学びます。

サンプルのパート

  1. Amazon マネージド ブロックチェーンを使用して Hyperledger Fabric ブロックチェーン ネットワークを構築することでワークショップを開始します。
  2. 非営利チェーンコードをデプロイします。
  3. RESTful API サーバーを実行します。
  4. アプリケーションを実行します。
  5. 新しいメンバーをネットワークに追加します。
  6. Amazon API Gateway と AWS Lambda を使用したブロックチェーンへの読み取りと書き込み。
  7. ブロックチェーン イベントを使用して、NGO の寄付をユーザーに通知します。
  8. Hyperledger Explorer を展開します。
  9. ブロックチェーンユーザーと Amazon Cognito の統合。

本ブログでは

2. 非営利チェーンコードをデプロイします。

を解説します。

チェーンコードのデプロイ

このセクションでは、こちらの README.mdを参考に、チェーンコードのデプロイを行います。

前回のパート 1 では、Fabric CLI に付属のチェーンコードをデプロイしましたが、今回は、サンプルが作成したカスタムチェーンコードのデプロイを行います。

本ブログの目標は以下の通りです:

  • チェーンコードの内容とデプロイ方法について理解を深める。
  • チェーンコード内で利用する API の一部について理解を深める。

構成図

このパートでは、以下の図に示すネットワーク構成を構築します。

構成図

前提条件

パート1のステップ 7ピアノードをチャネルに参加させるまで実施していることが前提となります。

また、クライアントノードのセッションを一度切断した場合は、
以下のコマンドを使用して環境変数を再度上書きする必要があります。

cd ~/non-profit-blockchain/ngo-fabric
source fabric-exports.sh
source ~/peer-exports.sh

これらの手順を完了すると、チェーンコードのデプロイを行う準備が整います。

ステップ 1:チェーンコードを CLI コンテナにコピーする

Hyperledger Fabric のネットワークを操作するための CLI ツールは、Docker コンテナとして動作しています。

このコンテナは、ホストマシンであるクライアントノードの特定のディレクトリをマウントしており、これによりホストマシン上のチェーンコードをコンテナ内からアクセスできます。

まず、以下のコマンドを実行して、CLI コンテナがどのディレクトリをマウントしているかを確認します。

docker inspect cli

出力結果(Mounts 抜粋)

[
  {
    "Mounts": [
      {
        "Type": "volume",
        "Name": "6fc665cbf8cc0569dd1a9270432e36ddac438e076a48590f92cd5ea7aa538554",
        "Source": "/var/lib/docker/volumes/6fc665cbf8cc0569dd1a9270432e36ddac438e076a48590f92cd5ea7aa538554/_data",
        "Destination": "/etc/hyperledger/fabric",
        "Driver": "local",
        "Mode": "",
        "RW": true,
        "Propagation": ""
      },
      {
        "Type": "bind",
        "Source": "/var/run",
        "Destination": "/host/var/run",
        "Mode": "rw",
        "RW": true,
        "Propagation": "rprivate"
      },
      {
        "Type": "bind",
        "Source": "/home/ec2-user/fabric-samples/chaincode",
        "Destination": "/opt/gopath/src/github.com",
        "Mode": "rw",
        "RW": true,
        "Propagation": "rprivate"
      },
      {
        "Type": "bind",
        "Source": "/home/ec2-user",
        "Destination": "/opt/home",
        "Mode": "rw",
        "RW": true,
        "Propagation": "rprivate"
      }
    ]
  }
]

CLI コンテナ内の/opt/gopath/src/github.comチェーンコード用のフォルダに当たります。

{
  "Type": "bind",
  "Source": "/home/ec2-user/fabric-samples/chaincode",
  "Destination": "/opt/gopath/src/github.com",
  "Mode": "rw",
  "RW": true,
  "Propagation": "rprivate"
}

そのため、クライアントノードの/home/ec2-user/fabric-samples/chaincodeにチェーンコードを格納することで、CLI コンテナからデプロイ可能となります。

早速、/non-profit-blockchain/ngo-chaincode/src/配下にあるサンプルのチェーンコードをコピーしましょう。

cd ~
mkdir -p ./fabric-samples/chaincode/ngo
cp ./non-profit-blockchain/ngo-chaincode/src/* ./fabric-samples/chaincode/ngo

これで、CLI コンテナ内の/opt/gopath/src/github.com/ngo ディレクトリにチェーンコードがマウントされ、デプロイの準備が整いました

番外:チェーンコードの書き方

ただ、デプロイするだけでは面白くないですよね。

なので、チェーンコードの基本的な書き方を学んだ後に、実際の中身を確認していきたいと思います。

基本テンプレート

まず基本的な、チェーンコードのテンプレートは下記のようになります。

// モジュールインポート
const shim = require("fabric-shim");
const util = require("util");
  
// チェーンコードのクラス定義
var Chaincode = class {
  // Init処理
  Init(stub) {
    // Init処理の内容
    // チェーンコードをインスタンス化する際の処理
  }
  
  // Invoke処理
  Invoke(stub) {
    // Invoke処理の内容
    // stubから実行する関数名と引数を取得し、別関数へと引き渡す
  }
  
  FunctionA(stub, args) {
    // Invokeから呼び出される関数A
  }
  
  FunctionB(stub, args) {
    // Invokeから呼び出される関数B
  }
};
  
// チェインコード処理の開始
shim.start(new Chaincode());

API の種類

チェーンコードを実装時に 2 つの API を利用できます。

  • fabric-shim(低レベル API)
  • fabric-contract-api(高レベル API)

boto3 の client API と resource API のような関係です。

それぞれ、インポートするモジュールが異なるのでご注意ください。

// fabric-shimのインポート
const shim = require("fabric-shim");
  
// fabric-contract-apiのインポート
const { Contract } = require("fabric-contract-api");

各 API の利用方法は、下記のドキュメントに記載されております。 https://hyperledger.github.io/fabric-chaincode-node/main/api/

基本的な API

Hyperledger Fabric ではデータは Key-Value 形式で保存されます。

API を利用して、データを Read/Write することで、チェーンコードを実装します。

これさえ知っていれば、最低限の処理を書ける API を 3 つ紹介します。

  • getFunctionAndParameters()
    • CLI から送信された関数名と引数を返却する
    • Invoke 処理で利用される
  • getState(key)
    • 指定した key の値を Read する
  • putState(key, value)
    • 指定した key の値を value 値で Write する

Read/Write さえできれば、大半のプログラムは作れます。

番外:実際のチェーンコードを確認する

それでは、デプロイするチェーンコードの中身を見ていきます。

今回デプロイするチェーンコードは下記の Git サンプルに格納されているものです。

https://github.com/aws-samples/non-profit-blockchain/blob/master/ngo-chaincode/src/ngo.js

このチェーンコードは JavaScript で書かれています。

Hyperledger Fabric は Node.js、Java、Go の SDK を提供しているため、これらの言語のいずれかを使用してチェーンコードを開発することが可能です。

Init 処理

まずは、チェーンコードの Init 処理について見ていきましょう。

/**
 * Initialize the state when the chaincode is either instantiated or upgraded
 *
 * @param {*} stub
 */
async Init(stub) {
  console.log('=========== Init: Instantiated / Upgraded ngo chaincode ===========');
  return shim.success();
}

このコードは、チェーンコードがインスタンス化またはアップグレードされたときに呼び出されます。

ここでは、単にログを出力し、成功の応答を返しています。
このチェーンコードでは、インスタンス化の際に台帳へのデータ書き込みは行われないようです。

Invoke 処理

次に、チェーンコードの Invoke 処理について見ていきましょう。

この Invoke メソッドは、チェーンコードが呼び出される際に実行されます。

/**
 * The Invoke method will call the methods below based on the method name passed by the calling
 * program.
 *
 * @param {*} stub
 */
async Invoke(stub) {
  console.log('============= START : Invoke ===========');
  let ret = stub.getFunctionAndParameters();
  console.log('##### Invoke args: ' + JSON.stringify(ret));
  
  let method = this[ret.fcn];
  if (!method) {
    console.error('##### Invoke - error: no chaincode function with name: ' + ret.fcn + ' found');
    throw new Error('No chaincode function with name: ' + ret.fcn + ' found');
  }
  try {
      let response = await method(stub, ret.params);
      console.log('##### Invoke response payload: ' + response);
    return shim.success(response);
  } catch (err) {
      console.log('##### Invoke - error: ' + err);
      return shim.error(err);
  }
}

stub.getFunctionAndParameters()を使用して、呼び出し元から渡された関数名と引数を取得します。

その後、該当する関数がチェーンコード内に存在するか確認します。

存在する場合、その関数を引数とともに実行し、結果を返します。関数が存在しない場合、エラーをスローします。

関数の説明

実際に呼び出される関数を一つ見ていきます。

こちらは寄付者をブロックに保存する関数となります。

async createDonor(stub, args) {
  console.log('============= START : createDonor ===========');
  console.log('##### createDonor arguments: ' + JSON.stringify(args));
  
  // args is passed as a JSON string
  let json = JSON.parse(args);
  let key = 'donor' + json['donorUserName'];
  json['docType'] = 'donor';
  
  console.log('##### createDonor payload: ' + JSON.stringify(json));
  
  // Check if the donor already exists
  let donorQuery = await stub.getState(key);
  if (donorQuery.toString()) {
    throw new Error('##### createDonor - This donor already exists: ' + json['donorUserName']);
  }
  
  await stub.putState(key, Buffer.from(JSON.stringify(json)));
  console.log('============= END : createDonor ===========');
}

args として想定されている値

{
  "donorUserName": "edge",
  "email": "edge@def.com",
  "registeredDate": "2018-10-22T11:52:20.182Z"
}

donor + donorUserNameの値をキーにして、DB に新規寄付者を登録する処理になります。

一度、stub.getState(key)で、DB 内の値の有無を確認した後、 stub.putState(key, Buffer.from(JSON.stringify(json)))で書き込みをしています。

一つ一つの処理は、非常に簡単になっております。

ステップ 2:チェーンコードをピアにインストールする

以降はパート 1 で説明した内容と同じことを行い、チェーンコードをデプロイします。

下記コマンドで、チェーンコードをピアにインストールします。

docker exec -e "CORE_PEER_TLS_ENABLED=true" -e "CORE_PEER_TLS_ROOTCERT_FILE=/opt/home/managedblockchain-tls-chain.pem" \
    -e "CORE_PEER_LOCALMSPID=$MSP" -e "CORE_PEER_MSPCONFIGPATH=$MSP_PATH" -e "CORE_PEER_ADDRESS=$PEER"  \
    cli peer chaincode install -n ngo -l node -v v0 -p /opt/gopath/src/github.com/ngo

-p /opt/gopath/src/github.com/ngoの箇所が、パート 1 と異なり、今回コピーしたチェーンコードをデプロイするようになってます。

ステップ 3:チャネル上でチェーンコードをインスタンス化する

下記コマンドで、チェーンコードをインスタンス化します。

docker exec -e "CORE_PEER_TLS_ENABLED=true" -e "CORE_PEER_TLS_ROOTCERT_FILE=/opt/home/managedblockchain-tls-chain.pem" \
    -e "CORE_PEER_LOCALMSPID=$MSP" -e "CORE_PEER_MSPCONFIGPATH=$MSP_PATH" -e "CORE_PEER_ADDRESS=$PEER"  \
    cli peer chaincode instantiate -o $ORDERER -C mychannel -n ngo -v v0 -c '{"Args":["init"]}' --cafile /opt/home/managedblockchain-tls-chain.pem --tls

ステップ 4:チェーンコードをクエリする

全ての寄付者の情報を取得する関数queryAllDonorsを呼び出します。

下記コマンドで、チェーンコードをクエリします。

docker exec -e "CORE_PEER_TLS_ENABLED=true" -e "CORE_PEER_TLS_ROOTCERT_FILE=/opt/home/managedblockchain-tls-chain.pem" \
    -e "CORE_PEER_ADDRESS=$PEER" -e "CORE_PEER_LOCALMSPID=$MSP" -e "CORE_PEER_MSPCONFIGPATH=$MSP_PATH" \
    cli peer chaincode query -C mychannel -n ngo -c '{"Args":["queryAllDonors"]}'

寄付者の登録がまだなので、空の配列が返却されます。

出力結果

[]

せっかくなので、queryAllDonorsのコードを見てみましょう。

async queryAllDonors(stub, args) {
    console.log('============= START : queryAllDonors ===========');
    console.log('##### queryAllDonors arguments: ' + JSON.stringify(args));
  
    let queryString = '{"selector": {"docType": "donor"}}';
    return queryByString(stub, queryString);
}

queryByStringも見てみようと思ったのですが、関数が長かったので一部抜粋します。

async function queryByString(stub, queryString) {
  console.log("============= START : queryByString ===========");
  console.log("##### queryByString queryString: " + queryString);
  
  // CouchDB Query
  // let iterator = await stub.getQueryResult(queryString);
  
  // Equivalent LevelDB Query. We need to parse queryString to determine what is being queried
  // In this chaincode, all queries will either query ALL records for a specific docType, or
  // they will filter ALL the records looking for a specific NGO, Donor, Donation, etc. So far,
  // in this chaincode there is a maximum of one filter parameter in addition to the docType.
  let docType = "";
  let startKey = "";
  let endKey = "";
  let jsonQueryString = JSON.parse(queryString);
  if (jsonQueryString["selector"] && jsonQueryString["selector"]["docType"]) {
    docType = jsonQueryString["selector"]["docType"];
    startKey = docType + "0";
    endKey = docType + "z";
  } else {
    throw new Error(
      "##### queryByString - Cannot call queryByString without a docType element: " +
        queryString
    );
  }
  
  let iterator = await stub.getStateByRange(startKey, endKey);
  
  // Iterator handling is identical for both CouchDB and LevelDB result sets, with the
  // exception of the filter handling in the commented section below
  let allResults = [];
  while (true) {
    let res = await iterator.next();
    // イテレータを利用して、各キーの値を格納する処理(省略)
  }
}

今回、注目なのがstub.getStateByRange(startKey, endKey)を利用している点です。

こちらは、startKey から endKey までの Key を持つデータのイテレータを取得する関数となります。

  • startKey: donor0
  • endKey: donorz

寄付者登録時のキーは、donor+ donorUserNameなので、大半のユーザーは当てはまります。

おやっと思われた方は、最後に検証してみたので、そちらをご確認ください。

ステップ 5:トランザクションを呼び出す

寄付者の登録を行う、createDonorを呼び出します。 今回は、edge さんと braendle の二人を登録します。

下記コマンドを実行します。

docker exec -e "CORE_PEER_TLS_ENABLED=true" -e "CORE_PEER_TLS_ROOTCERT_FILE=/opt/home/managedblockchain-tls-chain.pem" \
    -e "CORE_PEER_ADDRESS=$PEER" -e "CORE_PEER_LOCALMSPID=$MSP" -e "CORE_PEER_MSPCONFIGPATH=$MSP_PATH" \
    cli peer chaincode invoke -C mychannel -n ngo \
    -c  '{"Args":["createDonor","{\"donorUserName\": \"edge\", \"email\": \"edge@def.com\", \"registeredDate\": \"2018-10-22T11:52:20.182Z\"}"]}' -o $ORDERER --cafile /opt/home/managedblockchain-tls-chain.pem --tls
  
docker exec -e "CORE_PEER_TLS_ENABLED=true" -e "CORE_PEER_TLS_ROOTCERT_FILE=/opt/home/managedblockchain-tls-chain.pem" \
    -e "CORE_PEER_ADDRESS=$PEER" -e "CORE_PEER_LOCALMSPID=$MSP" -e "CORE_PEER_MSPCONFIGPATH=$MSP_PATH" \
    cli peer chaincode invoke -C mychannel -n ngo \
    -c  '{"Args":["createDonor","{\"donorUserName\": \"braendle\", \"email\": \"braendle@def.com\", \"registeredDate\": \"2018-11-05T14:31:20.182Z\"}"]}' -o $ORDERER --cafile /opt/home/managedblockchain-tls-chain.pem --tls

ステップ 6:チェーンコードをクエリする

最後に、もう一度、全ての寄付者の情報を取得する関数queryAllDonorsを呼び出します。

docker exec -e "CORE_PEER_TLS_ENABLED=true" -e "CORE_PEER_TLS_ROOTCERT_FILE=/opt/home/managedblockchain-tls-chain.pem" \
    -e "CORE_PEER_ADDRESS=$PEER" -e "CORE_PEER_LOCALMSPID=$MSP" -e "CORE_PEER_MSPCONFIGPATH=$MSP_PATH" \
    cli peer chaincode query -C mychannel -n ngo -c '{"Args":["queryAllDonors"]}'

出力結果

[
  {
    "Key": "donorbraendle",
    "Record": {
      "docType": "donor",
      "donorUserName": "braendle",
      "email": "braendle@def.com",
      "registeredDate": "2018-11-05T14:31:20.182Z"
    }
  },
  {
    "Key": "donoredge",
    "Record": {
      "docType": "donor",
      "donorUserName": "edge",
      "email": "edge@def.com",
      "registeredDate": "2018-10-22T11:52:20.182Z"
    }
  }
]

特定の寄付者の情報を取得する関数queryDonorもあるので、こちらも試します。

docker exec -e "CORE_PEER_TLS_ENABLED=true" -e "CORE_PEER_TLS_ROOTCERT_FILE=/opt/home/managedblockchain-tls-chain.pem" \
    -e "CORE_PEER_ADDRESS=$PEER" -e "CORE_PEER_LOCALMSPID=$MSP" -e "CORE_PEER_MSPCONFIGPATH=$MSP_PATH" \
    cli peer chaincode query -C mychannel -n ngo -c '{"Args":["queryDonor","{\"donorUserName\": \"edge\"}"]}'

出力結果

{
  "docType": "donor",
  "donorUserName": "edge",
  "email": "edge@def.com",
  "registeredDate": "2018-10-22T11:52:20.182Z"
}

無事、設定したデータを取得できました。

おまけ(サンプルプログラムのバグ)

バグ見つけました。

内容

queryAllDonorsでは、donorUserName の頭文字が z 以降のユーザーが取得できません。

このブログで関数の説明をした内容を引用します。

今回、注目なのがstub.getStateByRange(startKey, endKey)を利用している点です。

こちらは、startKey から endKey までの Key を持つデータのイテレータを取得する関数となります。

  • startKey: donor0
  • endKey: donorz

この関数は、startKey <= Key < endKey となる Key を返却します。 そのため、donorz 以降のキーは返却されません。(おそらく ASCII コード順)

検証

試しに、donorUserNamezzzさんを登録してみます。

docker exec -e "CORE_PEER_TLS_ENABLED=true" -e "CORE_PEER_TLS_ROOTCERT_FILE=/opt/home/managedblockchain-tls-chain.pem" \
    -e "CORE_PEER_ADDRESS=$PEER" -e "CORE_PEER_LOCALMSPID=$MSP" -e "CORE_PEER_MSPCONFIGPATH=$MSP_PATH" \
    cli peer chaincode invoke -C mychannel -n ngo \
    -c  '{"Args":["createDonor","{\"donorUserName\": \"zzz\", \"email\": \"zzz@def.com\", \"registeredDate\": \"2018-10-22T11:52:20.182Z\"}"]}' -o $ORDERER --cafile /opt/home/managedblockchain-tls-chain.pem --tls

次に、全ての寄付者の情報を取得する関数queryAllDonorsを呼び出します。

zzzさんは返却されるでしょうか。

docker exec -e "CORE_PEER_TLS_ENABLED=true" -e "CORE_PEER_TLS_ROOTCERT_FILE=/opt/home/managedblockchain-tls-chain.pem" \
    -e "CORE_PEER_ADDRESS=$PEER" -e "CORE_PEER_LOCALMSPID=$MSP" -e "CORE_PEER_MSPCONFIGPATH=$MSP_PATH" \
    cli peer chaincode query -C mychannel -n ngo -c '{"Args":["queryAllDonors"]}'

出力結果

[
  {
    "Key": "donorbraendle",
    "Record": {
      "docType": "donor",
      "donorUserName": "braendle",
      "email": "braendle@def.com",
      "registeredDate": "2018-11-05T14:31:20.182Z"
    }
  },
  {
    "Key": "donoredge",
    "Record": {
      "docType": "donor",
      "donorUserName": "edge",
      "email": "edge@def.com",
      "registeredDate": "2018-10-22T11:52:20.182Z"
    }
  }
]

やはり、zzzさんの情報が返ってきません。

DB には保存されていると思うので、特定の寄付者の情報を取得する関数queryDonorzzzさんの情報を取得します。

docker exec -e "CORE_PEER_TLS_ENABLED=true" -e "CORE_PEER_TLS_ROOTCERT_FILE=/opt/home/managedblockchain-tls-chain.pem" \
    -e "CORE_PEER_ADDRESS=$PEER" -e "CORE_PEER_LOCALMSPID=$MSP" -e "CORE_PEER_MSPCONFIGPATH=$MSP_PATH" \
    cli peer chaincode query -C mychannel -n ngo -c '{"Args":["queryDonor","{\"donorUserName\": \"zzz\"}"]}'

出力結果

{
  "docType": "donor",
  "donorUserName": "zzz",
  "email": "zzz@def.com",
  "registeredDate": "2018-10-22T11:52:20.182Z"
}

zzzさんの情報が無事返ってきました。

解決法

endKey をdonorの次の文字、donosにしてあげましょう。

  • startKey: donor0
  • endKey: donos

プログラムの変更内容

async function queryByString(stub, queryString) {
  console.log('============= START : queryByString ===========');
  console.log("##### queryByString queryString: " + queryString);
  let docType = "";
  let startKey = "";
  let endKey = "";
  let lastChar = "";  // (追加)docTypeの末尾一文字
  let nextChar = ""; // (追加)docTypeの末尾一文字の次の文字
  let jsonQueryString = JSON.parse(queryString);
  if (jsonQueryString['selector'] && jsonQueryString['selector']['docType']) {
    docType = jsonQueryString['selector']['docType'];
    startKey = docType + "0";
  
    lastChar = docType.slice(-1);
    nextChar = String.fromCharCode(lastChar.charCodeAt(0) + 1);
  
    endKey = docType.slice(0, -1) + nextChar; // (変更)docTypeの末尾一文字を次の文字に差し替え
  }

無事、取得することができました。

[
  {
    "Key": "donorbraendle",
    "Record": {
      "docType": "donor",
      "donorUserName": "braendle",
      "email": "braendle@def.com",
      "registeredDate": "2018-11-05T14:31:20.182Z"
    }
  },
  {
    "Key": "donoredge",
    "Record": {
      "docType": "donor",
      "donorUserName": "edge",
      "email": "edge@def.com",
      "registeredDate": "2018-10-22T11:52:20.182Z"
    }
  },
  {
    "Key": "donorzzz",
    "Record": {
      "docType": "donor",
      "donorUserName": "zzz",
      "email": "zzz@def.com",
      "registeredDate": "2018-10-22T11:52:20.182Z"
    }
  }
]

さいごに

この記事では、チェーンコードの説明からデプロイについて解説しました。 コマンドを叩くだけのサンプルが、少しでも理解しやすくなっていたのならば幸いです。

今後も、以降のサンプルパートについて、さらに詳しく解説していきますので、ぜひご期待ください。

本ブログがどなかたのお役に立てれば幸いです。

山本 真大(執筆記事の一覧)

アプリケーションサービス部 ディベロップメントサービス1課

2023年8月入社。カピバラさんが好き。