メインコンテンツへスキップ

コントラクトのユーザーインターフェースの構築

typescript
react
vite
wagmi
フロントエンド
初級
オリ・ポメランツ
2023年11月1日
32 分で読めます
ページを編集 (opens in a new tab)

イーサリアムエコシステムで必要な機能を見つけました。それを実装するためのスマートコントラクトを書き、おそらくオフチェーンで実行される関連コードも書いたことでしょう。これは素晴らしいことです!しかし残念ながら、ユーザーインターフェースがなければユーザーを獲得することはできません。そして、あなたが最後にウェブサイトを書いたのは、人々がダイヤルアップモデムを使用し、JavaScriptがまだ新しかった頃かもしれません。

この記事はそんなあなたのためのものです。プログラミングの知識があり、JavaScriptやHTMLについても少しは知っているものの、ユーザーインターフェースのスキルが錆びついて時代遅れになっていることを前提としています。一緒にシンプルな最新のアプリケーションを見ていき、最近の開発手法を学びましょう。

なぜこれが重要なのか

理論的には、ユーザーにEtherscan (opens in a new tab)Blockscout (opens in a new tab)を使ってコントラクトとやり取りしてもらうことも可能です。これは経験豊富なイーサリアムユーザーにとっては素晴らしいことです。しかし、私たちはさらに10億人の人々 (opens in a new tab)にサービスを提供しようとしています。優れたユーザーエクスペリエンスなしにはこれは実現しませんし、使いやすいユーザーインターフェースはその大きな部分を占めています。

Greeterアプリケーション

最新のUIがどのように機能するかについては多くの理論があり、それを説明している (opens in a new tab)優れたサイトがたくさんあります (opens in a new tab)。それらのサイトが行った素晴らしい仕事を繰り返す代わりに、あなたが実践から学ぶことを好むと仮定し、実際に触って遊べるアプリケーションから始めます。物事を成し遂げるためには依然として理論が必要であり、それについても触れていきます。ソースファイルを一つずつ見ていき、必要になった時点で議論を進めます。

インストール

  1. このアプリケーションはSepolia (opens in a new tab)テストネットワークを使用します。必要に応じて、SepoliaのテストETHを取得し、ウォレットにSepoliaを追加 (opens in a new tab)してください。

  2. GitHubリポジトリをクローンし、必要なパッケージをインストールします。

    git clone https://github.com/qbzzt/260301-modern-ui-web3.git
    cd 260301-modern-ui-web3
    npm install
    
  3. このアプリケーションは無料のアクセスポイントを使用しているため、パフォーマンスに制限があります。Node as a serviceプロバイダーを使用したい場合は、src/wagmi.ts内のURLを置き換えてください。

  4. アプリケーションを起動します。

    npm run dev
    
  5. アプリケーションに表示されたURLにブラウザでアクセスします。ほとんどの場合、それはhttp://localhost:5173/ (opens in a new tab)です。

  6. HardhatのGreeterを変更したコントラクトのソースコードは、ブロックチェーンエクスプローラーで (opens in a new tab)確認できます。

ファイルのウォークスルー

index.html

このファイルは、スクリプトファイルをインポートするこの行を除いて、標準的なHTMLのボイラープレートです。

<script type="module" src="/src/main.tsx"></script>

src/main.tsx

ファイル拡張子は、これが型チェック (opens in a new tab)をサポートするJavaScriptの拡張言語であるTypeScript (opens in a new tab)で書かれたReactコンポーネント (opens in a new tab)であることを示しています。TypeScriptはJavaScriptにコンパイルされるため、クライアント側で使用できます。

このファイルは、興味がある方のために主に説明されています。通常、このファイルを変更することはなく、src/App.tsxとそれがインポートするファイルを変更します。

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { WagmiProvider } from 'wagmi'

必要なライブラリコードをインポートします。

import App from './App.tsx'

アプリケーションを実装するReactコンポーネントをインポートします(下記参照)。

import { config } from './wagmi.ts'

ブロックチェーンの設定を含むWagmi (opens in a new tab)の設定をインポートします。

const queryClient = new QueryClient()

React Queryの (opens in a new tab)キャッシュマネージャーの新しいインスタンスを作成します。このオブジェクトには以下が保存されます:

  • キャッシュされたRPC呼び出し
  • コントラクトの読み取り
  • バックグラウンドでの再フェッチ状態

Wagmi v3は内部でReact Queryを使用しているため、キャッシュマネージャーが必要です。

ReactDOM.createRoot(document.getElementById('root')!).render(

ルートのReactコンポーネントを作成します。renderへのパラメーターは、HTMLとJavaScript/TypeScriptの両方を使用する拡張言語であるJSX (opens in a new tab)です。ここでの感嘆符は、TypeScriptコンポーネントに対して「document.getElementById('root')ReactDOM.createRootへの有効なパラメーターになるかどうかはわからないかもしれないが、心配しないでほしい。私は開発者であり、有効なパラメーターになると伝えているのだから」と伝えています。

  <React.StrictMode>

アプリケーションはReact.StrictModeコンポーネント (opens in a new tab)の中に入ります。このコンポーネントは、Reactライブラリに追加のデバッグチェックを挿入するように指示し、開発中に役立ちます。

    <WagmiProvider config={config}>

アプリケーションはWagmiProviderコンポーネント (opens in a new tab)の中にも入ります。Wagmi(これから作成します)ライブラリ (opens in a new tab)は、ReactのUI定義と、イーサリアムの分散型アプリケーション (dapp) を書くためのViemライブラリ (opens in a new tab)を接続します。

      <QueryClientProvider client={queryClient}>

そして最後に、任意のアプリケーションコンポーネントがキャッシュされたクエリを使用できるように、React Queryプロバイダーを追加します。

        <App />

これで、実際にUIを実装するアプリケーションのコンポーネントを配置できます。コンポーネントの最後にある/>は、XML標準に従って、このコンポーネントの内部に定義がないことをReactに伝えます。

      </QueryClientProvider>
    </WagmiProvider>
  </React.StrictMode>,
)

もちろん、他のコンポーネントも閉じる必要があります。

src/App.tsx

必要なライブラリと、Greeterコンポーネントをインポートします。

const SEPOLIA_CHAIN_ID = 11155111

SepoliaのチェーンIDです。

function App() {

これはReactコンポーネントを作成する標準的な方法です。レンダリングが必要になるたびに呼び出される関数を定義します。この関数には通常、TypeScriptまたはJavaScriptのコードが含まれ、その後にJSXコードを返すreturnステートメントが続きます。

  const connection = useConnection()

useConnection (opens in a new tab)を使用して、アドレスやchainIdなど、現在の接続に関連する情報を取得します。

慣例として、Reactではuse...と呼ばれる関数はフック (opens in a new tab)です。これらの関数はコンポーネントにデータを返すだけでなく、そのデータが変更されたときにコンポーネントが再レンダリングされる(コンポーネント関数が再度実行され、その出力がHTML内の前の出力を置き換える)ことを保証します。

  const { connectors, connect, status, error } = useConnect()

useConnect (opens in a new tab)を使用して、ウォレット接続に関する情報を取得します。

  const { disconnect } = useDisconnect()

このフック (opens in a new tab)は、ウォレットから切断するための関数を提供します。

  const { switchChain } = useSwitchChain()

このフック (opens in a new tab)を使用すると、チェーンを切り替えることができます。

  useEffect(() => {

ReactフックのuseEffect (opens in a new tab)を使用すると、変数の値が変更されるたびに関数を実行して、外部システムを同期させることができます。

    if (connection.status === 'connected' &&
        connection.chainId !== SEPOLIA_CHAIN_ID
    ) {
      switchChain({ chainId: SEPOLIA_CHAIN_ID })
    }

接続されているが、Sepoliaブロックチェーンに接続されていない場合は、Sepoliaに切り替えます。

  }, [connection.status, connection.chainId])

接続ステータスまたは接続のchainIdのいずれかが変更されるたびに、関数を再実行します。

  return (
    <>

ReactコンポーネントのJSXは、単一のHTMLコンポーネントを返す_必要があります_。複数のコンポーネントがあり、それらすべてをラップするコンテナが必要ない場合は、空のコンポーネント(<> ... </>)を使用してそれらを単一のコンポーネントに結合します。

現在の接続に関する情報を提供します。JSX内では、{<expression>}は式をJavaScriptとして評価することを意味します。

      {connection.status === 'connected' && (

構文{<condition> && <value>} means "if the condition is true, evaluate to the value; if it isn't, evaluate to false`"です。

これは、JSX内にif文を記述する標準的な方法です。

        <div>
          <Greeter />
          <hr />

JSXはHTMLよりも厳密なXML標準に従います。タグに対応する終了タグがない場合は、終了させるために末尾にスラッシュ(/)を付ける_必要があります_。

ここにはそのようなタグが2つあります。<Greeter />(実際にはコントラクトと通信するHTMLコードが含まれています)と、水平線用の<hr /> (opens in a new tab)です。

          <button type="button" onClick={disconnect}>
            Disconnect
          </button>
 
</div>
      )}

ユーザーがこのボタンをクリックした場合は、disconnect関数を呼び出します。

      {connection.status !== 'connected' && (

接続されて_いない_場合は、ウォレットに接続するために必要なオプションを表示します。

        <div>
          <h2>Connect</h2>
          {connectors.map((connector) => (

connectorsにはコネクタのリストがあります。map (opens in a new tab)を使用して、これを表示用のJSXボタンのリストに変換します。

            <button
              key={connector.uid}

JSXでは、「兄弟」タグ(同じ親から派生したタグ)が異なる識別子を持つ必要があります。

              onClick={() => connect({ connector })}
              type="button"
            >
              {connector.name}
            </button>
          ))}

コネクタボタンです。

          <div>{status}</div>
          <div>{error?.message}</div>
 
</div>
      )}

追加情報を提供します。式構文<variable>?.<field>は、変数が定義されている場合はそのフィールドを評価するようにJavaScriptに指示します。変数が定義されていない場合、この式はundefinedと評価されます。

error.messageは、エラーがない場合に例外を発生させます。error?.messageを使用すると、この問題を回避できます。

src/Greeter.tsx

このファイルには、UI機能の大部分が含まれています。通常は複数のファイルに分かれる定義が含まれていますが、これはチュートリアルであるため、プログラムはパフォーマンスやメンテナンスのしやすさよりも、初めて理解しやすいように最適化されています。

これらのライブラリ関数を使用します。繰り返しになりますが、これらは使用される箇所で後述します。

import { AddressType } from 'abitype'

abitypeライブラリ (opens in a new tab)は、AddressType (opens in a new tab)など、さまざまなイーサリアムのデータ型のTypeScript定義を提供します。

let greeterABI = [
  { "type": "function", "name": "greet", ... },
  { "type": "function", "name": "setGreeting", ... },
  { "type": "event", "name": "SetGreeting", ... },
] as const   // greeterABI

GreeterコントラクトのABIです。 コントラクトとUIを同時に開発している場合、通常はそれらを同じリポジトリに配置し、Solidityコンパイラによって生成されたABIをアプリケーション内のファイルとして使用します。ただし、ここではコントラクトがすでに開発されており変更されないため、その必要はありません。

as const (opens in a new tab)を使用して、これが_真の_定数であることをTypeScriptに伝えます。通常、JavaScriptでconst x = {"a": 1}と指定した場合、xの値を変更することはできますが、代入することはできません。

type AddressPerBlockchainType = {
  [key: number]: AddressType
}

TypeScriptは静的型付け言語です。この定義を使用して、Greeterコントラクトが異なるチェーンにデプロイされているアドレスを指定します。キーは数値(chainId)であり、値はAddressType(アドレス)です。

const contractAddrs : AddressPerBlockchainType = {
  // Sepolia
    11155111: '0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA'
}

Sepolia (opens in a new tab)上のコントラクトのアドレスです。

Timerコンポーネント

Timerコンポーネントは、指定された時間からの経過秒数を表示します。これはユーザビリティの観点から重要です。ユーザーが何かアクションを起こしたとき、即座の反応を期待します。ブロックチェーンでは、トランザクションがブロックに配置されるまで何も起こらないため、これは多くの場合不可能です。1つの解決策は、ユーザーがアクションを実行してからどれくらいの時間が経過したかを表示し、ユーザーが必要な時間が妥当かどうかを判断できるようにすることです。

type TimerProps = {
  lastUpdate: Date
}

Timerコンポーネントは、最後のアクションの時刻である1つのパラメーターlastUpdateを受け取ります。

const Timer = ({ lastUpdate }: TimerProps) => {
  const [_, setNow] = useState(new Date())

コンポーネントが正しく機能するためには、状態(コンポーネントに結び付けられた変数)を持ち、それを更新する必要があります。しかし、それを読み取る必要は決してないため、わざわざ変数にする必要はありません。

  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 1000)
    return () => clearInterval(id)
  }, [])

setInterval (opens in a new tab)関数を使用すると、関数を定期的に実行するようにスケジュールできます。この場合は1秒ごとです。この関数はsetNowを呼び出して状態を更新するため、Timerコンポーネントが再レンダリングされます。これを空の依存関係リストを持つuseEffect (opens in a new tab)内にラップすることで、コンポーネントがレンダリングされるたびではなく、1回だけ実行されるようにします。

  const secondsSinceUpdate = Math.floor(
    (Date.now() - lastUpdate.getTime()) / 1000
  )

  return (
    <span>{secondsSinceUpdate} seconds ago</span>
  )
}

最後の更新からの秒数を計算して返します。

Greeterコンポーネント
const Greeter = () => {

最後に、コンポーネントを定義します。

  const chainId = useChainId()
  const account = useAccount()

Wagmi (opens in a new tab)が提供する、使用しているチェーンとアカウントに関する情報です。これはフック(use...)であるため、この情報が変更されるたびにコンポーネントが再レンダリングされます。

  const greeterAddr = chainId && contractAddrs[chainId] 

Greeterコントラクトのアドレスです。チェーン情報がない場合、またはそのコントラクトがないチェーンにいる場合はundefinedになります。

  const readResults = useReadContract({
    address: greeterAddr,
    abi: greeterABI,
    functionName: "greet", // 引数なし
  })

useReadContractフック (opens in a new tab)は、コントラクト (opens in a new tab)greet関数を呼び出します。

  const [ currentGreeting, setCurrentGreeting ] = 
    useState("Please wait while we fetch the greeting from the blockchain...")
  const [ newGreeting, setNewGreeting ] = useState("")

ReactのuseStateフック (opens in a new tab)を使用すると、コンポーネントのレンダリング間で値が保持される状態変数を指定できます。初期値はパラメーターであり、この場合は空の文字列です。

useStateフックは、2つの値を持つリストを返します。

  1. 状態変数の現在の値。
  2. 必要に応じて状態変数を変更するための関数。これはフックであるため、呼び出されるたびにコンポーネントが再度レンダリングされます。

この場合、ユーザーが設定したい新しい挨拶のために状態変数を使用しています。

  const [ lastSetterAddress, setLastSetterAddress ] = useState("")

複数のユーザーが同時に同じコントラクトを使用している場合、お互いの挨拶を上書きしてしまう可能性があります。これはユーザーにとって、アプリケーションが誤動作しているように見えるでしょう。アプリケーションが最後に挨拶を設定した人を表示すれば、ユーザーはそれが他の誰かであり、アプリケーションが正しく機能していることがわかります。

  const [ status, setStatus ] = useState("")
  const [ statusTime, setStatusTime ] = useState(new Date())

ユーザーは自分のアクションが即座に効果を発揮することを見たいと思っています。しかし、ブロックチェーンではそうはいきません。これらの状態変数を使用すると、少なくともユーザーに何かを表示して、アクションが進行中であることを知らせることができます。

  useEffect(() => {
    if (readResults.data) {
      setCurrentGreeting(readResults.data)
      setStatus("Greeting fetched from blockchain")
    }
  }, [readResults.data])

上記のreadResultsがデータを変更し、それが偽の値(たとえばundefined)に設定されていない場合は、現在の挨拶をブロックチェーンから読み取ったものに更新します。また、ステータスも更新します。

  useWatchContractEvent({
    address: greeterAddr,
    abi: greeterABI,
    eventName: 'SetGreeting',
    chainId,

SetGreetingイベントをリッスンします。

    enabled: !!greeterAddr,

!!<value>は、値がfalseであるか、undefined0、空の文字列など、偽として評価される値である場合、式全体がfalseになることを意味します。その他の値の場合はtrueになります。これは値をブール値に変換する方法です。なぜなら、greeterAddrがない場合はイベントをリッスンしたくないからです。

    onLogs: logs => {
      const greetingFromContract = logs[0].args.greeting
      setCurrentGreeting(greetingFromContract)
      setLastSetterAddress(logs[0].args.sender)
      updateStatus("Greeting updated by event")
    },
  })

ログが表示された場合(新しいイベントが表示された場合に発生します)、挨拶が変更されたことを意味します。その場合、currentGreetinglastSetterAddressを新しい値に更新できます。また、ステータス表示も更新したいと考えます。

  const updateStatus = (newStatus: string) => {
    setStatus(newStatus)
    setStatusTime(new Date())
  }

ステータスを更新するときに、2つのことを行いたいと考えます。

  1. ステータス文字列(status)を更新する
  2. 最後のステータス更新時刻(statusTime)を現在に更新する。
  const greetingChange = (evt) =>
    setNewGreeting(evt.target.value)

これは、新しい挨拶の入力フィールドの変更に対するイベントハンドラーです。evtパラメーターの型を指定することもできますが、TypeScriptは型の指定がオプションの言語です。この関数はHTMLイベントハンドラーで1回だけ呼び出されるため、必要ないと思います。

  const { writeContractAsync } = useWriteContract()

コントラクトに書き込むための関数です。writeContracts (opens in a new tab)に似ていますが、より優れたステータス更新を可能にします。

  const simulation = useSimulateContract({
    address: greeterAddr,
    abi: greeterABI,
    functionName: 'setGreeting',
    args: [newGreeting],
    account: account.address    
  })

クライアントの観点からブロックチェーンのトランザクションを送信するプロセスは次のとおりです。

  1. eth_estimateGas (opens in a new tab)を使用して、ブロックチェーン内のノードにトランザクションを送信します。
  2. ノードからの応答を待ちます。
  3. 応答を受信したら、ウォレットを通じてトランザクションに署名するようにユーザーに求めます。ユーザーは署名する前にトランザクションのガス代を提示されるため、このステップはノードの応答を受信した後に実行する_必要があります_。
  4. ユーザーの承認を待ちます。
  5. 今度はeth_sendRawTransaction (opens in a new tab)を使用して、トランザクションを再度送信します。

ステップ2は知覚できるほどの時間がかかる可能性が高く、その間、ユーザーは自分のコマンドがユーザーインターフェースに受信されたかどうか、なぜまだトランザクションへの署名を求められないのか疑問に思うかもしれません。これは貧弱なユーザーエクスペリエンス(UX)を生み出します。

1つの解決策は、パラメーターが変更されるたびにeth_estimateGasを送信することです。そうすれば、ユーザーが実際にトランザクションを送信したいとき(この場合はUpdate greetingを押すことによって)、ガス代がわかっており、ユーザーはすぐにウォレットページを見ることができます。

  return (

これでようやく、返す実際のHTMLを作成できます。

    <>
      <h2>Greeter</h2>
      {currentGreeting}

現在の挨拶を表示します。

      {lastSetterAddress && (
        <p>Last updated by {
          lastSetterAddress === account.address ? "you" : lastSetterAddress
        }</p>
      )}

最後に挨拶を設定した人がわかっている場合は、その情報を表示します。Greeterはこの情報を追跡しておらず、SetGreetingイベントを遡って探したくないため、実行中に挨拶が変更された場合にのみ取得します。

      <hr />      
      <input type="text"
        value={newGreeting}
        onChange={greetingChange}
      />      
      <br />

これは、ユーザーが新しい挨拶を設定できる入力テキストフィールドです。ユーザーがキーを押すたびに、greetingChangeを呼び出し、それがsetNewGreetingを呼び出します。setNewGreetinguseStateから来ているため、Greeterコンポーネントが再レンダリングされます。これは次のことを意味します。

  • 新しい挨拶の値を保持するためにvalueを指定する必要があります。そうしないと、デフォルトの空の文字列に戻ってしまうからです。
  • simulationnewGreetingが変更されるたびに更新されます。つまり、正しい挨拶でシミュレーションを取得できます。ガス代はコールデータのサイズに依存し、コールデータのサイズは文字列の長さに依存するため、これは関連する可能性があります。
      <button disabled={!simulation.data}

トランザクションを送信するために必要な情報が得られた場合にのみ、ボタンを有効にします。

        onClick={async () => {
          updateStatus("Please confirm in wallet...")

ステータスを更新します。この時点で、ユーザーはウォレットで確認する必要があります。

          await writeContractAsync(simulation.data.request)
          updateStatus("Transaction sent, waiting for greeting to change...")
        }}
      >
        Update greeting
      </button>

writeContractAsyncは、トランザクションが実際に送信された後にのみ戻ります。これにより、トランザクションがブロックチェーンに含まれるのをどれくらい待っているかをユーザーに示すことができます。

      <h4>Status: {status}</h4>
      <p>Updated <Timer lastUpdate={statusTime} /> </p>
    </>
  )
}

ステータスと、更新されてからの経過時間を表示します。

export {Greeter}

コンポーネントをエクスポートします。

src/wagmi.ts

最後に、Wagmiに関連するさまざまな定義がsrc/wagmi.tsにあります。そのほとんどは変更する必要のないボイラープレートであるため、ここではすべてを説明するつもりはありません。

import { http, webSocket, createConfig, fallback } from 'wagmi'
import { sepolia } from 'wagmi/chains'
import { injected } from 'wagmi/connectors'

export const config = createConfig({
  chains: [sepolia],

Wagmiの設定には、このアプリケーションでサポートされているチェーンが含まれています。利用可能なチェーンのリスト (opens in a new tab)を確認できます。

  connectors: [
    injected(),
  ],

このコネクタ (opens in a new tab)を使用すると、ブラウザにインストールされているウォレットと通信できます。

  transports: {
    [sepolia.id]: http()

Viemに付属しているデフォルトのHTTPエンドポイントで十分です。別のURLが必要な場合は、http("https:// hostname ")またはwebSocket("wss:// hostname ")を使用できます。

  },
  multiInjectedProviderDiscovery: false,
})

別のブロックチェーンの追加

最近では多くのL2スケーリングソリューション (opens in a new tab)があり、Viemがまだサポートしていないものをサポートしたいと思うかもしれません。それを行うには、src/wagmi.tsを変更します。これらの手順では、Optimism Sepolia (opens in a new tab)を追加する方法を説明します。

  1. src/wagmi.tsを編集します

    A. ViemからdefineChain型をインポートします。

    import { defineChain } from 'viem'
    

    B. ネットワーク定義を追加します。Optimism Sepoliaについては、すでにviemに含まれている (opens in a new tab)ため、実際にはこれを行う必要はありませんが、この方法でviemに含まれていないブロックチェーンを追加する方法を学ぶことができます。

    C. createConfig呼び出しに新しいチェーンを追加します。

  2. src/App.tsxを編集して、Sepoliaへの自動切り替えをコメントアウトします。本番システムでは、おそらくサポートする各ブロックチェーンへのリンクを持つボタンを表示するでしょう。

  3. src/Greeter.tsxを編集して、アプリケーションが新しいネットワーク上のコントラクトのアドレスを確実に認識できるようにします。

    const contractAddrs: AddressPerBlockchainType = {
      // Optimism Sepolia
      11155420: "0x4dd85791923E9294E934271522f63875EAe5806f",
    
      // Sepolia
      11155111: "0x7143d5c190F048C8d19fe325b748b081903E3BF0",
    }
    
  4. ブラウザでの操作。

    A. ChainList (opens in a new tab)にアクセスし、表の右側にあるボタンのいずれかをクリックして、チェーンをウォレットに追加します。

    B. アプリケーションで、Disconnect(切断)してから再接続して、ブロックチェーンを変更します。これを処理するより良い方法はありますが、それにはアプリケーションの変更が必要になります。

まとめ

もちろん、あなたはGreeterのユーザーインターフェースを提供することにはあまり関心がないでしょう。あなた自身のコントラクトのためのユーザーインターフェースを作成したいはずです。独自のアプリケーションを作成するには、次の手順を実行します。

  1. Wagmiアプリケーションを作成するように指定します。

    npm create wagmi
    
  2. yと入力して続行します。

  3. アプリケーションに名前を付けます。

  4. Reactフレームワークを選択します。

  5. Viteバリアントを選択します。

さあ、あなたのコントラクトを世界中で使えるようにしましょう。

私の他の作品はこちらをご覧ください (opens in a new tab)

ページの最終更新: 2026年3月3日