コンポーネントのマウント時にリクエストし、アンマウント時にキャンセルする axios リクエスト

とりあえず解はこうなった。

ライブラリのバージョン
  • axio 0.18.0
  • react 16.8.3
  • react-dom 16.8.3

動作確認はこちら:

ボタンクリックでコンポーネントのマウント・アンマウントが切り替えできる。APIリクエスト中にアンマウントされると、下のようなエラーメッセージと共にリクエストがキャンセルされる。

f:id:uraway:20190320135204p:plain

解説

解説というものでもないかもしれないが、ちょっと説明。

useEffectでマウント時のAPIリクエストのメソッドにキャンセルトークンソースを渡す。また、useEffectの戻り値はアンマウント時に実行されるので、ここでAPIリクエストのキャンセルを定義する。

function useAxios(fetchAction) {
  useEffect(() => {
    const signal = axios.CancelToken.source();
    fetchAction({ signal });
    return () => {
      signal.cancel("Api is being canceled");
    };
  }, []);
}

あとはaxiosにキャンセルトークンを渡すだけ:

  useAxios(({ signal }) => {
    async function getUser() {
      const res = await axios.get("https://randomuser.me/api/", {
        cancelToken: signal.token
      });
      setResult(res.data.results[0]);
    }

    getUser();
  });

react-routerとの組み合わせが便利なんじゃなかろうか。

TypeScript handbook Enums

TypeScript handbook Enums

https://www.typescriptlang.org/docs/handbook/enums.html

Enums

Enums

enums では、名前付きの定数群を定義します。その定数群の内容を明示したり、それぞれ異なるケースを簡単に作成することができます。TypeScript では、数値ベースと文字列ベースの enums を提供しています。

Numeric enums

まずは、他の言語でも馴染みのある数値 enums を見ていきます。enum キーワードを用いて定義します。

enum Direction {
  Up = 1,
  Down,
  Left,
  Right
}

ここでは、初期値1を持つUpの数値 enum を定義しています。それ以降のメンバーは自動的にインクリメントされるので、Direction.Up1Down2Left3Right4となります。

また、初期値は省くこともできます。

enum Direction {
  Up,
  Down,
  Left,
  Right
}

この場合、Up0となり、Down1となります。このオートインクリメントは、値自体は何でも良いが、同じ enum 内で値を区別するのに役立ちます。

enum の利用法はとてもシンプルです。プロパティにアクセスするように enum のメンバーにアクセスし、enum の名前を使って型を宣言するだけです。

enum Response {
  No = 0,
  Yes = 1
}

function respond(recipient: string, message: Response): void {
  // ...
}

respond("Princess Caroline", Response.Yes);

数値 enum は、計算値と定数を混在させることができます。つまり、初期値のない enum は最初か、数値定数や他の定数 enum のメンバーの初期値を持つ数値 enum の後ろでなければなりません。したがって次のようには宣言できません。

enum E {
  A = getSomeValue(),
  B // エラー! 'A' は定数で初期化されていないため、'B'には初期値が必要
}

String enums

文字列 enum も似たようなコンセプトですが、後述するように実行時のふるまいに少しだけ違いがあります。文字列 enum では、メンバーはそれぞれ文字列リテラルかあるいは他の文字列 enum メンバーを初期値として持たなければなりません。

enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT"
}

文字列 enum はオートインクリメントしませんが、そのかわりうまく"serialize"することができます。例えばデバッグで、数値 enum の実行中の値を読み取ろうとしても、その値自体には意味はない(リバースマッピングはしばしば役に立ちますが)ので困難ですが、文字列 enum であれば、実行中の値からもメンバーの名前以上の意味を汲み取れます。

Heterogeneous enums

必要性は低いですが、文字列 enum と数値 enum を混在させることは可能です。

enum BooleanLikeHeterogeneousEnum {
  No = 0,
  Yes = "YES"
}

JavaScript の実行中のふるまいをうまく利用しようとしているのでない場合は、おすすめしません。

Computed and constant members

enum メンバーはそれぞれ定数か計算値を持ちます。次のように定義した場合、enum メンバーは定数を持っているとみなされます。

  • enum の最初のメンバーが初期値を持たない場合

この場合メンバーの値は 0 となります。

// E.X は定数:
enum E {
  X
}
  • 初期値を持たず、かつ一つ前のメンバーが数値定数である場合。

この場合、メンバーの値は一つ前のメンバーの値に 1 を足した数値定数となります。

// 'E1' と 'E2' のすべてのメンバーは定数

enum E1 {
  X,
  Y,
  Z
}

enum E2 {
  A = 1,
  B,
  C
}
  • enum メンバーの初期値が定数 enum 式である場合

定数 enum 式とは、コンパイル時に評価可能な式のことで、次のような場合に当てはまります。

  1. リテラル enum 式 (基本的には文字列リテラルか数値リテラル)
  2. すでに定義した enum メンバーへの参照 (他の enum でも可能)
  3. 括弧付きの定数 enum
  4. +-~の単項演算子が使われた定数 enum
  5. +-*/%<<>>>>>&|^二項演算子が使われた定数 enum 式。ただし、式の結果がNaNInfinityの場合はコンパイルエラーとなります。

上記以外の場合、enum メンバーはすべて計算値としてみなされます。

enum FileAccess {
  // 定数メンバー
  None,
  Read = 1 << 1,
  Write = 1 << 2,
  ReadWrite = Read | Write,
  // 計算値メンバー
  G = "123".length
}

Union enums and enum member types

特殊な enum メンバーのひとつに、リテラル enum メンバーがあります。リテラル enum メンバーは、初期値を持たないか、あるいは、次のような初期値を持ちます。

すべてのメンバーがリテラル enum メンバーであるとき、いくつかの特殊な構文が使用できます。

ひとつめは、enum メンバーは型としても使用することができるようになります。たとえば、特定のメンバーは、enum メンバーの値だけを持つことができるというように定義することができます。

enum ShapeKind {
  Circle,
  Square
}

interface Circle {
  kind: ShapeKind.Circle;
  radius: number;
}

interface Square {
  kind: ShapeKind.Square;
  sideLength: number;
}

let c: Circle = {
  kind: ShapeKind.Square,
  //    ~~~~~~~~~~~~~~~~ エラー!
  radius: 100
};

また、enum 型はそれ自身がメンバーの union 型となります。union 型についてはまだ説明していませんが、union enums では、enum 自身に存在する値の集まりであること利用するということを知っておいてください。これにより、TypeScript は値を比較するときに起こしがちなささいなバグを防ぐことが可能になります。次の例を見てください。

enum E {
  Foo,
  Bar
}

function f(x: E) {
  if (x !== E.Foo || x !== E.Bar) {
    //             ~~~~~~~~~~~
    // エラー! '!=='オペレーターは'E.Foo'と'E.Bar'には適用できない
  }
}

この例では、xE.Fooではないかどうかをチェックしています。このチェックがパスすれば、||はスキップされて、if文中が実行されます。しかし、前述のチェックがパスしなかった場合、xE.Fooに限定されます。そのため、xE.Barであるかどうかを確認することは無駄ということになります。

Enums at runtime

enums は実行時にも存在する実オブジェクトです。例えば次の enum

enum E {
  X,
  Y,
  Z
}

実際に次の関数に渡すことができます。

function f(obj: { X: number }) {
  return obj.X;
}

// 'E'は数値のプロパティである'X'を持つため動作する
f(E);

Reverse mappings

enum はメンバーの名前と同じプロパティを持つオブジェクトを作成することに加えて、enum の値からその名前に対する reverse mapping も生成します。例えば、次のようなコードは

enum Enum {
  A
}
let a = Enum.A;
let nameOfA = Enum[a]; // "A"

次のような JavaScriptコンパイルされます。

var Enum;
(function(Enum) {
  Enum[(Enum["A"] = 0)] = "A";
})(Enum || (Enum = {}));
var a = Enum.A;
var nameOfA = Enum[a]; // "A"

生成されたコードからは、enum は順方向(name -> value)と逆方向(value -> name)の両方のマッピングを持つオブジェクトにコンパイルされることがわかります。他の enum のメンバーへの参照は常にプロパティアクセスとして出力され、インラインされることはありません。

また、文字列 enum のメンバーは reverse mapping を生成しないことに注意してください。

const enums

ほとんどのケースでは、enums は有効な解決策ですが、より厳格な条件下で使用したいこともあるでしょう。余分に生成されるコードや enum の値へアクセスする間接的な参照のコストを避けるために、const enums を使用することができます。const enums はconst修飾子を enums につけて定義します。

const enum Enum {
  A = 1,
  B = A * 2
}

const enums は定数 enum 式にのみ用います。通常の enums とは異なり、コンパイル時に完全に取り除かれます。また、const enums メンバーはインラインされます。このために計算値を持ちません。

const enum Directions {
  Up,
  Down,
  Left,
  Right
}

let directions = [
  Directions.Up,
  Directions.Down,
  Directions.Left,
  Directions.Right
];

上記コードは、次のようにコンパイルされます。

var directions = [0 /* Up */, 1 /* Down */, 2 /* Left */, 3 /* Right */];

Ambient enums

ambient enums はすでにある enum 型を宣言するために使用します。

declare enum Enum {
  A = 1,
  B,
  C = 2
}

ambient と ambient ではない enums の大きな違いは、通常の enums では前のメンバーが定数であり、かつ、初期値を持たなければそのメンバーも定数とみなされるのに対して、ambient (const ではない) enum メンバーは初期値を持たなければ、常に計算値としてみなされます。

Rails 5 API + devise でパスワードのリセット

Rails 5 API + devise でパスワードのリセット

前回: http://uraway.hatenablog.com/entry/2016/07/11/090206

Rails 5 API + devise の資料があんまりなかったのでメモ。前提として devise を使ったユーザー登録、ログイン機能を持っている API を作成しているとします。

また、 この一連のチュートリアルでは devise_token_auth は使用しません。

ソースコードGitHub にあるので、gem のバージョンやフォルダ構成などの参考にしてください。

  • rails (5.2.2)
  • devise (4.5.0)

mailcatcher の導入

今回はメーラーを使用するので、mailcatcher を導入します。設定が楽なので Docker を使いますが必須ではないです。

# Dockerfile
FROM ruby:2.5

ENV APP_ROOT /usr/src/devise-api

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs
# SQLite
RUN apt-get install sqlite3 libsqlite3-dev -y

RUN mkdir $APP_ROOT
WORKDIR $APP_ROOT
COPY Gemfile $APP_ROOT/Gemfile
COPY Gemfile.lock $APP_ROOT/Gemfile.lock
RUN bundle install
COPY . $APP_ROOT
# docker-compose.yml
version: "3"
services:
  app:
    build: ./
    command: bundle exec rails s -p 3000 -b '0.0.0.0'
    volumes:
      - .:/usr/src/devise-api
    ports:
      - "3000:3000"
    tty: true
    stdin_open: true
  mailcatcher:
    image: schickling/mailcatcher
    ports:
      - "1080:1080"
      - "1025:1025"

mailcatcher の設定を書き加えます。Docker を使用している場合はホスト名にコンテナ名を使用することができます。

# config/environments/development.rb
  config.action_mailer.smtp_settings = {
    address: 'mailcatcher',
    port: 1025
  }

docker-compose up して、http://localhost:1080 にアクセスしましょう。

パスワードリセットメールを送信する

devise の recoverable をオンにします。

# app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :registerable, :validatable, :recoverable

passwords_controller.rbを新たに作り、ルートを割り当てます。send_reset_password_instructions は devise が用意したメソッドで、ユーザーにパスワードリセットメールを送信します。

このとき注意したいのがレスポンスに関してで、リクエストパラメータのメールアドレスを持つユーザーが存在しないとき、その有無のエラーメッセージを返すのではなく、有無にかかわらず同じメッセージを返したほうが良いでしょう。悪意のある送信者に、そのメールアドレスのユーザーが登録されているかを知られることを防ぐことができます。

# app/controllers/passwords_controller.rb
module V1
  class PasswordsController < ApplicationController
    skip_before_action :authenticate_user_from_token!, only: [:create]

    def create
      user = User.find_by(email: create_params[:email])
      user&.send_reset_password_instructions
      render json: {}
    end

    private

    def create_params
      params.require(:user).permit(:email)
    end
  end
end
# config/routes.rb
    ...
    resource :passwords, only: [:create]
    ...

send_reset_password_instructionsで送信されるメールテンプレートは、app/views/devise/mailers/reset_password_instructions.html.erb にて次のように定義されています。

<p>Hello <%= @resource.email %>!</p>

<p>
  Someone has requested a link to change your password. You can do this through
  the link below.
</p>

<p>
  <%= link_to 'Change my password', edit_password_url(@resource,
  reset_password_token: @token) %>
</p>

<p>If you didn't request this, please ignore this email.</p>
<p>
  Your password won't change until you access the link above and create a new
  one.
</p>

edit_password_url を token 付きのフロントエンドのパスワードリセットページ URL に変えます。

<p>
  <%= link_to 'Change my password', "https://yourfrontend.com?token=#{@token}"%>
</p>

mailcatcher を使ってメールが送信されることを確かめてみましょう。メールアドレスが user@example.com のユーザーが存在するとします。

$ curl -X POST -H "Content-Type: application/json" localhost:3000/v1/passwords --data '{"user": { "email": "user@example.com" } }'

{}

http://localhost:1080 にアクセスして、メールの内容を確認してみます。

Change my password をクリックすると、次のようなリセットパスワードトークン付きの URL が取得できます: https://yourfrontend.com/?token=8XAXEzhFxCnn6zidyb9B

トークンを受け取ってパスワードリセットする

リセットパスワードトークンは暗号化されてデータベースに保存されるため、リセットパスワードトークンからユーザーを見つけ出し、パスワードをリセットするには、devise によって追加されたクラスメソッド User.reset_password_by_token を使用します。このメソッドは reset_password_token/password/password_confirmationを受け取ります。

リセットパスワードトークンと新しいパスワードを受け取り、パスワードを更新するコントローラーを定義しましょう。

# app/controllers/passwords_controller.rb
module V1
  class PasswordsController < ApplicationController
    skip_before_action :authenticate_user_from_token!, only: [:create, :update]

    ...

    def update
      user = User.reset_password_by_token(update_params)
      render json: user, status: :ok, serializer: V1::UserSerializer
    end

    private

    ...

    def update_params
      params.require(:user).permit(:password, :password_confirmation, :reset_password_token)
    end
  end
end
# config/routes.rb
    ...
    resource :passwords, only: [:create, :update]
    ...

パスワードのリセットを試してみましょう。先程送信したメールのクエリにあるトークンと、新しく設定したいパスワードをデータに PUT リクエストを実行します。

$ curl -X PUT -H "Content-Type: application/json" localhost:3000/v1/passwords --data '{"user": {"password": "newpass" ,"reset_password_token": "nysLs8eSLHHZm3eNw2fg"}}'

{"email":"user@example.com"}

パスワードが新しく設定できたかどうかも確認してみましょう。

$ curl -X POST -H "Content-Type: application/json" localhost:3000/v1/login --data '{"email": "user@example.com", "password": "newpass"}'

{"email":"user@example.com","token_type":"Bearer","user_id":1,"access_token":"1:yyjsJ_4oLN6k6z9_rZxE"}

rspec を書いてみよう

リセットパスワードトークンはその期限が config/initializers/devise.rb にて定義されており、任意に変更できます。

  # Time interval you can reset your password with a reset password key.
  # Don't put a too small interval or your users won't have the time to
  # change their passwords.
  config.reset_password_within = 6.hours

rspec を書いてみることで、トークンの期限が正しいかを確認しましょう。rspec 自体の導入方法については割愛します。

Gemfile に以下の 2 つの gem を追記して、bundle install します:

gem 'factory_bot_rails', '~> 4.0'
gem 'rspec-rails', '~> 3.8'

TimeHelpers#travel_to を使って、次のようにリセットパスワードトークンが正しく期限切れになるかどうか確認することができます。

# spec/requests/passwords_spec.rb
require 'rails_helper'

include ActiveSupport::Testing::TimeHelpers

  ...

  describe 'PUT /v1/passwords' do
    before do
      @raw, enc = Devise.token_generator.generate(User, :reset_password_token)
      user.reset_password_token   = enc
      user.reset_password_sent_at = Time.now.utc
      user.save(validate: false)
    end

    context 'when params have a new password and a valid token' do
      it 'resets password' do
        travel_to(Time.current + 5.hours)
        params = { user: { password: 'newpass', password_confirmation: 'newpass', reset_password_token: @raw } }
        put '/v1/passwords', params: params
        expect(User.first.valid_password? 'newpass').to eq true
      end
    end

    context 'when params have invalid token' do
      it 'does not reset password with expired token' do
        travel_to(Time.current + 7.hours)
        params = { user: { password: 'newpass', password_confirmation: 'newpass', reset_password_token: @raw } }
        put '/v1/passwords', params: params
        expect(User.first.valid_password? 'newpass').to eq false
      end
    end

    ...

  end

Docker で始める golang 入門

Docker で始める golang 入門

Docker 環境

動作確認済のバージョン:

  • Version 2.0.0.0-mac81 (29211)

こちら http://studio-andy.hatenablog.com/entry/go-todo-crud がすごいわかりやすくて良いので、これに沿うような Docker 環境を整えてみる。

まずは何はともあれDockerfile

FROM golang:1.10.2

WORKDIR /go/src/go-todo/

RUN go get -u github.com/golang/dep/cmd/dep \
              bitbucket.org/liamstask/goose/cmd/goose

depはパッケージマネージャで、gooseマイグレーションツール。

WORKDIR/go/src以下でないとパスが通らなくなる。

次にdocker-compose.ymlを下記の内容で作成:

version: "3"
services:
    db:
        image: mysql:8
        volumes:
            - ./docker-runtime/mysql:/var/lib/mysql
        environment:
            MYSQL_ROOT_PASSWORD: password
            MYSQL_DATABASE: sampledb
            MYSQL_USER: sampleuser
            MYSQL_PASSWORD: password

    app:
        build: .
        volumes:
            - .:/go/src/go-todo/
        ports:
            - "8080:8080"
        tty: true
        stdin_open: true
        links:
            - db

ビルドしてバックグラウンド実行し、初期ロックファイルGopkg.lockを作成する:

$ docker-compose up -d --build
$ docker-compose exec app dep init

volumesしてあるので、ファイルがホストとも共有される。

Hello World

基本となるコントローラーファイルsrc/controller/index.goを作成:

package controller

import (
    "net/http"

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

func IndexGET(c *gin.Context) {
    c.String(http.StatusOK, "Hello World")
}

上記のコントローラーをルートに割り振る。

main.go:

package main

import (
    "go-todo/src/controller"

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

func main() {
    router := gin.Default()

    router.GET("/", controller.IndexGET)
    router.Run(":8080")
}

docker-compose.ymlappcommandを追加:

    app:
        command: go run main.go
        build: .
        volumes:
            - .:/go/src/go-todo/
        ports:
            - "8080:8080"
        tty: true
        stdin_open: true
        links:
            - db

これでdocker-compose up -dするとサーバーが立ち上がるので、http://localhost:8080 にアクセスできる :tada:

DBの用意

mysql のコンテナに入ってデータベースを作成:

docker-compose exec db mysql -uroot -ppassword
Enter password:

mysql> CREATE DATABASE sampledb;

データベース接続のための設定ファイル。ホスト名はdocker-compose.ymlで記述したdbになることに注意。

db/dbconf.yml:

development:
    driver: mymysql
    open: tcp:db:3306*sampledb/root/password

接続の確認:

$ docker-compose exec app goose status
goose: status for environment 'development'
    Applied At                  Migration
    =======================================

次のコマンドで、マイグレーションファイル(db/migrations/timestamp_createTask.sql)が作成されるので、SQL を追記。

$ docker-compose exec app goose create createTask sql
-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE IF NOT EXISTS task (
    id INT UNSIGNED NOT NULL,
    created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    title VARCHAR(255) NOT NULL,
    PRIMARY KEY(id)
);


-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE task;

マイグレーションを実行:

$ docker-compose exec app bash goose up

モデルの接続

$ docker-compose exec app bash
/go/src/go-todo # go get -u golang.org/x/tools/cmd/goimports
/go/src/go-todo # go get -u github.com/xo/xo

xo ドライバ://ユーザー:パスワード@ホスト/データベースコマンドで各 model ファイルを作成:

/go/src/go-todo # mkdir -p models
/go/src/go-todo # xo mysql://root:password@db/sampledb -o models

models/model.goファイルを作成し、接続情報を記述する:

package models

import (
    "database/sql"
    "log"

    // mysql driver
    _ "github.com/go-sql-driver/mysql"
)

// DBConnect returns *sql.DB
func DBConnect() (db *sql.DB) {
    dbconf := "root:password@tcp(db:3306)/sampledb"
    db, err := sql.Open("mysql", dbconf)
    if err != nil {
        log.Fatal(err)
    }
    return db
}

あとは同じなので省略!

VSCode からはてなブログに投稿できる拡張作りました

VSCodeからはてなブログに投稿できる拡張作りました

作りました: hatenablogger

前に作ったAtom版はこっち: hatena-blog-and-fotolife

下準備

使用には以下の3つが必要です

プラグインダウンロード後に、Code > Preferences > Settings からこれらを設定します。

エントリの投稿

コマンドパレットからHatenablogger: Post or Updateを選択するとプロンプトが出てくるので、タイトル、カテゴリー(カンマ区切り)、 公開するかどうか(yesなら公開)を入力すればエントリが投稿され、元のファイルにコンテキストコメントが挿入されます。

エントリの更新

同じく、コマンドパレットからHatenablogger: Post or Updateを選択します。コンテキストコメントの有無で投稿か更新かを判別しています。ただしAPIの仕様なのか、一度公開したエントリを下書きに戻すことはできないみたいです。

イメージのアップロード

コマンドパレットからHatenablogger: Upload Imageを選択して、はてなフォトライフに画像をアップロード、カーソル位置に画像リンクを挿入します。

とりあえず今の段階では3つの機能だけ。予約投稿したい場合は、残念ながらそのためのAPIは用意されていないので、はてなブログ上から設定するしかありません。

VSCodeならtextlintも使えるのでかなり快適な執筆環境が構築できます。

hatenablogger Visual Studio マーケットプレイス hatenablogger ソースコード

S3 + CloudFront の CORS 設定

手順をメモっとく

S3 の設定

[Permissions] > [CORS configuration] から以下のように設定を追加:

<CORSConfiguration>
<CORSRule>
    <AllowedOrigin>*</AllowedOrigin>
    <AllowedMethod>GET</AllowedMethod>
</CORSRule>
</CORSConfiguration>

CloudFront の設定

[Behavior] > [Cache Based on Selected Request Headers] から Origin ヘッダだけをS3に通す設定を行う:

f:id:uraway:20181128231949p:plain

で、最後に curl で確認して、access-control-allow-origin: * が返ってきていればよし

$ curl -X GET -I -H "Origin: https://example.com" https://example.cloundfront.net/path/to/image.png
HTTP/2 200 
content-type: image/png
content-length: 43881
date: Wed, 28 Nov 2018 14:24:17 GMT
access-control-allow-origin: *
access-control-allow-methods: GET
last-modified: Mon, 30 Oct 2017 07:22:28 GMT
etag: ""
accept-ranges: bytes
server: AmazonS3
vary: Origin
x-cache: Miss from cloudfront

PWAでオフラインでも使える辞書アプリ作ってみた

いつも英語学習にはアルク英辞郎の辞書アプリ使ってたんだけど、フィリピンのネットスピードでは使いづらいものがあって、オフラインでも使える辞書アプリ探すかなーと思っていたところ

Chrome拡張の高速な英語辞書ツールをつくりました(Mouse Dictionary) - Qiita

という記事の中で、英辞郎の辞書データを購入できることを知ったので、どうせならとPWAで辞書アプリを自作してみた。

https://uraway.github.io/dictionary-app/

Chrome CanaryとiPhoneSafariでオフライン動作することを確認している

f:id:uraway:20181118233337p:plain

ソースコード

もちろんCRA製

GitHub - uraway/dictionary-app

lighthouseはこんな感じ。フィリピンというかセブではそもそも4Gですらないが… f:id:uraway:20181118224335p:plain