強欲で謙虚なツボツボ

趣味の読書の書の方

【Aurora MySQL】日本語対応

概要

AWS RDSでAurora MySQLを選択してデータベースを作成した。
Railsとかでmigration実行をしてテーブルも作成した後、データ挿入を行うと日本語が全て???で保存されていた。
ちゃんと日本語で保存されるように頑張る。

具体的には、AWSのコンソールでパラメータグループを作成するのと、mysqldumpで作成済のデータベースやテーブルのエンコードを再設定する。

 

元の状態

パラメータ

Aurora MySQLはデフォルトでパラメータが以下のように設定される。

show variables like '%char%';
  • character_set_client: utf8
  • character_set_connection: utf8
  • character_set_database: latin1
  • character_set_filesystem: binary
  • character_set_results: utf8
  • character_set_server: latin1
  • character_set_system: utf8

latin1なので日本語に対応できずに???となってしまう。
今回はパラメータグループの設定によって、filesystem以外をutf8mb4に変更する。(mb4は絵文字とかにも対応できる)

作成済テーブルのエンコード

show create database {データベース名}
show create table {テーブル名}

出力される情報の末尾に、DEFAULT CHARACTER SET latin1と表示される。
今回はデータベースを削除して再作成することで、DEFAULT CHARACTER SET utfmb4となるように変更する。

 

パラメータグループの作成と設定

コンソールから「Create paramter group」で作成

  • Parameter group family: aurora-mysql5.7(各自のDBに合わせる)を選択
  • Type: DB Cluster Parameter Groupを選択

作成したパラメータグループを「Edit parameters」で編集する。
編集後は「Save changes」で保存。

  • character_set_client: utf8mb4
  • character_set_connection: utf8mb4
  • character_set_database: utf8mb4
  • character_set_results: utf8mb4
  • character_set_server: utf8mb4
  • collation_connection: utf8mb4_bin
  • collation_server: utf8mb4_bin
  • skip-character-set-client-handshake: 1

作成したパラメータグループを設定するためDBクラスターを「Modify」で編集。

Additional configuration > Database options > DB cluster parameter groupで作成したパラメータグループを選択。

設定後にDBクラスターに所属するDBインスタンスを「Reboot」して設定を反映。

これで、以後作成されるデータベースとテーブルは大丈夫。

 

作成済のデータベースとテーブルのエンコードを変更

DBの踏み台にしているEC2インスタンスssh接続したのち、mysqldumpを実行。

mysqldump -u {USER_NAME} -p -h {HOST_NAME} {DB_NAME} > {OUTPUT_FILE_NAME}

出力された.sqlファイルを編集したいが、編集作業はローカルの方が楽なのでローカルへコピーする。

scp -i {鍵ファイル} {ec2-user}@xxx.xxx.xxx.xxx:{OUTPUT_FILE_NAME} ./

OUTPUT_FILEのlatin1とutf8となっている箇所を全てutf8mb4へ置換して、EC2インスタンスへコピーする。

scp -i {鍵ファイル} ./{OUTPUT_FILE_NAME} {ec2-user}@xxx.xxx.xxx.xxx:./

EC2インスタンスからDBへ接続し、データベースを削除する。

mysql -u {USER_NAME} -p -h {HOST_NAME}
drop database {DB_NAME};
quit;

EC2インスタンスから編集後の.sqlファイルを適用してデータベースを作成する。

mysql -u {USER_NAME} -p -h {HOST_NAME} {DB_NAME} < {OUTPUT_FILE_NAME}

これで完了。

 

おしまい

データベースのエンコードとかタイムゾーンみたいなパラメータの設定は、データベース作成前にきちんとやっておくといい。

後からやるとデータベースを再作成しないといけないから面倒。

 

Redis + Node.js + Socket.io でチャットアプリの動きをつける

概要

何かと必要になりがちなチャットアプリの動きを実装する。
使うのはNodejs、Redis、Websocket。
docker-composeで起動させる。
typescriptで記述するのでその辺の設定についても。

 

 

完成物

https://github.com/di-kotobuki/chat

 

イメージ図

Redisでpub/sub機能を使うと複数サーバー間で同期できる。
サーバー側でRedisに保存されているデータの各キーに対してそれぞれ接続してサブスクリプションさせておくことで、publishされた時にそれが全てのサーバーに伝えられる。

 

やること

  1. dockerの構築
  2. typescriptの準備
  3. websocketのサーバ側を実装
  4. websocketのクライアント側を実装

 

ディレクトリ構造

/
├ app
│   ├ src
│   │   ├ public
│   │   │   ├ index.html
│   │   │   └ style.css
│   │   └ index.ts
│   ├ package.json
│   ├ tsconfig.json
│   └ yarn.lock
├ .gitignore
├ docker-compose.yml
├ Dockerfile
└ README.md

 

dockerの構築

サーバ用のコンテナとRedisのコンテナを作る。

# docker-compose.yml
version: "3"

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: web
    environment:
      - REDIS_HOST=redis
      - REDIS_PORT=6379
    volumes:
      - ./app:/usr/src/app
    ports:
      - 3000:3000
    tty: true
    depends_on:
      - redis

  redis:
    image: redis:latest
    container_name: redis
    ports:
      - 6379:6379
    volumes:
      - ./data:/data
    command: redis-server --appendonly yes
# Dockerfile
FROM node:16-alpine

RUN apk update && apk add --no-cache libc6-compat && ln -s /lib/libc.musl-x86_64.so.1 /lib/ld-linux-x86-64.so.2

WORKDIR /usr/src/app
COPY ./app/package.json ./app/yarn.lock ./
RUN yarn install

COPY ./app ./

EXPOSE 3000

 

typescriptの準備

サーバはtypescriptで記述したいので必要なnodeパッケージをインストールしたり、ビルドの設定を行う。

初期化

必要なnodeパッケージをインストール。

cd app
npm init -y
yarn add typescript

tsconfig

tsconfig.jsonを作成して編集する。

cd app
npx tsc --init

これでtsconfig.jsonが生成されるので、末尾にincludeとexcludeを追加してtargetとoutDirを変更。

# /app/tsconfig.json
{
  ...,
  "target": "ES6",
  "outDir": "./dist", 
  ..., 
  "include": [ "src/**/*" ],
  "exclude": [ "node_modules" ] 
}

ビルド設定

編集するたびにビルドと起動をしなくてもいいようにホットリロードの設定。

cd app
yarn add -D ts-node ts-node-dev rimraf npm-run-all

そして、package.jsonを編集。

# app/package.json
{
  "name": "chat",
  "version": "1.0.0",
  "description": "",
  "main": "dist/index.js",
  "scripts": {
    "dev": "ts-node-dev --respawn src/index.ts",
    "build": "npm-run-all clean tsc",
    "start": "node ."
  },
  // 省略...
}

起動確認

さっきpackage.jsonで"dev": "ts-node-dev --respawn src/index.ts"としたので、app/src/index.tsを作成して適当に編集。

# app/src/index.ts
console.log("hello world");

yarn devでindex.tsを編集するたびにホットリロードされることを確認できる。

cd app
yarn dev

 

 

Socketのサーバー側

さっきのindex.tsにwebsocketの記述をしていく。

必要なパッケージ

ioredisとsocketをインストール。
NodejsでRedisを使うならioredisがいいらしい。
Websocketを使用するためのsocket.io。
Nodejsのフレームワークといったらのexpress。
POSTメソッドのリクエストでパラメータをパースするためのbody-parser。
日時の処理がしたいのでmoment。

cd app
yarn add ioredis socket.io socket.io-client express body-parser moment
yarn add -D @types/ioredis @types/socket.io @types/express

コード

大まかに、redisへの接続とwebsocketの実装を行う。

redisに保存できるデータ型はいくつかあるが、今回はチャットが送られた順番も大事なのでソート済セット型を使う。
ソート済セット型は「キー」「スコア」「値」から構成され、チャットを例に挙げるとキーがチャットルームID、スコアがチャット送信日時、値がチャット内容にあたる。
キーはデータベースのテーブルにあたると考えると理解しやすいかもしれない。
キーの数だけredisとの接続を行って、各接続でそれぞれのキーにおいてサブスクリプションさせる。チャットルームが閉鎖された時は該当するキーへのサブスクリプションを行っているredisへの接続を切断したらいい。

websocketはクライアント側からのメッセージ送信などのイベントを受け取り、redisへのデータ保存や他のクライアントへの転送を行う。
双方向でのやりとりができ、一方のemitに他方のonが呼応する関係。
一方でemit("hoge")を実行すると他方でon("hoge")が実行される。

# app/src/index.ts
import express from "express";
import * as http from "http";
import * as socketio from "socket.io"
import Redis from "ioredis";
import moment from "moment";
import bodyParser from "body-parser";


// 環境変数
const redis_host = process.env.REDIS_HOST;
const redis_port = parseInt(process.env.REDIS_PORT || "6379", 10);

const app: express.Express = express();
const server: http.Server = new http.Server(app);
const io: socketio.Server = new socketio.Server(server);
const redis: Redis = new Redis({ host: redis_host, port: redis_port });
const subscribers: Record<string, Redis> = {};

(async () => {
  // publicフォルダに静的ファイルを格納
  app.use(express.static("src/public"));

  // POSTのリクエストボディ
  app.use(bodyParser.urlencoded({
    extended: true
  }));
  app.use(bodyParser.json());

  // 全てのkeyを取得
  const keys = await redis.keys("*");
  for (let i = 0; i < keys.length; i++) {
    await addSubscriber(keys[i]);
  }

  // チャットルーム作成
  app.use("/create", async (req, res) => {
    const { key, id } = req.body;
    const date = moment.now();
    const message = { id: id, text: `NEW CHANNEL: ${key}`, date: date };
    await redis.zadd(key, moment.now(), JSON.stringify(message));
    await addSubscriber(key);
    res.send({ name: key, messages: [message] });
  });

  // チャットルーム閉鎖
  app.use("/delete", async (req, res) => {
    const { key } = req.body;
    subscribers[key].disconnect();
    delete subscribers[key];
    res.send("ok");
  });

  // チャットルーム取得
  app.use("/get", async (req, res) => {
    const keys = await redis.keys("*");
    const rooms = [];
    for (let i = 0; i < keys.length; i++) {
      const result = await redis.zrange(keys[i], 0, -1);
      const room = {
        name: keys[i],
        messages: result.map((msg) => JSON.parse(msg))
      }
      rooms.push(room);
    }
    res.send(rooms);
  })

  // Redis接続時の処理を登録
  io.on("connection", (socket: socketio.Socket) => {
    // クライアント側からのメッセージ送信を待ち受ける
    // クライアント側: socket.emit("send", { key: channel, id: id, text: text })
    socket.on("send", async (data) => {
      const date = moment.now();
      const message = JSON.stringify({
        id: data.id,
        text: data.text,
        date: date
      });
      await redis.zadd(data.key, date, message);
      await redis.publish(data.key, message);
      console.log(`メッセージ送信: ${message}`);
    });
  });

  server.listen(3000, () => {
    console.log("listening on *:3000")
  })
})();

// subscriberを追加
async function addSubscriber(key: string) {
  const client = new Redis({ host: redis_host, port: redis_port });
  await client.subscribe(key);
  client.on("message", (_, msg) => {
    // クライアント側: socket.on(room_name, (message) => {})
    io.emit(key, JSON.parse(msg));
    console.log(`メッセージ転送: ${msg}`);
  });
  subscribers[key] = client;
}

 

Socketのクライアント側

htmlとか結構長くなってしまうので、websocketやajaxに関する記述のみ記載

# app/src/public/index.html
<!-- importmapでcdn読み込み -->
<script type="importmap">
  {
    "imports": {
      "socket.io-client": "https://cdn.socket.io/4.4.1/socket.io.esm.min.js",
      "axios": "https://cdn.skypack.dev/axios"
    }
  }
</script>
<script type="module">
  import { io } from "socket.io-client";
  import axios from "axios";
  
  (() => {
    // io()の引数は接続するwebsocketサーバーのホストだが、今回はこの同じdockerコンテナの中なのでio()でいい。
    const socket = io();
    
    async function init() {
      document.getElementById("send").addEventListener("click", async () => {
        const el = document.getElementById("send_text");
        // サーバ側: socket.on("send", async (key: string, id: number, text: string) => {})
        await socket.emit("send", { key: channel, id: id, text: el.value });
        el.value = "";
      })
    }
    
    // メッセージ受信
    async function receiveMessage(room_name) {
      // サーバ側: io.emit(key, JSON.parse(msg));
      socket.on(room_name, (message) => {
        // メッセージ受信時の描画処理
      });
    }
    
    // チャットルーム取得
    async function getRooms() {
      axios.get("/get")
      .then((response) => {
        const rooms = response.data;
        for (let i = 0; i < rooms.length; i++) {
          // 取得したチャットルームでのメッセージを受信できるように設定
          receiveMessage(rooms[i].name);
          // チャットルーム取得時の描画処理
        }
        console.log(response);
      })
      .catch((error) => {
        console.log(error);
      });
    }
    
    // チャットルーム作成
    async function createRoom(room_name) {
      axios.post("/create", {
        key: room_name,
        id: id
      })
      .then((response) => {
        // 作成したチャットルームでのメッセージを受信できるように設定
        receiveMessage(room_name);
        // チャットルーム作成時の描画処理
      })
      .catch((error) => {
        console.log(error);
      });
    }
  })();
</script>

 

おわり

なんだかんだで二週間くらいかかってしまったけど、websocketの記述とredisを少しわかることができたのでよかった。

 

参考

Amazon ElastiCache for Redis を使ったChatアプリの開発 | Amazon Web Services ブログ

GitHub - aws-samples/elasticache-refarch-chatapp: Example architecture for building a chat application using ElastiCache for Redis.

【Rails】form_withで非同期通信して表示を動的に変更(data-remote="true")

概要

リストを表示するときに追加読み込み、並び替え、絞り込みなどで表示を動的に変更したいことがしばしばある。
そんな時に、:remote => true.js.erbなどを利用して対応する。
また、そのままだと:remote=>trueをしてもJSリクエストとして処理されず、.js.erbではなく.html.erbが描画されてしまったのでrails-ujsを使うようにする。

 

環境

ruby 3.1.2p20 (2022-04-12 revision 4491bb740a)
Rails 7.0.3.1
Bundler version 2.3.7

 

完成物

github

https://github.com/di-kotobuki/hatena/tree/ajax

・動作の様子

 

data-remote="true"

フォームやリンクのhtml要素にdata-remote="true"の属性が付くと通信がJSリクエストとなって非同期通信になる。
これの何がいいかと言うと、画面がリロードされずに画面の一部の要素を再描画できたりすることだと思う。
VueやReactを使うなら不要になる。

 

やり方

link_toであればremote: trueを、form_withであればlocal: falseを設定すればいい。

Rails で JavaScript を利用する - Railsガイド

route

適当なgetとpostのルートを作成。

Rails.application.routes.draw do
  get "/articles", to: "articles#index"
  post "/articles", to: "articles#create"
end

controller

上記で参照しているarticlesコントローラーとそのメソッドを作成。
コマンド一つでcontrollerやviewを作ってくれるのでそれを編集していく。

$ rails generate controller Articles index --skip-routes
class ArticlesController < ApplicationController
  def index
    @articles = ["aaa", "bbb", "ccc"]
  end

  def create
    @article = params[:title]
    respond_to do |format|
      format.html { redirect_to articles_path }
      format.js
    end
  end
end

view

indexでレンダリングされるindex.html.erbは先のコマンド実行で作成されているので適当に加筆。

<Articles#index>
<Find me in app/views/articles/index.html.erb>
<div>
  <%= form_with url: articles_path, local: false do |f| %>
    <%= f.text_field :title %>
    <%= f.submit %>
  <% end %>
  <%= render "list", articles: @articles %>
</div>

リスト部分をテンプレートに分割する(_list.html.erb)。

<ul id="articles">
  <% articles.each do |article| %>
    <li><%= article %></li>
  <% end %>
</ul>

createでレンダリングされるのはcontroller側で特に指定していなければcreate.js.erbレンダリングされる。これは自分でファイルを作成する。
内容は、formで入力された値でhtml要素を追加するというもの。

(() => {
  document.getElementById("title").value = "";
  const target = document.getElementById("articles");
  const el = document.createElement("li");
  el.textContent = "<%= @article %>";
  target.appendChild(el);
})();

 

rails-ujs

ここまでの状態でrails -sで起動して確認してみると、submitしても何もページが再読み込みされるだけで何も起こらない。
本来formt.jscreate.js.erbが読み込まれるはずが、format.htmlindexへのリダイレクトになってしまっている。
この理由は、submit時のリクエストのContent-TypeでMIMEタイプがtext/htmlになっているからで、これはrails-ujsを適用すれば解決するらしい。
この辺りの通信に関する部分はRailsのバージョンで異なっていたりするらしいので、調べるときはRailsのバージョンをちゃんと見た方がいい。
下記を参考にする。

javascript - Rails UJS not firing with Rails 7 - Stack Overflow

プロジェクトルートで実行。

$ ./bin/importmap pin @rails/ujs

app/javascript/application.jsに追記。

import Rails from '@rails/ujs';
Rails.start();

 

おしまい

これで完成物の出来上がり。

【Rails】Macでの環境構築(エラー対応も)

概要

macOSRailsの開発作業ができるようにするまでの手順。
macには元からrubyとbundlerが入っているが、バージョン管理をしたいのでrbenvでrubyをインストールしてそちらを適用させる。
あと、途中のrbenv installの時にエラーが出たのでその対応。

 

環境

macOS Big Sur 11.4
MacBook Air(Retina, 13-inch, 2019)
プロセッサ Intel Core i5
メモリ 8GB

 

手順

バージョン管理用のrbenvをインストール
rbenvで好きなバージョンのrubyをインストール
gemで好きなバージョンのrailsをインストール

rbenvをインストール

brew install rbenv ruby-build

brew installできたら.bashrcや.zshrcに下記を追記

PATH="$HOME/.rbenv/bin:$PATH"
eval "$(rbenv init -)"

.bashrcや.zshrcを再読み込みして(source ~/.bashrc)、rbenv -vでrbenvのバージョンを確認できたらOK

rubyをインストール

rbenvでインストールできるrubyのバージョンを確認する

rbenv install --list
2.6.10
2.7.6
3.0.4
3.1.2
jruby-9.3.6.0
mruby-3.1.0
picoruby-3.0.0
rbx-5.0
truffleruby-22.1.0
truffleruby+graalvm-22.1.0

Only latest stable releases for each Ruby implementation are shown.
Use 'rbenv install --list-all / -L' to show all local versions.

今回は一番新そうな3.1.2をインストールする(そこそこ時間かかります)

rbenv install 3.1.2

私はここでエラーが出ました。

これだけ見てもわからないのでログ全文を確認する(/var/folders/....../.log)。
下のスクショはログの前半部分、残りはエラー解決に関係なさそう。

デカデカと表示されているOpenSSL関連に問題がありそう。
インストールされているOpenSSLを確認していく。

$ which openssl
/usr/bin/openssl

$ openssl version -a
LibreSSL 2.8.3
built on: date not available
platform: information not available
options:  bn(64,64) rc4(16x,int) des(idx,cisc,16,int) blowfish(idx)
compiler: information not available
OPENSSLDIR: "/private/etc/ssl"

LibreSSLとでているが、これはmacOSに標準搭載されているものらしい。
以前、homebrewでインストールした気がするのでそちらの状態も確認する。

$ brew list
==> Formulae
autoconf ca-certificates gettext git m4 openssl@1.1	pcre2 pkg-config pyenv rbenv readline ruby-build xz

$ brew info openssl@1.1
openssl@1.1: stable 1.1.1q (bottled) [keg-only]
Cryptography and SSL/TLS Toolkit
https://openssl.org/
/usr/local/Cellar/openssl@1.1/1.1.1m (8,081 files, 18.5MB)
  Poured from bottle on 2021-12-15 at 23:34:25
/usr/local/Cellar/openssl@1.1/1.1.1q (8,097 files, 18.5MB)
  Poured from bottle on 2022-07-13 at 12:40:34
From: https://github.com/Homebrew/homebrew-core/blob/HEAD/Formula/openssl@1.1.rb
License: OpenSSL
==> Dependencies
Required: ca-certificates ✔
==> Caveats
A CA file has been bootstrapped using certificates from the system
keychain. To add additional certificates, place .pem files in
  /usr/local/etc/openssl@1.1/certs

and run
  /usr/local/opt/openssl@1.1/bin/c_rehash

openssl@1.1 is keg-only, which means it was not symlinked into /usr/local,
because macOS provides LibreSSL.

If you need to have openssl@1.1 first in your PATH, run:
  echo 'export PATH="/usr/local/opt/openssl@1.1/bin:$PATH"' >> ~/.zshrc

For compilers to find openssl@1.1 you may need to set:
  export LDFLAGS="-L/usr/local/opt/openssl@1.1/lib"
  export CPPFLAGS="-I/usr/local/opt/openssl@1.1/include"

For pkg-config to find openssl@1.1 you may need to set:
  export PKG_CONFIG_PATH="/usr/local/opt/openssl@1.1/lib/pkgconfig"

==> Analytics
install: 1,225,966 (30 days), 3,060,362 (90 days), 11,394,164 (365 days)
install-on-request: 51,798 (30 days), 122,474 (90 days), 473,105 (365 days)
build-error: 1,766 (30 days)

openssl@1.1を使うには色々と環境変数を設定しなければいけないようだ。
まとめて.zshrcに追記してしまう。

export PATH="/usr/local/opt/openssl@1.1/bin:$PATH"
export LDFLAGS="-L/usr/local/opt/openssl@1.1/lib"
export CPPFLAGS="-I/usr/local/opt/openssl@1.1/include"
export PKG_CONFIG_PATH="/usr/local/opt/openssl@1.1/lib/pkgconfig"

.zshrcを読み込んでOpenSSLがどうなったか確認する。

$ which openssl
/usr/local/opt/openssl@1.1/bin/openssl

$ openssl version -a
OpenSSL 1.1.1q  5 Jul 2022
built on: Tue Jul  5 09:08:33 2022 UTC
platform: darwin64-x86_64-cc
options:  bn(64,64) rc4(16x,int) des(int) idea(int) blowfish(ptr)
compiler: clang -fPIC -arch x86_64 -O3 -Wall -DL_ENDIAN -DOPENSSL_PIC -DOPENSSL_CPUID_OBJ -DOPENSSL_IA32_SSE2 -DOPENSSL_BN_ASM_MONT -DOPENSSL_BN_ASM_MONT5 -DOPENSSL_BN_ASM_GF2m -DSHA1_ASM -DSHA256_ASM -DSHA512_ASM -DKECCAK1600_ASM -DRC4_ASM -DMD5_ASM -DAESNI_ASM -DVPAES_ASM -DGHASH_ASM -DECP_NISTZ256_ASM -DX25519_ASM -DPOLY1305_ASM -D_REENTRANT -DNDEBUG
OPENSSLDIR: "/usr/local/etc/openssl@1.1"
ENGINESDIR: "/usr/local/Cellar/openssl@1.1/1.1.1q/lib/engines-1.1"
Seeding source: os-specific

これでrbenv install 3.1.2をしてもエラーが変わりませんでした。
調べてみると--with-openssl-dirというオプションでrubyをインストールするときのopensslを指定できるらしい。

# ディレクトリは$ which opensslで確認したopenssl@1.1までのパス
export RUBY_CONFIGURE_OPTS="--with-openssl-dir=/usr/local/opt/openssl@1.1"

やっとインストールできました。

$ rbenv install 3.1.2
Downloading ruby-3.1.2.tar.gz...
-> https://cache.ruby-lang.org/pub/ruby/3.1/ruby-3.1.2.tar.gz
Installing ruby-3.1.2...
ruby-build: using readline from homebrew
Installed ruby-3.1.2 to /Users/otam/.rbenv/versions/3.1.2

$ rbenv versions
* system
  3.1.2

作業ディレクトリ内で3.1.2を適用する。

$ rbenv local 3.1.2
$ rbenv rehash

$ rbenv versions
  system
* 3.1.2

$ ruby -v
ruby 3.1.2

railsをインストール

$ gem install rails
$ rails -v
Rails 7.0.3.1

アプリケーションを作成する。

$ rails new hatena

アプリケーション起動

$ cd hatena
$ rails s
=> Booting Puma
=> Rails 7.0.3.1 application starting in development
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 5.6.4 (ruby 3.1.2-p20) ("Birdie's Version")
*  Min threads: 5
*  Max threads: 5
*  Environment: development
*          PID: 15596
* Listening on http://127.0.0.1:3000
* Listening on http://[::1]:3000
Use Ctrl-C to stop

 

エラーの対応で結構時間がかかった。
もう深夜3時だ、明日は平日だから問題あるね。
でもエラー解決できたのは嬉しかったからよし。

【AWS】 Amazon MemoryDB for Redis vs ElasticCache

概要

チャット機能を実装する際によく利用されているRedisだが、AWSを利用する場合はどのサービスを選択すべきかがよくわかっていなかったので調べた。

 

選択肢

redisで検索すると以下の2つがヒットする。

  • Amazon MemoryDB for Redis
  • ElasticCache

これらのどちらを利用すべきか。
例えばチャット機能を実装したい場合、「AWS チャット」みたいに検索するとElasticCacheを利用するモデルが検索結果には多い。
ただ、MemoryDBの方が後発サービスらしく、こちらを使った方が良さそうにも思える。

間違ってreidsと検索しているが結果は変わらない

 

比較検討

しばらく調べてみて一番参考になりそうだったのが↓

https://cloudwellserved.com/amazon-elasticache-for-redis-vs-amazon-memorydb-for-redis/

ElasticCache

AWSのサービス概要にも「In-Memory Cache」とあるように、データをメモリにキャッシュとして保存して取り扱うサービス。
本来データベースにデータを取りに行くところを、キャッシュがあればそちらを取りに行かせることができて、データベースへのアクセスを減らせて処理も軽くなるから速度改善できるよ、というサービスのようだ。

決してデータベースではなく、あくまでデータベースのキャッシュとしての立ち位置であり、AWS上ではRDSと共に運用されるサービスである。
また、読み込みはキャッシュからできるが、書き込みはデータベースに対して行わなければならず、その後にキャッシュの更新も必要。

単体で利用するならばセッションの保持とかできそう。

Amazon MemoryDB for Redis

AWSのサービス概要にも「Fully managed, Redis-compatible, in-memory database service」とあるように、データベースサービスである。
RDS + ElasticCacheで運用していたものをまとめて一つのサービスとしたようなもの。
データの読み書き共にこのサービスに対して実行すればよく、こちらの方がデータベースとしてRedisを使いたいという利用目的に適っている。

料金はどこを見てもElasticCacheの約1.5倍と見積もっているところが多い気がする。

 

結論

セッションの保持などのブラウザ上でいうCookieのような利用がしたいのであれば、ElasticCache単体で良さそう。

チャットなどのデータベースに対する読み書きのような利用がしたいのであれば、ElasticCache + RDSよりも運用しやすそうなAmazon MemoryDB for Redisを使いたい。

備忘録(go-swaggerでAPIサーバを作る、gorm, air, dockerと共に)

GoでREST APIを作ります。

go-swaggerはyamlでWebAPIの定義をすることで、サーバーサイド・クライアントサイドのコードをコマンドの実行で生成してくれる。
今回はサーバーサイドだけ欲しいのでクライアントサイドは生成しない。
ただし、リクエストを受けた時にパラメータに従ってデータベースとやりとりする部分の処理(handler)は自身で実装する必要がある。
Railsとかでいうcontrollerのメソッドが中身空っぽな状態で必要なだけ自動生成され、その中身を自分で実装するみたいな感じ?

データベースはmysql

DockerでAPI用のコンテナとデータベース用のコンテナを起動させる。
また、コード編集でホットリロードさせるためにAPIの起動にはairを使う。

 

 

ディレクトリ構成

最終的にこうなる(ディレクトリと使うファイルのみ表示)
gen以下が自動生成されたコードで、ルートディレクトリからだとgo run api/server/gen/cmd/xxx/main.goで起動できる。
gen以下の生成されたコードは編集不可だが、restapi内のconfigure_xxx.goだけは編集可能。起動時処理やミドルウェアを記述したりするもので、このファイル存在時にgo-swaggerによるコード生成を行っても上書きされないから編集することができる。

root
  ├─ api
  │    ├─ server
  │    │    ├─ gen
  │    │    │    ├─ cmd
  │    │    │    │    └─ xxx
  │    │    │    │         └─ main.go
  │    │    │    ├─ models
  │    │    │    └─ restapi
  │    │    │         ├─ operations
  │    │    │         └─ configure_xxx.go
  │    │    └─ handlers
  │    │         └─ 自分で作成したhandler
  │    ├─ swagger
  │    │    └─ swagger.yaml
  │    ├─ .air.toml
  │    ├─ go.mod
  │    ├─ go.sum
  │    └─ Dockerfile
  ├─ config
  │    └─ config.go
  ├─ db
  │    └─ Dockerfile
  └─ docker-compose.yml

 

Docker

Dockerfileとdocker-compose.ymlを作成

# api/Dockerfile
FROM golang:1.16-alpine

RUN apk update && apk add git

# ホットリロードのためにairを導入
RUN go get github.com/cosmtrek/air

WORKDIR /usr/src/app
COPY api .
RUN go mod download && go mod verify

EXPOSE 8000
# db/Dockerfile
FROM mysql:5.7

COPY db/my.conf /etc/mysql/conf.d

EXPOSE 3306
# db/my.conf
[mysqld]
character-set-server=utf8mb4
collation-server = utf8mb4_bin
general_log=1
general_log_file=/var/log/mysql/mysql.log
log-error=/var/log/mysql/mysql-error.log

[mysqld]
character-set-server=utf8mb4

[client]
character-set-server=utf8mb4
# docker-compose.yml
version: "3"

services:
  api:
    build:
      context: .
      dockerfile: api/Dockerfile
    container_name: api
    environment:
      - DB_DRIVER=mysql
      - DB_DATABASE=dev
      - DB_USER=user
      - DB_PASSWORD=password
      - DB_HOST=db
      - DB_PORT=3306
    volumes:
      - api:/usr/src/app
    ports:
      - 8000:8000
    tty: true
    command: /bin/sh -c "air -c .air.toml"
    depends_on:
      - db

  db:
    build:
      context: .
      dockerfile: db/Dockerfile
    container_name: db
    environment:
      - MYSQL_DATABASE=dev
      - MYSQL_USER=user
      - MYSQL_PASSWORD=password
      - MYSQL_ROOT_PASSWORD=password
      - TZ=Asia/Tokyo
    ports:
      - 3306:3306

 

go-swagger導入

cd api
go mod init github.com/{githubのユーザー名}/{githubリポジトリ名}
go get -u github.com/go-swagger/go-swagger/cmd/swagger

 

swagger.yamlを作成

go-swaggerはOpenAPI2.0対応で、3.0は対応していないので注意

VScodeを使っているならば、Swagger Viewerというプラグインが便利

# api/swagger/swagger.yaml
---
swagger: "2.0"
info:
  version: 1.0.0
  title: Test
basePath: /api/v1
shemes:
  - http
paths:
  /user:
    get:
      tags:
        - user
      produces:
        - application/json
      responses:
        200:
          desciption: List of users
          schema:
            type: array
            items:
              $ref: "#/definitions/User"
        default:
          description: Unexpected error
          schema:
            $ref: "#/definitions/Error"
definitions:
  User:
    type: object
    properties:
      id:
        type: integer
        example: 1
      name:
        type: string
        example: テスト名前
      email:
        type: string
        example: test@gmail.com
      created_at:
        type: string
      updated_at:
        type: string
      deleted_at:
        type: string
  Error:
    type: object
    properties:
      code:
        type: integer
        example: 400
      message:
        type: string
        example: bad request

 

swagger.ymlからコードを生成

これによってgen以下にコードが生成される

cd api
mkdir -p server/gen
swagger generate server --strict-additional-properties -t ./server/gen -f ./swagger/swagger.yaml

 

.air.tomlの編集

起動するのに生成されたmain.goを参照するように変更し、hostとportを指定する
hostはデフォルトが127.0.0.0でDockerコンテナ外からアクセスするのに不便なので0.0.0.0を指定
portは起動するたびにランダムでホットリロードされるたびにポートが変化して面倒なので8000を指定

# api/.air.toml
cmd = "go build -o ./tmp/main ./server/gen/cmd/xxx"
full_bin = "APP_ENV=dev APP_USER=air ./tmp/main --host 0.0.0.0 --port 8000"

 

ハンドラを作成

この時点でdocker-compose up -dで起動できるが、localhost:8000/api/v1/userにアクセスすると「operation user.GetUser has not yet been implemented」と返ってくる
自動生成されたコードを見るとapi/server/gen/restapi/configure_xxx.goでapi.UserGetUserHandlerが定義されていないことがわかる。
なのでハンドラを作成して設定する。

# api/server/handers/user.go
type UserHandler struct {}

func NewUserHandler() UserHandler {
    return UserHandler{}
}

func (uh *UserHandler) GetUser(params user.GetUserParams) middleware.Responder {
    ver us []*models.User
    return user.NewGetUserOK().WithPayload(us)
}
# api/server/gen/restapi/configure_xxx.go
# configureAPI内に下記を追加
uh := handlers.NewUserHandler()
api.UserGetUserHandler = user.GetUserHandlerFunc(uh.GetUser)

これでlocalhost:8000/api/v1/userにアクセスするとhanderのWithPayloadで設定した空の配列が返ってくる

 

環境変数設定

docker-compose.ymlのenvironmentに記述した環境変数を読み込んでおく

# api/config/config.go
package config

type Config struct {
    DbDriver   string
    DbDatabase string
    DbUser     string
    DbPassword string
    DbHost     string
    DbPort     string
}

func NewConfig() Config {
    return Config{
        DbDriver:   os.Getenv("DB_DRIVER"),
        DbDatabase: os.Getenv("DB_DATABASE"),
        DbUser:     os.Getenv("DB_USER"),
        DbPassword: os.Getenv("DB_PASSWORD"),
        DbHost:     os.Getenv("DB_HOST"),
        DbPort:     os.Getenv("DB_PORT"),
    }
}

 

データベース接続

dockerで起動しているdbへ接続する。
接続情報は先の環境変数
今回はgormを使用する。

go get -u gorm.io/gorm
go get -u gorm.io/driver/mysql
# app/database/database.go
package database

type DatabaseClient struct {
    c *config.Config
}

func NewDatabaseClient(config *config.Config) DatabaseClient {
    return DatabaseClient{
        c: config,
    }
}

func (dc *DatabaseClient) ConnectDatabaseClient() *gorm.DB {
    driver := dc.c.DbDriver
    dsn := fmt.Sprintf(
        "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
        dc.c.DbUser,
        dc.c.DbPassword,
        dc.c.DbHost,
        dc.c.DbPort,
        dc.c.DbDatabase,
    )
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatal("Failed to connect database: ", err.Error())
    }
    return db
}

func (dc *DatabaseClient) CloseDatabaseClient(db *gorm.DB) {
    sqlDb, _ := db.DB()
    if err := sqlDb.Close(); err != nil {
        log.Fatal("Failed to close connection database: ", err.Error())
    }
}
# api/server/gen/restapi/configure_xxx.go
# configureAPIを編集(下記は編集後の最終形態)
func configureAPI(api *operations.xxxAPI) http.Handler {
    // 下記を追記
    c := config.NewConfig()
    dc := database.NewDatabaseClient(&c)
    db := dc.ConnectDatabaseClient()
    
    // さっきhandler作成の時に追加した記述(引数にdbを持たせる)
    uh := handlers.NewUserHandler(db)
    api.UserGetUserHandler = user.GetUserHandlerFunc(uh.GetUser) 
    
    // 以下は初期のまま
    api.ServerError = errors.ServerError
    api.UseSwaggerUI() 
    api.JSONConsumer = runtime.JSONConsumer()
    api.JSONProducer = runtime.JSONProducer() 
    api.PreServerShutdown = func() {} 
    
    // CloseDatabaseClientを追記 
    api.ServerShutdown = func() { 
        dc.CloseDatabaseClient(db) 
    }
    
    return setupGlobalMiddleware(api.Serve(setupMiddleware)) }
# api/server/handlers/user.go
# データベースから情報を取得するように編集(下記は編集後の最終形態)

# DBを操作できるように変更
type UserHandler struct {
    db *gorm.DB
}

# 引数にdbを受け取るように変更
func NewUserHandler(db *gorm.DB) UserHandler {
    return UserHandler{
        db: db,
    }
}

# DBからデータを取得するように変更
func (uh *UserHandler) GetUser(params user.GetUserParams) middleware.Responder {
    ver us []*models.User
    res := uh.db.Find(&us)
    if err := res.Error; err != nil {
        return user.NewGetUserDefault(500)
    }
    return user.NewGetUserOK().WithPayload(us)
}

 

これでlocalhost:8000/api/v1/userにアクセスすれば、データベースに登録した情報を取得できる。

備忘録(データベース名、ユーザー名の変更)

データベース名とユーザー名の変更の仕方

 

データベース名

MySQL 5.2以上であれば、RENAME DATABASEが使えないので新たに作成して古いものを削除する
テーブルは新しいDBに属するように名前を変更

CREATE DATABASE new_db;
RENAME TABLE old_db.table1 TO new_db.table1, old_db.table2 TO new_db.table2, ... ;
DROP DATABASE db_old;

 

ユーザー名

RENAME USER old_name TO new_name;