くま's Tech系Blog

くま's Tech系Blog

基本的には技術で学んだことを書き留めようと思います。雑談もやるかもね!

Railsのアソシエーションについて

今回はRailsのアソシエーションについて記載します。

アソシエーションとは?

Railsでは、アソシエーションという機能が存在し、モデルとモデルを関連付けすることによって他モデルのデータも合わせて操作することができます。

例えば、userの投稿したpostを全件取得したい場合、アソシエーションの設定を行っていない場合には、whereメソッドを用いる必要があります。

user = User.find(1)
posts = Post.where(user_id: user.id)

これがアソシエーションの設定を行っていた場合、以下のように書くことができます。

user = User.find(1)
posts = user.posts

使用できるアソシエーション

ここからは設定できるアソシエーションについて確認していきます。

belongs_to

belongs_toは他のモデルに所属している場合に使用します。

あるモデルでbelongs_to関連付けを行なうと、宣言を行ったモデルの各インスタンスは、他方のモデルのインスタンスに所属します。

belongs_toの説明のために商品とレビューの関係について説明します。

例えば、イヤフォンの商品説明ページがあるとします。そこに1件のレビューされていたとして、belongs_toは商品につけるのでしょうか?もしくはレビューにつけるのでしょうか?
belongs_toはレビューに設定します。レビューが商品に必ず属しています。レビューは商品がなければ存在しないので、商品にレビューが所属していると考えます。

モデルは下記のように表します。

class Product < ApplicationRecord
end

class Review < ApplicationRecord
  belongs_to :product
end

belongs_to :productのように所属先のモデルを単数で指定します。

どちらにbelongs_toを設定するか迷った時には外部キーをどちらに置くか考えるといいと思います。

外部キーを持つ方にbelongs_toを設定するのが望ましいです。

実際にテーブルを検討すると下記のようになると思います。

・Productテーブル

id product_name
1 イヤフォンA
2 イヤフォンA

・Reviewテーブル

id product_id review_point
1 1 100
2 1 70
2 2 30

Reviewテーブルには外部キーのproduct_idを作成してどの商品のレビューかわかるようにしています。 なので、今回はReviewにbelongs_toを設定します。

has_one

has_oneはモデル同士が1対1の関係の場合に使用します。

class User < ApplicationRecord
  has_one :profile
end

class Profile < ApplicationRecord
  belongs_to :user
end

例えば、上記のようなuser各々がprofileを持っている場合です。

外部キーを持つ側にbelongs_toを使用し、外部キーを持たない側がhas_oneを使用するというのが基本的な使い分けになります。

下記のようにすることでプロフィールの情報を取得できます。

user = User.find(1)
// プロフィール情報の取得
user.profile

has_many

has_manyはモデル同士が1対多の関係の場合に使用します。

belongs_toのときに使った商品とレビューの例をもとに説明します。

belongs_toの説明の際には商品1つに対してレビューが1つという条件で説明しました。 実際には商品1つに対して、レビューは複数のパターンはありえます。

1対多の関係の場合にはhas_manyを下記のように設定します。

class Product < ApplicationRecord
  has_many :reviews
end

class Review < ApplicationRecord
  belongs_to :product
end

has_many関連付けを宣言する場合、相手のモデル名は複数形で指定する必要があるので、注意が必要です。

商品に紐づいたレビューの情報の一覧は下記方法で取得できます。

product = Product.find(1)
product.reviews

has_one :through

has_one :throughは1対1のつながりの2つのモデルの間に第3のモデルが紐づいており、それを経由して相手モデルの1個のインスタンスとマッチします。

例えば、userは一つのpostを持ち、postは一つのtagを持つとします。 その場合以下のように構造を表現することが出来ます。

class User < ApplicationRecord
  has_one :post
  has_one :tag, through: :post
end

class Post < ApplicationRecord
  belongs_to :user
  has_one :tag
end

class Tag < ApplicationRecord
  belongs_to :post
end

user has one postとpost has one tagという関連性がまず存在します。 そして、Userはhas_one :tag, through: :postというようにPostを経由してTagが1つ紐づいていることを表します。

こうすることで、Userから直接紐づいているTagを取得できます。

user = User.find(1)
tag = user.tag

PostモデルがUserとTagの間の仲介モデルとして機能しています。 Postモデルを介してUserとTag間の間接的な一対一関係を確立しています。

has_many :through

has_many :throughは、1対多のつながりの2つのモデルの間に第3のモデルが紐づいており、それを経由して相手モデルの複数のインスタンスとマッチします。

1人の学生が複数の授業を受講し、1つの授業に複数の学生が参加するというパターンを考えます。 その場合以下のように構造を表現することが出来ます。

class Student < ApplicationRecord
  has_many :enrollments
  has_many :courses, through: :enrollments
end

class Enrollment < ApplicationRecord
  belongs_to :student
  belongs_to :course
end

class Course < ApplicationRecord
  has_many :enrollments
  has_many :students, through: :enrollments
end

Enrollmentという中間テーブルを作成します。

中間テーブルには外部キーをそれぞれ設定します。今回の場合にはStudentとCourseの外部キーをそれぞれ登録して対象のデータを絞り込めるようにします。

StudentとCourseは中間テーブルと1対多の関係になっていて、Enrollmentという中間テーブルを経由して他テーブルのデータを取得します。

下記方法で生徒が受けているコースの一覧を取得できます。

student = Student.find(1)
courses = student.courses

has_one :throughの場合には、PostモデルがUserとTagの間の仲介モデルとして機能していました。 has_many :throughの場合には、throughで指定されたPostモデルがUserとTagの間の中間テーブルとして機能しています。

中間テーブルはこちらの記事で説明しているので、合わせて確認してみてください!

kumaskun.hatenablog.com

has_and_belongs_to_many

has_and_belongs_to_manyはモデル同士が多対多の関係の場合に使用します。

has_many :throughとは違い、中間テーブルを作成しません。 has_many :throughで使用したStudentとCourseの例で考えます。

class Student < ApplicationRecord
  has_and_belongs_to_many :courses
end

class Course < ApplicationRecord
  has_and_belongs_to_many :students
end

Studentは複数のCourseを受けて、Courseは複数のStudentが参加しているという多対多の関係をhas_and_belongs_to_manyで表しています。

ここで、has_many :throughとhas_and_belongs_to_manyは同じ関連付けをしていることがわかりますが、どちらを選択した方がいいでしょうか?

has_many :throughとhas_and_belongs_to_manyの大きな違いは中間モデルが存在するかどうかです。

has_and_belongs_to_many の場合、中間モデルは存在していません。 そのかわり、結合用のテーブルが存在するのですが、結合用のテーブルは外部キーのみ登録されるものです。

has_many :through はモデルに情報が全て明示的に残されています。 また、中間テーブルを作成するので、そこに外部キー以外の情報を含めることも可能です。(カスタマイズしやすいです)

個人的にはhas_many :throughで中間テーブルを作成する方法で優先的に考えたいと思っています。

理由としては、まず、id(プライマリーキー)をもたせる事ができないので、indexをはることができないので、レコード量が多いとき時間かかる可能性があるからです。 そして、開発していく中で、少なくとも結合テーブルの属性が増える可能性は十分にありえます。でもhas_and_belongs_to_many はカスタマイズできないので不安です。

まとめ

関連付けの方法をいくつか説明しました。 DBやモデルの設計をしていく中でどの方法を選択するかは決まっていくと思いますので、おおまかな方法を知っておく必要があると思います。

参照

api.rubyonrails.org

zenn.dev

railsguides.jp

railsguides.jp

Railsでの中間テーブルについて

今回はRailsでの中間テーブルについてまとめます。

中間テーブルは、多対多の関係を持つ2つのモデル間に配置されるテーブルのことを指します。これは、あるモデルと別のモデルの間に多対多の関係が存在する場合に使用されます。

今回は一例としてユーザーとタスクの関係を用いて説明します。

ユーザーは複数のタスクを担当でき、1つのタスクは複数のユーザーがアサインされている場合があるような多対多の関係を持つ2つのモデルという前提です。

テーブル設計

最初に、なぜ中間テーブルを作るのでしょうか?

中間テーブルは必ず作らないとエラーになるとかそんなことはありません。 ただし、中間テーブルを用いなければ非常に冗長なテーブル設計となってしまいます。

サンプルとして、Userテーブルは簡易的に用意します。

id name
1 ユーザー1
2 ユーザー2
3 ユーザー3
4 ユーザー4

そして、Taskテーブルも作成します。

id name
1 タスク1
2 タスク2
3 タスク3

中間テーブルを作成しない場合には、Userテーブルでユーザーが担当するタスクを管理、もしくはTaskテーブルで担当しているユーザーを管理する事になります。 下記の例では、Userテーブルでユーザーが担当するタスクを管理しています。 これではユーザーが新しいタスクが作られるたびに、Userテーブルのカラムを増やす事になるため、テーブル設計時にカラム数を決めることができません。

id name task1 task2 task3
1 ユーザー1 タスク1 タスク2 タスク3
2 ユーザー2 タスク1 タスク2
3 ユーザー3 タスク2
4 ユーザー4 タスク3

なので、多対多の関係を構成するときには、UserとTaskのような別々のテーブルがあって、「Userは複数のTaskを担当する」、「TaskにはUserが複数参加する」という状態をうまく表現するためにUserTaskのような中間テーブルを使います。

UserTask中間テーブルは下記のイメージで作成されます。

id user_id task_id 説明
1 1 2 ユーザー1はタスク2を担当している
2 2 1 ユーザー2はタスク1を担当している
3 4 3 ユーザー4はタスク3を担当している
4 4 1 ユーザー4はタスク1を担当している

中間テーブルの作成

Modelの作成

実際にModelを作ってみましょう。(Userモデル・Taskモデルは作成済みとします)

まずは中間テーブルのUserTaskモデルを作成します。

rails g model UserTask

作成したら、db/migrate配下にマイグレーションファイルが作成されているはずなので、下記のように設定を追加します。

class CreateUserTasks < ActiveRecord::Migration[7.0]
  def change
    create_table :follow_relationships do |t|
      t.references :user, foreign_key: true
      t.references :task, foreign_key: true
      
      t.timestamps
      
      t.index %i[ user_id task_id ], unique: true
    end
  end
end

今回はuser_idとtask_idを参照できればいいので、t.referenceを使ってidをカラムに入れられるようにします。 そして、UserテーブルとTaskテーブルを使うので、それぞれt.references :usert.references :taskと記述します。

それぞれのidは存在しない値は入って欲しくないので、外部キー制約をつけます。 外部キーの制約はforeign_key: trueで設定します。

これに加えて、user_idとtask_idの組み合わせはたった1つである必要があります。user_id=1のユーザーが、task_id=2のタスクを担当しているという状況は1つだけです。 その制約をt.index %i[ user_id task_id ], unique: trueで追加しています。

最後に、rails db:migrateコマンドでマイグレーションを実行します。

アソシエーションの追加

次にModel同士の関連性を定義します。

ユーザーは、タスクの情報を複数持っています。 つまり、Userテーブルは複数のUserTaskテーブルを持っていることになるので has_many を使って表現します。

class User < ApplicationRecord
  has_many :user_tasks
end

逆にUserTaskテーブルから見ると、Userテーブルに所属していることになるので、 belngs_to を使って表現します。

class UserTask < ApplicationRecord
  belongs_to :user
end

同様に、TaskテーブルとUserTaskの関連性も追加します。

class Task < ApplicationRecord
  has_many :user_tasks
end

class UserTask < ApplicationRecord
  belongs_to :user
  belongs_to :task
end

これだけでなく、UserがTaskテーブルは1:多数のなので、次のように定義できます。

class User < ApplicationRecord
  has_many :user_tasks
  has_many :tasks, through: :user_tasks
end

has_many :throughで中間テーブルを経由したTaskとの関連付けを定義しています。

こうすることで、userが持っているtask情報を取得することができるます。

// 関連付けの追加
class Task < ApplicationRecord
  has_many :user_tasks
  has_many :users, through: :user_tasks
end


user = User.first

// userが担当しているタスクの情報が取得できる
user.tasks

同様にTaskからユーザーの情報を取得することも可能です。

task = Task.first

// アサインされているユーザー情報が取得できる
task.users

フォロー機能の検討

次に、フォロー・フォロワーの関係性を考えます。

先ほどとの違いは、多対多のアソシエーションなのですが、 一般的な多対多のテーブルと違い、対になるテーブルがUserテーブルで同じものです。 ユーザーがユーザーをフォローし、ユーザーはユーザーにフォローされる関係です。

ここでは、中間テーブルをFollowRelationshipテーブルとします。 このテーブルには、Userのidを使ってUserのidが1のユーザーが、Userのidが2のユーザーをフォローしているといった情報を持ちます。

FollowRelationshipは下記のイメージです。

id user_id follow_id 説明
1 1 2 ユーザー1はユーザー2をフォローしている
2 2 1 ユーザー2はユーザー1をフォローしている
2 4 3 ユーザー4はユーザー3をフォローしている

次に中間テーブルを作成します。

class CreateFollowRelationships < ActiveRecord::Migration[7.0]
  def change
    create_table :follow_relationships do |t|
      t.references :user, foreign_key: true
      t.references :follow, foreign_key: { to_table: :users }

      t.index %i[ user_id follow_id ], unique: true
      
      t.timestamps
    end
  end
end

外部キーの制約はforeign_key: trueで設定できるのですが、今回はFollowテーブルという架空のテーブルがあり、その実態はUserテーブルです。 先ほどと同じやり方では存在しないFolllowテーブルを探しにいってしまいます。 なので、t.references :followの後ろにはforeign_key: { to_table: :users }と書いて、参照先のテーブルを教えてあげるようにします。

FollowRelationshipの関連性の追加は下記のようにします。

class FollowRelationship < ApplicationRecord
  belongs_to :user
  belongs_to :follow, class_name: 'User'
end

userとfollowの2つを所有していますが、followはユーザーテーブルなのでbelongs_to :followだけだと存在しないテーブルと関連付けすることになります。 そこで、class_nameを指定することでUserテーブルを参照するようにします。

class_name: "User"オプションを使用することで、followの関連付けにはUserモデルが使用されます。これにより、followはUserモデルのインスタンスとして扱われます。 class_nameを指定しない場合、デフォルトで関連付け名を基に関連するテーブルを探します。しかし、followというテーブルは存在しないため、エラーが発生します。

次にUserモデルの関連付けです。 まず、完成したものを下記に記載します。

class User < ApplicationRecord
  has_many : active_relationships, class_name: 'FollowRelationship', foreign_key: 'user_id'   →①
  has_many : passive_relationships, class_name: 'FollowRelationship', foreign_key: 'follow_id'  →②
  has_many :followings, through: : active_relationships, source: :follow  →③
  has_many :followers, through: : passive_relationships, source: :user  →④
end

少し補足します。

①は、Userモデルがフォローしている関連付けを定義しています。FollowRelationshipモデル(中間テーブル)との関連付けを行い、active_relationshipsという名前で参照します。 外部キーとしてuser_idカラムを使います。

②は、Userモデルがフォローされている関連付けを定義しています。FollowRelationshipモデル(中間テーブル)との関連付けを行い、passive_relationshipsという名前で参照します。 外部キーとしてfollow_idカラムを使います。

③は、ユーザーがフォローしているユーザーの一覧を取得するための関連付けを定義しています。Userモデルは、active_relationships経由でfollowings(フォローしているユーザー)と関連付けられます。source: :followedにより、active_relationshipsテーブルのfollowedカラムを参照します。

④は、ユーザーをフォローしているユーザー(フォロワー)の一覧を取得するための関連付けを定義しています。Userモデルは、passive_relationships経由でfollowers(フォロワー)と関連付けられます。source: :followerにより、passive_relationshipsテーブルのfollowerカラムを参照します。

sourceオプションはデータを取得しにいくテーブルを明示するためのものです。 今回は、 has_many :followingshas_many :followersと名前が異なるので、明示的に指定するために使用しています。

このように設定することで、user.followingやuser.followersのようにしてフォローワーやフォローしているユーザーの情報を取得できます。

参照

qiita.com

zenn.dev

Rspecの使い方

今回はRspecについてまとめようと思います。

RSpecは、Rubyで書かれたアプリケーションの挙動・機能をテストするために利用されます。(テストフレームワーク)

導入

まずはRspecを使えるようにします。

group :test do
  gem "rspec-rails"
end

Gemfileにrspec-railsというGemを追加して、bundle installを行います。

Gemをインストールしたら、rails generate rspec:installコマンドでセットアップを行います。

成功すれば、specフォルダが作成されているはずなので、確認しましょう。

rails generate rspec:installを実行する前に、config/application.rbに下記設定を追加することで、specフォルダ内にデフォルトで作成されるファイルを作成されないように設定できます。

config.generators do |g|
      g.test_framework :rspec,
                       fixtures: false,   # テストデータを作るfixtureを作成しない
                       view_specs: false,   # テストデータを作るfixtureを作成しない
                       helper_specs: false,   # ヘルパー用のスペックを作成しない
                       routing_specs: false   # ルーティングのスペックを作成しない
    end

これで、RSpecを実行する準備が整いました。

テストの配置ルールと命名規則

テストを実行するためにはファイルの配置ルールと命名規則に従う必要があります。

Ruby on Railsの場合は、次のように配置します。

 spec/
   models/
      モデルファイル名_spec.rb
   controllers/  
      コントローラファイル名_spec.rb
   views/
      erbファイル名_spec.rb

このように、Ruby on Railsディレクトリ構成と同じ階層構成でspecディレクトリ配下に配置します。

Rspecの基本構造

Rspecに限らず、基本的なテストはデータを準備→確認したい処理を呼び出す→結果が想定通りか確認という流れになると思います。

その流れの中で、Rspecの書き方を見ていきます。

まずは、ファイルの先頭にはrequire 'rails_helper'をつけてください!

describe

まず、describeでテストの対象が何かを記述します。

describe "計算を確認するテスト" do
end

このように日本語でも書くことができます(もちろん、英語の可能です)

また、どのクラスのテストケースかを指定するだけでなく、 type: :modeltype: :controllerを指定することで、どのモジュールのテストかを指定できます。

controllerのテストで type: :requestを指定できます。type: :requestはURLの接続テストになるので、APIのテストという意味合いをもちます。(エンドポイントやPOST/GETなどのメソッドの種類を指定して実行します)

RSpec.describe Bookmark, type: :model do
end

context

contextは特定の条件や実施する際の条件を記載します。

describe "計算を確認するテスト" do
  context "マイナスの2つの数字の足し算" do
    calculator = Calculator.new
    result = calculator.add(-1, -2)
  end
end

contextはネストすることができるので、1つのdescribeの中に複数のcontextを記載できます。

describe "計算を確認するテスト" do
  context "2つの数字を加算" do
    context "2つのプラスの数字を加算" do
    end

    context "2つのマイナスの数字を加算" do
    end
  end

  context "2つの数字を減算" do
  end
end

it

itはアウトプットの内容を記載します。

describe "計算を確認するテスト" do
  context "マイナスの2つの数字の足し算" do
    calculator = Calculator.new
    result = calculator.add(-1, -2)

    it "-3が出力される(マイナス通しの足し算で想定通りになる)" do
       expect(result).to eq(-3)
    end
  end
end

expectメソッドの引数にテスト対象コード(オブジェクト)を渡します。 expect(...).to につづけてマッチャーを書きます。 今回はeqでresultと-3が一致するかを確認しています。
resultと-3が一致しない場合にはテストケースの実行で失敗します。

eq以外にtrueかfalseかをチェックするbe_validなどマッチャーはいろんな種類があるので、公式ドキュメントを確認するといいでしょう。(最後の参照にリンク貼ってます)

before

beforeは各テストケースの前に実行されるコードブロックです。

テストケースの前にデータの準備を行いたいパターンやいろんなテストケースで共通に行いたい処理を記載するなどができます。

describe "計算を確認するテスト" do
  before do
    # セットアップコード
  end

  it "マイナス通しの足し算" do
    # テストケースの実装
  end

  it "0が含まれた足し算" do
    # テストケースの実装
  end

  it "マイナスの数字とプラスの数字の足し算" do
    # テストケースの実装
  end
end

let

letは変数を定義する際に使用します。 describeかcontextの中でのみ使用可能です。

letとlet!の2種類あります。 letとlet!の違いは実行タイミングです。 letは利用時に実行され、let!は書かれた場所で実行されます。

letは遅延評価とも言われています。 { create(:post,user: user) }はテストが実行されるまで実際には評価されません。

let(:post) { create(:post,user: user) }

let!にするとテストが始まる前に{ create(:post,user: user) }が実行され、 その結果がlet!の後の:postにセットされます。

let!(:post) { create(:post,user: user) }

つまり、letはあるテストケース内で変数を複数回使用するが、その値が変更されない時に便利です。 そして、let!はあるテストケース内で変数の値が変更される可能性があり、テストケースごとに初期化が必要な時に便利です。

テストの実行

specフォルダに移動して、bundle exec rspecコマンドで全テストケースを実行します。

bundle exec rspec ./spec/ファイル名 というコマンドで特定のファイルに絞ってテスト実行もできます。

データ作成

テストデータを作成するときにはRspec標準で使えるfixtureに代わり、テストデータの準備をサポートしてくれるFactorybotを使用します。 fixtureを使用することもありますが、今回はFactorybotについて記載します。

Gem追加

Gemfileにfactory_bot_railsというGemを追加して、bundle installを行います。

group :test do
  gem "rspec-rails"
  gem "factory_bot_rails"
end

ファイル作成

rails g factory_bot:model モデル名

上記コマンドでfactoryファイルを作成します。test/factoriesに作成されます。

モデル作成

ファイル作成で作られたファイルがUserだと仮定して、Userのテストデータを作成します。

FactoryBot.define do
  factory :user do
    sequence :email do |n|
      "person#{n}@example.com"
    end
    password { "Password1" }
    password_confirmation { "Password1" }
    name { "test" }
    status { 0 }
end

password { "Password1" }はpasswordが固定で"Password1"という値になっていることを表しています。

sequence :email do |n|
  "person#{n}@example.com"
end

上記はnの部分が連番でデータが作成されます。

データを呼び出す

作成したテストデータを呼び出す場合には下記のようにcreate(:user)もしくは、build(:user)とすることで作成したデータを使用できます。

require "rails_helper"

describe "xxxxx" do
  before do
    @user = create(:user) #ここで定義
  end

  context "xxxxx" do
    xxxxx
  end
end

createとbuildには下記違いがあります。

create

  • DB上にインスタンスを永続化する。
  • DB上にデータを作成する。
  • DBにアクセスする処理のときは必須。(何かの処理の後、DBの値が変更されたのを確認する際は必要)

build

  • メモリ上にインスタンスを確保する。
  • DB上にはデータがないので、DBにアクセスする必要があるテストのときは使えない。
  • DBにアクセスする必要がないテストの時には、インスタンスを確保する時にDBにアクセスする必要がないので処理が比較的軽くなる。

さらに、データを上書きすることもできます。

@user = create(:user, name: "test更新")

このようにすることで、nameがtest更新のユーザーデータを作成できます。

trait

traitはパターンA、B 、C などデータをパターン分けして準備できる機能です。

FactoryBot.define do
  factory :user do
    sequence :email do |n|
      "person#{n}@example.com"
    end
    password { "Password1" }
    password_confirmation { "Password1" }
    name { "test" }
    status { 0 }

    trait :fixed_id do
        id { 100 }
    end
end

上記は先ほどUserモデルにidが固定バージョンのデータを追加しました。

let(:user) { create(:user, :fixed_id) }のようにfixed_idを追加することで指定したデータにできます。

こうすることで、毎回idが固定は困るけど、特定のケースではidを固定で使用したいなど使い分けができます。

複数データを作成する

複数データを作成する場合に、個数分createするのは骨が折れる作業です。

そこで、FactoryBotのcreate_listメソッドを使って複数個のデータを一気に作成できます。

users = create_list(:user, 5)

または、Userの生成時に、Taskも一緒に生成したい場合には下記のようにすることができます。

trait :with_tasks do
      after(:create) do |user|
        create_list(:task, 5, user:)
      end
    end

TransientとEvaluatorを使用した関連データの生成

trasientはファクトリの生成時に動的なデータにする属性です。

transientをcallback関数内で参照したい場合、evaluatorを使います。

callback関数のブロック引数でevaluatorを宣言することで、transientの値を参照することができます。

FactoryBot.define do
  factory :user do
  end

  trait :with_tasks do
      transient do
        tasks_count { 5 }
      end
      after(:create) do |user, evaluator|
        # evaluatorを経由して、transientのtasks_countを参照している
        create_list(:knowledge, evaluator.tasks_count, user:)
      end
    end
end

上記はUserのwith_tasksで、生成するtaskの数を指定したい場合の定義です。

画像のサンプルを用意

画像のサンプルを用意する場合にはspec/fixtures配下に画像を置いてください。

画像のアップロードを行うテストはfixture_file_uploadメソッドを使用します。

例えば、下記はアップロードされた画像とみなすことができます。

let(:image1) { fixture_file_upload("spec/fixtures/image.png", "image/png") }

おまけ

type: :requestでheaderを指定するのは簡単ですが、type: :controllerの場合には少し工夫が必要です。

describe 'Sample' do
    let(:header) { { 'X-Requested-With': "XMLHttpRequest" } }
    let(:params) { { 'TEST': "SAMPLE" } }
 
    it 'set header' do
      post 'path/to/endpoint', params: params, headers: header
      expect(response.status).to eq 200
    end
end

type: :controllerの場合にはheaderを指定するだけで適用されますが、type: :controllerでは指定できません。

require 'rails_helper'

RSpec.describe Api::LoginsController, type: :controller do
  
   let(:header) { { 'X-Requested-With': "XMLHttpRequest" } }

  describe 'logout' do
    it 'returns status 204' do
      request.headers.merge!(header)
      post :logout

      expect(response.status).to eq(204)
    end
  end
end
end

type: :controllerで使用する場合にはrequest.headersに追加したいヘッダー情報をマージしましょう。

参照

rspec.toolboxforweb.xyz

hackmd.io

zenn.dev

github.com

qiita.com

qiita.com

qiita.com

Vue.jsでのhistoryモードについて

今回はVue.jsでのhistoryモードについて記載しようと思います。

というのも、vue-routerを使ってページリロードしたときに404になることがありました。(historyモードの設定をしていました)

調べてみるとhistoryモードが関連しているようでした。

vue-routerのモードについて

まずは、vue-routerではhistoryモードについて説明します。 historyモードには数種類あります。

hashモード

まずはhashモードについてです。

hashモードではURLが「http://localhost:8080/#/sample」のようにhash付きで表示されます。

hashモードはルーティングにURL hashを使用しています。 この形式で入力されるとvue-routerは、urlの#を見つけてそれより先の文字列を元に動的にコンポーネントを出し分けます。

例えば、http://localhost:8080/#/sampleというURLの場合、ブラウザはこのURLのフラグメント部分("/sample")を認識し、Vue Routerがこれを処理します。 しかし、実際にサーバーに送信されるURLは、http://localhost:8080/ です

つまり、サーバーにはフラグメント部分が含まれないため、特別な設定をする必要がありません。ブラウザ側で完結して処理されるため、サーバー側の設定は不要です。

HTML5モード

一方で、HTML5モードではURLは「http://localhost:8080/sample」のようにhashは無しで表示されます。

HTML5モードではlocalhostにリクエストを送信する際は、ローカルサーバがindex.htmlを返すようになっています。 ホスティングサービスを利用する際は、index.htmlを返すように設定しなければならないので注意が必要です。

vue-router のデフォルトは hashモードですが、下記のようにモードを変更することはできます。(index.jsに定義)

const router = createRouter({
  history: createWebHistory(),
  routes,
});

export default router

createWebHistory()はHTML5モードを指定します。

404になる仕組み

では、vue-routerを使用したときにリロードで404になるのはなぜでしょうか?

先ほども少し触れていますが、リロードで404になるのはHTML5モードのときです。 hashモードでは発生しません。

また、ローカルで開発時は発生しないはずで、ホスティングサービスを使用してデプロイしたら発生します。

一般的な Vue アプリケーションでは、 index.htmlと呼ばれる単一の HTML エントリーポイントがあります。 サーバーは、すべてのリクエストに対して index.htmlにリダイレクトして、ルーティングの処理を行う必要があります。

ローカル環境では、Vite(またはwebpack)開発サーバーが処理してくれますが、本番環境ではWebサーバーを自分で構成する必要があります。

index.htmlビルドを実行すると、dist/index.htmlというビルド済みコピーが作成されるはずです。 本番環境では、dist/index.htmlのみ存在することになります。

例えばログイン画面(https://sample.com/login)に直接アクセスしようとした場合、ユーザからのアクセスを受けたサーバはindex.htmlを経由せずlogin.htmlへリクエストを行おうとします。

ただし、アプリはSPAで実装しているためデプロイをしている実体ファイルはjsやcssや画像ファイルなど除いてindex.htmlのみです。

そのためリクエストを受けるlogin.htmlが存在しないので、ページリロードや直接URLを入力してアクセスするした際に404となります。

vue-routerを使ってページリロードしたときの404対策

ここまでで404になる仕組みはわかったと思いますが、じゃあどうすればいいのでしょうか?

公式に記載はあるのですが、サーバーでの対策が必要になります。

URLがどの静的なアセットにもマッチしなかった時はindex.htmlページで受け付けるように設定します。

どのサーバーを使用しているかによって変わるので、公式のドキュメントにあるApacheの設定とVercelでのRewriteのリンクを参照に載せています。

サーバー側のWebサーバー設定で、存在しないURLをVueアプリのエントリーポイントにリダイレクトする設定を行っています。

リンクにご自身で使用しているサーバーの対策の記載がない場合には、Rewriteで検索したら見つかると思いますので試してみてください!

番外編

ここまでで、ページリロードしたときに404にならないように設定を変更しました。 ただ、意図的に404の場合にはカスタマイズした画面を表示したい場合があると思います。 例えば、削除されたデータを参照しようとするとデータがないので、404のエラーページを表示するなどです。

その場合に404でもindex.htmlを見るので初期ページが表示されるはずです。 そうならないように下記のようなルーティングを追加できます。

const router = new VueRouter({
  mode: 'history',
  routes: [{ path: '*', component: NotFoundComponent }]
})

URLをチェックして、マッチしなかった場合に 404 を返答します。 「*」でワイルドカードのパスを作り、どのパスにもマッチしなかった時のビューに飛ばすことができます。

参照

v3.router.vuejs.org

router.vuejs.org

vue-land.github.io

vercel.com

CSSのflex:1とは?

CSSdisplay: flexを使用するときに、flex: 1;の記載を見かけることがあるかもしれません

この記事では、何気なく書いてしまう「flex: 1;」について解説しようと思います。

flexとは?

まずこの場合の flex は、display: flexを指定した要素内にある子要素に指定するプロパティです。

display: flexはフレックスボックスと呼ばれていて、ある要素に定義するだけで、その直下の要素が並列になるスタイルです。 シンプルな導入であれば、CSSdisplay:flexというスタイルを指定するだけです。左から並べたり右から並べたり、均等に並べたりのカスタマイズも可能です。

flex:1とは?

flex: 1とは、flexプロパティを一括指定で、flex:1 1 0を省略して書いたものです。

それぞれのプロパティを細かく見てみると、flex-grow: 1flex-shrink: 1flex-basis: 0の組み合わせです。

3種類のプロパティをそれぞれを確認していきます。

flex-grow

flex-growは、親要素のflexコンテナの余っているスペースを、子要素のflexアイテムに分配して、flexアイテムを伸ばすプロパティです。 flex-growの値は整数値のみで、flexアイテムが伸びる比率を指定します。

下記HTMLとCSSを例にして少し説明します。

<div class="flex-container">
  <div class="flex-item item1">アイテム①</div>
  <div class="flex-item item2">アイテム②</div>
</div>

.flex-container {
  display: flex;
  background-color: blue;
}

.flex-item {
  background-color: red;
  margin: 10px;
  padding: 50px 0;
}

表示すると下記のように横並びになります

アイテム①とアイテム②の右側は空きスペースになります。

そこで下記のCSSを追加するとどうなるでしょうか?

.item1 {
  flex-grow: 1;
}

.item2 {
  flex-grow: 2;
}

表示させるとアイテム①とアイテム②で横幅が埋まります。

これは、それぞれのflexアイテムに「flex-grow: 1;」と「flex-grow: 2;」が指定されているため、空きスペースが3分割されます。 そして、3分割された空きスペースが、flex-growで指定された割合に応じて、各flexアイテムに分配されます。

今回の場合にはitem1には空きスペースの1/3、item2には2/3が割り当てられます。

flex-shrink

flex-shrinkは、親要素のflexコンテナからはみ出した分を元に、子要素のflexアイテムを縮めるプロパティです。 flex-shrinkも値は整数値のみで、flexアイテムを縮める比率を指定します。

さっそく、コードを見ていきます。 flex-shrinkを0に指定すると、縮まずにオリジナルサイズで表示されます。

.flex-container {
  display: flex;
  background-color: blue;
}

.flex-item {
  background-color: red;
  margin: 10px;
  padding: 50px 0;
  width: 1000px;
}

.item1 {
  flex-shrink: 0;
}

.item2 {
  flex-shrink: 0;
}

そこで下記のようにitem1だけflex-shrink: 1を設定すると、両方とも枠内に収まるようにitem1が縮みます。

.item1 {
  flex-shrink: 1;
}

.item2 {
  flex-shrink: 0;
}

flex-basis

flex-basisは、flexアイテムの基準となる幅を設定するプロパティです。

flex-basisプロパティでは、widthまたはheightプロパティと同じ値を使用でき、px、em などの単位付きの数値や、親要素のflexコンテナに対するパーセンテージを指定します。 デフォルトは auto で、コンテンツの内容に応じて自動サイズ設定されます。

flex-basisは、flex-grow、flex-shrinkがついてなければ、widthやheightプロパティと同じです。 flexアイテムが横並びのときに widthプロパティと同じ動作をし、縦並びの時にheightプロパティと同じ動作になります。

例えば、下記のようなCSSがあるとします。

.flex-container {
  display: flex;
  background-color:blue;
}

.flex-item {
  background-color:red;
  margin: 10px;
  padding: 50px 0;
  width: 300px;
}

.item1 {
  flex-basis: 150px;
}

.item2 {
  flex-basis: 100px;
}

それぞれのflexアイテムは、flex-basisで指定した横幅になっています。 flex-itemクラスで、width を指定していますが、widthよりflex-basisで指定したものが優先されます。

ここまででプロパティについて説明しました。 そして、今回はflex:1について説明しましたが、それ以外のパターンも設定できるので、下記にパターンの一例を記載します。

/* 単位がない数値を 1 つ指定: flex-grow
この場合 flex-basis は 0 と等しくなる*/
flex: 2;

/* 幅または高さを 1 つ指定: flex-basis */
flex: 10em;
flex: 30%;
flex: min-content;

/* 値を 2 つ指定: flex-grow | flex-basis */
flex: 1 30px;

/* 値を 2 つ指定: flex-grow | flex-shrink */
flex: 2 2;

/* 値を 3 つ指定: flex-grow | flex-shrink | flex-basis */
flex: 2 2 10%;

参照

developer.mozilla.org

Route53でドメインを管理する

今回は、Route53でドメインを管理する方法をまとめます。

Route53とは?

Route53は、AWSが提供するDNSサービスです。

Route53を使用することで、AWSで開発したWebサービスを任意のURLで公開することができます。
例えば、EC2でWebサービスを提供するときに、ユーザーには「test.com」という名前でアクセスしてほしいときなどに、Route53を利用します。Rote53がEC2と「test.com」という名前を紐づけます。

ちなみに、Route53では、コンソール画面からドメイン名を登録(購入)することができます。
そして、お名前.comのような外部サービスで低価格で購入したドメイン名をRoute53で扱うことも可能です。

今回はお名前.comで購入したドメインを例にして進めます。

お名前.comで取得したドメインのホストゾーンをRoute53で管理する

では、実際にお名前.comで取得したドメインのホストゾーンをRoute53で管理します。 ドメインは既に取得済みであることを前提にしています。ご了承ください!

大まかには以下の2つを実施します。

  • jpサーバーにexample.jpへのアクセスは「自身の権威サーバーにアクセスしてください」と登録する
  • 取得したドメインIPアドレスの対応を自身の権威サーバーに登録する

※権威サーバーはレジストリによって管理されていて、お名前.comなどのDNSサービスを使ってドメインの登録をしているはずです。 このレジストリへの登録や更新を我々の代わりに行なってくれている組織をレジストラと呼びます。

なので、レジストラはお名前.com、自社の権威サーバーはRoute 53となります。

Route53の設定

AWSコンソールにログインをおこない、サービスからRoute53を選択します。 管理をするドメインのホストゾーンを追加するためホストゾーンの作成をクリックします。

ホストゾーン設定の画面が表示されるので、ドメイン名を入力してホストゾーンの作成ボタンで作成してください

作成が完了すると、下記のようにドメインに関連付けされたNSレコード(ネームサーバ)4つとSOAレコードが表示されます。 お名前.comでNSレコードの設定が必要になるため、4つのNSレコードをメモに残します。

お名前.comの設定

お名前.comでは、ドメインとNSレコードを紐づける作業を行います。

NSレコードが自身の権威サーバーの場所を示す情報なので、NSレコードをお名前.comに登録します。

まずは、お名前.comにログインしてご利用中のサービスからネームサーバーをクリックします。

ネームサーバーの変更の画面で他のネームサーバーを利用を選択して、NSレコードを赤枠の部分に追加します。 その時に紐づけたいドメインにチェックを入れてください

この時にNSレコードの末尾に「.」(ドット)がある場合にはドットを外して登録します。

これで登録できました。

登録できているか確認する場合にはnslookup -type=NS [対象ドメイン]のコマンドを実施して確認できます。 登録した4つのNSレコードが表示されていれば登録できています。

参照

aws.amazon.com

docs.aws.amazon.com

qiita.com

Vue.jsでのFormの送信について

今回はVue.jsでのFormの送信について記載します。

自分で実装する中で想定通りにはいかない部分もあったので備忘録としてまとめようと思います。

@submitとは?

まず、@submitとはなんでしょうか?

<template>
  <div>
    <form @submit="handleSubmit">
      <label for="username">ユーザー名:</label>
      <input type="text" id="username" v-model="username" />

      <label for="password">パスワード:</label>
      <input type="password" id="password" v-model="password" />

      <button>ログイン</button>
    </form>
  </div>
</template>

上記のようなtemplateを例にします。

ここでは、ログインボタンをタップすると、handleSubmitの処理が行われます。

buttonにクリックアクションをつけていないのにと思ったかもしれません。 buttonタグにはsubmitresetbuttonを指定することができますが、何も指定していないとsubmitがデフォルトで設定されていることになります。 submitはフォームのデータをサーバーへ送信します。

そして、@submitは、formが送信されたときに呼び出されるカスタムイベントハンドラーです。 よって、ボタンをタップしたらhandleSubmitが実行されます。

例えば、buttonに@clickを追加することもできます。 ただし、メソッドが2回実行される可能性があるため、不要な処理が行われる恐れがあります。 そのため、通常はフォームの送信イベントだけを監視する@submitを使うのが適切だと思います。

@submitのイベント修飾子

@submitを使用すれば、formの内容を送信することができますが、1つ問題点がありました。

結果を含んだレスポンスが受け取れませんでした。

axiosを使用してバックエンドで処理を行った結果を受け取る予定でしたが、成功か失敗かわかりませんでした。

調べてみると画面がリロードされているよう...

さらに調べると、リロードされるのはImplicit submissionというHTMLの仕様のようでした。

submitボタンは無いがフォームのinput要素が1つだけの場合、もしくはフォームのinput要素が複数あるが、有効なsubmitボタンが存在する場合はImplicit submissionが発生すると記載があります。

なので、リロードしないようにする処理にする必要がありました。

ここでイベント修飾子を設定します。 イベントに修飾子をつけることでイベントハンドリングが簡単にできるようになります。 修飾子の書き方は@イベント.修飾子というように、イベントの後に「.」で繋いで記述します。また修飾子は複数繋げて記述することも可能です。

今回の場合には「@submit.prevent="handleSubmit」のようにprevent修飾子を使用することでリロードを防ぐことができます。

その他のイベント修飾子は参照に載せているVueのドキュメントに一覧が載っています。

最後に

HTMLの面とVueの面から確認するべき内容がありました。

当たり前ですが、Vue.jsで実装する上でHTMLの知識は前提として必要ですね(久々なので思い出さないと)

参照

macoblog.com

developer.mozilla.org

v2.ja.vuejs.org

html.spec.whatwg.org

iOSのWebViewについて

今回はiOSでのWebViewについてまとめます

AndroidのWebViewについては下記まとめていますので、気になる方はみてください!!

kumaskun.hatenablog.com

※今回はUIkitです。SwiftUIはこの記事に追加するか、別で記事にします🙇

Webページを表示させる

Webページを表示させるにはWKWebViewを使用します。

今回、レイアウトはUIViewにWKWebViewをaddSubViewします。

下記がWKWebViewの基本的な使い方です。

WKWebView を利用する際はUIKitとWebKitをimportします。 WKWebView のinitializeはCGRectとWKWebViewConfigurationを引数にとります。 該当するURLページを開くには、WKWebView の loadメソッドを実行します。

import UIKit
import WebKit

class WebViewController: UIViewController {

    var webView: WKWebView!

    override func viewDidLoad() {
        super.viewDidLoad()

        setupWebView()

        let url = URL(string:"https://www.apple.com")
        // URLRequestオブジェクトを生成
        let myRequest = URLRequest(url: url!)

        // URLをWebView にロード
        webView.load(myRequest)
    }

    private func setupWebView() {
        let userContentController = WKUserContentController()
        userContentController.add(self, name: "testCallBack")
        let webConfiguration = WKWebViewConfiguration()
        
        let customFrame = CGRect(
            origin: CGPoint.zero,
            size: webViewContainer.frame.size
        )
        
        webConfiguration.userContentController = userContentController
        
        webView = WKWebView(frame: customFrame, configuration: webConfiguration)
        webView.translatesAutoresizingMaskIntoConstraints = false
        webView.uiDelegate = self
        webView.navigationDelegate = self
        webView.scrollView.backgroundColor = .clear
        
        // webViewContainerは自前で用意したUIView(名前は自由に設定してください)
        webViewContainer.addSubview(webView)
        webView.topAnchor.constraint(equalTo: webViewContainer.topAnchor).isActive = true
        webView.rightAnchor.constraint(equalTo: webViewContainer.rightAnchor).isActive = true
        webView.leftAnchor.constraint(equalTo: webViewContainer.leftAnchor).isActive = true
        webView.bottomAnchor.constraint(equalTo: webViewContainer.bottomAnchor).isActive = true
        webView.heightAnchor.constraint(equalTo: webViewContainer.heightAnchor).isActive = true
    }
}


extension ViewController: WKUIDelegate {
}


extension ViewController: WKNavigationDelegate {
}

この設定で表示させられます。ここからは細かい設定について説明します。

WebViewの生成の補足

WKWebViewConfiguration

WKWebViewConfigurationはWKWebViewの初期化時に参照される設定プロパティが含まれるクラスです。

WKWebViewConfigurationを使用すると、Webページがレンダリングされるまでの時間、メディア再生の処理方法、ユーザーが選択できるアイテムの細分などのオプションを決定できます。

WKWebViewConfigurationは、WKWebViewが最初に初期化されたときにのみ使用されます。 このクラスを使用して、WKWebViewの作成後にその設定を変更することはできません。

WKUserContentController

WKUserContentControllerJavaScriptからのコールバック受信や、スクリプトをWebビューに挿入する方法を提供します。 必要であれば、WKWebViewConfiguration に生成したWKUserContentControllerを登録する処理を追加してください。

基本的な使い方の箇所で、userContentController.add(self, name: "testCallBack")のようにコールバックを登録しています。 具体的なコールバックの処理は下記のように追加します。

extension WebViewController: WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if(message.name == "testCallBack") {
             print(" \(message.body)") 
        }
    }
} 

これでJavascriptからメッセージが送られてきたらアプリで受け取って処理を行うことができます。

WKUIDelegateとWKNavigationDelegate

基本的な使い方の箇所でWKUIDelegateWKNavigationDelegateの設定を行いました。

ここでは、WKUIDelegateとWKNavigationDelegateについて補足します。

まず、WKUIDelegateはWebビューのUIを制御するデリゲートです。 主にウィンドウ、Javascriptのダイアログ(alert, confirm, prompt)、コンテキストメニューを制御します。

下記に例を記載します。

extension WebViewController: WKUIDelegate {

    // Webビューを作成
    func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
        guard let url = navigationAction.request.url else {
            return nil
        }
        
        guard let targetFrame = navigationAction.targetFrame,
              targetFrame.isMainFrame else {
            // 同一ページでWebを読み込む
            webView.load(URLRequest(url: url))
            return nil
        }
        
        return nil
    }

    // Javascriptのalertダイアログを表示する
    func webView(
        _ webView: WKWebView,
        runJavaScriptAlertPanelWithMessage message: String,
        initiatedByFrame frame: WKFrameInfo,
        completionHandler: @escaping () -> Void
    ) {
        let alertController = UIAlertController(
            title: "",
            message: message,
            preferredStyle: .alert
        )
        alertController.addAction(UIAlertAction(title: "OK", style: .default) {action in
            completionHandler()
        })
        present(alertController, animated: true, completion: nil)
    }
}

他にもデリゲート処理はありますが、公式ドキュメント等を参考にしてみてください。

WKNavigationDelegateはwebビューのナビゲーションのデリゲートです。 遷移開始時・開始時・ページの読み込み完了時・読み込みエラー発生時リダイレクト時などのイベントを受け取りたい場合はWKNavigationDelegateを継承します。

下記に例を記載します。

extension WebViewController: WKNavigationDelegate {
    // リクエストを許可するか判定
    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        decisionHandler(.allow)
    }
    
    // コンテンツを読み込んだ時に呼ばれるメソッド
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    }
}

この例では、特定のURLのみ許可するのに使用したり、コンテンツを読み込んだ後に何か処理を行う場合に使用します。

HTMLを表示させる

次にHTMLを表示させる方法です

HTMLファイルを置いたら、Webページと同じように、ロードするだけで表示できます。

func loadLocalHTML() {
    guard let path: String = Bundle.main.path(forResource: "index", ofType: "html") else { return }
    let localHTMLUrl = URL(fileURLWithPath: path, isDirectory: false)
    webView.loadFileURL(localHTMLUrl, allowingReadAccessTo: localHTMLUrl)
}

Bundle.main.pathはプロジェクトのBuild PhasesのCopy Bundle Resourcesにあるファイルを取得するはずです。

また、HTMLのファイルではなく、文字列を読み込む場合にはloadHTMLStringメソッドを使用します。

func loadLocalHTML() {
    let webView = WKWebView()
    webView.loadHTMLString("<html><body><p>Hello!</p></body></html>", baseURL: nil)
}

CSSの適応

CSSを適用させるにはページ読み込み完了したらCSSファイルを読み込む処理を行います

まずはHTML読み込み時にCSSも読み込むパターンです。

private func loadHTML() {
        guard let html = Bundle.main.url(forResource: "index"(ファイル名), withExtension: "html")  else { return }
        guard let htmlString = try? String(contentsOf: html) else { return }

        /// CSSのファイルURLを取得する
        guard let css = Bundle.main.url(forResource: "style"(ファイル名), withExtension: "css")  else { return }

        webView.loadHTMLString(htmlString, baseURL: css)
 }

ただ、CSSファイルを読み込むパターンはうまく適用されない場合もあります。

そこで、HTML表示後にCSSを適用させるパターンも紹介します。

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        let css = "img {max-width: 100%; width: 100%; height: 75%; vertical-align: middle;}"
        
        let js = "var style = document.createElement('style'); style.innerHTML = '\(css)'; document.head.appendChild(style);"
        
        cssWebView.evaluateJavaScript(js, completionHandler: nil)
}

WKNavigationDelegateのdidFinish(表示完了時)にCSSを含めたJavascriptを実行する方法です。 evaluateJavaScriptメソッドはJavascriptを実行するメソッドです。

また、下記のようにスクリプトを追加することで表示前にCSSを適用するパターンもあります。

let script = WKUserScript(source: "JavaScriptコード", injectionTime: .atDocumentEnd, forMainFrameOnly: true)

webView.configuration.userContentController.addUserScript(script)

let controller = WKUserContentController()
controller.addUserScript(script)
let configuration = WKWebViewConfiguration()
configuration.userContentController = controller
let webView = WKWebView(frame: view.bounds, configuration: configuration)

CSSの適用は場合によってはできない場合にもあるので注意が必要です。

Javascriptとの連携

Javascriptからアプリの処理を実行したり、アプリからJavaScriptのメソッドを実行したい場合があると思います。 それぞれの方法を紹介します。

Javascriptからアプリの処理を実行する

Javascriptでボタンのタップアクションをアプリ側で受け取りアプリで処理を行う方法について説明します。 WKUserContentControllerの説明と多少、重複します。

HTMLは次の例を使用します。

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
    </head>
    <body>
        <script type="text/javascript">
            function showMessage() {
                webkit.messageHandlers.showMessage.postMessage("");
            }
            function setMessage(message) {
                document.getElementById("text").innerHTML = message;
            }
        </script>
        <input type="button" value="Button"
               onClick="showMessage();" />
        <p id="text"></p>
    </body>
</html>

基本的な使い方の箇所でコールバックの設定を行いましたが、同様にハンドラを追加します。

let config: WKWebViewConfiguration = WKWebViewConfiguration()
let controller: WKUserContentController = WKUserContentController()

// JavaScriptから呼び出せるメッセージハンドラを設定する
controller.add(self, name: "showMessage")

config.userContentController = controller
webView = WKWebView(frame: self.view.bounds, configuration: config)

// 既にwebViewを初期化している場合は、こちらでもよい
webView.configuration.userContentController.add(self, name: "showMessage")

WKScriptMessageHandlerに準拠して、上で定義したメッセージハンドラをJavaScriptから呼び出すと、userContentController(_:didReceive:)が実行され、メッセージに応じた処理ができます。

extension WebViewController: WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if message.name == "showMessage" {
            print("\\\\(message.body)")
        }
    }
}

message.bodyはHTMLでwebkit.messageHandlers.showMessage.postMessage("");の箇所のpostMessageで設定した文字列になります。

アプリからJavaScriptのメソッドを実行する

今度は先程と逆でアプリからJavascriptを実行する方法です。

次のようにevaluateJavascriptメソッドでJavascriptの関数を呼び出します

let message = "Message"

let jsFunc = "test(\\\\"\\\\(message)\\\\")"

// JavaScriptメソッドを実行する
webView.evaluateJavaScript(jsFunc, completionHandler: { (object, error) -> Void in
    // JavaScriptメソッドの実行結果を受け取れる
})

参照

developer.apple.com

sogablog.net

gekkado.com

qiita.com