React 入門 (9) ~ TODOアプリを作る(4) ~ データの永続化(1)

さて、今までは React & Redux & Typescript によってTODOアプリを作成してきたわけだが、更新ボタンを押すとすべてのデータが消えてしまう。なんとかサーバーにデータを保持しておきたい。フロントエンドからバックエンドにデータを送ってDBと通信するような形でデータの永続化を試みる。

Redux 通信 で調べると、React + Reduxでサーバー通信ってどこでやればいいの? - TIS ENGINEER NOTE がトップでヒットする。が、その下の記事 ReduxでのMiddleware不要論 - Qiita も気になるところ。

色々記事を見て回ったけど、やはり、redux の middleware に頼らず、まずは ReduxでのMiddleware不要論 - Qiita みたいに、自前のクラスでどうにかしてみようと思う。そのほうが処理の流れが分かりやすいかなと。それでうまくいかない場合は redux-saga だとか redux-thunk だとかを使っていけばいいと思う。(redux-saga とかがよくわからなかったわけではないぞ!ちがうんだからな!)


ある程度の方向性は決まったので、まずはバックエンドサーバーを作る。作りこみは後にして、とりあえずアクセスできるエンドポイントを用意してある程度のサーバーを作成する。今回はgo言語で作成する。

package main

import (
    "fmt"
    "net/http"

    "github.com/gin-gonic/gin"
)

type AddTodoAction struct {
    Type string `json:"type"`
    Text string `json:"text"`
    ID   int64  `json:"id"`
}

func postAddTodoHandler(c *gin.Context) {
    var action AddTodoAction
    if err := c.ShouldBindJSON(&action); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    fmt.Println(action)

    c.JSON(http.StatusOK, gin.H{
        "type": action.Type,
        "text": action.Text + " from server",
        "id":   action.ID,
    })
}

func main() {
    r := gin.Default()
    r.POST("/api/add_todo", postAddTodoHandler)
    r.Run(":3000")
}

Chrome 拡張 の Advanced REST client を インストールして、実際に http://localhost:3000/api/add_todoPOST リクエストを送ると、来たデータをそのまま返すことを確認できた。(実際は来たデータから type, text, id を抜き出して、詰めなおして返している)


次にリバースプロキシサーバーを作成する。ここ を見ると、 webpack-dev-server を apiサーバーのリバースプロキシサーバーとして動作させることができるようだ。webpack.config.js に以下の設定を追記する。

module.exports = {
  devServer: {
    proxy: {
      "/api": {
        target: "http://localhost:3000"
      }
    },
    historyApiFallback: {
      index: "index.html"
    }
  },
}

これでAPIサーバーに接続する準備はできたので、フロント側に通信部分を追加する。最初に挙げた記事 では、アクションクリエイターとディスパッチャーの間に通信用のクラスを挟むことをしているようなので、そのように作成する。今回は単純にラップ関数を一つ追加してADD_TODOアクションに対して通信させてみる。

import { Dispatch} from "redux";

// 通信用関数
const sendToApiServer = async (dispatch: Dispatch<any>, action : any): Promise<void> => {
  switch (action.type) {
    case 'ADD_TODO':
      const obj = action;
      const method = 'POST';
      const body = JSON.stringify(obj);
      const headers = {
        'Accept': 'application/json',
        'Content-Type': 'applicatoin/json'
      };
      const res: Response = await fetch('/api/add_todo', {method, headers, body})

      if(res.ok) {
        const json = await res.json()
        dispatch(json)
      }
    default:
      dispatch(action)
  }
}

渡ってくるアクションのタイプをトラップして、ADD_TODO だったら通信するようにしている。ディスパッチャーには普通にアクションが渡るようにしたかったので、アクションをそのまま戻り値としている。送信するデータはアクションそのものを渡してみる。今後都合が悪くなったらその都度修正しよう。そして、この関数をアクションクリエイターをラップする形で修正する。

sendToApiServer(this.props.dispatch, addTodo(this.textRef.current!.value));

これで追加ボタンを押すと、通信させることができるはずだ。


さて、これでデータベースとアクセスできるインターフェースができたので、サーバーサイドを作りこんでいく。永続化にはsqlite3を使用する。

package main

import (
    "database/sql"
    "fmt"
    "log"
    "net/http"
    "os"

    "github.com/gin-gonic/gin"
    _ "github.com/mattn/go-sqlite3"
)

var db *sql.DB

var dbfile = "./data.db"

func init() {
    if _, err := os.Stat(dbfile); err != nil {
        os.Create(dbfile)
    }

    var err error
    db, err = sql.Open("sqlite3", dbfile)
    if err != nil {
        log.Fatal(err)
    }
    if _, err := createTable(); err != nil {
        log.Fatal(err)
    }
}

func createTable() (sql.Result, error) {
    var stmt = ""
    stmt += "CREATE TABLE IF NOT EXISTS todo ("
    stmt += " id INTEGER PRIMARY KEY AUTOINCREMENT"
    stmt += ", text TEXT NOT NULL "
    stmt += ", completed INTEGER NOT NULL)"
    return db.Exec(stmt)
}

func insertTable(act AddTodoAction) (sql.Result, error) {
    var stmt = ""
    stmt += "INSERT INTO todo "
    stmt += " (text, completed) "
    stmt += " VALUES("
    stmt += fmt.Sprintf("\"%v\"", act.Text)
    stmt += ",0)"
    return db.Exec(stmt)
}

func fetchAddTodoAction(id int64) (*AddTodoAction, error) {
    var stmt = ""
    stmt += fmt.Sprintf("SELECT * FROM todo where id = %v", id)
    rows, err := db.Query(stmt)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    var act AddTodoAction
    for rows.Next() {
        if err := rows.Scan(&act.ID, &act.Text, &act.Completed); err != nil {
            return nil, err
        }
        break
    }
    return &act, nil
}

type AddTodoAction struct {
    Type      string `json:"type"`
    Text      string `json:"text"`
    Completed bool   `json:"completed"`
    ID        int64  `json:"id"`
}

func postAddTodoHandler(c *gin.Context) {
    var action AddTodoAction
    if err := c.ShouldBindJSON(&action); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    var ret sql.Result
    var err error
    if ret, err = insertTable(action); err != nil {
        log.Printf("[INSERT] %v", err.Error())
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    var id int64
    if id, err = ret.LastInsertId(); err != nil {
        log.Printf("[LAST INSERT ID] %v", err.Error())
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    var act *AddTodoAction
    if act, err = fetchAddTodoAction(id); err != nil {
        log.Printf("[SELECT] %v", err.Error())
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "type":      action.Type,
        "text":      act.Text,
        "completed": act.Completed,
        "id":        act.ID,
    })
}

func main() {
    r := gin.Default()
    r.POST("/api/add_todo", postAddTodoHandler)
    r.Run(":3000")
}

渡ってきたアクションをインサートして、最後にインサートしたレコードのIDで取得したレコードから新たにアクションを作成してクライアントサイドにレスポンスを返している。各アクションに対して、これらを繰り返していけばDBとの連携ができるはずだ。それはまた次回ということにしよう。


成果物

上記の内容は以下ののリポジトリにアップしている。

GitHub - bamchoh/react-study at 5a5e2f04ba30a840e3af792d592e0a4deb67d801