lelelemon’s blog

カメの歩みでのんびり学んでいます。

【Golang】GoでFireStoreにつないでデータを登録する

はじめに

Golang で FireStore 上にデータを登録するやり方が分からなかったので、その対応メモです。

 

事前準備

1.Firebase Project を作成する

Firebase にログインして任意のプロジェクトを作成します。

firebase.google.com その後、左メニューから「すべてのプロダクト」->「Cloud FIrestore」を選択。

画面の指示に従って、FIrestore を使用する準備を完了させます。

 

2.Firebase Admin SDK から Firestore にアクセスできるように秘密鍵を登録する

続いて、下記のサンプルコードにあるように、Firebase Admin SDK で Firestore につなげるために秘密鍵を登録していきます

firebase.google.com

 

Google Cloud コンソールにログインして、プロジェクトは上記手順で作成したプロジェクトを選択します。

サービスアカウントページにアクセスすると、デフォルトで firebase-adminsdk のサービスアカウントが登録されていると思うので、このアカウントを選択 -> キー -> 鍵を追加 と進んで秘密鍵を作成します。
画面の指示に従って、推奨されているJSON 形式で作成するとJSONファイルがダウンロードされます。

 

実装

ここまでで事前準備が完了したので、実装を進めていきます。

firestore.go

package db

import (
	"context"
	"log"
	"os"
	"sync"

	"cloud.google.com/go/firestore"
	firebase "firebase.google.com/go"
	"google.golang.org/api/option"
)

var (
	clientInstance *firestore.Client
	clientOnce     sync.Once
	closeOnce      sync.Once
)

func InitializeFirestoreClient() {
	clientOnce.Do(func() {
		ctx := context.Background()
		sa := option.WithCredentialsFile(os.Getenv("GCLOUD_CREDENTIAL_FILE_PATH"))
		app, err := firebase.NewApp(ctx, nil, sa)
		if err != nil {
			log.Fatalf("error initilizing app: %v", err)
		}

		clientInstance, err = app.Firestore(ctx)
		if err != nil {
			log.Fatalf("error initializing Firestoer client: %v", err)
		}
	})
}

func CloseFirestoreClient() {
	closeOnce.Do(func() {
		err := clientInstance.Close()
		if err != nil {
			log.Printf("error closing Firestore client: %v", err)
		}
	})
}

// テストデータ登録
func PrepareTestData() {
	records := []struct {
		Text string
		Num  int
	}{
		{Text: "text1", Num: 1},
	}
	ctx := context.Background()
	for _, record := range records {
		_, _, err := clientInstance.Collection("records").Add(ctx, record)
		if err != nil {
			log.Printf("Failed to adding record: %v", err)
		} else {
			log.Printf("Successfully added record: %+v", record)
		}
	}
}
  • Firestore ライブラリを import し、InitializeFirestoreClient で初期化します
  • 初期化は1度だけ行えればよいので、sync.Once で行っています
  • 事前準備でダウンロードしたJSONファイルを指定して、GCLOUD_CREDENTIAL_FILE_PATH の環境変数からパスを取得して読み込んでいます。(環境変数読み込み部分のコードは後述)

 

environment.go

package configs

import (
	"log"
	"os"

	"github.com/joho/godotenv"
)

func LoadEnv() error {
	err := godotenv.Load("./configs/.env")

	if err != nil {
		log.Fatalf("error loading env file: %v", err)
		return err
	}

	gcloud_file_path := os.Getenv("GCLOUD_CREDENTIAL_FILE_PATH")
	log.Printf("GCLOUD_CREDENTIAL_FILE_PATH: %v", gcloud_file_path)

	return nil
}

 

  • 環境変数の読み込みには godotenv を使用しています

github.com

  • .env に下記のような形式で環境変数を定義します
GCLOUD_CREDENTIAL_FILE_PATH=

 

main.go

package main

import (
	"backend/configs"
	"backend/internal/db"
)

func main() {
	err := configs.LoadEnv()
	if err != nil {
		panic("error loading env file")
	}
	db.InitializeFirestoreClient()
	defer db.CloseFirestoreClient()

	db.PrepareTestData()
}
  • ここまでの処理を呼び出し、PrepareTestData 関数を呼び出して Firestore に登録を行っています

動作確認

実行前

 

main.go 実行後

 

終わりに

Firestore に接続するまでの事前準備の部分で少し手間取りましたが、シンプルな実装で Go から Firestore に接続できることがわかりました。

今回は登録だけでしたが、参照や更新、削除なども試していきたいです。

【React】React/Python/ElasticSearchで簡易的なドキュメント検索Webアプリを作成

はじめに

CSVやPDF, Excel などの様々なデータソースを ElasticSearch に溜めて、指定の文言が含まれているファイルを検索できる簡易的なドキュメント検索Webアプリケーションを作成しました。

今回のソースコードは下記にプッシュしています。

github.com

 

機能、動作イメージ

ドキュメント検索機能
  • 任意の文字列を入力して検索を押下
  • 検索文字列が含まれるドキュメントがある場合、そのファイル名とファイル内のテキスト(200文字まで)を検索結果として表示する

 

 

ドキュメントアップロード機能
  • 選択ボタンをクリックするとファイル選択ダイアログが立ち上がり、任意のドキュメントを選択
  • 送信ボタンをクリックすると選択したドキュメントが ElasticSearch に登録される

※今回は、CSV, PDF, Excel ファイルいずれかのみ受け付ける形にしています

 

 

使用技術

  • フロントエンド
    • React v18.2.0
    • TypeScript v4.9.5
  • バックエンド
    • Python v3.8.10
    • FastAPI v0.110.0
    • ElasticSearch v7.5.1
    • Docker v20.10.17

 

実装詳細ーバックエンド

以下で ElasticSearch とのつなぎこみ箇所を中心にソースコードを添付しています。

ElasticSearch

ElasticSearch のDockerイメージから環作成しています。

docker-compose.yml
version: "3"
services:
  sysctl:
    image: alpine
    container_name: sysctl
    command: ["sysctl", "-w", "vm.max_map_count=262144"]
    privileged: true
    networks:
      - esnet
  es01:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: es01
    environment:
      - node.name=es01
      - cluster.initial_master_nodes=es01
      - cluster.name=docker-cluster
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - esdata01:/usr/share/elasticsearch/data
    ports:
      - 9200:9200
    networks:
      - esnet
    depends_on:
      - sysctl

volumes:
  esdata01:
    driver: local

networks:
  esnet:

 

  • esdata01 というボリュームを作成してマウントしています
  • ElasticSearch を起動後、メモリ割り当てが足りない旨のエラーが出てそのまま停止してしまう事象が発生したため、 vm.max_map_count=262144 でメモリ割り当てを増やして停止しないようにしています
Dockerfile
FROM docker.elastic.co/elasticsearch/elasticsearch:7.5.1
RUN elasticsearch-plugin install analysis-kuromoji

 

  • ElasticSearch コンテナはこちらで指定しています
  • 今回日本語も扱うため、kuromojiプラグイン を追加しています

 

バックエンド

リクエストハンドリング
@app.get("/search", response_model=SearchDocumentResponse)
def search(text: Optional[str] = Query(None, description="検索クエリー")):
    return search_document(text)


@app.post("/upload")
async def upload(file: UploadFile = File(...)):
    result = await upload_document(file)
    return JSONResponse(content={"result": result})

 

  • FastAPI を使い、「ドキュメント検索」と「ドキュメントアップロード」機能用のエンドポイントを定義しています
  • 検索エンドポイントは検索文字列を受け取る必要があるため、text というクエリーパラメーターで受け取るようにしています
  • ドキュメントアップロードで送信されたファイルデータは、UploadFile というクラスで受け取ることができます

 

ElasticSearch のドキュメント検索
import re
import requests
from models.search_document import SearchDocumentResponse, SearchDocumentResult


def search_document(text: str):
    try:
        # Elasticsearchへのクエリ実行
        if is_alphanumeric(text):
            # 半角英数字の場合
            query = {"query": {"regexp": {"doc.text": f".*{text}.*"}}}
        else:
            # 日本語の場合
            query = {"query": {"match_phrase": {"doc.text": text}}}

        response = requests.post("http://localhost:9200/book/_search", json=query)

        # ステータスコードが200以外の場合はエラーを出力して終了
        if response.status_code != 200:
            print(
                f"Error: Failed to retrieve data from Elasticsearch. Status code: {response.status_code}"
            )
            return SearchDocumentResponse(count=0, result=)

        # レスポンスからhitsを取得
        data = response.json()["hits"]["hits"]

        # 検索結果を格納するリスト
        search_results =

        # レスポンスのhitsに対してループ処理
        for hit in data:
            file_name = hit["_source"]["doc"]["name"]
            # textがリストか文字列かで処理を分岐
            text_content = hit["_source"]["doc"]["text"]
            if isinstance(text_content, list):
                # textがリストの場合、各要素を連結して1つの文字列にする
                text_content = "\n".join(text_content)
            search_results.append(
                SearchDocumentResult(file_name=file_name, text=text_content)
            )

        # 検索結果をSearchDocumentResponseに格納して返す
        return SearchDocumentResponse(count=len(search_results), result=search_results)
    except Exception as e:
        print(
            f"Error: An error occurred while processing the response from Elasticsearch: {str(e)}"
        )
        return SearchDocumentResponse(count=0, result=)


def is_alphanumeric(text):
    pattern = re.compile(r"^[a-zA-Z0-9]*$")
    return bool(pattern.match(text))

 

  • 検索文字列を text 変数として受け取り、ElasticSearch への検索クエリーを構築します
  • 日本語と英数字両方に対応している検索クエリーが見当たらかなったため、日本語と英数字で投げるクエリーを動的に変えています(いずれも部分一致検索で指定の文字列が含まれるか検索する)
  • 複数件ヒットする可能性があるため、配列に検索結果を詰めて返却しています

 

ElasticSearch にドキュメント登録
async def upload_document(file: UploadFile = File(...)):
    content_type = file.content_type
    if content_type == FILE_TYPE_PDF:
        await upload_pdf(file)
    elif content_type == FILE_TYPE_CSV:
        await upload_csv(file)
    elif content_type == FILE_TYPE_EXCEL:
        await upload_excel(file)
    else:
        print("not found")
        pass
  • 今回はCSV, Excel, PDF ドキュメントを対象に、ElasticSearch にドキュメントを登録します
ElasticSearch にドキュメント登録(CSV
from csv import DictReader
import json
import os
from fastapi import File, UploadFile
import requests

from constant.constant import (
    ELASTIC_SEARCH_REQUEST_HEADERS,
    ELASTIC_SEARCH_URL,
)


async def upload_csv(file: UploadFile = File(...)):
    try:
        file_name = file.filename

        file_path = os.path.join(os.getcwd(), file_name)

        # アップロードされたファイルを保存
        with open(file_path, "wb") as f:
            f.write(await file.read())

        file_content =

        with open(file_path, "rt", encoding="utf-8") as file:
            reader = DictReader(file)

            for row in reader:
                row_json = json.dumps(row, ensure_ascii=False)
                file_content.append(row_json)

        # Elasticsearchに送信するデータを構築
        data = {"doc": {"name": file_name, "text": file_content}}
        print("Uploaded file data:", data)

        response = requests.post(
            ELASTIC_SEARCH_URL,
            json=data,
            headers=ELASTIC_SEARCH_REQUEST_HEADERS,
        )
        if response.status_code == 201:
            print("Document indexed successfully.")
        else:
            print(f"Failed to index document. Status code: {response.text}")
            return False

        # 一時ファイルを削除
        os.unlink(file_path)

        return True
    except Exception as e:
        print(
            f"Error: An error occurred while processing uploading csv to Elasticsearch: {str(e)}"
        )
        return False

 

  • 引数の UploadFile からファイル名とファイル内容を読み込みます
  • ファイル内容の読み込み部分は、いったん Temp ファイルに退避しておいて、Temp ファイルからファイル内容を文字列に読み込んでいます(直接 UploadFile から読み込んで ElasticSearch に送信したところエラーになったため。。)
  • name text というフィールドを持ったドキュメントを登録しています。(※事前に book というインデックスを作成しこれを使用しています)

 

 

ElasticSearch にドキュメント登録(Excel
import json
import os
from fastapi import File, UploadFile
import pandas as pd
import requests

from constant.constant import ELASTIC_SEARCH_REQUEST_HEADERS, ELASTIC_SEARCH_URL


async def upload_excel(file: UploadFile = File(...)):
    try:
        file_name = file.filename

        file_path = os.path.join(os.getcwd(), file_name)

        # アップロードされたファイルを保存
        with open(file_path, "wb") as f:
            f.write(await file.read())

        # ブック全体の内容を入れるための空のリストを作成
        book_content = []

        # Excelファイルから各シートのデータを読み込んでリストに追加する
        with pd.ExcelFile(file_path) as xls:
            for sheet_name in xls.sheet_names:
                df = pd.read_excel(xls, sheet_name)
                records = df.to_dict(orient="records")
                book_content.extend(records)

        # Elasticsearchに送信するデータを構築
        data = {
            "doc": {
                "name": file_name,
                "text": json.dumps(book_content, ensure_ascii=False),
            }
        }
        print("Uploaded file data:", data)

        response = requests.post(
            ELASTIC_SEARCH_URL,
            json=data,
            headers=ELASTIC_SEARCH_REQUEST_HEADERS,
        )
        if response.status_code == 201:
            print("Document indexed successfully.")
        else:
            print(f"Failed to index document. Status code: {response.text}")
            return False

        # 一時ファイルを削除
        os.unlink(file_path)

        return True
    except Exception as e:
        print(
            f"Error: An error occurred while processing uploading excel to Elasticsearch: {str(e)}"
        )
        return False

 

  • CSVの処理同様に、ファイル名とファイル内容を読み込んでいます
  • pandas を使いシートごとに読み込んだ内容を配列に読み込み、最終的に1つの文字列に変換して ElasticSearch に登録します

 

ElasticSearch にドキュメント登録(PDF)
import os
from tempfile import NamedTemporaryFile
from fastapi import File, UploadFile
from pdfminer.high_level import extract_text
import requests

from constant.constant import ELASTIC_SEARCH_REQUEST_HEADERS, ELASTIC_SEARCH_URL


async def upload_pdf(file: UploadFile = File(...)):
    try:
        file_name = file.filename

        file_path = os.path.join(os.getcwd(), file_name)

        # アップロードされたファイルを保存
        with open(file_path, "wb") as f:
            f.write(await file.read())

        # PDFからテキストを抽出
        file_text = extract_text(file_path)

        # Elasticsearchに送信するデータを構築
        data = {"doc": {"name": file_name, "text": file_text}}
        print("Uploaded file data:", data)

        response = requests.post(
            ELASTIC_SEARCH_URL,
            json=data,
            headers=ELASTIC_SEARCH_REQUEST_HEADERS,
        )
        if response.status_code == 201:
            print("Document indexed successfully.")
        else:
            print(f"Failed to index document. Status code: {response.text}")
            return False

        # 一時ファイルを削除
        os.unlink(file_path)

        return True
    except Exception as e:
        print(
            f"Error: An error occurred while processing uploading pdf to Elasticsearch: {str(e)}"
        )
        return False

 

  • PDF の読み込みは pdfminer がシンプルに書けて使いやすかったので採用しました
  • こちらもCSV, Excel の処理と同じ形式で、ファイル名とファイル内容を読み込んで ElasticSearch に登録します

 

以上のような実装で ElasticSearch へドキュメントの登録と検索を実現することができました。

elasticsearch · PyPI があり、これを最初試していたもののDocker起動している ElasticSearch にうまく接続ができず、今回は HTTP リクエストで登録する方式を取りました

 

おわりに

今回はCSV, Excel, PDF のみでしたが、他にもあらゆる形式のドキュメントを ElascticSearch に溜めこむことができると思いました。

これを応用して例えば、自組織のファイルを ElascticSearch にためていって、任意の文章で検索して目的のファイルを探す、みたいな使い方もできそうですね。

 

 

【トラブルシューティング】Docker起動したElasticSearchのコンテナが起動後少しすると停止する

docker-compose.yml
version: "3"
services:
  es01:
    build: .
    container_name: es01
    environment:
      - node.name=es01
      - discovery.seed_hosts=es02
      - cluster.initial_master_nodes=es01
      - cluster.name=docker-cluster
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - esdata01:/usr/share/elasticsearch/data
    ports:
      - 9200:9200
    networks:
      - esnet

volumes:
  esdata01:
    driver: local

networks:
  esnet:

 

ElasticSearch をローカル起動して色々検証してみようと思って Docker Compose ファイルを用意し、

docker-compose up -d

で起動したものの、下記のエラーで起動したコンテナが停止していたので、その対処法をまとめました。

OpenJDK 64-Bit Server VM warning: Option UseConcMarkSweepGC was deprecated
in version 9.0 and will likely be removed in a future release.
ERROR: [1] bootstrap checks failed
[1]: max virtual memory areas vm.max_map_count [65530] is too low,
increase to at least [262144]

 

対応方法

エラーメッセージの通り、ElasticSearch コンテナに割り当てられるメモリ容量が少なすぎるのが原因のようなので、

 

vi /etc/sysctl.conf

で設定ファイルを開き、

 

 

vm.max_map_count=262144

をファイル末尾に追加して

sudo sysctl -p
で変更を反映する。

その後再度、

docker-compose up -d

でElasticSearchコンテナを起動したら今度は停止せず起動しました。

curl でレスポンスが得られました。

 

curl -X GET http://localhost:9200/
{
  "name" : "es01",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "iGPg2pzlRpKqs6F80SSqDA",
  "version" : {
    "number" : "7.5.1",
    "build_flavor" : "default",
    "build_type" : "docker",
    "build_hash" : "3ae9ac9a93c95bd0cdc054951cf95d88e1e18d96",
    "build_date" : "2019-12-16T22:57:37.835892Z",
    "build_snapshot" : false,
    "lucene_version" : "8.3.0",
    "minimum_wire_compatibility_version" : "6.8.0",
    "minimum_index_compatibility_version" : "6.0.0-beta1"
  },
  "tagline" : "You Know, for Search"
}

 

 

【トラブルシュート - Golang】gqlgen で生成した GraphQL API と WebSocket 通信できない

gqlgen.com

 

gqlgen version: v0.17.44

 

gqlgen 公式の手順に従いAPIを作成し、

const wsLink = new GraphQLWsLink(
  createClient({
    url: "ws://localhost:8080/query",
  })
);

 

Apollo Client を使って上記のように WebSocket 通信を確立しようとしたものの、

ChatMessage.tsx:16  WebSocket connection to 'ws://localhost:8080/query' failed:

のエラーに遭遇したときの対応です。

 

エラーの原因

エラーの原因は下記箇所でした。

srv := handler.NewDefaultServer

 

golang のサーバー側では下記エラーがコンソール出力されています。

unable to upgrade *http.response to websocket websocket: request origin not allowed by Upgrader.CheckOrigin: 

 

handler.NewDefaultServer 関数の中身のコードをみてみると、

func NewDefaultServer(es graphql.ExecutableSchema) *Server {
    srv := New(es)

    srv.AddTransport(transport.Websocket{
        KeepAlivePingInterval: 10 * time.Second,
    })
    srv.AddTransport(transport.Options{})
    srv.AddTransport(transport.GET{})
    srv.AddTransport(transport.POST{})
    srv.AddTransport(transport.MultipartForm{})

    srv.SetQueryCache(lru.New(1000))

    srv.Use(extension.Introspection{})
    srv.Use(extension.AutomaticPersistedQuery{
        Cache: lru.New(100),
    })

    return srv
}

 

上記のようになっており、transport.Websocket の箇所でOriginまわりの設定がないです。

 

エラー対応

handler.New 関数でサーバーを生成し、あとから WebSocket まわりの設定を追加することで WebSocket 通信できるようになりました。

 

srv := handler.New(
        graph.NewExecutableSchema(
            graph.Config{Resolvers: graph.NewResolver()}),
    )

    // add ws transport configured by ourselves
    srv.AddTransport(transport.Options{})
    srv.AddTransport(transport.GET{})
    srv.AddTransport(transport.POST{})
    srv.AddTransport(transport.MultipartForm{})
    srv.AddTransport(&transport.Websocket{
        Upgrader: websocket.Upgrader{
            //ReadBufferSize:  1024,
            //WriteBufferSize: 1024,
            CheckOrigin: func(r *http.Request) bool {
                // add checking origin logic to decide return true or false
                return true
            },
        },
        KeepAlivePingInterval: 10 * time.Second,
    })

 

同じエラーでハマっている方は上記試してみてください。

 

(この記事は、以下を参考にさせていただきました。)

stackoverflow.com

 

vallettaio.hatenablog.com

 

【React】簡易的なWebチャットアプリを作成

チャットアプリのようなものを作ってみたいと思い、React で簡易的なWebチャットアプリを作成してみました。

 

ソースコードの全量は以下です。

 

 

作成したアプリ

動画キャプチャ↓

 

 

システム構成

以下の機能を持つアプリを作成しました。

ホーム画面
  • メッセージの送信元と送信先のユーザーをそれぞれ選択
  • 「チャットルームへ」のリンクをクリックすることでチャットルーム画面に遷移する
チャットルーム画面
  • 画面を2分割し、ホーム画面で選択した送信元と送信先のユーザーそれぞれのメッセージエリアを表示
  • それぞれのユーザーはメッセージの送信が可能
  • 送信したメッセージは自身のメッセージエリアに追記されていく

 

主な使用技術

フロントエンド
  • Node.js (v16.20.2)
  • React (v18.2.0)
  • TypeScript (v4.9.5)
  • Apollo Client (v3.9.5)
  • tailwindcss 
バックエンド

実装について

バックエンド

バックエンドは golangGraphQL サーバーを構築し、メッセージの送受信を行うようにしました。

メッセージが投稿されたら瞬時に画面に反映するにはどうすればよいか考えた時に、GraphQLSubscription でメッセージの受信を監視すればよいのではないかと思い、試してみました。

 

なお、golang で GraphQL の作成は下記とても詳しく書いてくださっていました。

こちらを大変参考にさせていただきました。

 

www.ohitori.fun

 

スキーマ定義
schema.graphqls

# GraphQL schema example
#
# https://gqlgen.com/getting-started/

type Message {
  id: ID!
  text: String!
  createdAt: String!
  userId: ID!
}

type User {
  id: ID!
  name: String!
  createdAt: String!
  deletedAt: String!
}

type Query {
  messages: [Message!]!
}

input NewMessage {
  text: String!
  userId: ID!
}

type Mutation {
  postMessage(input: NewMessage!): Message!
}

type Subscription {
  messagePosted(userId: ID!): Message!
}

 

各種スキーマを定義しています。

  • Message: やり取りされるチャットメッセージ
  • User: ユーザー情報
  • Mutation: メッセージの投稿処理、NewMessage 型のメッセージ内容を受け付ける処理を実装していきます。
  • Subscription: メッセージの購読処理、指定のユーザーIDについて投稿されたメッセージを購読する処理を実装していきます。

※ Query はスキーマ定義していますが、今回は使っていません

ゾルバー
schema.resolvers.go
package graph

// This file will be automatically regenerated based
// on the schema, any resolver implementations
// will be copied through when generating and any unknown code
// will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.44

import (
    "backend/graph/model"
    "context"
    "fmt"
    "log"
    "time"

    "github.com/segmentio/ksuid"
)

// PostMessage is the resolver for the postMessage field.
func (r *mutationResolver) PostMessage(ctx context.Context,
input model.NewMessage) (*model.Message, error) {
    message := &model.Message{
        ID:        ksuid.New().String(),
        CreatedAt: time.Now().Format(time.RFC3339),
        UserID:    input.UserID,
        Text:      input.Text,
    }

    // 投稿されたメッセージを保存し、subscribeしている全てのコネクションに
//ブロードキャスト
    r.mutex.Lock()
    r.messages = append(r.messages, message)
    for _, ch := range r.subscribers {
        ch <- message
    }
    r.mutex.Unlock()

    return message, nil
}

// Messages is the resolver for the messages field.
func (r *queryResolver) Messages(ctx context.Context) (*model.Message, error) {
    return r.messages, nil
}

// MessagePosted is the resolver for the messagePosted field.
func (r *subscriptionResolver) MessagePosted(ctx context.Context, userID string)
(<-chan *model.Message, error) {
    r.mutex.Lock()
    defer r.mutex.Unlock()

    // すでにサブスクライブされているかチェック
    if _, ok := r.subscribers[userID]; ok {
        err := fmt.Errorf("`%s` has already been subscribed", userID)
        log.Print(err.Error())
        return nil, err
    }

    // チャンネルを作成し、リストに登録
    ch := make(chan *model.Message, 1)
    r.subscribers[userID] = ch

    log.Printf("`%s` has been subscribed!", userID)

    // コネクションが終了したら、このチャンネルを削除する
    go func() {
        <-ctx.Done()
        r.mutex.Lock()
        defer r.mutex.Unlock()

        delete(r.subscribers, userID)

        close(ch)
        log.Printf("`%s` has been unsubscribed.", userID)
    }()

    // このチャンネルが利用するメッセージを選択するためのフィルタリング
    filteredCh := make(chan *model.Message)

    go func() {
        for msg := range ch {
            // メッセージのユーザーIDとサブスクライバーのユーザーIDを比較
            if msg.UserID == userID {
                filteredCh <- msg
            }
        }
    }()

    return filteredCh, nil
}

// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }

// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }

// Subscription returns SubscriptionResolver implementation.
func (r *Resolver) Subscription() SubscriptionResolver {
return &subscriptionResolver{r} }

type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type subscriptionResolver struct{ *Resolver }

 

  • (Mutation) PostMessage: メッセージ内容を受け取り、受け取ったメッセージを Subscriber に送信。Mutex を使い排他制御を行っています
  • (Subscription) MessagePosted: メッセージ受信用のチャネルを生成し、userID をキーに購読チャネルリストに追加。受信されたメッセージが購読対象の userID と一致する場合にメッセージが受信されます
GraphQL サーバー
server.go
package main

import (
    "backend/graph"
    "log"
    "net/http"
    "os"
    "time"

    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/handler/transport"
    "github.com/99designs/gqlgen/graphql/playground"
    "github.com/gorilla/websocket"
)

const defaultPort = "8080"

func main() {
    port := os.Getenv("PORT")
    if port == "" {
        port = defaultPort
    }

    srv := handler.New(
        graph.NewExecutableSchema(
            graph.Config{Resolvers: graph.NewResolver()}),
    )

    // add ws transport configured by ourselves
    srv.AddTransport(transport.Options{})
    srv.AddTransport(transport.GET{})
    srv.AddTransport(transport.POST{})
    srv.AddTransport(transport.MultipartForm{})
    srv.AddTransport(&transport.Websocket{
        Upgrader: websocket.Upgrader{
            //ReadBufferSize:  1024,
            //WriteBufferSize: 1024,
            CheckOrigin: func(r *http.Request) bool {
                // add checking origin logic to decide return true or false
                return true
            },
        },
        KeepAlivePingInterval: 10 * time.Second,
    })

    http.Handle("/", playground.Handler("GraphQL playground", "/query"))
    http.Handle("/query", cors(srv.ServeHTTP))

    log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
    log.Fatal(http.ListenAndServe(":"+port, nil))
}

func cors(h http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "*")
        w.Header().Set("Access-Control-Allow-Headers", "*")
        h(w, r)
    }
}

 

  • http://localhost:8080/query」のエンドポイントで GraphQL サーバーを立ち上げる
  • srv.AddTransport(&transport.Websocket」の箇所で WebSocket 通信ができるように定義しています (フロント側から Subscription できるようにする)

 

フロントエンド

フロントエンドは React + TypeScript で作成しています。

個人的に画面作るときは React を使うことが多いです。

 

今回はバックエンドで作成した GraphQL サーバーに対して、

React 側が GraphQL クライアントになるわけですが、

Apollo Client が有名なようなので使ってみました。

公式ドキュメントが充実していてわかりやすかったです。

 

www.apollographql.com

 

GraphQL クライアント定義
index.tsx
const httpLink = new HttpLink({
});

const wsLink = new GraphQLWsLink(
  createClient({
    url: "ws://localhost:8080/query",
  })
);

const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === "OperationDefinition" &&
      definition.operation === "subscription"
    );
  },
  wsLink,
  httpLink
);

const client = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache(),
});

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);
root.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>
);

 

  • 公式ドキュメントの例に従い、ApolloClient を定義
  • ApolloClientProvider でラップすることで、App コンポーネント以下で useQuery など GraphQL フックが使えるようになるようです。
  • httpLink は Query や Mutation のエンドポイントとして、wsLink は Subscription のエンドポイントとして使われます。
メッセージ投稿 
mutation.ts
import { gql } from "@apollo/client";

export const POST_MESSAGE_MUTATION = gql`
  mutation ($input: NewMessage!) {
    postMessage(input: $input) {
      id
      userId
      text
      createdAt
    }
  }
`;
ChatTextField.tsx
import { useMutation } from "@apollo/client";
import { useState } from "react";
import { POST_MESSAGE_MUTATION } from "../../apis/mutation";

function ChatTextField({ targetUserID }: { targetUserID: string }) {
  const [message, setMessage] = useState("");

  const [postMessage, { data, loading, error }] = useMutation(
    POST_MESSAGE_MUTATION
  );

  return (
    <div className="flex p-3">
      <input
        type="text"
        onChange={(e) => setMessage(e.target.value)}
        className="flex-auto mr-2 rounded-l p-3"
      />
      <button
        onClick={() => {
          postMessage({
            variables: { input: { text: message, userId: targetUserID } },
          });
        }}
        className="rounded-r p-3 bg-blue-500 text-white
         transition duration-300 hover:bg-blue-600"
      >
        送信
      </button>
    </div>
  );
}

export default ChatTextField;

 

  • gql でラップして Query や Mutation, Subscription が定義できます。
  • useMutation フックに上記の Mutation 定義を渡すことで、Mutation を実行する関数、およびその結果変数が得られます。
  • useMutation フックを定義しただけでは実行はされず、以下のように onClick をトリガーに関数呼び出しのように呼び出すことで実行することができます。
<button
        onClick={() => {
          postMessage({
            variables: { input: { text: message, userId: targetUserID } },
          });
        }}

 

メッセージリアルタイム受信
subscription.ts
import { gql } from "@apollo/client";

export const MESSAGE_POSTED_SUBSCRIPTION = gql`
  subscription ($userID: ID!) {
    messagePosted(userId: $userID) {
      id
      userId
      text
      createdAt
    }
  }
`;
ChatMessage.tsx


||<

import { useSubscription } from "@apollo/client";
import { MESSAGE_POSTED_SUBSCRIPTION } from "../../apis/subscription";
import { formatDate } from "../../util/date_util";
import { useEffect, useState } from "react";
import { userIconMap } from "../../constant/Constant";

function ChatMessage({
  userID,
  className,
}: {
  userID: string;
  className?: string;
}) {
  const [messages, setMessages] = useState<any>([]);

  const { data, loading } = useSubscription(MESSAGE_POSTED_SUBSCRIPTION, {
    variables: { userID },
  });

  useEffect( () => {
    if (data && data.messagePosted) {
      setMessages( (prevMessages) => [...prevMessages, data.messagePosted]);
    }
  }, [data]);

  return (
    <div className={`${className} p-3`}>
      {!loading && (
        <>
          {messages.map( (data) => (
            <>
              <p className="text-xs p-3">
                {formatDate(data.createdAt, "MM月dd日(E) HH:mm")}
              </p>
              <div className="flex h-8">
                <div className="mr-6">{userIconMap[userID] || null}</div>

                <p className="flex-auto h-8 bg-blue-500 text-white
                 rounded-lg p-3 font-bold flex items-center">
                  {data.text}
                </p>
              </div>
            </>
          ))}
        </>
      )}
    </div>
  );
}

export default ChatMessage;
 
 

 

  • useSubscription フックに Subscription 定義を渡すことでコンポーネントレンダリング時に購読が開始されます。
  • Mutation が実行されてブロードキャストされると、ここで購読している data 変数に値が受信され、コンポーネントが再描画されます。
  • 上記の例では、状態管理している messages 変数に受信されたメッセージを追加しています。

 

以上になります。

Apollo Client を使うことでシンプルに GraphQL クライアントを構築できるのが良い発見になりました。

【React】EventEmitter でイベントを検知し任意の処理を実行する

最近、Node.js の EventEmitter というイベント処理用の機能を知ったので、React と組み合わせて簡単な実装サンプルを試してみました。

 

EventEmitter とは

独自のイベントを登録し、そのイベントがトリガーされたときに任意の処理を実行することができます。

 

emiton を使ってイベントとトリガーされる処理を登録します。

Node.jsの公式ページ では下記のように記載されています。

  • emit is used to trigger an event
  • on is used to add a callback function that's going to be executed when the event is triggered

この仕組みを利用したサンプルとして、React でいくつかのページを作成し、各ページが開かれた際にEventEmitterのイベントをトリガーする実装をしてみたいと思います。

 

環境構築

下記のコマンドで環境を検証用プロジェクトを作成します。

npx create-react-app event-emitter --template typescript

あとの手順でルーティングの機能を利用するので、react-router-dom を依存関係に追加します。

npm i react-router-dom

 

画面作成

検証用プロジェクトが作成できたので、いくつか画面を作成します。

cd event-emitter

mkdir src/component

touch src/component/page1.tsx

touch src/component/page2.tsx

touch src/component/page3.tsx

touch src/component/top.tsx

 

それぞれのファイルの中身は下記です。

page1.tsx
import { Link } from "react-router-dom";

export default function Page1() {
  return (
    <>
      <p>Page1</p>
      <Link to="/">戻る</Link>
    </>
  );
}
page2.tsx
import { Link } from "react-router-dom";

export default function Page2() {
  return (
    <>
      <p>Page2</p>
      <Link to="/">戻る</Link>
    </>
  );
}
page3.tsx
import { Link } from "react-router-dom";

export default function Page3() {
  return (
    <>
      <p>Page3</p>
      <Link to="/">戻る</Link>
    </>
  );
}
top.tsx
import { Link } from "react-router-dom";

export default function TopPage() {
  return (
    <>
      <p>
        <Link to="/page1">Page1へ</Link>
      </p>
      <p>
        <Link to="/page2">Page2へ</Link>
      </p>
      <p>
        <Link to="/page3">Page3へ</Link>
      </p>
    </>
  );
}

 

それぞれの画面に遷移できるように、ルーティングを定義します。

App.tsx
import { Route, BrowserRouter as Router, Routes } from "react-router-dom";
import TopPage from "./component/top";
import Page1 from "./component/page1";
import Page2 from "./component/page2";
import Page3 from "./component/page3";

function App() {
  return (
    <Router>
      <div className="App">
        <Routes>
          <Route path="/" element={<TopPage />} />
          <Route path="/page1" element={<Page1 />} />
          <Route path="/page2" element={<Page2 />} />
          <Route path="/page3" element={<Page3 />} />
        </Routes>
      </div>
    </Router>
  );
}

export default App;

 

ここまでで、下記のコマンドでアプリを起動してみます。

npm start

トップ画面表示

Page1表示

上記が画面表示され、それぞれのページに遷移できます。

 

EventEmitterで任意のイベントをハンドリングする

ここから EventEmitterを使用してみます。

今回は、各ページが開かれた際に、そのページ名称をコンソールに出力するようなイベント処理を定義してみようと思います。

 

まずは下記のコマンドでEventEmitterを依存関係に追加します。

(EventEmitterは色々と改良が重ねられて、現在はバージョン3が最新の模様。)

npm i eventemitter3

mkdir src/eventContext

touch src/eventContext/EventContext.ts

 

EventContext で EventEmitter を生成し、useContext フックを使い、各コンポーネントからグローバルに参照できる構成としました。

EventContext.ts
import EventEmitter from "eventemitter3";
import { createContext } from "react";

const EventContext = createContext(new EventEmitter());

function triggerEvent(name: String) {
  console.log(`${name}が表示されました`);
}

const eventEmitter = new EventEmitter();

eventEmitter.on("pageEvent", triggerEvent);

export { EventContext, eventEmitter };

 

このEventContext で全体をラップすることで、配下のコンポーネントでEventEmitter を参照できるようになります。App.tsx を下記のように書き換えます。

App.tsx
import { Route, BrowserRouter as Router, Routes } from "react-router-dom";
import TopPage from "./component/top";
import Page1 from "./component/page1";
import Page2 from "./component/page2";
import Page3 from "./component/page3";
import { EventContext, eventEmitter } from "./eventContext/EventContext";

function App() {
  return (
    <Router>
      <EventContext.Provider value={eventEmitter}>
        <div className="App">
          <Routes>
            <Route path="/" element={<TopPage />} />
            <Route path="/page1" element={<Page1 />} />
            <Route path="/page2" element={<Page2 />} />
            <Route path="/page3" element={<Page3 />} />
          </Routes>
        </div>
      </EventContext.Provider>
    </Router>
  );
}

export default App;

 

コンポーネントレンダリングが完了したら、EventEmitter にイベントを発行するように修正します。

 

Page1.tsx
import { useContext, useEffect } from "react";
import { Link } from "react-router-dom";
import { EventContext } from "../eventContext/EventContext";

export default function Page1() {
  const eventEmitter = useContext(EventContext);

  useEffect(() => {
    eventEmitter.emit("pageEvent", "Page1");
  }, [eventEmitter]);

  return (
    <>
      <p>Page1</p>
      <Link to="/">戻る</Link>
    </>
  );
}

 

Page2.tsx
import { useContext, useEffect } from "react";
import { Link } from "react-router-dom";
import { EventContext } from "../eventContext/EventContext";

export default function Page2() {
  const eventEmitter = useContext(EventContext);

  useEffect(() => {
    eventEmitter.emit("pageEvent", "Page2");
  }, [eventEmitter]);

  return (
    <>
      <p>Page2</p>
      <Link to="/">戻る</Link>
    </>
  );
}
Page3.tsx
import { useContext, useEffect } from "react";
import { Link } from "react-router-dom";
import { EventContext } from "../eventContext/EventContext";

export default function Page3() {
  const eventEmitter = useContext(EventContext);

  useEffect(() => {
    eventEmitter.emit("pageEvent", "Page3");
  }, [eventEmitter]);

  return (
    <>
      <p>Page3</p>
      <Link to="/">戻る</Link>
    </>
  );
}

 

これで完成です。

npm start

でアプリを立ち上げます。

 

トップページから、Page1, Page2, Page3 に画面遷移して、コンソールに下記のメッセージが表示されることを確認できます。

 

 

以上サンプルになります。

今回はただコンソールに出力するだけでしたが、各画面を開いたらGoogle Analytics に送信して画面表示数を計測したり、エラーが発生したら通知したり、など EventEmitter を活用することで色々できそうに思いました。

 

 

【React】APIコールして取得結果を画面に反映する

React を使って外部APIをコールし、取得したレスポンスを画面に反映する簡単なサンプルです。

一連のサンプルコードは こちら にプッシュしています。

 

サンプル実行キャプチャ

 

 

実行環境について

下記の環境で動かしています。

  • Ubuntu 20.04.4 LTS (Focal Fossa)
  • go version go1.21.6 linux/amd64
  • Node v16.20.2
  • yarn 1.22.21
  • React 18.2.0
  • TypeScript 4.9.5

 

外部APIについて

【Go言語】go/gin で簡単なREST API を作成 にて作成したAPI を使用しています。

 

事前準備

docker-compose.yml
version: '3'
services:
  postgres:
    image: 'postgres:14'
    container_name: postgres_gin_rest
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: sample
      TZ: Asia/Tokyo
      PGDATA: /var/lib/postgresql/data/pgdata
    ports:
      - '5432:5432'
    volumes:
      - './db-store:/var/lib/postgresql/data'

 

docker-compose up -d 

コマンドで PostgreSQL を起動した後、

docker exec -it postgres_gin_rest psql -U user -d sample

コマンドでDBに接続して、下記CREATE文でUserテーブルを作成しておきます。

CREATE TABLE "user" (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255),
    age INT
);

 

React を使って外部APIをコールサンプル実装

モデル定義
api/model/User.ts
type User = {
  Id?: number;
  Name: String;
  Age: number;
};

export default User;
  • APIレスポンスを受けるUserモデルを定義

 

APIコール処理
api/UserApi.ts
import User from "./model/User";

export const fetchUserData = async (id: number): Promise<User | null> => {
  try {
    const response = await fetch(`http://localhost:8080/user/${id}`);
    if (!response.ok) {
      console.error("Error fetching user data. Status:", response.status);
      return null;
    }

    const responseData: { user: User } = await response.json();
    const userData: User = responseData.user;
    return userData;
  } catch (error) {
    console.error("Error fetching user data:", error);
    return null;
  }
};

export const fetchUserDatas = async (): Promise<User | null> => {
  try {
    const response = await fetch("http://localhost:8080/users");
    if (!response.ok) {
      // レスポンスが成功でない場合の処理
      console.error("Error fetching user data. Status:", response.status);
      return ;
    }

    const { users }: { users: User } = await response.json();
    return users || ;
  } catch (error) {
    console.error("Error fetching user data:", error);
    return null;
  }
};

export const RegisterUser = async (userData: User): Promise<User | null> => {
  try {
    const response = await fetch("http://localhost:8080/user", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(userData),
    });

    if (!response.ok) {
      console.error("Error registering user. Status:", response.status);
      return null;
    }

    const registeredUser: User = await response.json();
    return registeredUser;
  } catch (error) {
    console.error("Error registering user:", error);
    return null;
  }
};

export const UpdateUser = async (userData: User): Promise<User | null> => {
  try {
    const apiUrl = `http://localhost:8080/user/${userData.Id}`;

    const response = await fetch(apiUrl, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(userData),
    });

    if (!response.ok) {
      console.error("Failed to update user");
      return null;
    }

    const updatedUser: User = await response.json();
    return updatedUser;
  } catch (error) {
    console.error("Error updating user:", error);
    return null;
  }
};

export const deleteUser = async (userId: number): Promise<boolean> => {
  try {
    const apiUrl = `http://localhost:8080/user/${userId}`;

    const response = await fetch(apiUrl, {
      method: "DELETE",
      headers: {
        "Content-Type": "application/json",
      },
    });

    if (!response.ok) {
      console.error("Failed to delete user");
      return false;
    }

    return true;
  } catch (error) {
    console.error("Error deleting user:", error);
    return false;
  }
};
  • 外部APICRUDに対応する処理をここに記述しています。

 

ここまででモデルとAPI呼び出し処理ができたので、画面コンポーネントを作成していきます。

 

トップ画面
component/UserComponent.tsx
import { useEffect, useState } from "react";
import User from "../api/model/User";
import { deleteUser, fetchUserDatas } from "../api/UserApi";
import "./UserComponent.css";
import { Link } from "react-router-dom";

const UserComponent: React.FC = () => {
  const [userList, setUserList] = useState<User | null>(null);
  const [isDeleteDialogOpen, setDeleteDialogOpen] = useState<boolean>(false);
  const [deletingUserId, setDeletingUserId] = useState<number | null>(null);

  useEffect(() => {//➀
    const fetchData = async () => {
      const data = await fetchUserDatas();
      setUserList(data);
    };

    fetchData();
  }, );

  const handleDeleteClick = (userId: number) => {
    setDeletingUserId(userId);
    setDeleteDialogOpen(true);
  };

  const handleDeleteConfirm = async () => {
    if (deletingUserId !== null) {
      try {
        const isDeleted = await deleteUser(deletingUserId);

        if (isDeleted) {
          setDeleteDialogOpen(false);
          const updatedData = await fetchUserDatas();
          setUserList(updatedData);
        } else {
          console.error("Error deleting user");
        }
      } catch (error) {
        console.error("Error deleting user data:", error);
      }
    }
  };

  const handleDeleteCancel = () => {
    setDeletingUserId(null);
    setDeleteDialogOpen(false);
  };

  return (
    <div>
      {userList ? (
        <div>
          <h2>User List</h2>
          <table className="user-table">
            <thead>
              <tr>
                <th>ID</th>
                <th>Name</th>
                <th>Age</th>
              </tr>
            </thead>
            <tbody>
              {userList.map*1}
            </tbody>
          </table>
          <div>
            <Link to={"/user/register"}>新規ユーザー追加</Link>//➂
          </div>

          {/* 削除ダイアログ */}
          {isDeleteDialogOpen && (
            <div className="delete-dialog">
              <p>本当に削除しますか?</p>
              <button onClick={handleDeleteConfirm}>はい</button>
              <button onClick={handleDeleteCancel}>いいえ</button>
            </div>
          )}
        </div>
      ) : (
        <p>ユーザーデータを読み込んでいます...</p>//➁
      )}
    </div>
  );
};

export default UserComponent;
  1. 副作用フック (useEffect) を使ってコンポーネント読み込み完了後に fetchUserDatas() を呼び出し、APIからユーザーの一覧を取得します
  2. データの読み込みが完了するまでは、「ユーザーデータを読み込んでいます...」を表示しておきます
  3. 「新規ユーザー追加」および「更新」をクリックしたら、それぞれの画面に遷移します
  4. 「削除」をクリックしたら、削除画面に遷移します

 

登録画面
component/user-register/UserRegister.tsx
import { useState } from "react";
import { RegisterUser } from "../../api/UserApi";
import { Link } from "react-router-dom";

const UserRegister: React.FC = () => {
  const [name, setName] = useState<string>("");//➀
  const [age, setAge] = useState<number>(0);
  const [isSubmitSuccess, setIsSubmitSuccess] = useState<boolean>(false);

  const handleRegister = async () => {
    const registeredUserData = await RegisterUser({
      Name: name,
      Age: age,
    });

    if (registeredUserData) {
      setIsSubmitSuccess(true);
    }
  };

  return (
    <div>
      <div>
        <h2>User Register</h2>

        <div style={{ marginBottom: "10px" }}>
          <label>Name</label>
          <input
            type="text"
            value={name}
            onChange={(e) => setName(e.target.value)}
          />
        </div>

        <div style={{ marginBottom: "10px" }}>
          <label>Age</label>
          <input
            type="number"
            value={age}
            onChange={(e) => setAge(parseInt(e.target.value, 10))}
          />
        </div>

        <div>
          <button onClick={handleRegister} style={{ marginRight: "5px" }}>
            登録
          </button>
          <Link to={"/"}>戻る</Link>
        </div>

        {isSubmitSuccess && <p>登録しました。</p>}
      </div>
    </div>
  );
};

export default UserRegister;
  1. useState フックで画面の入力状態を保持します

 

更新画面
component/user-update/UserUpdate.tsx
import { useEffect, useState } from "react";
import User from "../../api/model/User";
import { UpdateUser, fetchUserData } from "../../api/UserApi";
import { Link, useParams } from "react-router-dom";

const UserUpdate: React.FC = () => {
  const { id } = useParams();//➀
  const numericId: number = id ? parseInt(id, 10) : 0;

  const [user, setUser] = useState<User | null>(null);
  const [updatedName, setUpdatedName] = useState<string>("");
  const [updatedAge, setUpdatedAge] = useState<number | undefined>(undefined);
  const [isUpdateSuccess, setIsUpdateSuccess] = useState<boolean>(false);

  useEffect*2}
            />
          </div>

          <div>
            <button onClick={handleUpdate} style={{ marginRight: "5px" }}>
              Update
            </button>
            <Link to={"/"}>戻る</Link>
          </div>

          {isUpdateSuccess && <p>更新しました。</p>}
        </div>
      ) : (
        <p>Loading user data...</p>
      )}
    </div>
  );
};

export default UserUpdate;
  1. useParams フックでURLパラメーターを取得します。後で記載するルーティング設定にて、
    <Route path="user/update/:id" element={<UserUpdate />} />

    の定義を行っており、ここで指定している :id の値を読み込んでいます

 

 

ルーティング設定
App.tsx
import React from "react";
import logo from "./logo.svg";
import "./App.css";
import UserComponent from "./component/UserComponent";
import { Route, Router, Routes } from "react-router-dom";
import UserUpdate from "./component/user-update/UserUpdate";
import UserRegister from "./component/user-register/UserRegister";

const App: React.FC = () => {
  return (
    <Routes>
      <Route path="/" element={<UserComponent />} />
      <Route path="user/register" element={<UserRegister />} />
      <Route path="user/update/:id" element={<UserUpdate />} />
    </Routes>
  );
};

export default App;

React router を使用して設定しています。パスに対してどのコンポーネントを読み込むか、シンプルにルーティングを定義できます。詳しくは下記を参照ください。

Feature Overview v6.22.0 | React Router

 

以上、サンプルになります。

 

画面UIやファイル・コンポーネント名などの構成はかなり雑です... 今回は React で外部APIをコールしてレスポンスを画面に反映するまでの簡単な流れを記載したかったので、そのほかの要素は省いています。

 

*1:user) => (

                <tr key={user.Id}>
                  <td>{user.Id}</td>
                  <td>{user.Name}</td>
                  <td>{user.Age}</td>
                  <td>
                    <Link to={`/user/update/${user.Id}`}>更新</Link>//➂
                    {" | "}
                    <button onClick={() => handleDeleteClick(user.Id!)}>
                      削除//➃
                    </button>
                  </td>
                </tr>
             

*2:) => {

    const fetchData = async () => {
      const data = await fetchUserData(numericId);
      setUser(data);
    };

    fetchData();
  }, []);

  const handleUpdate = async () => {
    if (user) {
      const updatedUserData = await UpdateUser({
        Id: numericId,
        Name: updatedName || user.Name,
        Age: updatedAge !== undefined ? updatedAge : user.Age,
      });

      if (updatedUserData) {
        setIsUpdateSuccess(true);
      }
    }
  };

  return (
    <div>
      {user ? (
        <div>
          <h2>User Update</h2>
          <p>ID: {user.Id}</p>
          <p>Name: {user.Name}</p>
          <p>Age: {user.Age}</p>

          <div style={{ marginBottom: "10px" }}>
            <label>Updated Name:</label>
            <input
              type="text"
              value={updatedName}
              onChange={(e) => setUpdatedName(e.target.value)}
            />
          </div>

          <div style={{ marginBottom: "10px" }}>
            <label>Updated Age:</label>
            <input
              type="number"
              value={updatedAge !== undefined ? updatedAge : ""}
              onChange={(e) => setUpdatedAge(parseInt(e.target.value, 10