行動すれば次の現実

テック中心の個人ブログ

FTPでCSVファイルをダウンロードしてアプリに取り込む処理の実装例 | Rails

FTPサーバーに配置されたCSVファイルを使ってRailsアプリで処理する機会がありましたので、実装例について説明いたします。

net/ftpを使用した実装例

RubyでFTPサーバー接続するためにはnet/ftpというライブラリを使用します。

ftp = Net::FTP.new
ftp.connect('接続先ホスト名')
ftp.login('FTP接続ユーザ', 'パスワード')
ftp.get('share/products.csv','tmp/products.csv')
ftp.close
  • ftp.connectでは、接続先のホスト名を指定します
  • ftp.loginでは、FTPユーザ名およびパスワードを指定します
  • ftp.getでは、ダウンロードしたいファイルのパスおよび、それを保存するローカルのファイルパスを指定します
  • ftp.closeで接続を終了します

CSV取り込み処理の実装例

ダウンロードしたCSVファイルを使用して、取り込み処理を書いていきます。

File.open('tmp/products.csv', 'r', encoding: Encoding::WINDOWS_31J) do |file|
  csv = CSV.new(file, headers: false, col_sep: ',')
  csv.each do |row|
    # rowに各レコードが配列で格納されているのでparse処理を書いていきます
    # 例)
    code = row[0]
    name = row[1]
    price = row[2]
    Product.create(code: code, name: name, price: price)
  end
end
# 取り込み後にローカルファイルを削除
File.delete('tmp/products.csv')

【超簡単】Next.jsのページ遷移時にLoadingを表示させる方法

Next.jsの初回アクセス後にnext/router等で画面遷移すると、getServerSidePropsの処理中が挟むことによって画面遷移の待ちが発生してしまいます。

フリーズしているような見た目になってしまうので、見た目としてあまり好ましくありません。 待機中にloadingしているように見せるライブラリがありましたので、今回はそちらを紹介します。

NProgressを使用したローディングの実装例

NProgressというライブラリを使用することでNext.jsの画面に簡単に以下のようなローディングバーを表示することができます。

Nprogressのローディングイメージ

インストール方法

yarn add nprogress

Typescriptの場合は以下もインストールします

yarn add -D @types/nprogress

実装例

インストールが完了しましたら、_app.tsに以下のコードを入れるだけでローディングバーを表示させることができます。

ステータス管理などを自前で用意する必要がありませんので導入がとてもラクです。簡易的なローディングであればこれで十分だと思います。

import Router from 'next/router';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';

Router.events.on('routeChangeStart', () => NProgress.start());
Router.events.on('routeChangeComplete', () => NProgress.done());
Router.events.on('routeChangeError', () => NProgress.done());

function MyApp() {
// ...以下省略

devise-token-authでemailではなくusernameでもログインさせる方法

email以外のキーを使った認証方法について、devise単体では同じような記事がいくつもありましたが、devise-token-authを交えた記事があまりなかったので作成しました。

How To: Allow users to sign in using their username or email address · heartcombo/devise Wiki · GitHub

上記の記事等を参考にしましたが、devise-token-auth側でdevise本体の挙動が色々とオーバーライドされており、そのままの設定では動作しない部分がありました。

右往左往しましたが、結果的には思っていたよりも少ない修正で実現できましたので、同様のことでお悩みの方の参考になれば幸いです。

usersテーブルにusernameを追加する

以下のようにユニーク制約を付与してstring型のusernameをusersテーブルに追加します。

class AddUsernameToUsers < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :username, :string
    add_index :users, :username, unique: true
  end
end

Userモデルへの設定

authentication_keysの設定

deviseメソッドのauthentication_keysにemailとusernameを設定します。

  devise :database_authenticatable, :registerable, :rememberable, :trackable, :validatable,
         authentication_keys: %i[email username] # ←この部分を設定します

こうすることで、emailまたはusernameが認証キーとして設定されるようになります ログイン時はどちらか一方のキーで認証することが可能となります。

バリデーションの設定

このままでは、usernameに対してバリデーションが全く効いていない状態なので、以下のようにバリデーションを設定します。

validates :username, presence: true, uniqueness: true

emailに対しては:validatableが有効であれば自動でバリデーションチェックが走ります。

任意で細かなチューニング

基本的には上記の設定変更のみでemailまたはusernameでログインすることが可能となります。

ただし現状のままでは不完全な部分があります。

例えばemailとusernameに同じ値が設定できてしまうので考慮が必要です。 また、ユーザー情報変更(account_update)時にusernameが変更できませんので、そこの考慮もする必要があるかと思います。

必要に応じて以下の記事を参考にチューニングを行ってください。

github.com

Rubymineのファイル保存時にPrettierでフォーマットさせる方法

Rubymineの保存時にPrettierによるフォーマットを適用する方法を説明します。

開発環境にPrettierをインストールする

以下のコマンドで開発環境にprettierをインストールします。 インストール先はどこでも良いのですが、私はプロジェクト配下にしました。

yarn add -D prettier

インストールが完了したら.prettierrcというprettier用の設定ファイルをプロジェクトのルートに配置しておきます。

{
  "singleQuote": true
}

Rubymine側の設定

RubymineではデフォルトでPrettierのプラグインがインストールされていますので、ここではセットアップのみ説明します。

Preferences → Languages & Frameworks → Javascript → Prettier を開きます。

Prettier packageに先程インストールしたPrettierを選択します。 プロジェクト配下にインストールしたのであれば、自動で検出されます。

On Reformat Code actionとOn saveにチェックを付ければセットアップは完了です。

これでファイル保存時および、Reformat時にPrettierによりフォーマットされるようになります。

Next.jsとdevise_token_authを使って認証周りを実装する

前回、Rails7 APIモードの認証機能をdevise_token_authで実装するという記事で、devise_token_authの導入方法や使い方を一通り説明いたしました。

今回はNext.jsとdevise_token_authを使ってログイン処理と認証制御周りを具体的な実装例を踏まえて説明します。

認証処理はクライアントサイドで行うか、サーバーサイドで行うか

まず実装に入る前に、認証処理をクライアントサイドで行うのか、サーバーサイドで行うのかを決める必要があります。

クライアントサイドで認証

クライアントサイドで認証を行う場合は、認証用のAPIを別途設けて、その結果により遷移先を分けるという実装をします。 認証結果が真の場合は、該当ページへの遷移を許可し、偽の場合はログインページへリダイレクトさせます。

クライアントサイドでの認証の場合、ページリクエストの前に毎回認証用APIが実行されるので、リクエストが2回発生することになります。

サーバーサイドで認証

サーバーサイドで認証する場合は、認証処理も含めてサーバーサイドで行われます。認証結果が真の場合は、該当アクションのデータを取得し、偽の場合はログインページへリダイレクトさせます。

リクエスト回数が1回で済みますので、私はサーバーサイドでの認証を採用することにしました。

ログイントークンはCookieで保持するか、LocalStorageで保持するか

devise-token-authではaccess-token、client、uidという3つのトークン情報を用いて認証を行います。 これら3つの情報をリクエストヘッダーにセットしておくことで認証処理が行われますので、トークン情報をクライアント側でも保持しておく必要があります。

トークンを保持するにはCookieかlocalStorageを使用するのが一般的かと思います。

私は初めにlocalStorageを検討しました。サーバーサイド認証を実装するにはServerSidePropsを使用するのですが、ServerSidePropsではlocalStorageが読み取れない仕様のようです。

そのため、今回はCookieを使用してトークン情報を保持することにしました。

具体的な実装例

ログインページ

ログインページの実装例を記します。ポイントは/api/v1/auth/sign_inへのログイン処理の結果をCookieにセットしている部分です。 js-cookieを使用してaccess-token、client、uidをそれぞれCookieにセットしています。

// pages/login.tsx

import React, { ReactElement, useState } from "react";
import { useRouter } from "next/router";
import {
  Alert,
  Box,
  Button,
  Container,
  TextField,
  Typography,
} from "@mui/material/";
import axios from "axios";
import Cookies from "js-cookie";

const Login = () => {
  const router = useRouter();
  const [isError, setIsError] = useState<boolean>(false);
  const [errorMessage, setErrorMessage] = useState<string>("");

  const handleSubmit = (event) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    const axiosInstance = axios.create({
      baseURL: `http://localhost:3000/api/v1/`,
      headers: {
        "content-type": "application/json",
      },
    });
    (async () => {
      setIsError(false);
      setErrorMessage("");
      return await axiosInstance
        .post("auth/sign_in", {
          email: data.get("email"),
          password: data.get("password"),
        })
        .then(function (response) {
          // Cookieにトークンをセットしています
          Cookies.set("uid", response.headers["uid"]);
          Cookies.set("client", response.headers["client"]);
          Cookies.set("access-token", response.headers["access-token"]);
          router.push("/home");
        })
        .catch(function (error) {
          // Cookieからトークンを削除しています
          Cookies.remove("uid");
          Cookies.remove("client");
          Cookies.remove("access-token");
          setIsError(true);
          setErrorMessage(error.response.data.errors[0]);
        });
    })();
  };

  return (
    <Container component="main" maxWidth="xs">
      <Box>
        <Typography component="h1" variant="h5">
          ログイン
        </Typography>
        <Box component="form" onSubmit={handleSubmit}>
          <TextField
            id="email"
            label="メールアドレス"
            name="email"
            autoComplete="email"
            autoFocus
          />
          <TextField
            name="password"
            label="パスワード"
            type="password"
            id="password"
            autoComplete="current-password"
          />
          <Button
            type="submit"
            variant="contained"
            sx={{ mt: 3, mb: 2 }}
          >
            ログイン
          </Button>
          {isError ? (
            <Alert
              onClose={() => {
                setIsError(false);
                setErrorMessage("");
              }}
              severity="error"
            >
              {errorMessage}
            </Alert>
          ) : null}
        </Box>
      </Box>
    </Container>
  );
};

ServerSidePropsのラッパー関数

getServerSideProps関数に認証機能の制御をもたせたラッパー関数を実装します。 APIリクエスト時にCookieからトークンを取得してリクエストヘッダーにセットします。

レスポンスが認証失敗(401)だった場合はログイン画面へリダイレクトさせます。成功した場合はレスポンスデータをpropsとして返却します。

// lib/auth.tsx

import { GetServerSideProps } from "next";

export const withAuthServerSideProps = (url: string): GetServerSideProps => {
  return async (context) => {
    const { req, res } = context;

    const response = await fetch(`${process.env.API_ORIGIN}/${url}`, {
      headers: {
        "Content-Type": "application/json",
        uid: req.cookies["uid"],
        client: req.cookies["client"],
        "access-token": req.cookies["access-token"],
      },
    });
    if (!response.ok && response.status === 401) {
      return {
        redirect: {
          destination: "/login",
          permanent: false,
        },
      };
    }
    // TODO: 他にも500エラーを考慮した分岐も必要
    const props = await response.json();
    return { props };
  };
};

認証が必要なページ

認証が必要なページのgetServerSideProps関数を、上記で実装したwithAuthServerSideProps関数でラッパーした状態で実行します。 それによりサーバーサイドレンダリング時に認証処理を制御することが可能になります。

// pages/home.tsx
import * as React from "react";
import styles from "../styles/Home.module.css";
import Head from "next/head";
import { GetServerSideProps } from "next";
import { withAuthServerSideProps } from "../lib/auth";

export const getServerSideProps: GetServerSideProps =
  withAuthServerSideProps("/api/v1/home");

const Home = () => {
  return (
    <>
      <div className={styles.container}>
        <main className={styles.main}>
          <h1 className={styles.title}>HOME</h1>
          <p className={styles.description}>ホーム画面です</p>
        </main>
      </div>
    </>
  );
};

export default Home;

Rails側の処理

Railsのアクションではbefore_actionでdevise_token_authによる認証メソッド(authenticate_api_v1_user!)を実行します。 これにより認証処理が実行されて、認証が失敗した場合は401が返却されます。

class Api::V1::HomeController < ApplicationController
  before_action :authenticate_api_v1_user!

  def index
    render json: { message: 'hello' }
  end
end

終わりに

Next.jsとdevise_token_authを使った認証制御周りの実装は以上になります。

上記の実装以外にも「ログアウト処理」や「認証用Layoutと通常Layoutの2つを用意してナビゲーションバーの共通化する」などの考慮が必要になると思いますが本記事では省略しております。また機会がありましたら記事にしようと思います。

inputタグに入力した数字を3桁ごとのカンマ区切りにして表示する方法

input要素に入力した数字を3桁ごとのカンマ区切りにして表示する方法を説明します。 本記事はReactをベースに実装例を記述していますが、考え方は他のフレームワークでも同じですので、Reactを使用していない方も参考になるかと思います。

input type属性は「tel」がおすすめ

実装に入る前に前提知識として抑えておいてほしいポイントがあります。

それはinput type属性による入力制御の違いです。

input type="number"を使用することで数字のみが入力できるようになります。 モバイルの場合も同様に、数字のキーボードが表示されますので、ユーザビリティ的には問題有りません。

ただし今回のようにカンマ区切りされた数字は実質的には文字列と認識されますので、type="number"は使用できません。

そのため、input type="tel"を使用することおすすめします。 input type="tel"は、電話番号のように数字主体の文字列の入力を想定しています。

あくまでも入力できるのは文字列になりますが、type="text"との違いはモバイルの場合に数字のキーボードが表示されることです。

数字キーボード

以下に各input typeごとの挙動をまとめました。

input type 入力できる値 スマホ入力時 使用例
text 文字列 文字列キーボード 氏名
number 数字のみ 数字キーボード 年齢
tel 文字列 数字キーボード クレカ番号

具体的な実装例

上記の仕様を踏まえた上でtype="tel"を採用してサンプルプログラムを実装します。

サンプルではReactを使用していますが、onChangeのイベントハンドラ以外は他のフレームワークでも使用できるかと思います。

まずは、数字文字列に対して3桁ごとにカンマ区切りを適用させる関数を定義します。

function replaceFormatNumber(numberText) {
  if (!numberText) {
    return '';
  }
  return String(numberText).replace(/\d+/, function (m) {
    return m.replace(/(\d)(?=(?:\d{3})+$)/g, "$1,");
  });
}

次に、3桁区切りされた数字文字列をフラットな数字文字列に変換させる関数も定義します。

function replaceNumberText(formatNumber) {
  if (!formatNumber) {
    return '';
  }
  return formatNumber.replace(/,/g, '');
};

上記2つの関数を使用してonChangeイベントでstateを制御します。

handleChange(e) {
  let value = e.target.value;
  // カンマ区切りされた文字列をフラットな数字文字列に変換します
  value = String(replaceNumberText(value));
  // フラットな数字文字列に対してカンマ区切りを適用します
  value = replaceFormatNumber(value);
  // stateにセットします
  this.setState({[e.target.name]: value});
}

// render部分
<Input
  type="tel"
  name="number"
  value={number}
  onChange={this.handleChange}
/>

Herokuで適切なDynoタイプと台数を決める方法

Herokuでアプリを本番稼働させる上で、「Dynoタイプをどうするか」「台数をどうするか」については一番最初に悩むポイントかと思います。

恥ずかしながら私自身も、アプリ稼働当初は何となくの感覚でDynoタイプ、台数を決めていました。それにより根拠無くオーバースペックな構成にしてしまい、無駄な維持費を掛けてしまっていたことがあります。

今回の記事では定量的にDynoタイプと台数を選定する方法についてまとめてました。同様のことでお悩みの方の参考になれば幸いです。

全てはMetricsタブから

Heroku管理画面のMetricsタブを見ることである適切なDynoを検討することが可能です。 主に見る項目は「Memory Usage」「Response Time」「Throughput」「Dyno Load」の4つです。

Memory UsageとDyno loadはDynoタイプの選定に有効

Memory UsageとDyno loadにより、適切なDynoタイプを判断することができます。もちろんDynoの台数を増やすことの判断材料としても有効ですので積極的に値を確認しましょう。

Memory Usage

Memory UsageではDynoのメモリ使用量を把握することができます。

Memory Usage

主に見るのは「MEM QUOTA」「MAX RSS」です。 MEM QUOTAはDynoが許容できるメモリ使用量です。MAX RSSは実際に使用しているメモリ使用量です。 MAX RSSがMEM QUOTAを超えると基本的にNGです。

超えてしまうとスワップメモリが頻繁に発生するようになります。スワップメモリが発生するとレスポンス速度が著しく低下してしまいユーザ体験の低下に繋がります。

MAX RSSがMAX QUATAを超えないようにDynoタイプもしくはDynoの台数を調整する必要があります。

一概には言えませんが、個人的な経験則ではDynoの台数を増やすよりもDynoタイプをアップグレードするほうが効果的であると感じます。

より詳細にアプリケーションレベルで原因を追求したい場合はScoutAPM等のアドオンを入れる必要があります。 詳しくは下記記事をご参照ください。

HerokuのR14エラーをScoutAPMで対策する方法 | Rails - 行動すれば次の現実

また、Herokuの設定等によってもメモリ消費量を抑えることができます。詳しくは下記記事を参考にしてチューニングすると良いでしょう。

R14 - Memory Quota Exceeded in Ruby (MRI) | Heroku Dev Center

Dyno load

Dyno loadとは直訳すると「Dynoの負荷」という意味で、Dynoに対してどのくらいのCPU負荷がかかっているのかを測定した結果が表示されます。

Dyno load

HerokuではDynoタイプごとに負荷の許容値が設定されています。

free: 1
hobby: 1
standard-1x: 1
standard-2x: 1
performance-m: 2
performance-l: 8

Performance Dyno以外は1が設定されています。1を超えるとCPUの許容を超えたことを意味します。

許容を超えるとCPUが占領されていることになりますので、待ち時間が発生してしまいます。

一時的に許容値を超える分には大きな問題はないと思いますが、なるべく許容値を下回るように考慮しておくほうが望ましいです。

1を超えるようであれば台数を増やしてみる、またはDynoタイプを上げてみて数値の変動を監視すると良いでしょう。 ちなみにすでに複数Dynoを並行稼動使用している場合は、稼働しているDynoの平均値が計算されて評価されるようです。

ThroughputとResponse TimeはDynoの台数を増やすかどうかの指標

ThroughputとResponse Timeの結果によりDynoの台数が適切であるかどうかの判断ができます。

※この2つの指標はwebプロセスのみ有効な指標です。workerプロセスでは表示されません。

Throughput

Throughputではrpsという単位(1秒あたりに捌いているリクエスト数)でグラフ化しています。 rpsが大きい時間帯がアクセスのピークを示しています。

例えば下記の図のようなグラフであればPM3時あたりとPM6時あたりがピークであることが把握できます。

Throughput

Response Time

Response Timeでは時間帯ごとのレスポンス時間がグラフで確認できます。 Response Timeの数値が高い時間帯のThroughputのrpsを確認すると、ある程度の相関関係があることが確認できるかと思います。

Response Time

rpsとResponse Timeの両方が高い場合、Dynoが捌ききれるキャパシティを超えている可能性があります。 レスポンス時間がサービスの許容している時間を超えてしまっている状況が続くと、ユーザー体験の低下に繋がります。そのため、ピーク時間に合わせてDynoの台数を増やすなどの対策をすることでレスポンス時間の削減を図ることができます。

参考

https://help.heroku.com/88G3XLA6/what-is-an-acceptable-amount-of-dyno-load https://devcenter.heroku.com/articles/dyno-types https://devcenter.heroku.com/articles/ruby-memory-use#detecting-a-problem