GoでREST APIを開発する方法②~ブログアプリ編~(Go + React )

Golang_react

こんにちは、ミナトです。

本記事では、Go言語の標準ライブラリを利用してREST APIを実装する方法を解説します。

バックエンドにGo言語、フロントエンドにReactを利用して、簡易的なブログアプリを開発します。

前回の記事でAPIの実装が完了しましたので、今回はReactを利用してフロントエンドのアプリケーションを実装してきます。

https://github.com/Minato000/go_blog_frontend

Golang_react

GoでREST APIを開発する方法①~ブログアプリ編~(Go + React )

2021年11月2日

React アプリケーションの作成

まずは、以下のコマンドを実行してReactアプリを作成します。

今回は状態管理にReduxを利用するので、--templateにreduxを指定します。

# アプリの作成
npx create-react-app go_blog_frontend --template redux

ライブラリのインストール

axiosはAPIへリクエストするためのHTTPクライアントです。

react-router-domはルーティングを行うためのライブラリです。URLに応じて画面を切り替えます。今回は投稿の一覧画面と詳細画面を作成します。

残りの2行は簡単に見栄えの良いUIを作るためのコンポーネント利用できるMaterial UIとアイコンを利用できるライブラリです。

# ライブラリのインストール
npm install axios
npm install react-router-dom
npm install @mui/material @emotion/react @emotion/styled
npm install @mui/icons-material
# アプリの起動
npm start

共通で利用するコンポーネント

まずは青いヘッダー部分を作成するコンポーネントを作成します。

srcディレクトリの下にcommonディレクトリを作成し、commonディレクトリの中にHeader.jsを作成してください。

以下がHeaderコンポーネントとなります。

import React from 'react';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';

const Header = () => {
  return (
    <div>
      <AppBar position="static">
        <Toolbar>
          <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
            Blog
          </Typography>
        </Toolbar>
      </AppBar>
    </div>
  );
};

export default Header;

投稿の状態管理

投稿に関するデータを管理するために今回はReduxを利用します。

/src/featuresの下にpostディレクトリを作成し、postディレクトリの中にpostSlice.jsを作成してください。

また、コードの中でAPIのベースURLを.envファイルから取得するのでワークディレクトリの中に.envを作成しておいてください。

REACT_APP_API_BASE_URL = "http://localhost:8080"

postSlice.jsは以下となります。

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import axios from 'axios';

const apiBaseUrl = process.env.REACT_APP_API_BASE_URL;

// APIへリクエストし、指定したIDの投稿を1件取得する
export const fetchAsyncGetPost = createAsyncThunk(
  'post/get',
  async (id) => {
    const response = await axios.get(`${apiBaseUrl}/posts/${id}`);
    return response.data;
  }
);

// APIへリクエストし、投稿の一覧を取得する
export const fetchAsynchGetPosts = createAsyncThunk(
  'post/list',
  async () => {
    const response = await axios.get(`${apiBaseUrl}/posts`)
    return response.data;
  }
);

// APIへリクエストして新規に投稿を作成する
export const fetchAsyncNewPost = createAsyncThunk(
  'post/post',
  async (inputPost) => {
    const response = await axios.post(`${apiBaseUrl}/posts/`, inputPost, {
      headers: {
        "Content-Type": "application/json",
      },
    });
    return response.data;
  }
);

// APIへリクエストし、指定したIDの投稿を更新する
export const fetchAsyncUpdatePost = createAsyncThunk(
  'post/put',
  async (inputPost) => {
    const response = await axios.put(`${apiBaseUrl}/posts/${inputPost.id}`, inputPost, {
      headers: {
        "Content-Type": "application/json",
      },
    });
    return response.data;
  }
);

// APIへリクエストし、指定したIDの投稿を削除する
export const fetchAsyncDeletePost = createAsyncThunk(
  'post/delete',
  async (id) => {
    const response = await axios.delete(`${apiBaseUrl}/posts/${id}`);
    return response.data
  }
)

export const postSlice = createSlice({
  name: 'post',
  initialState: {
    currentPost: {
      id: null,
      title: '',
      body: '',
      author: ''
    },
    posts: []
  },
  reducers: {},
  extraReducers: (builder) => {
    // fetchAsyncGetPostが成功した場合にstateのcurrentPostにAPIからのレスポンスをセットする
    builder.addCase(fetchAsyncGetPost.fulfilled, (state, action) => {
      return {
        ...state,
        currentPost: action.payload
      };
    });
    // fetchAsynchGetPostsが成功した場合にstateのpostsにAPIからのレスポンスをセットする
    builder.addCase(fetchAsynchGetPosts.fulfilled, (state, action) => {
      return {
        ...state,
        posts: action.payload === null ? [] : action.payload
      }
    });
    // fetchAsyncNewPostが成功した場合にstateのpostsにAPIからのレスポンスを追加する
    builder.addCase(fetchAsyncNewPost.fulfilled, (state, action) => {
      return {
        ...state,
        posts: [...state.posts, action.payload]
      }
    });
    // fetchAsyncNewPostが成功した場合にstateのpostsにAPIからのレスポンスを追加する
    builder.addCase(fetchAsyncUpdatePost.fulfilled, (state, action) => {
      console.log(action.payload)
      let posts = state.posts.filter((item) => item.id !== action.payload.id)
      posts = [...posts, action.payload]
      posts.sort((a, b) => a.id - b.id)
      return {
        ...state,
        posts: posts
      }
    });
    // fetchAsyncDeletePostが成功した場合にstateのpostsから削除したIDの投稿を削除します
    builder.addCase(fetchAsyncDeletePost.fulfilled, (state, action) => {
      return {
        ...state,
        posts: state.posts.filter((item) => item.id !== action.payload.id)
      }
    })
  }
});

// stateからcurrentPostを取得する
export const selectPost = (state) => state.post.currentPost;
// stateからpostsを取得する
export const selectPosts = (state) => state.post.posts;

export default postSlice.reducer;

createSliceの引数にオブジェクト形式でオプションを渡してReduxのSliceを作成します。

name: Sliceの名称

initialState: stateの初期値

reducers: stateを変更する処理を記述します。今回は利用しないので空としています。

extraReducers: APIへのアクセス後にstateを変更する処理を記述します。

次に/src/app/store.jsを変更して、postSliceのreducerを登録しておきます。

import { configureStore } from '@reduxjs/toolkit';
import postReducer from '../features/post/postSlice';

export const store = configureStore({
  reducer: {
    post: postReducer,
  },
});

投稿一覧を表示するコンポーネン

次に投稿一覧を表示するコンポーネントを作成します。

/src/features/postディレクトリの配下にpostList.jsを作成してください。

import React, { useEffect } from 'react';
import {useSelector, useDispatch} from 'react-redux';
import {Link} from "react-router-dom";
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import IconButton from '@mui/material/IconButton';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction';
import Modal from '@mui/material/Modal';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import {
  fetchAsyncGetPost,
  fetchAsynchGetPosts,
  fetchAsyncNewPost,
  fetchAsyncDeletePost,
  selectPosts,
  selectPost, fetchAsyncUpdatePost
} from './postSlice';

// Style
const styles = {
  container: {
    marginTop: '20px'
  },
  modal: {
    position: 'absolute',
    top: '50%',
    left: '50%',
    transform: 'translate(-50%, -50%)',
    width: 400,
    bgcolor: 'background.paper',
    border: '2px solid #000',
    boxShadow: 24,
    p: 4,
  },
  link : {
    width: '100%'
  }
};

const PostList = () => {
  const dispatch = useDispatch();
  const posts = useSelector(selectPosts);
  const currentPost = useSelector(selectPost);


  // 初回レンダリング時にpostの一覧を取得
  useEffect(() => {
    dispatch(fetchAsynchGetPosts());
  }, []);

  // Editモーダル起動時に選択されたpostの値をセット
  useEffect(() => {
    setInputPost({
      id: currentPost.id,
      title: currentPost.title,
      body: currentPost.body,
      author: currentPost.author
    });
  }, [currentPost]);

  // Modalが開いているかどうか管理するstate
  const [open, setOpen] = React.useState(false);
  // 入力中の内容を管理するstate
  const [inputPost, setInputPost] = React.useState({
    id: null,
    title: "",
    body: "",
    author: ""
  });
  // 更新用にモーダルを起動しているかどうかを管理するstate
  const [isEdit, setIsEdit] = React.useState(false);

  // 作成用Modalの起動
  const handleOpen = () => {
    setOpen(true);
    setIsEdit(false);
  }
  // 更新用Modalの起動
  const handleOpenEdit = (id) => {
    setOpen(true);
    setIsEdit(true);
    dispatch(fetchAsyncGetPost(id))
  }
  // モーダルを閉じる
  const handleClose = () => {
    setInputPost({
      id: null,
      title: "",
      body: "",
      author: ""
    });
    setOpen(false);
  }

  // Form入力時のハンドラ
  const handleInputTitle = (e) => {
    setInputPost({
      ...inputPost,
      title: e.target.value
    });
  }
  const handleInputBody = (e) => {
    setInputPost({
      ...inputPost,
      body: e.target.value
    });
  }
  const handleInputAuthor = (e) => {
    setInputPost({
      ...inputPost,
      author: e.target.value
    });
  }

  // CREATEボタンクリック時の処理
  const handleClickCreate = (e) => {
    dispatch(fetchAsyncNewPost(inputPost));
    setInputPost({
      id: null,
      title: "",
      body: "",
      author: ""
    });
    setOpen(false);
  }
  // UPDATEボタンクリック時の処理
  const handleClickUpdate = (e) => {
    dispatch(fetchAsyncUpdatePost(inputPost));
    setInputPost({
      id: null,
      title: "",
      body: "",
      author: ""
    });
    setOpen(false);
  }

  // 削除ボタンクリック時の処理
  const handleClickDelete = (id) => {
    dispatch(fetchAsyncDeletePost(id));
  }

  return (
    <div>
      <Container fixed maxWidth="md" style={styles.container}>
          <Button variant="outlined" onClick={handleOpen}>ADD POST</Button>
          <List>
            {posts && (
                posts.map((post) => (
                  <ListItem key={post.id}>
                    <Link to={`/posts/${post.id}`} style={styles.link}>
                      <ListItemButton>
                        {post.id} - {post.title}
                      </ListItemButton>
                    </Link>
                    <ListItemSecondaryAction>
                      <IconButton
                        type="button"
                        edge="end"
                        aria-label="Edit"
                        value={post.id}
                        onClick={() => handleOpenEdit(post.id)}
                      >
                        <EditIcon />
                      </IconButton>
                      <IconButton
                        type="button"
                        edge="end"
                        aria-label="delete"
                        value={post.id}
                        onClick={() => handleClickDelete(post.id)}
                      >
                        <DeleteIcon />
                      </IconButton>
                    </ListItemSecondaryAction>
                  </ListItem>
                )))
            }

          </List>

        <Modal
          open={open}
          onClose={handleClose}
          aria-labelledby="modal-modal-title"
          aria-describedby="modal-modal-description"
        >
          <Box sx={styles.modal}>
            <Typography id="modal-modal-title" variant="h6" component="h2">
              { isEdit ? "Edit Post" : "Create Post" }
            </Typography>
            <Box
              component="form"
              sx={{
                '& > :not(style)': { my: 1 },
              }}
              noValidate
              autoComplete="off"
            >
              <TextField
                id="title"
                label="Title"
                value={inputPost.title}
                required
                fullWidth
                onChange={handleInputTitle}
              />
              <TextField
                id="body"
                label="Body"
                value={inputPost.body}
                multiline
                rows={4}
                required
                fullWidth
                onChange={handleInputBody}
              />
              <TextField
                id="author"
                label="Author"
                value={inputPost.author}
                required fullWidth
                onChange={handleInputAuthor}
              />
              <Stack direction="row" spacing={2}>
                <Button
                  id="save"
                  variant="outlined"
                  color="secondary"
                  onClick={handleClose}
                >
                  CANCEL
                </Button>
                {
                  isEdit
                    ? <Button id="save" variant="outlined" onClick={handleClickUpdate}>UPDATE</Button>
                    : <Button id="save" variant="outlined" onClick={handleClickCreate}>CREATE</Button>
                }

              </Stack>
            </Box>
          </Box>
        </Modal>
      </Container>
    </div>
  );
};

export default PostList;

全て説明すると長くなってしまうので、細かい箇所は割愛してポイントのみ解説します。

const dispatch = useDispatch();

こちらでは、アクションをディスパッチするための関数を取得するため、useDispatch()を利用しています。dispatch(action)をすることでreducerにアクションを渡せます。

const posts = useSelector(selectPosts);
const currentPost = useSelector(selectPost);

上記では、useSelector()を利用して、Reduxのstoreからstateを取得しています。

  // 初回レンダリング時にpostの一覧を取得
  useEffect(() => {
    dispatch(fetchAsynchGetPosts());
  }, []);

  // Editモーダル起動時に選択されたpostの値をセット
  useEffect(() => {
    setInputPost({
      id: currentPost.id,
      title: currentPost.title,
      body: currentPost.body,
      author: currentPost.author
    });
  }, [currentPost]);

useEffect()の第一引数には、第二引数で指定した値が変更された場合に実行する処理を記述します。

第二引数が空の場合は、初回のマウント時のみ実行されます。

  // Modalが開いているかどうか
  const [open, setOpen] = React.useState(false);
  // 入力中の内容
  const [inputPost, setInputPost] = React.useState({
    id: null,
    title: "",
    body: "",
    author: ""
  });
  // 更新用にモーダルを起動しているかどうか
  const [isEdit, setIsEdit] = React.useState(false);

[stateの値, stateをセットする関数] = React.useState(初期値)では、コンポーネント内でのみ利用するstateを作成しています。

handleXXX()という関数はフォームの入力やボタンをクリックした際のイベントハンドラです。

投稿詳細を表示するコンポーネント

続いて投稿詳細を表示するコンポーネントを作成します。

/src/features/postディレクトリの配下にPost.jsを作成してください。

import React, {useEffect} from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {useLocation, Link} from 'react-router-dom';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import Typography from '@mui/material/Typography';
import {
  fetchAsyncGetPost,
  selectPost
} from './postSlice';

const styles = {
  author: {
    marginTop: '10px',
    marginBottom: '10px',
  },
  body: {
    whiteSpace: 'pre-line',
    marginBottom: '20px'
  },
  link: {
    textDecoration: 'none'
  },
};

const Post = () => {
  const dispatch = useDispatch();
  const currentPost = useSelector(selectPost);

  const location = useLocation();
  const postId = location.pathname.split('/')[2];

  useEffect(() => {
    dispatch(fetchAsyncGetPost(postId));
  }, []);

  return (
    <div>
      <Container fixed maxWidth="md">
        <Box sx={{ width: '100%', marginTop: '20px' }}>
          <Typography variant="h3" component="h2">{currentPost.title}</Typography>
          <Typography variant="h5" component="div" style={styles.author}>{currentPost.author}</Typography>
          <article>
            <Typography variant="body1" style={styles.body}>{currentPost.body}</Typography>
          </article>
        </Box>
        <Link to="/" style={styles.link}>
          <Button variant="outlined">Back</Button>
        </Link>
      </Container>
    </div>
  );
};

export default Post;
const location = useLocation();
const postId = location.pathname.split('/')[2];

useEffect(() => {
  dispatch(fetchAsyncGetPost(postId));
}, []);

上記でアクセスしているURLのパスから投稿のIDを取得して、初回マウント時に指定したIDの投稿を取得しています。

Post.jsではAPIから取得した投稿1件のデータを表示しています。

コンポーネントの表示とルーティングの設定

続いて、上記で作成したコンポーネントの親となるコンポーネントを作成していきます。

/src/App.jsを以下の用に修正してください。

import React from 'react';
import {BrowserRouter, Route, Switch} from "react-router-dom";
import Header from './features/common/Header';
import Post from './features/post/Post';
import PostList from './features/post/postList';

function App() {
  return (
    <div className="App">
      <Header />
      <main>
        <BrowserRouter>
          <Switch>
            <Route path="/" exact children={<PostList />}/>
            <Route path="/posts/:postId" children={<Post />} />
          </Switch>
        </BrowserRouter>
      </main>
    </div>
  );
}

export default App;

react-router-dom<BrowserRouter><Switch>で囲われている箇所がURLに応じて変更されるコンポーネントです。

<Route>のpathにパスのパターンをセットします。childrenにはパスのパターンがマッチした際に表示されるコンポーネントを指定します。

次に/src/index.jsを以下のように修正してください。

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { store } from './app/store';
import { Provider } from 'react-redux';
import * as serviceWorker from './serviceWorker';

ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
  document.getElementById('root')
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

<Provider>で囲われているコンポーネント内で、Reduxのstoreにアクセスできるようになります。

以上で実装は完了です。アプリ作成から存在してたcouterコンポーネントなど不要なファイルは削除しておいてください。

npm startでアプリを起動して、動作を確認してみてください。

まとめ

これで簡易ブログアプリの実装は完了です。

Go言語とReactを利用することで、高パフォーマンスかつモダンなアプリを効率的に開発できます。

Go言語、Reactともに非常に人気が拡大しています。まだ利用されていない方はぜひ習得して、エンジニアとしての市場価値を高めてください。

最後まで読んでいただき、ありがとうございます。

この記事が、「面白いな」、「勉強になったな」という方は、SNSでシェアしていただけると嬉しいです。

Golang_react

GoでREST APIを開発する方法①~ブログアプリ編~(Go + React )

2021年11月2日