2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React + Vite アプリ環境構築、デプロイ備忘録

Last updated at Posted at 2023-11-03

はじめに

個人開発でReactを使用する機会が多いです。

ですが、毎回「どうやって作成してたっけ?」となるため、今後の個人開発円滑化のために備忘録として残しておきます。

コマンドやファイルなどを列挙する程度なので、説明などはあまりありません。
ご了承ください。

プロジェクト作成

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

docker run --rm -it -v $PWD:/app -w /app node:20-slim \
sh -c 'npm install -g npm && npm create -y vite@latest react-vite-sample -- --template react-swc-ts'   

Dockerコンテナを作成し、viteプロジェクトを作成するコマンドを実行しています。
私の端末がAppleシリコンのMacのため--platform=linux/amd64をつけています。
Docker Desktop 4.25から正式版となったRosetta for Linuxのおかげで、必要なくなりました。

react-vite-sampleの部分は任意のプロジェクト名に変更してください。

Docker関連ファイル作成

フォルダ階層

プロジェクトルート
├─ .devcontainer
|  └─ devcontainer.json
├─ docker-compose.yml
└─ Dockerfile

docker-composeを使用するとスタック名が親ディレクトリの名称となります。

以前はdockerディレクトリにdocker-compose.ymlを配置していましたが、それだと他のプロジェクトとスタック名が重複してしまうため、プロジェクトルート直下に配置しています。

ファイル

devcontainer.json
{
    "service": "react-vite-sample",
    "dockerComposeFile": "../docker-compose.yml",
    "workspaceFolder": "/workdir",
    "customizations": {
      "vscode": {
        "extensions": ["eamodio.gitlens", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode"],
        "settings": {
          "prettier.configPath": ".prettierrc.json",
          "editor.defaultFormatter": "esbenp.prettier-vscode",
          "editor.formatOnSave": true,
          "editor.codeActionsOnSave": {
            "source.fixAll.eslint": true
          },
          "editor.tabSize": 2
        }
      }
    }
}
docker-compose.yml
version: '3.9'
services:
  react-vite-sample:
    ports:
      - 5173:5173
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - .:/workdir
    tty: true
Dockerfile
FROM node:20-slim

RUN apt-get update && apt-get install -y git vim locales-all

ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8

WORKDIR /workdir

locales-allをインストールし、ENVを用いて環境変数を設定することで、コンテナ内でgitを使用する際に日本語入力時に文字化けしなくなります。

上記を配置し、下記コマンドでuser.emailとuser.nameを設定すればコンテナ内でコミットなどが可能になります。

git config --global user.email <メールアドレス>
git config --global user.name <名前>

また、ライブラリインストールのために以下コマンドも実行します。

npm install

ESlint, Prettier設定

フォルダ階層

プロジェクトルート
├─ .eslintrc.cjs
├─ .prettier.json
├─ tsconfig.json
└─ vite.config.ts

ファイル

下記コマンドを実行

npm install eslint prettier @typescript-eslint/eslint-plugin \
@typescript-eslint/parser eslint-config-prettier \
eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react \
eslint-import-resolver-typescript

.eslintrc.cjsを変更

.eslintrc.cjs
module.exports = {
  root: true,
  env: { browser: true, es2020: true },
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:react-hooks/recommended',
+   'plugin:import/recommended',
+   'plugin:jsx-a11y/recommended',
+   'eslint-config-prettier',
  ],
  ignorePatterns: ['dist', '.eslintrc.cjs'],
  parser: '@typescript-eslint/parser',
  plugins: ['react-refresh'],
+ settings: {
+   react: {
+     version: 'detect',
+   },
+   'import/resolver': {
+    typescript: {},
+   },
+ },
  rules: {
    'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
+   'no-restricted-imports': ['error', { patterns: ['./', '../'] }],
  },
};

.prettier.jsonをプロジェクトフォルダ直下に新規作成

.prettier.json
{
  "trailingComma": "all",
  "tabWidth": 2,
  "semi": true,
  "singleQuote": true,
  "printWidth": 120,
  "bracketSpacing": true
}

srcディレクトリ配下を@/の形でインポートできるようにします。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    
+   "baseUrl": "./",
+   "paths": {
+     "@/*": ["src/*"]
+   }
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
+ resolve: {
+   alias: {
+     '@': '/src',
+   },
+ },
});

エラーになるため、main.tsx, App.tsxを修正します。

main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
- import App from './App.tsx';
+ import App from '@/App.tsx';
- import '@/index.css';
+ import '@/index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);
App.tsx
import { useState } from 'react';
- import reactLogo from './assets/react.svg';
+ import reactLogo from '@/assets/react.svg';
+ // eslint-disable-next-line import/no-unresolved
import viteLogo from '/vite.svg';
- import './App.css';
+ import '@/App.css';

function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <div>
        <a href="https://vitejs.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>Vite + React</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>count is {count}</button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">Click on the Vite and React logos to learn more</p>
    </>
  );
}

export default App;

テスト準備

下記コマンドを実行

npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom

srcディレクトリ直下にApp.test.tsxを作成します。
テストを一つ記述しているのみなので、必要に応じて追加、修正してください。

App.test.tsx
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import App from '@/App';

test('renders h1 text', () => {
  render(<App />);
  const headerElement = screen.getByText(/Vite \+ React/);
  expect(headerElement).toBeInTheDocument();
});

vite.config.tsを下記のように変更します。

vite.config.ts
+ /// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': '/src',
    },
  },
+ test: {
+   globals: true,
+   environment: 'jsdom',
+ },
});

package.jsonにscriptプロパティにtestを追加します。

package.json
{
  "name": "test-project",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",
+   "test": "vitest"
  },
  以下略...
}

CloudFormationテンプレート作成

CloudFront + S3でホスティングし、CodeCommitへのpush(この構成においてはGitHub ActionsでのCodeCommitへのミラーリング)をトリガーとしてCodePipelineを起動し、デプロイを行うテンプレートになります。

react-vite-sampleReactViteSampleの文字列を任意のプロジェクト名に置換することで他のプロジェクトへ流用が簡単になると思います。

フォルダ構成

.
├─ cloudformation
│  ├─ parameters
│  │   ├─ codepipeline.json
│  │   ├─ hosting.json
│  │   └─ repository.json
│  ├─ shell
│  │   ├─ 01-repository.sh
│  │   ├─ 02-hosting.sh
│  │   ├─ 03-codebuild.sh
│  │   └─ 04-codepipeline.sh
│  └─ templates
│      ├─ codebuild.yml
│      ├─ codepipeline.yml
│      ├─ hosting.yml
│      └─ repository.yml
└─ buildspec.yml

parameters

repository.json
{
  "Parameters": {
    "RepositoryName": "react-vite-sample"
  }
}
hosting.json
{
  "Parameters": {
    "SourceBucketName": "react-vite-sample-source-bucket",
    "DestributionName": "react-vite-sample-destribution"
  }
}
codepipeline.json
{
  "Parameters": {
    "CodePipeLineName": "react-vite-sample-pipeline",
    "ArtifactBucketName": "react-vite-sample-pipeline-artifact-store",
    "EventBridgeName": "react-vite-sample-eventbridge-pipeline",
    "EventBridgeRoleName": "react-vite-sample-eventbridge-role",
    "EventBridgePolicyName": "react-vite-sample-eventbridge-policy"
  }
}

shell

ファイル名に01などをつけていますが、CodePipelineが最後に実行されれば、それ以外は順不同です。

01-repository.sh
#!/bin/bash

CHANGESET_OPTION="--no-execute-changeset"

if [ $# = 1 ] && [ $1 = "deploy" ]; then
  echo "deploy mode"
  CHANGESET_OPTION=""
fi

CFN_TEMPLATE=../templates/repository.yml
CFN_STACK_NAME=react-vite-sample-repository

aws cloudformation deploy --stack-name ${CFN_STACK_NAME} --template-file ${CFN_TEMPLATE} ${CHANGESET_OPTION}\
    --parameter-overrides file://../parameters/repository.json
02-hosting.sh
#!/bin/bash

CHANGESET_OPTION="--no-execute-changeset"

if [ $# = 1 ] && [ $1 = "deploy" ]; then
  echo "deploy mode"
  CHANGESET_OPTION=""
fi

CFN_TEMPLATE=../templates/hosting.yml
CFN_STACK_NAME=react-vite-sample-hosting

aws cloudformation deploy --stack-name ${CFN_STACK_NAME} --template-file ${CFN_TEMPLATE} ${CHANGESET_OPTION}\
    --capabilities CAPABILITY_NAMED_IAM \
    --parameter-overrides file://../parameters/hosting.json
03-codebuild.sh
#!/bin/bash

CHANGESET_OPTION="--no-execute-changeset"

if [ $# = 1 ] && [ $1 = "deploy" ]; then
  echo "deploy mode"
  CHANGESET_OPTION=""
fi

CFN_TEMPLATE=../templates/codebuild.yml
CFN_STACK_NAME=react-vite-sample-codebuild

aws cloudformation deploy --stack-name ${CFN_STACK_NAME} --template-file ${CFN_TEMPLATE} ${CHANGESET_OPTION}\
    --capabilities CAPABILITY_NAMED_IAM \
04-codepipeline.sh
#!/bin/bash

CHANGESET_OPTION="--no-execute-changeset"

if [ $# = 1 ] && [ $1 = "deploy" ]; then
  echo "deploy mode"
  CHANGESET_OPTION=""
fi

CFN_TEMPLATE=../templates/codepipeline.yml
CFN_STACK_NAME=react-vite-sample-pipeline

aws cloudformation deploy --stack-name ${CFN_STACK_NAME} --template-file ${CFN_TEMPLATE} ${CHANGESET_OPTION}\
    --capabilities CAPABILITY_NAMED_IAM \
    --parameter-overrides file://../parameters/codepipeline.json

templates

repository.yml
AWSTemplateFormatVersion: '2010-09-09'

Parameters:
  RepositoryName:
    Type: String

Resources:
  CodeCommitRepository:
    Type: AWS::CodeCommit::Repository
    Properties:
      RepositoryName: !Ref RepositoryName

Outputs:
  RepositoryName:
    Value: !Ref RepositoryName
    Export:
      Name: ReactViteSampleRepositoryName

  RepositoryArn:
    Value: !GetAtt CodeCommitRepository.Arn
    Export:
      Name: ReactViteSampleRepositoryArn
hosting.yml
AWSTemplateFormatVersion: '2010-09-09'

Parameters:
  SourceBucketName:
    Type: String

  DestributionName:
    Type: String

Resources:
  S3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref SourceBucketName
      VersioningConfiguration:
        Status: Enabled

  BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3Bucket
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Sid: Allow CloudFront Access
            Effect: Allow
            Principal:
              Service: cloudfront.amazonaws.com
            Action: s3:GetObject
            Resource: !Sub ${S3Bucket.Arn}/*
            Condition:
              StringEquals:
                AWS:SourceArn: !Sub arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${Distribution}

  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Origins:
          - Id: S3Origin
            DomainName: !GetAtt S3Bucket.DomainName
            S3OriginConfig:
              OriginAccessIdentity: ''
            OriginAccessControlId: !GetAtt CloudFrontOriginAccessControl.Id
        Enabled: true
        DefaultRootObject: index.html
        Comment: !Ref DestributionName
        DefaultCacheBehavior:
          TargetOriginId: S3Origin
          CachePolicyId: !GetAtt CachePolicy.Id
          ViewerProtocolPolicy: redirect-to-https

  CloudFrontOriginAccessControl:
    Type: AWS::CloudFront::OriginAccessControl
    Properties:
      OriginAccessControlConfig:
        Description: TestProject Origin Access Control
        Name: TestProjectOAC
        OriginAccessControlOriginType: s3
        SigningBehavior: always
        SigningProtocol: sigv4

  CachePolicy:
    Type: AWS::CloudFront::CachePolicy
    Properties:
      CachePolicyConfig:
        DefaultTTL: 86400
        MaxTTL: 31536000
        MinTTL: 0
        Name: TestProjectCachePolicy
        ParametersInCacheKeyAndForwardedToOrigin:
          CookiesConfig:
            CookieBehavior: none
          EnableAcceptEncodingGzip: false
          HeadersConfig:
            HeaderBehavior: none
          QueryStringsConfig:
            QueryStringBehavior: none

Outputs:
  URL:
    Value: !Sub https://${Distribution.DomainName}

  SourceBucketName:
    Value: !Ref SourceBucketName
    Export:
      Name: ReactViteSampleSourceBucketName

codebuild.yml
AWSTemplateFormatVersion: '2010-09-09'

Resources:
  CodeBuild:
    Type: AWS::CodeBuild::Project
    Properties:
      Artifacts:
        Type: CODEPIPELINE
      Environment:
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/amazonlinux2-x86_64-standard:4.0
        Type: LINUX_CONTAINER
      Name: test-project-frontend-build
      ServiceRole: !GetAtt CodeBuildRole.Arn
      Source:
        BuildSpec: buildspec.yml
        Type: CODEPIPELINE

  CodeBuildRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - codebuild.amazonaws.com
            Action: sts:AssumeRole
      Path: /
      Policies:
        - PolicyName: CodeBuildPolicy
          PolicyDocument:
            Statement:
              - Effect: Allow
                Resource: '*'
                Action:
                  - 's3:*'
                  - 'logs:*'

Outputs:
  CodeBuild:
    Value: !Ref CodeBuild
    Export:
      Name: ReactViteSampleCodeBuild
codepipeline.yml
AWSTemplateFormatVersion: '2010-09-09'

Parameters:
  CodePipeLineName:
    Type: String

  ArtifactBucketName:
    Type: String

  EventBridgeName:
    Type: String

  EventBridgeRoleName:
    Type: String

  EventBridgePolicyName:
    Type: String

Resources:
  CodePipeline:
    Type: AWS::CodePipeline::Pipeline
    Properties:
      Name: !Ref CodePipeLineName
      RoleArn: !GetAtt PipelineRole.Arn
      ArtifactStore:
        Type: S3
        Location: !Ref ArtifactStoreBucket
      Stages:
        - Name: Source
          Actions:
            - ActionTypeId:
                Category: Source
                Owner: AWS
                Provider: CodeCommit
                Version: 1
              Configuration:
                RepositoryName: !ImportValue ReactViteSampleRepositoryName
                BranchName: main
                PollForSourceChanges: false
              Name: Source
              OutputArtifacts:
                - Name: SourceArtifact
              RunOrder: 1

        - Name: Build
          Actions:
            - ActionTypeId:
                Category: Build
                Owner: AWS
                Provider: CodeBuild
                Version: 1
              Configuration:
                ProjectName: !ImportValue ReactViteSampleCodeBuild
              InputArtifacts:
                - Name: SourceArtifact
              Name: Build
              OutputArtifacts:
                - Name: BuildArtifact
              RunOrder: 1

        - Name: Deploy
          Actions:
            - ActionTypeId:
                Category: Deploy
                Owner: AWS
                Provider: S3
                Version: 1
              Configuration:
                BucketName: !ImportValue ReactViteSampleSourceBucketName
                Extract: true
              Name: Deploy
              InputArtifacts:
                - Name: BuildArtifact
              RunOrder: 1
      RestartExecutionOnUpdate: false

  ArtifactStoreBucket:
    Type: 'AWS::S3::Bucket'
    Properties:
      BucketName: !Ref ArtifactBucketName
      LifecycleConfiguration:
        Rules:
          - Id: clear-old-objects-rule
            Status: Enabled
            ExpirationInDays: 3
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True

  PipelineRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - codepipeline.amazonaws.com
            Action: sts:AssumeRole
      Path: /
      Policies:
        - PolicyName: CodePipelinePolicy
          PolicyDocument:
            Statement:
              - Effect: Allow
                Resource: '*'
                Action:
                  - 's3:*'
                  - 'codecommit:*'
                  - 'codebuild:*'

  EventBridge:
    Type: AWS::Events::Rule
    Properties:
      Description: for codepipeline
      EventPattern:
        source:
          - aws.codecommit
        detail-type:
          - 'CodeCommit Repository State Change'
        resources:
          - !ImportValue ReactViteSampleRepositoryArn
        detail:
          event:
            - referenceCreated
            - referenceUpdated
          referenceType:
            - branch
          referenceName:
            - main
      Name: !Ref EventBridgeName
      State: ENABLED
      Targets:
        - Arn: !Sub arn:${AWS::Partition}:codepipeline:${AWS::Region}:${AWS::AccountId}:${CodePipeline}
          Id: CodePipeline
          RoleArn: !GetAtt EventBridgeIAMRole.Arn

  EventBridgeIAMRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - events.amazonaws.com
            Action:
              - 'sts:AssumeRole'
      ManagedPolicyArns:
        - !Ref EventBridgeIAMPolicy
      RoleName: !Ref EventBridgeRoleName

  EventBridgeIAMPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Action:
              - 'codepipeline:StartPipelineExecution'
            Resource:
              - !Sub arn:${AWS::Partition}:codepipeline:${AWS::Region}:${AWS::AccountId}:${CodePipeline}
      ManagedPolicyName: !Ref EventBridgePolicyName

buildspec.yml

buildspec.yml
version: 0.2

phases:
  install:
    on-failure: ABORT
    commands:
      - if [ -e /tmp/node_modules.tar ]; then tar xf /tmp/node_modules.tar; fi
      - npm install

  pre_build:
    on-failure: ABORT
    commands:
      - npm run test

  build:
    on-failure: ABORT
    commands:
      - npm run build

artifacts:
  files:
    - '**/*'
  base-directory: dist

cache:
  paths:
    - /tmp/node_modules.tar

githubリポジトリ作成、ミラーリング用の設定

上記記事を参考に、下記を実施してください。

  • SSHキーを作成
  • CodeCommitの公開SSHキーとして登録
  • GitHubリポジトリ作成
  • 作成したリポジトリのActionsのSecretsを設定

githubリポジトリミラーリング用ファイル作成

CodePipelineを使うにあたりCodeCommitとの連携がやりやすいのですが、公開はできません。
リポジトリを公開できるようにGitHubリポジトリをCodeCommitへミラーリングします。

こうすることでCodePipelineはCodeCommitと連携させることができるので設定が楽になります。

CodeCommitリポジトリ作成

cloudformation/shellディレクトリへ移動し下記コマンドを実行します。
コンテナにはaws-cliをインストールしていないので、aws-cliをインストールしているホスト端末側で実行します。

sh 01-repository.sh deploy

上記コマンドでCodeCommitリポジトリが作成されるので、CodeCommitのページでSSHのURLをコピーします。

image.png

フォルダ階層

プロジェクトルート
└─ .github
   └─ workflows
      └─ main.yml

下記を作成します。target_repo_urlはコピーしたURLを貼り付けます。

main.yml
name: Mirroring

on: [ push, delete ]

jobs:
  to_codecommit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0 
      - uses: pixta-dev/repository-mirroring-action@v1
        with:
          target_repo_url: <コピーしたCodeCommitリポジトリのSSHのURL>
          ssh_private_key: ${{ secrets.CODECOMMIT_SSH_PRIVATE_KEY }}
          ssh_username: ${{ secrets.CODECOMMIT_SSH_PRIVATE_KEY_ID }}

サービス作成

cloudformation/shellディレクトリ配下のリポジトリを除いたシェルを順に実行します。
こちらもコンテナ内ではなく、aws-cliをインストールしたホスト端末で実行します。

sh 02-hosting.sh deploy
sh 03-codebuild.sh deploy
sh 04-codepipeline.sh deploy

デプロイ

githubリポジトリにコミットするとCodeCommitへ連携され、CodePipelineが起動して自動でデプロイがされます。

git init 
git commit -m "first commit"
git remote add <作成したgithubリポジトリのURL>
git push -u origin main

デプロイ後はCloudFrontで対象のディストリビューションを選択し、ディストリビューションドメイン名をコピーしてURLに貼り付ければ画面の確認ができると思います。

反映まで時間がかかるので、エラーのような画面が表示される場合は時間をおいて確認してみてください。

おわりに

Amplifyを使えば簡単に構築できるかと思いますが、何をやっているのか分からないのが怖かったため、構築を行ってみました。

今後は何かを作る際に、この記事を見返して構築を完了させてから開発に取り掛かれるのは非常に嬉しいところです。

下記がGitHubリポジトリになります。
適当な場所にクローンして必要なフォルダを都度コピーして持ってくるのがいいかと思います。

参考記事

ESLint, Prettier

CloudFormation

Test

GitHubリポジトリミラーリング

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?