Rails、Redis 實作短連結系統
短連結系統
0、前言
寫此篇文章的動機
為了練習Redis的功能,因此直接做了一個比較簡單的Project,此專案用到ROR、Redis、TailsWind CSS
1、Redis優點、特性
在實作之前先介紹一下Redis的特性,Redis是非關聯式資料庫。
所有Redis資料都存放在記憶體中,從而實現低延遲和高輸送量的資料存取。 與傳統資料庫不同,記憶體內資料儲存不需要存取磁碟,從而將引擎延遲縮減到微秒。
因此,記憶體內資料儲存能夠支援更大規模的操作,而且回應時間更快。 這項優勢提供超快速的效能,平均讀取和寫入操作時間低於一毫秒,並支援每秒百萬個操作。
FROM AMAZON
1-1、Redis優點
1-1-1、速度
而最大特性就是速度很快,Redis能讀的速度是110000次/s,寫的速度是81000次/s。
PS. 雖然查到說Redis可以這麼快,不過沒碰上大量資料之前,完全無感XD
1-1-2、豐富的數據類型
Redis支持二進制案例的Strings、Lists、Hashes、Sets及Ordered Sets 數據類型操作。
1-1-3、原子
Redis的所有操作都是原子性的,意思就是要馬成功執行要馬失敗完全不執行
1-1-4、其他特別特性
Redis還支持publish/subscribe、通知、key expire(過期)等等特性。
2、開始實作
介紹完Redis的特性了,那就先把實作的大綱、步驟先寫出來吧~
2-1、實作大綱
2-1-1、環境設定
1、先產生一個存放連結資料的資料庫 - model取名為Link
2、設定一個方法,當使用者輸入完整連結後,會亂數產生一個slug,這個slug會是之後的短網址其中的字串
3、接著安裝Redis,等等準備用Redis Hash來儲存使用者輸入的連結內容
4、把路徑設定好
2-1-2、使用者模擬
把東西都設定好後,可以模擬使用者輸入連結後的狀況
1、使用者輸入連結 - https://google.com/
2、輸入的時候會使用SecureRandom方法,直接亂數產生slug,對應到使用者輸入的連結 - 此時會先存進資料庫
3、接著再存進Redis Hash裡面
4、當使用者點擊剛剛亂數產生的短連結時,會先去Redis找有沒有這一筆資料(我們在步驟3,有存一份在Redis)
5、如果沒有在Redis找到(可以用expire把redis的資料先刪掉,好處等等會說),去資料庫找該短連結,並寫一份到Redis裡面
2-2、實作細節
2-2-1、設定環境
1、新增一個rails專案
$ rails new _6.1.7_ ShortLink
2、新增Link model - model的column包含以下幾項
(1) url(使用者輸入完整連結)
(2) slug(每個連結對應的亂數)
(3) clicked(點擊次數)
$ rails g model Link url slug clicked:integer
3、新增亂數方法
# Link Model
def generate_slug
self.slug = SecureRandom.uuid[0..5] if self.slug.nil? || self.slug.empty?
end
4、使用before_validation,讓link在產生前就會用到此方法、再加上幾個url、slug限制
# Link Model
validates :url, format: URI::regexp(%w[http https])
validates_uniqueness_of :slug
before_validation :generate_slug
def generate_slug
self.slug = SecureRandom.uuid[0..5] if self.slug.nil? || self.slug.empty?
end
5、安裝Redis
用brew來安裝redis
$ brew install redis
啟動redis環境
$ redis-server
啟動redis console
$ redis-cli
新增一個資料夾,設定全域變數$redis,這樣之後就不用每個action都要重新設定
# config/initializes/redis.rb
$redis = Redis.new
6、制定redis hash的設定
> (1) key - 每個連結的redis key是資料庫連結的id-數字:slug
> (2) 第一個hash - url https://fintechrich.com
> (3) 第二個hash - short http://localhost:3000/shorts/6365a2
> (4) 第三個hash - slug 6365a2
> (5) 第四個hash - visit 0
> 格式設定好後,等等用hset新增
> Ex. hset id-43:6365a2 url https://fintechrich.com short http://localhost:3000/shorts/6365a2 slug 6365a2 visit 0
7、路徑設定
# routes.rb
resources :links
2-2-2、完整連結新增到資料庫、redis
環境、方法都設定好後,就可以來完成controller
首先我們來把Link_controller補完
(1) 當使用者輸入連結的時候,先儲存一份在資料庫中
(2) 此時link因為資料庫已經存入並且亂數產生slug,把此slug設定為一個變數
(3) 用hset存一份到redis裡面,hash裡面包含url、short、slug、visit,並且key的格式為”資料庫的id-數字:slug”
# LinkController
before_action :link_params, only: [:create]
def new
@link = Link.new
end
def create
@link = Link.new(link_params)
if @link.save
@shortcode = @link.slug
# 用hash新增 - hset id-43:6365a2 url https://fintechrich.com short http://localhost:3000/shorts/6365a2 slug 6365a2 visit 0
hash = {"url" => @link.url, "short" => "#{ENV["WEB_DOMAIN"]}/shorts/#{@link.slug}", "slug" => @link.slug, "visit" => 0}
$redis.hset("id-#{@link.id}:#{@link.slug}", hash)
end
redirect_to root_path
end
private
def link_params
params.require(:link).permit(:url, :slug)
end
2-2-3、點擊連結後,增加點擊次數
資料庫、redis都有數據後,就可以來設計,當使用者點到連結,連結被點擊的次數就要加一
1、首先設定路徑
# routes.rb
resources :links do
collection do
get :increase_visit
end
end
2、設定view
<!-- index.erb.html -->
> <table class="w-full text-base text-left text-gray-500 dark:text-gray-400 table-auto">
> <thead class="text-base text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
> <tr>
> <th class="px-6 py-3">redis的key</th>
> <th class="px-6 py-3">原本的連結</th>
> <th class="px-6 py-3">短連結</th>
> <th class="px-6 py-3">slug</th>
> <th class="px-6 py-3">點擊次數</th>
> <th class="px-6 py-3">redis刪除</th>
> </tr>
> </thead>
> <% @keys.each do |k| %>
> <tbody>
> <tr>
> <td class="px-6 py-4"><%= k %></td>
> <td class="px-6 py-4"><%= $redis.hgetall(k)["url"] %></td>
> <td class="px-6 py-4"><%= link_to $redis.hgetall(k)["short"], increase_visit_links_path(k) %></td>
> <td class="px-6 py-4"><%= $redis.hgetall(k)["slug"] %></td>
> <td class="px-6 py-4"><%= link_to $redis.hgetall(k)["visit"], increase_visit_links_path(k) %></td>
> <td class="px-6 py-4"><%= link_to 'delete', expire_key_links_path(k) %></td>
> </tr>
> </tbody>
> <% end %>
> </table>
重點在這一行,先用hgetall抓到該則短連結,並且如果使用者點到此則短連結,會連到剛剛新建的action那邊,因此等等要來寫action
> <td class="px-6 py-4"><%= link_to $redis.hgetall(k)["short"], increase_visit_links_path(k) %></td>
2、設定controller
假如今天點擊到此路徑,先用hgetall抓到該短連結,並且用hincrby方法,針對visit這個field增加值,點一次增加1,並且最後連到該網頁
# LinksController
def increase_visit
if $redis.hgetall(params[:format])["url"]
$redis.hincrby(params[:format], "visit", 1)
redirect_to $redis.hgetall(params[:format])["url"]
end
end
2-2-4、用expire刪除redis資料
如果今天資料有超級多筆,這些資料一直存在記憶體中(搞不好有些連結根本沒啥點擊),這樣會造成儲存成本上升(存在資料庫比較便宜),因此我們可以使用redis提供的expire功能,來讓存在redis中的key過期(刪除)。
常用的手法可能是,在創建出該短連結的時候,就直接把expire時間加上去(7天後過期),如果7天內這個連結被點過,這個7天就會重置,這樣就可以達到熱資一直存在redis裡面,冷資料等到有人點擊的時候才會再出現。
用expire是刪除記憶體的資料喔!不是刪除資料庫裡面的,所以今天如果不小心刪掉redis的某一筆資料,直接從資料庫拿就好
1、路徑設定
不過現在因為我想要直接體驗expire的功能,所以我直接設定一個連結,當我點下這個連結,該key就會失效,所以我們先來設定路徑
# routes.rb
resources :links do
collection do
# 這一行
get :expire_key
get :increase_visit
end
end
2、設定view
當使用者點擊該連結時,會觸發這個action
> <td class="px-6 py-4"><%= link_to 'delete', expire_key_links_path(k) %></td>
3、設定controller
觸發action後,會把抓到的key值,直接設定expire為0秒,這樣就可以直接把key刪掉
# LinksController
def expire_key
# redis = Redis.new
key = params[:format]
$redis.expire(key, 0)
redirect_to root_path
end
2-2-5、redis資料到期,從資料庫抓出該資料,並寫一份到redis
接著來實作,假設今天在redis的key到期了,使用者想要點擊該短連結,就會從原本記憶體拿資料,變成先去資料庫拿資料,並且寫一份到記憶體裡面,這樣redis又會出現這一個key了
1、設定路徑
# routes.rb
resources :shorts, only: [:show]
2、設定view
<!-- index.html.erb -->
> <table class="w-full text-base text-left text-gray-500 dark:text-gray-400">
> <thead class="text-base text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
> <tr>
> <th class="px-6 py-3">id</th>
> <th class="px-6 py-3">使用者輸入連結</th>
> <th class="px-6 py-3">slug</th>
> <th class="px-6 py-3">點擊次數</th>
> <th class="px-6 py-3">短連結</th>
> <th class="px-6 py-3">資料庫刪除</th>
> </tr>
> </thead>
> <% @links.each do |link| %>
> <tbody>
> <tr>
> <td class="px-6 py-4"><%= link.id %></td>
> <td class="px-6 py-4"><%= link.url %></td>
> <td class="px-6 py-4"><%= link.slug %></td>
> <td class="px-6 py-4"><%= link.clicked %></td>
> <td class="px-6 py-4"><%= link_to link.short, link.short, data:{ turbolinks: "false" } %></td>
> <td class="px-6 py-4"><%= link_to 'delete', link_path(link), data: {method: "delete"} %></td>
> </tr>
> </tbody>
> <% end %>
> </table>
重點在這一行,點下去後,會觸發show的action
> <td class="px-6 py-4"><%= link_to link.short, link.short, data:{ turbolinks: "false" } %></td>
Ps. 會把turbolinks關掉的原因,等等會提到
3、完成controller
這邊有幾件事情要設定
(1) 點擊該連結的時候,先看一下redis有沒有該連結
(2) 如果redis有此連結的話,直接幫此連結的visit + 1
(3) 如果redis裡面沒有的話,從資料庫裡面找出該連結(@link)
(4) 再寫一份到redis裡面
(5) 並且幫此redis的visit + 1
class ShortsController < ApplicationController
before_action :find_link, only: [:show]
def show
# 確認redis中有沒有這個key(用*可以拿到相關字串)
key = $redis.keys("*#{params[:id]}").first
if key
# 如果redis裡面有存此id,幫visit + 1
$redis.hincrby(key, "visit", 1)
redirect_to $redis.hgetall(key)["url"]
else
# 如果redis中沒有這個key,就寫一份到redis裡面
hash = {"url" => @link.url, "short" => "#{ENV["WEB_DOMAIN"]}/shorts/#{@link.slug}", "slug" => @link.slug, "visit" => @link.clicked}
$redis.hset("id-#{@link.id}:#{@link.slug}", hash)
# 寫一份到redis後,直接幫點擊數 + 1
$redis.hincrby("id-#{@link.id}:#{@link.slug}", "visit", 1)
redirect_to @link.url
end
end
private
# 這個就是資料庫中的link
def find_link
@link = Link.find_by!(slug: params[:id])
end
end
如果今天沒有把這個連結的turbolink關掉的話,會導致visit + 2喔!所以記得要關掉
2-2-6、redis的key到期的時候,把資料匯進資料庫
如果今天使用者數量很大的話,如果要新增使用者點擊連結的次數,就要先撈出該連結,並且讓點擊次數增+1,最後再寫進資料庫,這樣一直操作資料庫,量一大的時候就會造成資料庫有負擔,因此我們把增加點擊次數的操作,改成由redis來操作,並且在一個固定的時間點,再定時匯入資料庫,這樣不就可以用最小的負擔,達成增加短連結的點擊次數!
1、改寫controller
原本expire_key的action,只有把key刪除,先在我們在刪除前,還要把資料匯進資料庫
def expire_key
# 抓到傳過來的key
key = params[:format]
# 把link找出來,並且把快取中的visit回填資料庫中
id = key[3..4]
@link = Link.find_by!(id: id)
@link.clicked = $redis.hgetall(key)["visit"].to_i
@link.save
# 刪除快取
$redis.expire(key, 0)
redirect_to root_path
end
2-2-7、額外補充:一次把redis中的資料,匯入資料庫
前面我們提到,可以訂一個時間,用active job把所有redis中的資料回填給資料庫,不過今天我先改成直接按一個按鈕,就可以把所有資料回填給資料庫(這樣比較直觀)
1、設定路徑
resources :links do
collection do
get :expire_key
get :increase_visit
# 這個路徑
get :import_clicked
end
end
2、設定view
新增一個按鈕,只要使用者點下去,會觸發import_clicked這個action
<!-- index.html.erb -->
> <div class="text-center">
> <button><%= link_to '把redis瀏覽次數,匯入資料庫', import_clicked_links_path, class: "second-btn" %></button>
> </div>
3、設定controller
# LinksController
def import_clicked
# 列出redis中所有的key
keys = $redis.keys("*")
# 根據redis的key,把link所有visit數灌回資料庫的點擊數
keys.each do |key|
# 找到資料庫中對應的slug
link = Link.find_by!(slug: $redis.hgetall(key)["slug"])
# 資料庫中的連結點擊數 = redis中的visit數量
link.clicked = $redis.hgetall(key)["visit"].to_i
link.save
end
redirect_to root_path
end
2-3、增加連結的UTM
由於之前是數位行銷出生的,因此很常用到UTM這個連結參數,於是就順手增加了UTM的設定,不過由於這一段code比較多,所以只會說大概的運作模式,要看整段code就麻煩到github看拉~
Ps. rails的JS是使用stimulus JS
(1) 使用者增加填上連結 (2) UTM會有五個額外的欄位,分別是campaign、medium、source、term、content (3) 使用者在這五個欄位填寫資料的時候,會直接把對應的字串,直接加到一開始填寫的欄位
舉個例子:
> link field : https://google.com
> campaign field : 20230205
> medium field : social_post
> source field : facebook
> term field : yee
> content field : 3c
>
> 最後的完整連結 : https://google.com?utm_campaign=20230205?utm_medium=social_post?utm_source=facebook?utm_term=yee?utm_content=3c
3、小記
這樣就完成了rails、redis的短連結系統!再次總結一下這個小專案做了哪些事情~
(1) 使用者新增的網址,可以生成一段短網址(短網址後段是亂數生成)
(2) 生成的短網址會先存進資料庫
(3) 再來存一份到Redis裡面
(4) 使用者點擊短連結,會先從redis找該筆資料
(5) 如果今天redis沒有該筆資料,而使用者還是點擊了,會去資料庫找連結,並且寫一份到redis裡面
(6) 使用者點擊短連結後,會增加點擊次數
(7) UTM功能
3-1、使用到的工具
Rails On Ruby、Redis、Tailwind CSS
3-2、GitHub連結
[https://github.com/eagle0526/rails_redis_shortlink]https://github.com/eagle0526/rails_redis_shortlink