♥️

LambdaとReactでいいねボタンを作った

2024/01/02に公開

あけましておめでとうございます。今年もよろしくお願いします。

個人サイトでいいねボタンを実装するため、バックエンドにAWS LambdaとAWS DynamoDB、フロントエンドにReactを使っていいねボタンを作ってあげました。

こんな感じです。以下は実際の配置内容ではなく、Localstackでの実行内容になります。
動画

さらにユーザは各自ログインが可能で、個別にいいねの数をカウントしています。簡易的なものですが、いいね数を保存する程度ならユーザ管理もDynamoDBでやっちゃっていいかなと。

実物はこちら。知り合いのサイトです。
https://unnamedworks.com/

概要

リポジトリ

詳しいコードは以下をご覧ください。→favoExtend提供により非公開にしました。すみません。
https://github.com/nkte8/favo-system/tree/v2024-01-02

コード非公開に伴ってterraform部分の解説が減ってしまったので追記しています。(2024-05-19)

全体構成

  • フロントエンド
    • AstroでWebサイトを構築(システム外)
    • Cloudflare Pagesでデプロイ(システム外)
    • Reactでいいねボタンをコンポーネントとして作成
  • バックエンド
    • Lambda URLをエンドポイントとして使用
      • AWS API Gatewayは高度なRESTが必要ないこと、コストが高くつくため不採用
    • 処理サーバ: AWS Lambda
    • DB: AWS DynamoDB

構成はひねりもなくシンプルな内容になっています。
構成

バックエンド側実装

私はPythonがLambda対応で書ける言語の中で一番マシな言語だったのでPythonで書いています。これから勉強される方は絶対Nodejsとかの方がいいです。動的型付けは難しすぎるし、静的型付けが使いたくて仕方ない。

Lambdaの実装について

構成

ファイル構成は以下のようになっています。

  • base_api.py: DynamoDBと直接やりとりするいわゆるローレイヤー
  • api.py: APIとbase_apiの間をとりもつ中間レイヤー
  • lambda_function.py: APIと直接やり取りする最上位レイヤー

構成

base_api.py

今回は他の用途でもバックエンドを使いまわしたかったためクラス化しました。

base_api.py
import boto3

class base_api:
    def __init__(self, table_name, index_key_name, endpoint_url = None) -> None:
        __table = boto3.resource('dynamodb', endpoint_url=endpoint_url)
        self.table = __table.Table(table_name)
        self.index_key_name = index_key_name

    def table_check(self, index_value, velify_key, velify_secret):
        try:
            exist_data = self.table.get_item(
                Key={
                    self.index_key_name: index_value
                }
            )
            return (
                200 if exist_data['Item'][velify_key] == velify_secret else
                -401)
        except KeyError:
            print('E: Not found')
            return -404
        except Exception as e:
            print('E: Unexpected error')
            return -500
    
    def table_index_exist(self, index_value, ):
        try:
            exist_data = self.table.get_item(
                Key={
                    self.index_key_name: index_value
                }
            )
            return (exist_data['Item'] != None)
        except KeyError:
            return False
        except Exception as e:
            print('E: Unexpected error')
            return None

    def table_read(self, index_value, request_data_key):
        try:
            exist_data = self.table.get_item(
                Key={
                    self.index_key_name: index_value
                }
            )
            return exist_data['Item'][request_data_key]
        except KeyError:
            print('I: Not found')
            return 0
        except Exception as e:
            print('E: Unexpected error')
            return -500

    def table_push(self, index_value, data_body):
        update_item = {
            self.index_key_name: index_value
        }
        update_item.update(data_body)
        try:
            self.table.put_item(Item=update_item)
            result = 200
        except Exception as e:
            result = -500
            print('E: Unexpected error')
        return result

boto3でDynamoDBをロードして書き込んだり読み込んだりしているだけです。継承先クラスでレスポンスを作成することを想定しているため、戻り値はシンプルに数値としていて、正常と異常状態の区別は戻り値の正負で判定という仕様にしています。

api.py

ソースコードから切り出して捕捉します。

戻り値の作成は関数を作成して実施しました。

api.py
    def __r_json(self, s_code, favo_value, rmsg_value = None):
        return {
            'statusCode': s_code,
            'body': {
                self.rcode_key_name: s_code,
                self.favo_key_name: favo_value,
                self.rmsg_key_name: rmsg_value,
            },
            'isBase64Encoded': False
        }

Lambdaが実行された際に返却する内容を示しています。
上記により、APIレスポンスとして以下のようなjsonを取得できます。

{
    "rc": "200",
    "favcount": 10,
    "msg": null
}
statusCodeとrcで重複してしまっている課題

statusCodeを省いた場合、Lambdaの仕様なのかそのままの状態でAPIレスポンスが行われました。(具体的には以下のように応答された)

{
    "body": {
        "hoge1": "hoge",
        "hoge2": "huga",
        ...
    },
    "isBase64Encoded": "False"
}

おそらくstatusCodeが設定されていないレスポンスについては、どこからがAPIレスポンスでどこまでがLambda側が吸収する必要のある設定かがわからないため、すべてをレスポンスとして扱ってしまうことが原因...だと思われます。

ちなみにLambdaが関数を実行できた時点でステータスコードは200なので、パラメータを使いたい場合はpayloadに含める必要があるようです。今回はパラメータに使いたい分をbody内に設定しています。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/invocation-sync.html

statusCodercで重複しちゃっていて気持ち悪いので、動作には影響はないといえどうにかしたいです。

いいね数の読み書きは以下で実施しています。

api.py
    def db_fav_read(self, identify_value,
                        auth_key_secret = None):

        if self.auth_key_name != None:
            auth_result = self.__db_id_auth(
                identify_value,auth_key_secret)
            if auth_result < 0:    
                return self.__r_json(abs(auth_result),None)

        value = self.table_read(
            index_value=identify_value,
            request_data_key=self.db_data_name
        )

        if value >= 0:
            return self.__r_json(200,value)
        else:
            return self.__r_json(abs(value), None)


    def db_fav_push(self, identify_value,
                        auth_key_secret = None):

        data_body = {}
        if self.auth_key_name != None:
            auth_result = self.__db_id_auth(
                identify_value,auth_key_secret)
            if auth_result < 0:    
                return self.__r_json(abs(auth_result),None)
            data_body.update({
                self.auth_key_name: auth_key_secret
            })

        value = self.table_read(
            index_value=identify_value,
            request_data_key=self.db_data_name
        )
        if value < 0:
            return self.__r_json(abs(value), None)

        value += 1
        data_body.update({
            self.db_data_name: value
        })

        r_val = self.table_push(
            index_value=identify_value, 
            data_body=data_body
        )
        if r_val < 0:
            value -=1

        return self.__r_json(abs(r_val),value)

authと書かれている部分はユーザが実装されている場合に動作する仕組みで、いいね処理の度にPOSTに付与されたハッシュ(パスワード)とユーザIDが一致しているかを確認しています。

いいねの加算はDBから読み込んだ値に+1して上書きを実施しているだけです。大型なサイトの場合は、同時接続時の排他処理などを考慮する必要がありますが、個人ブログならこれで十分です。

また、DBへの書き込みに失敗した場合もいいね数をレスポンスしています。これはフロントエンド側の実装を簡略化することに役立っています。

いいねとは直接関係はないユーザ認証部分は以下になります。

api.py
    def __randomname(self, n):
        randlst = [random.choice(string.ascii_letters + string.digits) for i in range(n)]
        return ''.join(randlst)
    
    def db_id_register(self, identify_value):
        if self.auth_key_name == None:
            return self.__r_json(abs(500),None)
        
        if self.table_index_exist(index_value=identify_value):
            return self.__r_json(abs(409),None)
        
        db_data_init_value = 0

        auth_key_secret = self.__randomname(20)
        data_body = {
            self.db_data_name: db_data_init_value,
            self.auth_key_name: auth_key_secret
        }

        r_val = self.table_push(
            index_value=identify_value, 
            data_body=data_body
        )

        return self.__r_json(abs(r_val),db_data_init_value, auth_key_secret)

    def db_id_auth(self, identify_value, auth_key_secret):
        if self.auth_key_name != None:
            auth_result = self.__db_id_auth(
                    identify_value,auth_key_secret)
            return self.__r_json(abs(auth_result),None)
        else:
            return self.__r_json(abs(-405),None)

    def __db_id_auth(self, index_value, auth_key_secret):
        if auth_key_secret != None:
            return self.table_check(
                index_value=index_value, 
                velify_key=self.auth_key_name,
                velify_secret=auth_key_secret
                )
        else:
            return -405 

ユーザー管理もDynamoDBで実装しました。ただ、DynamoDBのダッシュボード上でKeyValueが平文で表示されてしまうためセキュリティ的にあまりよろしくありません。そのため、パスワードについてはサーバ側で生成し、これを利用してもらう方法をとりました。

base64で難読化することも考えましたが、難読化したとしても個人的に他人のパスワード情報を所持するのはちょっと荷が重いのでやめました。パスワードをなくしちゃった場合にアカウント復元ができませんが、逆に言うと個人を特定できない情報しか扱っていないため安全です。ブログでやるようなことかは謎ですが、この辺はそのうち何とかしたいですね。

lambda_function.py

lambdaが最初にハンドルする関数になります。

lambda_function.py
import json
from api import favo_api

favo_table = "favodb"
user_table = "userdb"

# endpoint_url="http://localhost:4566"

page_api = favo_api(favo_table,
                    favo_key_name="favcount")

user_api = favo_api(user_table,
                    auth_key_name="secret",
                    favo_key_name="favcount")

def handler(event, context):
    result = {
        'statusCode': 404
    }
    if (event.get('requestContext', {}).get('http', {}).get('method') is not None):
        method = event['requestContext']['http']['method']

        if method != "POST":
            raise Exception("Invalid method")

    body = json.loads(event.get('body',None))

    arg = body.get('arg',None)
    user_id = body.get('user',None)
    user_pw = body.get('secret',None)
    page_id = body.get('id',None)

    if user_id != None:        
        match arg:
            case "read":
                result = user_api.db_fav_read(
                    user_id,auth_key_secret=user_pw)
            case "push":
                result = user_api.db_fav_push(
                    user_id,auth_key_secret=user_pw)
            case "register":
                result = user_api.db_id_register(user_id)
            case "auth":
                result = user_api.db_id_auth(
                    user_id,auth_key_secret=user_pw)
            case default:
                raise Exception("Invalid order")

    if page_id != None:
        match arg:
            case "read":
                result = page_api.db_fav_read(page_id)
            case "push":
                result = page_api.db_fav_push(page_id)
            case default:
                raise Exception("Invalid order")

    return result

favodbとuserdbでクラスを使いまわし、別々のインスタンスを作成しています。
lambda_urlに送られた情報はevent変数に格納されていて、これを展開・解析してメソッドを実行します。今回はREST APIではないので全部POSTに統一しました。

基本的にはインスタンスからメソッドを実行して、resultを最終的な戻り値として返却するだけの作りになっています。

AWSへの構築手段について

AWSへのデプロイはTerraformを利用しました。冪等的であるのと、楽だからです。

localstackの利用

LocalStackを用いるとかなり気軽にトライアンドエラーができるのでお勧めです。

★追記

https://zenn.dev/horitaka/articles/localstack-docker
https://docs.localstack.cloud/getting-started/installation/#docker-compose

レポジトリでタグ打ちされているlocalstackのdocker-composeの設定が古かったみたいで、現在のlocalstack(v3.0.2)では、以下のような設定で必要十分なようです。

localstack/docker-compose.yml
version: "3.8"

services:
  localstack:
    container_name: "${LOCALSTACK_DOCKER_NAME:-localstack_main}"
    image: localstack/localstack:3.0.2
    ports:
      - "127.0.0.1:4510-4559:4510-4559"
      - "127.0.0.1:4566:4566"
    environment:
      - DEBUG=${DEBUG:-0}
      - EXTRA_CORS_ALLOWED_ORIGINS=*
    volumes:
      - type: bind
        source: ${TMPDIR:-/tmp/}localstack
        target: /var/lib/localstack
      - type: bind
        source: /var/run/docker.sock
        target: /var/run/docker.sock
レポジトリ上のタグ打ちされている古い&誤っている内容

※記事公開時の内容です

参考
https://zenn.dev/ry_km/articles/best-way-to-dev-lambda
https://localstack.cloud/

docker-composeは以下のように設定します。

localstack/docker-compose.yml
version: "3.8"

services:
  localstack:
    container_name: "${LOCALSTACK_DOCKER_NAME-localstack_main}"
    image: localstack/localstack:3.0.2
    ports:
      - "127.0.0.1:53:53"                # only required for Pro (DNS)
      - "127.0.0.1:53:53/udp"            # only required for Pro (DNS)
      - "127.0.0.1:443:443"              # only required for Pro (LocalStack HTTPS Edge Proxy)
      - "127.0.0.1:4510-4559:4510-4559"  # external service port range
      - "127.0.0.1:4566:4566"            # LocalStack Edge Proxyll
    environment:
      - DEBUG=${DEBUG-}
      - DATA_DIR=${DATA_DIR-}
      - LAMBDA_EXECUTOR=${LAMBDA_EXECUTOR-}
      - LOCALSTACK_API_KEY=${LOCALSTACK_API_KEY-}  # only required for Pro
      - HOST_TMP_FOLDER=${TMPDIR:-/tmp/}localstack
      - DOCKER_HOST=unix:///var/run/docker.sock
      - EXTRA_CORS_ALLOWED_ORIGINS=*
    volumes:
      # - "${TMPDIR:-/tmp}/localstack:/tmp/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"

なぜかHyper-V上のUbuntuでDocker composeが/tmpディレクトリをマウントできなかったためコメントアウトしています。コンテナ再起動の際に環境が消えてしまうデメリットを抱えてしまうことになりましたが、デプロイの確認がしたい程度だったので許容しました。
そもそもHOST_TMP_FOLDERはLambda volume mountをする際しか使われないとのことで、コメントアウトして平気な設定でした。
https://docs.localstack.cloud/references/configuration/

ローカル環境と本番環境の使い分け

★追記

Terraformのmoduleの機能を使ってtfvarsを使い分けすることができました。
現在のリポジトリでは以下のようなディレクトリ構成になっています。

ディレクトリ構成
local
    \ .terraform.lock.hcl
    \ main.tf  <------------- moduleの読み込みを定義
    \ output.tf
    \ provider.tf  <---------- backendをlocalに指定
    \ terraform.tfvars
    \ variable.tf
modules
    \ dynamodb  <------------- 個別のresourceを定義
        \ ....tf
    \ lambda_function
        \ ....tf
prod
    \ .terraform.lock.hcl
    \ main.tf  <------------- moduleの読み込みを定義
    \ output.tf
    \ provider.tf  <---------- backendをs3に指定
    \ terraform.tfvars
    \ variable.tf

main.tfは以下のように設定します。module内で使用する変数をmain.tfのmoduleブロックから設定を行うことで、module内に読み込みを行うことができます。

local/main.tf
module "dynamodb" {
    source = "../modules/dynamodb"
    env = var.env

    dynamodb_props = var.dynamodb_props
    dynamodb_no_import_props = var.dynamodb_no_import_props
}
module "lambda" {
    source = "../modules/lambda_function"

    iam_lambda = var.iam_lambda

    lambda_favo_counter_payload_name = var.lambda_favo_counter_payload_name
    lambda_favo_counter_payload_src = var.lambda_favo_counter_payload_src

    lambda_favo_counter_name = var.lambda_favo_counter_name
    lambda_favo_counter_handler = var.lambda_favo_counter_handler
    lambda_favo_counter_function_url_cors_origin = var.lambda_favo_counter_function_url_cors_origin

    lambda_layer_payload_name = var.lambda_layer_payload_name
    lambda_layer_payload_src = var.lambda_layer_payload_src
    lambda_layer_name = var.lambda_layer_name
}
module機能を知る前の内容

localstackと本番環境への環境の切り替えは面倒ですが手動で行っています。
本番環境のtfstateはs3で管理したかったのですが、localstack側はlocalで十分だという考えからです。

provider.tf
# provider.tf
variable "aws_region" {}

provider "aws" {
	region     = var.aws_region
}

terraform {
	required_providers {
		aws = {
			source  = "hashicorp/aws"
			version = "~> 5.0"
		}
	}
	backend "s3" {
		bucket  = "favo-system"
		region  = "ap-northeast-1"
		key     = "terraform.tfstate"
		encrypt = true
	}
	# backend "local" {
	# 	path = "tflocal.tfstate"
	# }
}

lambdaのTerraform構築

リポジトリを非公開にしてしまったので、こちらで一部追記しておきます。

lambdaへ関数を構築するコードはこんな感じです。

backend/terraform/modules/lambda_function/lambda.tf
# lambda.tf
data "archive_file" "lambda_favo_counter_payload" {
  type        = "zip"
  source_dir = var.lambda_favo_counter_payload_src
  output_path = var.lambda_favo_counter_payload_name
}

resource "aws_lambda_function" "lambda_favo_counter_function" {
  filename      = var.lambda_favo_counter_payload_name
  function_name = var.lambda_favo_counter_name
  role          = aws_iam_role.iam_lambda.arn
  handler       = var.lambda_favo_counter_handler

  source_code_hash = data.archive_file.lambda_favo_counter_payload.output_base64sha256

  runtime = "python3.11"

  layers = [
    aws_lambda_layer_version.lambda_layer.arn
    ]
}

data "archive_file" "lambda_layer_archive" {
  type        = "zip"
  source_dir = var.lambda_layer_payload_src
  output_path = var.lambda_layer_payload_name
}

resource "aws_lambda_layer_version" "lambda_layer" {
  filename   = var.lambda_layer_payload_name
  layer_name = var.lambda_layer_name

  compatible_runtimes = ["python3.11"]
}
...(中略)...

同じディレクトリにてiamリソースも作成しています。面倒くさかったので、既存のRole設定を紐づけているだけですが。そんなかんじ。

modules/lambda_function/iam.tf
# iam.tf

data "aws_iam_policy_document" "assume_role" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}

resource "aws_iam_role_policy_attachment" "dynamodb_access" {
  role = aws_iam_role.iam_lambda.id
  policy_arn = "arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess"
}

resource "aws_iam_role" "iam_lambda" {
  name               = var.iam_lambda
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

Lambda URLリソース自体はTerraformでデプロイ可能ですが、デプロイ後にURLがわからないと不便なため、outputにより出力をしています。

backend/terraform/modules/lambda_function/lambda.tf
...(中略)...

resource "aws_lambda_function_url" "lambda_favo_counter_function_url" {
  function_name      = aws_lambda_function.lambda_favo_counter_function.arn
  authorization_type = "NONE"

  cors {
    allow_credentials  = true
    allow_origins      = var.lambda_favo_counter_function_url_cors_origin
    allow_methods      = ["POST"]
    allow_headers      = ["content-type"]
    expose_headers     = []
    max_age            = 86400
  }
  depends_on = [
    aws_lambda_function.lambda_favo_counter_function
  ]
}

output "lambda_favo_counter_function_url" {
  value = aws_lambda_function_url.lambda_favo_counter_function_url.function_url
}

outputはlocalstackの場合も実施され、以下のようなlocalhost向けのアドレスがきちんと出力されます。

stdout
http://o23gmw2qx8e5vxz23wgk7m9u79173h18.lambda-url.ap-northeast-1.
localhost.localstack.cloud:4566/

dynamodbのterraform構築

DBについては前にも書いたようにファボ用とユーザ用で分けていますが、コード上では別の捉え方をしていました。
「既存のDBバックアップから復元するか否か」という基準でコードが分かれています。

backend/terraform/modules/dynamodb/dynamodb.tf
# dynamodb.tf 
resource "aws_dynamodb_table" "dynamodb" {
	for_each = var.dynamodb_props

	name           = each.value.name
	billing_mode   = "PROVISIONED"
	read_capacity  = each.value.read_capacity
	write_capacity = each.value.write_capacity
	hash_key       = each.value.hash_key

	attribute {
		name = each.value.hash_key
		type = "S"
	}
	point_in_time_recovery {
		enabled = true
	}
    deletion_protection_enabled = true

    import_table {
        input_compression_type = "GZIP"
        input_format = "DYNAMODB_JSON"
        s3_bucket_source {
            bucket = each.value.import_s3_bucket
            key_prefix = each.value.import_s3_key_prefix
        }
    }
}

resource "aws_dynamodb_table" "dynamodb_no_import" {
	for_each = var.dynamodb_no_import_props

	name           = each.value.name
	billing_mode   = "PROVISIONED"
	read_capacity  = each.value.read_capacity
	write_capacity = each.value.write_capacity
	hash_key       = each.value.hash_key

	attribute {
		name = each.value.hash_key
		type = "S"
	}
	point_in_time_recovery {
		enabled = true
	}
    deletion_protection_enabled = true
}

dynamodb_no_import_propsを使った場合は新規で作成、dynamodb_propsを使った場合は、既存のS3バケットからバックアップをimportして新規作成します。
変数は以下のような感じで設定していました。

backend/terraform/prod/terraform.tfvars
...(中略)...
dynamodb_no_import_props = {}
dynamodb_props = {
        favodb = {
            name = "favodb"
            read_capacity = 5
            write_capacity = 5
            hash_key = "Identify"
			import_s3_bucket = "favo-system"
			import_s3_key_prefix = "AWSDynamoDB/01704264010692-bf533c21/data/"
        },
		userdb = {
			name = "userdb"
			read_capacity = 5
			write_capacity = 5
			hash_key = "Identify"
			import_s3_bucket = "favo-system"
			import_s3_key_prefix = "AWSDynamoDB/01704264023314-39b2fc15/data/"	
		},
    }

このコードを書いた頃、terraform力が低かったのであまりいい書き方ではないです。
favoExtendでは、Terraformコードに関しても改善されています。

フロントエンド側実装

最近勉強していて、Reactの便利さを理解し始めました。Typescriptは型付けが静的で素晴らしい、そのうちバックエンドもTypescript/Nodejsに置き換えたいと思っています。

Reactの実装について

バックエンドへのアクセスはfetchモジュールを用いました。また、これを実行するフロントエンドとしてのコンポーネントを開発しました。

本文章では以下三つについて説明します。

  • favoapi.ts: APIアクセス用モジュール
  • favobutton: いいねボタン本体
  • userform: ユーザ登録フォーム

favoapi.ts について

ここではlocalstorageなどには触れず、API部分のみ補足します。

utils/favoapi.ts
type ApiResponse = {
    rc: number;
    favcount: number | null;
    msg: string | null;
}

export const favo_api = async (
    api_url: string,
    id: string | null,
    userid: string | null,
    secret: string | null,
    arg: "read" | "auth" | "register" | "push",
): Promise<ApiResponse> =>
    fetch(api_url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(
            {
                id: id,
                user: userid,
                secret: secret,
                arg: arg,
            })
    }
    ).then((response) => response.json()
    ).catch(() => {
        console.log("error");
    })

fetchモジュールは非同期的にインターネットへアクセスを行うモジュールです。fetchをそのまま利用するとPromise<any>となってしまいます。

これでは使いにくいため、favo_api関数はfavo_apiはfetchモジュールのrapperとして実装しました。これによりPromise<ApiResponse>型として返却されます。

favobuttonコンポーネント

機能部について

favobutton/main.tsx
import { useState, useEffect } from 'react';

import './main.css';
import { favo_api, get_auth_local } from '@/utils/favoapi'

interface Props {
    api_url: string,
    page_name: string,
}
export default function Favobutton({ api_url, page_name }: Props) {

    const auth = get_auth_local()

    const [count, setCount] = useState(0);
    const [active, setActive] = useState(false);

    const runFavoApi = async (arg: "read" | "push") => {
        try {
            let c = await favo_api(api_url, page_name, auth.id, auth.secret, arg)
            if (c.favcount !== null) {
                setCount(c.favcount)
            }
            return (c.favcount !== null)
        } catch (e) {
            return false
        }
    }

    useEffect(() => {
        // 非同期処理の場合は、関数を定義しそれを呼び出すような形式で記述すること
        runFavoApi("read")
    }, [])

    const handleClick = async () => {
        if (await runFavoApi("push")) {
            setActive(true);
        }
    };

    return (
        <button className={`favobutton ${active ? "clicked" : ""}`} onClick={handleClick}>
            <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 15">
                <path d="M4 1c2.21 0 4 1.755 4 3.92C8 2.755 9.79 1 12 1s4 1.755 4 3.92c0 3.263-3.234 4.414-7.608 9.608a.513.513 0 0 1-.784 0C3.234 9.334 0 8.183 0 4.92 0 2.755 1.79 1 4 1z" />
            </svg>
            &nbsp;
            {count}
        </button>
    );
};

tsxはReactコンポーネントで、戻り値に記載された内容をそのまま呼び出し元に利用することができます。
非同期的に処理を行うためにuseEffectを用いています。初回のいいね数読み込みはこれにより解決していて、タップ/クリックによるカウントの増加についてはAPIからのレスポンスを利用しています。

DBへの書き込みに失敗した場合もいいね数をレスポンスしています。これはフロントエンド側の実装を簡略化することに役立っています。

この仕様により、DBへの書き込みの可否によらず、フロントエンド側はレスポンスを表示するだけで機能します。

ボタンのアニメーションについて

ボタンのアニメーションはCSSで行っています。

favobutton/main.css
button.favobutton {
...(中略)...
    transition: 0.05s linear;
    vertical-align: middle;
}
button:active  {
    transform: scale(0.90);
}

button.clicked {
    color: #e44;
}
...(中略)...

transformで形状変化させているだけです。クリック後赤い状態になるのはReact側でclikedクラスを付与して対応しています。
このclassは動的なものなので、ページをリロードすると元に戻りますが仕様です。ボタンは何度でも押せるようにしているので、状態について気にする必要がないためです。

userformコンポーネント

機能部について

長いのでかいつまんで解説します。

userform/main.tsx
...(中略)...
    const userid_pattern = "^([a-zA-Z0-9]{4,10})$"
    const secret_pattern = "^([a-zA-Z0-9]{20,20})$"

    const onClickLogin = async () => {
        if (userid.match(userid_pattern)) {
            if (arg === "auth" && !secret.match(secret_pattern)) {
                setMsg("IDは4~10文字の半角英数で指定してください")
                return
            }
            try {
                let result = await favo_api(api_url, null, userid, secret, arg);
                switch (result.rc) {
                    case 200:
                        let sec_local = arg === "register" ? result.msg : secret
                        if (sec_local == null) { 
                            setMsg("不明なサーバーエラーが発生しました。");
                            return
                        }
                        set_auth_local({ id: userid, secret: sec_local })
                        window.location.href = login_path + "?login";
                        break;
                    case 401:
                        setMsg("ID/PWの組み合わせが誤っています。");
                        break;
                    case 409:
                        setMsg("すでに使用されているIDです。");
                        break;
                    case 500:
                        setMsg("不明なサーバーエラーが発生しました。");
                        break;
                }
            } catch (e) {
                setMsg("不明なサーバーエラーが発生しました。")
            }
        }
    }
...(中略)...

上記はログインまたはユーザ登録時に実行されるコードです。レスポンスのrc値を見て処理を判断させています。

userform/main.tsx
...(中略)...
    const checkUserByApi = async () => {
        if (userid !== null && secret !== null) {
            try {
                let r = await favo_api(api_url, null, userid, secret, "auth")
                if (r.rc === 200) {
                    setLoginStatus(true)
                } else { 
                    rm_auth_local()
                    setMsg("再ログインをお願いします。");
                }
            } catch (e) {
                setMsg("不明なサーバーエラーが発生しました。");
            }
        }
    }

    useEffect(() => {
        checkUserByApi()
    }, [])
...(中略)...

例によってロード時にログインが可能かをuseEffectを用いて確認しています。不可能な場合はログアウトの処理(localstorageからkeyvalueを削除する処理)を行っています。

userform/main.tsx
...(中略)...
    return (
        <div className="userform">
            {
                islogin !== true &&
                <div className="context">
                    {form_msg}
                </div>
            }
            {
                islogin === true &&
                <React.StrictMode>
                <div className="context">
                    {userid}はログイン中です。
                </div>
                <div className="context">
                    再ログインする場合は以下のパスワードをご利用ください。
                </div>
                <div className="context smaller">
                    パスワードの再発行はできません。ブラウザへの保存をお願いします。
                </div>
                </React.StrictMode>
            }
            {
                islogin !== true &&
                <React.StrictMode>
                    {msg !== null &&
                        <span className='context alert'>
                            {msg}
                        </span>
                    }
                    <div className='component'>
                        <label>ID:</label><input
                            type='text'
                            className='textbox'
                            pattern={userid_pattern}
                            value={userid}
                            onKeyDown={handleKeyDown}
                            onChange={(event) => setUserid(event.target.value)}
                        />
                    </div>
                </React.StrictMode>
            }
            {
                (arg === "auth" || islogin === true) &&
                <div className='component'>
                    <label>PW:</label><input
                        type={isOpenpw}
                        className='textbox'
                        pattern={secret_pattern}
                        value={secret}
                        onFocus={() => setOpenPW("text")}
                        onBlur={() => setOpenPW("password")}
                        onKeyDown={handleKeyDown}
                        onChange={(event) => setSecret(event.target.value)}
                    />
                </div>
            }
            {
                islogin !== true &&
                <React.StrictMode>
                    <div className='component'>
                        <button className='submit' onClick={onClickLogin}>
                            {button_msg}
                        </button>
                    </div>
                </React.StrictMode>
            }
            {
                islogin === true &&
                <React.StrictMode>
                    <div className='component'>
                        <button className='submit' onClick={onClickLogout}>
                            Logout
                        </button>
                    </div>
                </React.StrictMode>
            }
        </div>
    );
};

かなりごちゃごちゃ書いていてわかりにくいですが、主にフォームが登録用かログイン用かを示す式arg === "register" | "auth"、ログイン中かの状態を記録している変数isloginを用いて判定しています。

登録とログインで同じフォームを用いているため上記のような実装を行っています。

不適切な値表示について

フォームに不適切な値、具体的には機能部で指定した正規表現にマッチしない場合に表示するようになっています。

これについてはCSSで表現可能です。

userform/main.css
...(中略)...
.userform input[type=text]:invalid,
input[type=password]:invalid {
    background: pink;
}
...(中略)...

あくまで表現が変化するだけで、入力を受け付けない等の制御はできません。これについてはReact側で対応しています。

Astroへの実装について

AstroはReactを公式でサポートしており、インテグレーションとして導入が可能です。

astro.config.mjs
...(中略)...
import react from "@astrojs/react";

// https://astro.build/config
export default defineConfig({
...
  integrations: [
    ...
    , react()
  ]
});

Astro側の実装としても、通常のコンポーネントと同様に呼び出してあげれば使用可能です。

pagerefbuttons.astro
---
...(中略)...
import Favobutton from "@/components/favobutton/main.tsx";

const api_url = lambdaurl;
---

<div class="pagerefbuttons">
    <div class="left">
        {nextlink !== null && <a href={nextlink.href}>{nextlink.msg}</a>}
    </div>
    <div class="center">
        <Favobutton
            client:load
            api_url={api_url}
            page_name={currentpage_slug}
        />
    </div>
    <div class="right">
        {prevlink !== null && <a href={prevlink.href}>{prevlink.msg}</a>}
    </div>
</div>

参考

https://twinkangaroos.com/aws-lambda-apigateway-to-create-like-function.html
https://blog.datsukan.me/made-a-like-button/

Discussion