RailsでURL短縮サービスをつくってみる

# 概要
### どのようなサービスか
長いURLを短いURLに変換するサービスです。
有名どころで言うと[bitly](https://bitly.com/)ですね。
試しに、Wikipediaの長いURLを変換してみます
長いURL：https://ja.wikipedia.org/wiki/1-%E3%83%94%E3%83%AD%E3%83%AA%E3%83%B3-4-%E3%83%92%E3%83%89%E3%83%AD%E3%82%AD%E3%82%B7-2-%E3%82%AB%E3%83%AB%E3%83%9C%E3%83%B3%E9%85%B8%E3%83%87%E3%82%A2%E3%83%9F%E3%83%8A%E3%83%BC%E3%82%BC
短いURL：https://bit.ly/49UA01h
どちらも同じページへ遷移することが確認できます。

### 短縮方法
URL短縮用のGemもありますが、今回は使用せずにハッシュ変換+衝突判定で開発します。

### リポジトリ
コードを確認したい方は以下へ

https://github.com/kakeruAoyama/url-shortener

# 開発環境
```bash
$ ruby -v
ruby 3.0.4p208 (2022-04-12 revision 3fa771dded) [x86_64-linux]
$ rails -v
Rails 7.0.8
```
# Rails new
```bash
$ rails new url-shortener --css tailwind
$ cd url-shortener
$ bin/dev
```
[http://localhost:3000/](http://localhost:3000/)にアクセスしてRailsのページがうまく表示されているか確認します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324819/1bca9a51-85cd-d9fc-551e-98e9f4f35633.png)

# deviseの導入
deviseをつかってログイン機能を実装します。
公式リポジトリに沿って実装していきます。

https://github.com/heartcombo/devise#getting-started

```bash
$ bundle add devise
$ rails generate devise:install
$ rails generate devise User
$ rails db:migrate
```
[http://localhost:3000/users/sign_up](http://localhost:3000/users/sign_up)にてサインアップができるか試してみてください。

ここまでの差分

https://github.com/kakeruAoyama/url-shortener/commit/ff676d5906b70d358fe78e0292bbc3945527b2a6

# 管理画面の作成
### URLをCRUDできるようにする
```bash
$ rails g scaffold Url original hash_value
$ rails db:migrate
```
root_urlを追加
```diff_ruby:config/routes.rb
Rails.application.routes.draw do
  resources :urls
  devise_for :users
+ root 'urls#index'
end
```
hash_valueはアプリケーション側で作成するので入力と表示を削除
```diff_ruby:app/views/urls/_form.html.erb
<%= form_with(model: url, class: "contents") do |form| %>
  <% if url.errors.any? %>
    <div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
      <h2><%= pluralize(url.errors.count, "error") %> prohibited this url from being saved:</h2>

      <ul>
        <% url.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="my-5">
    <%= form.label :original %>
    <%= form.text_field :original, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
  </div>

- <div class="my-5">
-   <%= form.label :hash_value %>
-   <%= form.text_field :hash_value, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
- </div>

  <div class="inline">
    <%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
  </div>
<% end %>
```
```diff_ruby:app/views/urls/_url.html.erb
<div id="<%= dom_id url %>">
  <p class="my-5">
    <strong class="block font-medium mb-1">Original:</strong>
    <%= url.original %>
  </p>

- <p class="my-5">
-   <strong class="block font-medium mb-1">Hash value:</strong>
-   <%= url.hash_value %>
- </p>

  <% if action_name != "show" %>
    <%= link_to "Show this url", url, class: "rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
    <%= link_to "Edit this url", edit_url_path(url), class: "rounded-lg py-3 ml-2 px-5 bg-gray-100 inline-block font-medium" %>
    <hr class="mt-6">
  <% end %>
</div>
```
[http://localhost:3000/](http://localhost:3000/)にアクセスしてURLを作成・表示・編集・削除ができるか試してみてください。
ここまでの変更

https://github.com/kakeruAoyama/url-shortener/commit/fcd5499a857be9fd63d848dfbc82466fdfadeb93
### ログイン制限
ログインしないとURLのCRUDができないようにします。

```diff_ruby:app/controllers/urls_controller.rb
class UrlsController < ApplicationController
  before_action :set_url, only: %i[ show edit update destroy ]
+ before_action :authenticate_user!

  # GET /urls or /urls.json
  def index
    @urls = Url.all
  end

  # GET /urls/1 or /urls/1.json
  def show
  end
  # 以下省略
```
ログアウトボタンを作成します。
```diff_erb:app/views/layouts/application.html.erb
<%# 省略 %>
  <body>
    <main class="container mx-auto mt-28 px-5 flex">
+     <% if user_signed_in? %>
+       <%= button_to 'ログアウト', destroy_user_session_path, method: :delete, class: "text-white bg-red-500 hover:bg-red-700 font-medium py-2 px-4 rounded" %>
+     <% end %>

      <%= yield %>
    </main>
  </body>
</html>
```
自分が作成したURLのみを表示させます。また、ストロングパラメータも変更しておきます。
```diff_ruby:app/controllers/urls_controller.rb
class UrlsController < ApplicationController
  before_action :set_url, only: %i[ show edit update destroy ]
  before_action :authenticate_user!

  # GET /urls or /urls.json
  def index
-   @urls = Url.all
+   @urls = current_user.urls
  end

  # GET /urls/1 or /urls/1.json
  def show
  end
  
# 省略
  # POST /urls or /urls.json
  def create
-   @url = Url.new(url_params)
+   @url = current_user.urls.new(url_params)

    respond_to do |format|
      if @url.save
 # 省略
  private
    # Use callbacks to share common setup or constraints between actions.
    def set_url
-     @url = Url.find(params[:id])
+     @url = current_user.urls.find(params[:id])
    end

    # Only allow a list of trusted parameters through.
    def url_params
-     params.require(:url).permit(:original, :hash_value)
+     params.require(:url).permit(:original)
    end
end
```
アソシエーションを追加します。
```bash
$ rails generate migration AddUserToUrls user:references
$ rails db:migrate
```
```diff_ruby:app/models/user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
+ has_many :urls
end
```

```diff_ruby:app/models/url.rb
class Url < ApplicationRecord
+ belongs_to :user
end
```

今まで作成したURLは削除しておきます。
```bash
$ rails c
$ Url.delete_all
$ exit
```

これで違うユーザーでログインした場合にも、そのユーザーが投稿したURLのみが表示されます。
[http://localhost:3000/](http://localhost:3000/)にアクセスして複数ユーザーで投稿してみてください。

ここまでの差分

https://github.com/kakeruAoyama/url-shortener/commit/66ac5b63f3aba666d85ed0078bbfdd24b567cc1a

# 短縮URLの生成
### URLのバリデーション
オリジナルのURLを投稿した際にURLとして適していない場合はバリデーションで弾くようにしましょう
```diff_ruby:app/models/url.rb
class Url < ApplicationRecord
  belongs_to :user
+ validates_format_of :original, with: /\A(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*|%[0-9a-fA-F]{2,})*\/?\z/
end
```
正規表現を使ってバリデーションを作ることで、でURLのみ投稿できるようになりました。
### ハッシュ変換
長いURLを短いURLに変換する際に、短いURLは最低でもどれくらい必要でしょうか。
数字とアルファベットのみで短いURLを表すとする場合62文字使用できます。
62^1=62
62^2=3,844
62^3=238,328
.
.
.
62^7=3,521,614,606,208
となるので、7桁あれば、3.5兆のレコードに対応できるので十分でしょう。
そして、ハッシュ関数をつかってオリジナルURLをハッシュ化し、その最初の7桁を取得し、衝突判定を行いDBで重複していないことを確認してから、hash_valueに保存するという実装にします。

```diff_ruby:
class Url < ApplicationRecord
  belongs_to :user
  validates_format_of :original, with: /\A(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*|%[0-9a-fA-F]{2,})*\/?\z/
  validates :hash_value, uniqueness: true

+ before_create :generate_hash_value
+
+ private
+
+ def generate_hash_value
+   self.hash_value = create_unique_hash_value
+ end
+
+ def create_unique_hash_value
+   loop do
+     hash = Digest::SHA256.hexdigest(original)[0...7]
+     break hash unless Url.exists?(hash_value: hash)
+   end
+ end
end
```
### indexを張る
hash_valueカラムの検索パフォーマンス向上のためにindexをつけます。
```bash
$ rails generate migration AddIndexToHashValue
```

```diff_ruby:db/migrate/xxxxxxxxxxxxxxx_add_index_to_hash_value.rb
class AddIndexToHashValue < ActiveRecord::Migration[7.0]
  def change
+   add_index :urls, :hash_value, unique: true
  end
end
```


### ユニーク制限
hash_valueが重複しないように、バリデーションとDBでユニーク制限をつけておきます。
```diff_ruby:
class Url < ApplicationRecord
  belongs_to :user
  validates_format_of :original, with: /\A(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*|%[0-9a-fA-F]{2,})*\/?\z/
+ validates :hash_value, uniqueness: true
#　省略
end
```
```diff_ruby:db/migrate/xxxxxxxxxxxxxxx_add_index_to_hash_value.rb
class AddIndexToHashValue < ActiveRecord::Migration[7.0]
  def change
    add_index :urls, :hash_value, unique: true # 先程追加したコードで実装済み
  end
end
```
```bash
$ rails db:migrate
```
これによって、短いURLに使用するhash_valueを作成できるようになりました。
ここまでの差分

https://github.com/kakeruAoyama/url-shortener/commit/0b8b4697799126aba392315828c6814ab26f3e37
# 短縮URLにアクセスされた際のリダイレクト処理
### 短縮URLの表示
```diff_ruby:app/views/urls/_url.html.erb
<div id="<%= dom_id url %>">
  <p class="my-5">
    <strong class="block font-medium mb-1">Original:</strong>
    <%= url.original %>
  </p>

+ <p class="my-5">
+   <strong class="block font-medium mb-1">Short url:</strong>
+   <%= root_url + url.hash_value %>
+ </p>
<%# 省略 %>
</div>
```
### リダイレクト処理
routesを追加
```diff_ruby:config/routes.rb
Rails.application.routes.draw do
  resources :urls
  devise_for :users
  root 'urls#index'
+ get '/:hash_value', to: 'urls#redirect', as: 'redirect'
end
```

アクションを追加
```diff_ruby:app/controllers/urls_controller.rb
class UrlsController < ApplicationController
#省略
+ def redirect
+   url = Url.find_by(hash_value: params[:hash_value])
+   redirect_to url.original, allow_other_host: true
+ end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_url
      @url = current_user.urls.find(params[:id])
    end
#省略
end
```

これで、長いURLを短いURLに変換し、短いURLにアクセスされた際は長いURLへ遷移させることができました。

ここまでの差分

https://github.com/kakeruAoyama/url-shortener/commit/677daca77b6e0b4e303260738ec4502956e42f9b

# まとめ
以上で基本機能は完成です。
もし、今後時間があれば、以下の機能も追加できればと思います。

 - 管理画面にて、短縮URLのアクセス数をグラフで表示する
 - コピーボタンを作成する

(いいねをしてくださると追加する励みになります！)