RailsでWixやShopifyのような独自ドメインの紐付けができるマルチテナントサービスを作る


#背景
最近流行りのノーコードサービスでは自分のウェブサイトやサービスを作って、独自ドメインを登録できたりしますよね？　あれどうやってやるんだろうな？Railsでもできるのかな？と思ったので挑戦してみました。

※この記事ではアプリケーションレベルでの、ドメイン、サブドメインの取り扱い、データの分離について紹介します。本番環境では場合によってプロキシーサーバーを立てたり、追加でのDNS設定が必要です。

##独自ドメインの紐付けとは？
通常のウェブサービスだとユーザーのページは「https://サービスのドメイン.com/user/ユーザーのID」のような感じです。しかしWix,Shopify、BASEのようなノーコードサービスでは自分のドメイン、またはサブドメインを登録してサイトを展開することができます。独自ドメインの紐付けとは「https://サービスのドメイン.com/user/ユーザーのID」を「https://ユーザーが取得したドメイン.com」からアクセスできるようにすることです。

##マルチテナントサービスとは？
プラットフォーム上にユーザーが独自のデータ領域を持つサービスです。例えば、SmartHRのようなSaaSサービスはクライアントが法人で法人は様々な社員データをサービスに登録することになります。この時、会社Aの個人情報が会社Bの個人情報と同じデータプールで管理されていたらリスクですよね？　なのでこのようなサービスではデータ領域をユーザー（テナント）ごとに分けて管理することが多いです（マルチテナンシー）。

##作るもの
オンラインで簡単にオンラインスクールを開設できるサービスを作ろう！（仮 anyclass）
講師（instructor）はスクール（school）を作成できる
schoolテーブルはdomain,subdomainカラムを持っていて自分で設定できる
![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3232343937342f64376562376435662d343561302d343562612d363335352d6132313663366331623061332e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224974/3dd39d30-743a-6eea-53b0-6fe2182d8d78.png)


##この記事で説明しないこと
deviseを使ったユーザー認証等、独自ドメイン紐付けに関係のない部分

##この記事での目標
プラットフォーム上で作成したスクールに独自ドメインでアクセスできるようになること

##実装手順
- スクールのページをサブドメインごとに切り分ける
- スクールのページに独自ドメインを紐付ける
- テナントごとにデータを分離する（途中）


## STEP1：スクールページをサブドメインごとに切り分ける
ここではユーザーがスクール開設時に登録したサブドメインをもとに、スクールのトップページを表示するということをやっていきます。

####1. ルーティングの制御 
まずルーティングを設定していきます。
www以外のサブドメインへリクエストがきたらこの範囲のパスを読み込みます！というのを実行する為の設定です。railsのconstrain機能を使って実装します。

参考記事：　rails routing constraintsについて
https://y-yagi.tumblr.com/post/92386974040/rails-routing-constraints%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6

lib以下にconstarain用のクラスを定義します。
constrain用のクラスで定義したmatches?がtrueの場合、任意のスコープ内にあるパスが採用される。という感じです。

```ruby:lib/school_domain.rb
class SchoolDomain
  class << self
    def matches?(request)
      matches_subdomain?(request)
    end

    private
      def matches_subdomain?(request)
        # wwwの場合は追加するルーティングにマッチングしないようにしておく
        request.subdomain.present? && request.subdomain != 'www'
      end
  end
end
  
```


```ruby:route.rb
Rails.application.routes.draw do
  #constratinファイルの読み込み
  require Rails.root.join('lib', 'school_domain.rb')
  
  #constratinでの制御　matches?で該当したパスはここのルーティングが適応される
  constraints SchoolDomain do 
    scope module: :school do
      get '/', to: 'schools#show'
      resources :lessons, only: [:show, :index]
    end
  end

  ...省略
```


####2. サブドメインの抽出とスクールの検索

requestに入ってくるドメインの情報を元にサブドメインを特定します。このとき、wwwがサブドメインの場合は除外しておきたいので、そのロジックも入れます。

このメソッドはbefore_actionで呼び出すのですが、先ほど定義したルーティングないで実行されるコントローラーで呼びだすことが多いと思います。例えば今回の場合は、schools controllerのshowで呼び出します。　

```ruby:application_controller.rb
    def set_school_by_domain_name
      if request.subdomain.present? && request.subdomain != 'www'
        @school = School.find_by(subdomain: request.subdomain)
      end  
      
      # 該当するスクールがない場合、URLをリセットしてトップページにリダイレクト
      redirect_to root_url(domain: ENV["MY_DOMAIN_NAME"], subdomain: "") unless @school.present?
    end
```

ローカルで動作確認
ローカルでの動作確認は２種類方法があります。一つは、etc/hostファイルを変更して任意のURLがきたらlocalhost:3000を向くようにする方法、２つ目は、lvh.me:3000のようなループバックドメインの利用する方法。今回はループバックドメインを使います。

ループバックドメインとは、
>メインドメインでアクセスしてもサブドメインでアクセスしても 127.0.0.1 に返してくれるドメイン(サービス)

参照元記事：Railsでユーザごとにサブドメインが切られたユーザページを提供する手順
https://qiita.com/himatani/items/729568277588fcf37720

####...実際にやってみる
まず講師登録＆ログイン
![スクリーンショット 2020-11-29 22.55.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224974/b0b3dc81-8a27-ee79-5b9d-b8a119c11933.png)

スクール作成
![スクリーンショット 2020-11-29 22.56.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224974/2db48ced-720c-5e44-ab87-5bc08b614391.png)

![スクリーンショット 2020-11-29 22.57.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224974/9f0a7b19-97f0-d875-e4ce-041619daccbc.png)

スクールページへアクセス（ローカル環境：　http://helloworld.lvh.me:3000/）
うまく表示できました！
![スクリーンショット 2020-11-29 22.58.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224974/d7eb7d5f-45a0-45ba-6355-a216c6c1b0a3.png)

試しに適当なURL(helloworld.lvh.me:3000とか)にアクセスすると、講師のダッシュボードトップに戻ってくるので、そっちもうまく行ってそうです。

####本番環境での注意点
通常のサービスドメインのSSLに加えて、サブドメインのワイルドカードを持ったSSLを取得していないと全てのサブドメインをSSL通信させることはできません。

特にHerokuでデプロイしている場合は注意が必要です。ACMでサブドメインのSSLを発行できないので、自分で取得または作成してアップロードすることになります。僕はLet'sEncryptoを使ってオレオレ証明書を作成しました。その際に参考にした記事です。

参考記事１： MacOS に CertBot を入れて Let's Encrypt 証明書を作ってみる
https://neos21.hatenablog.com/entry/2019/03/11/080000

参考記事２： Let's Encrypt ワイルドカード証明書の取得手順メモ
https://qiita.com/fukmaru/items/dd75f2a3601472590ead

参考記事3： Heroku SSL + Let's Encrypt で、ワイルドカードなSSL証明書を設定する
https://qiita.com/fukmaru/items/dd75f2a3601472590ead

## STEP２：独自ドメインとの紐付け
サブドメインの紐付けがうまくいきました。次は独自ドメインの紐付けです。
まずconstrainの部分を変更して、別ドメインがきた時も同じルーティングを採用するようにします。

```ruby:lib/school_domain.rb
class SchoolDomain
  class << self
    def matches?(request)
      matches_custom_domain?(request) || matches_subdomain?(request)
    end

    private
      def matches_custom_domain?(request)
        request.domain.present? && request.domain != ENV["MY_DOMAIN_NAME"]
      end

      def matches_subdomain?(request)
        # wwwの場合は追加するルーティングにマッチングしないようにしておく
        request.subdomain.present? && request.subdomain != 'www'
      end
  end
end
  
```

次にapplication_controllerでドメインをrequestから抽出して、schoolを検索する処理をします

```ruby:application_controller.rb
    def set_school_by_domain_name
      if request.domain.present? && request.domain != ENV["MY_DOMAIN_NAME"]
        @school = School.find_by(domain: request.domain)
      end

      if request.subdomain.present? && request.subdomain != 'www'
        @school = School.find_by(subdomain: request.subdomain)
      end  
      
      # 該当するスクールがない場合、URLをリセットしてトップページにリダイレクト
      redirect_to root_url(domain: ENV["MY_DOMAIN_NAME"], subdomain: "") unless @school.present?
    end

```

この状態でローカル環境でテストしていきます！

僕の環境ではlvh.meをENV["MY_DOMAIN_NAME"]に設定しているので、別のループバックURL(localtest.me)を独自ドメインとして登録してテストします。
今の時点でlocaltest.me:3000にアクセスしても、正常にroo_urlに戻されました。

![スクリーンショット 2020-11-29 23.10.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224974/fc4ec564-70ce-85fd-43e4-df8e95f4c51d.png)

この状態で、localtest.me:3000にアクセスします
![スクリーンショット 2020-11-29 23.15.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224974/e9d04fd5-5fa2-83d6-e52f-2006e6ce0370.png)

うまくいきました！

##本番環境で独自ドメインを紐付ける
本番環境の実装方法は、環境によって異なるので今回の記事では割愛します🙇‍♂️
後日追記するかもしれません。
軽く調べた感じだと「多段CNAME」や「プロキシサーバー」がキーワードになっている印象

####ユーザー側で必要な処理
- 独自ドメインの取得
- DNSの設定（cnameまたはAレコード）
BASEやSHOPIFYのドメイン登録ページがわかりやすい（https://apps.thebase.in/detail/6）

## STEP３： テナントごとにデータを分離する
個人情報を取り扱うようなSaaSサービスは、テナントごとにデータを分離することが重要です。データの分離がされていないと何かの拍子に会社Aの情報を会社Bのユーザーに向けて表示してしまう等のリスクが発生します。

### apartment gem
「Rails マルチテナンシー」で調べるとapartment gemについての記事がよく出てきます。apartment gemはマルチテナント実装には最適なgemですが、スケールしていく上で落とし穴もあるようです。SmartHRさんが、aparmtnet gemを最初使っていて、途中でCitusData（postgresqlの拡張）に乗り換えるというイベントがありました。詳しくは参考記事をよんでください。

参考記事：　SmartHR が定期メンテナンスを始めた理由とやめる理由
https://tech.smarthr.jp/entry/2018/04/06/100000

### activerecord-multi-tenant
マルチテナンシーを同一DB上で実現させるためのgemです。テナントごとにテーブルが別れているわけではないので、データの横断は不可能ではないですが、アプリケーションレベルでデータの領域分けを強制させられるのでマルチテナンシーとして機能しそうです（全部は理解できていない汗）

参考記事：　ActiverecordMultiTenant でマルチテナンシー by yks0406さん (マルチテナンシーについて、Railsでのアプローチについて一番詳しくまとまっている記事です)
https://note.com/yks0406/n/n09c181400561

参考記事に習って、今回のサンプルアプリもactiverecord-multi-tenantで実装を進めていきます。

####スクール（テナント）ごとに生徒の情報を管理する
🙇‍♂️２０２０年１２月中に追記予定🙇‍♂️

###最後に
SaaSやノーコードアプリを開発するエンジニアのかた増えてきていると思います。
参照記事多目、まとめ記事と言っても過言ではないですが、お役に立てれば幸いです。