# Short URL API Server 架設心得與教學 這是某S公司面試的專案題目,題目要求就如題目所說,架一個能提供縮網址功能的API Server,本身之前沒有學過架server的相關經驗,不過機會難得,紀錄一下學習的過程與一些初學者可能會被坑的點。 本次專案會使用Ubuntu16.04作為作業系統,Flask作為server的framework,Redis作為資料庫,uWSGI與Nginx作為最後架站的工具。 keyword: Flask, Redis, uWSGI, Nginx ## 事前準備:相關知識 一開始連API server是什麼都不知道,後來查了才知道說所謂API server與一般Web server只差在回傳的東西不一樣,API server只需要簡單回傳json格式的資料,而Web server則是回傳我們平常看到的網業,也就是html檔。 接著也了解了一下所謂HTTP的methods,像是POST,GET,PUT等等,還有何謂RESTful的網頁(這邊沒有真正實作肯定是不知道這什麼鬼),不過重點還是了解HTTP的幾種method大概在做些什麼。 最後才是了解縮網址功能大概會需要怎樣的技術,這邊我看了幾個youtube的教學影片,其中我覺得[這篇](https://youtu.be/fMZMm_0ZhK4)講的最好,他提到了很多需要考慮的點例如應該要用什麼編碼,系統應該考慮些什麼東西等等。 ## 事前準備:實作工具 了解了系統架構後接著就是要把他實現出來,題目並沒有指定使用的語言與套件,所以我就以我比較熟悉的Python作為目標(畢竟有限時間,要我重學PHP或是其他語言不切實際)。Python主流的網頁framework有Flask和Django兩種,Django比較肥一點,而且Flask比較新,所以我就挑了Flask當作搜尋的目標,找到了一篇中文的[教學](https://blog.liang2.tw/posts/2015/09/flask-draw-member/)寫的挺不錯的,一開始可以照著這篇教學走,寫個Hello World出來,也大概能了解flask運作的方式,雖然在這篇教學中用的是sqlite當作資料庫,不過也沒差,現在Linux或是macOS都已經內建支援sqlite,在都不懂的情況下照著打也不會有問題。 接著就要選擇使用的資料庫,以一個什麼都不會的菜B8而言,馬上就想到:資料庫?MySQL嘛!不過後來查了資料才了解了MySQL其實算很肥,像這種小專案如果需要relational的資料庫用sqlite就好了,不過由於我們這個專案對資料庫大小的需求很低,根據上面那篇教學影片所說,存滿所有可能的key大概也就10幾G,那我不如用速度更快的Redis來做會更好,不過缺點就是如果server的記憶體太小(像AWS的免費空間,記憶體只給1G),那就不適合。 Redis是一種key-value的NoSQL資料庫,使用起來跟Python的`dict`完全一樣,資料會儲存在記憶體裡,所以存取速度飛快,再加上他限制一次只有一個thread能存取,也免去了我們考慮race condition的問題。 ## 實作:Server ### 編碼 接著可以開始實作這個server,根據教學影片中我們可以了解,一個好的縮網址服務我們會希望輸入不同網址要能產生不同的短網址,不然這個短網址到底是會連到哪裡去呢?這邊我們可以很輕易的想到用hash去做,不過MD5產生的hash有128bit這也太長了,所以其實只需要取前40個bit就可以了,這邊我使用了教學中的base62編碼,也就是說編出來的key會是由0-9,a-z,A-Z組成的。 這邊我們可以使用別人寫好的base62編碼套件,把產生key的function寫成如下: ```python import hashlib import base62 def short(url): # MD5 hash and take first 40bit encdoe with base62 return base62.encodebytes(hashlib.md5(url).digest()[-5:]) ``` ### 架構 有了最基本的編碼後,再來要想想server要怎樣接收request,接到request後大概要做些什麼動作,其實這些在教學影片中都有提到,大概就是如果我透過POST輸入一個url,那我會先編碼,檢查這個編碼有沒有在database裡面,沒有就把key跟url存進去。如果透過GET想要連到產生出來的短網址,我要先確認key有沒有在database裡面,有就導向到對應的url,沒有就報錯。 這邊我也試玩了幾個現行比較有名的縮網址服務([tinyURL](https://tinyurl.com/),[bitly](https://bitly.com/)...),試試看他們大概會對我的輸入做出哪些反應,這對我考慮回傳的錯誤訊息很有幫助。 這邊我們可以知道主要會需要兩種頁面,一個是接POST並回傳短網址的,一個是接GET並重新導向的,假設我們的server IP位址是`1.2.3.4`,我們可以規劃程式如下: ```python @app.route("/shortURL", methods=['POST']) def shorten_request(): """ Get the shorten request from client. """ SERVER_PREFIX = "http://1.2.3.4/" url = request.get_data() url_key = short(url) if DB.get(url_key) is None: add_to_db(url_key, url) return SERVER_PREFIX+url_key @app.route("/<url_key>") def redirect_to_url(url_key): """ Check the url_key is in DB, redirect to original url. """ url = DB.get(url_key) if url is None: return False return redirect(url) ``` 不過只有這樣是不夠的,我們要想到程式可能會出包的情況而且最好把錯誤訊息一起傳給使用者。 ### Debug 首先根據我把玩TinyURL的結果,我們可以知道隨便亂打一串字進去他是不會給你過的,回傳的錯誤訊息是: ``` We couldn't detect the exact problem with your URL, but make sure it starts with a valid scheme such as 'http:' or 'ftp:', points to a valid host, and is properly formatted. ``` 由此可知,他會先parse輸入的url,如果使用的scheme怪怪的他就會擋下來,這邊我們也可以透過Python內建的url parser來預先檢查輸入的url是不是在那邊亂打。要注意的是這個url parse的功能,python2跟python3 import的方法不一樣。 ```python #Python2 from urlparse import urlparse parsed = urlparse(url) print parsed.scheme ``` ```python #Python3 from urllib.parse import urlparse parsed = urlparse(url) print (parsed.scheme) ``` 再來我們可以想到的是,如果再寫入的過程中,url的key很不幸還是發生的collison怎麼辦呢?這邊我是選擇把舊的蓋掉,畢竟發生collision的機率還是蠻低的,之前有研究過MD5 hash的差分攻擊,找到collision的組合也沒那麼簡單。 最後我們還有一些小東西要考慮,例如寫入database的時候可能會有錯,輸入的url不能太長等等 ## 實作:指定url_key ### 架構 在TinyURL其實還有一個功能,那就是你可以指定自己想要的url_key,也就是說你可以指定縮出來會長什麼樣子(不過只有最後幾個字),這邊我也跟著實作了這個功能,其實也不會算很難,不過要多加一個接request的頁面,如下所示: ```python @app.route("/specify/<specify_key>", methods=['POST']) def specify_url_key(specify_key): """ Use user specify url_key to generate shorten url """ SERVER_PREFIX = "http://1.2.3.4/" url = request.get_data() if DB.get(specify_key) is None: DB.set(specify_key, url) return SERVER_PREFIX+url_key ``` ### Debug 這邊我們一樣要考慮一些可能會出錯的東西,透過把玩TinyURL我們可以發現,有時候你指定的key會沒辦法使用,我想這是因為TinyURL設計成如果使用了已經存在的key會把你擋掉,並且回傳另一個他幫你產生的可以使用的key給你,錯誤訊息如下: ``` The custom alias you've chosen is not available. We've created a random one for you instead, but you can try assigning a different custom alias again below. Use 6 characters or more for the best chance of getting a unique unassigned alias. ``` 所以我們也需要考慮這種情況的發生,如果使用了一個已經存在的key要把他擋下來,不過我們可不能隨便亂擋,所以要先確認資料庫裡已使用的key的url是不是跟你給的url相同(不然明明是可行的卻被擋下就不好了),如果確定被擋下還要產生一組新的key給使用者,這邊的流程其實跟上面提到的一般流程一樣,都是先產生key,然後各種檢查,如果前面已經有把正常流程寫成function就可以直接導向他,加上補救方案的程式碼如下所示: ```python @app.route("/specify/<specify_key>", methods=['POST']) def specify_url_key(specify_key): """ Use user specify url_key to generate shorten url """ SERVER_PREFIX = "http://1.2.3.4/" url = request.get_data() if DB.get(specify_key) is None: DB.set(specify_key, url) else: if DB.get(specify_key) != url: result = use_rand_key(url) # 前面寫的正常流程 return result return SERVER_PREFIX+specify_key ``` ## 實作:安全性問題 縮網址服務一直有為人所詬病的安全性問題,上面我們也有提到過會有collision的可能,而這件事也不是只有我有發現,在2016年[趨勢科技的文章](https://blog.trendmicro.com/are-shortened-urls-safe/)中就有提到縮網址"絕對"會有安全性上的疑慮,他們透過自動化的亂丟key去測幾個比較大的縮網址服務還真的給他跑出幾個網站來,其中還有一些是私人的雲端儲存空間,這可不妙,不過目前我也還沒看到能解決的演算法,只能在此呼籲大家不要用縮網址來存重要的網址吧。 再來是無限重複導向的問題,這個早在[2010年](http://xuv.be/Looping-url-shortening.html)就有人發現了,大概就是說,透過指定輸入key的功能,輸入`http://1.2.3.4/aaa`後,指定他的key也是`aaa`,這樣就會造成重複導向同一個網站,雖然現在的瀏覽器都會自己偵測重複導向了,不過我們還是可以把這種情況抓出來,因為這種情況只會在自訂key的地方出現,我們可以在自訂key功能的地方多加一個判斷,讓他判斷輸入網址的域名不要跟server一樣且指定key不要跟輸入網址的path一樣就可以了。 ```python def check_url_loop(url_key, url): parsed_url = parse.urlparse(url.decode()) url_path = parsed_url.path.replace("/", "") server_domain = SERVER_URL_PREFIX.replace(":", "").replace("/", "") if ( server_domain == parsed_url.scheme+parsed_url.netloc and url_path == url_key): return False return True ``` ## 架站:uWSGI 完成server主要的功能後,再來就是要把他架起來,其實Flask本身就有提供測試server的功能,只要用`python`去執行你放`app.run(host=localhost, host=5000)`的程式就可以了,這時你從另外一個terminal視窗輸入`curl http://localhost:5000`就能連上你自己的server,不過這個內建的server功能很簡單,也沒有支援多執行緒,所以[Flask的官方文件](http://flask.pocoo.org/docs/1.0/deploying/#deployment)有推荐我們使用其他的資源來完成server的佈署,這邊我查了一下幾個相關的套件,我覺得我會需要用到Nginx的反向代理功能,所以選擇直接支援Nginx的uWSGI作為與Nginx的溝通介面 這邊稍微簡介一下Flask,uWSGI與Nginx之間的關係,Flask是網頁框架,就是用來開發網頁的主要功能,Nginx是一種Web server,負責做[負載平衡](https://zh.wikipedia.org/wiki/%E8%B4%9F%E8%BD%BD%E5%9D%87%E8%A1%A1)、[反向代理](https://zh.wikipedia.org/wiki/%E5%8F%8D%E5%90%91%E4%BB%A3%E7%90%86)等等的功能,而uWSGI就是負責Flask與Nginx之間溝通的介面了 了解了uWSGI的定位後首先要先安裝uWSGI,uWSGI本身就是支援python的一種套件,我們使用`pip`安裝就可以 ``` $ pip install uwsgi ``` 再來就要開始做uWSGI的設定,反正設定好讓他幫你跑就好了,這邊我們會需要兩個東西,一個是啟動你設計的application的script,另一個是uWSGI server的設定檔,首先姑且將啟動script稱之為`start_server.py`好了,這份檔案裡面放的東西很簡單如下所示,就是import你前面用flask寫好的那個app然後讓他跑就可以了 ```python from your_flask_project import app if __name__ == '__main__': app.run(host='0.0.0.0') ``` 第二是設定檔的編寫,其實不需要設定檔每次都在command line裡面打option也可以,但因為我懶得每次都打一大串東西,所以還是透過設定檔來開比較好,我自己的設定檔`uwsgi.ini`如下所示: ``` [uwsgi] # uWSGI socket connect to nginx socket=/tmp/short_url.sock chmod-socket = 666 # project direction chdir=/home/jason/Desktop/short_url # virtual environment direction home=/home/jason/Desktop/short_url/env # Specify start script wsgi-file=/home/jason/Desktop/short_url/start_server.py callable=app # uWSGI server parameter master=true processes=4 enable-threads = true thunder-lock = true die-on-term = true ``` 這邊有幾個需要特別提的,第一是`socket`,這個選項指定了與Nginx溝通的port是哪個,一共有兩種方法,一種是像上面使用file socket,另一種則是使用TCP port,例如我開一個port12345,然後在Nginx也指定port12345,這樣也可以進行溝通,雖然TCP port設定比較簡單(`socket=12345`就可以),不過其實這樣設定的話,溝通的訊號會先經由router繞出去再接回來,很多此一舉,所以還是使用file socket更好。 再來是`home`的設定,如果有使用virtualenv才需要設這個,不然uWSGI會去使用外面的Python環境。 最後就是`wsgi-file`和`callable`的設定,這邊經過上面的解釋很容易就能理解設這個要幹嘛了,就是指定到你要跑的應用程式中。 剩下就是關於server本身的設定,像是需不需要master process,支援幾個process等等。 一切都設定好後我們可以透過以下指令讓uWSGI啟動 ``` $ uwsgi --ini uwsgi.ini ``` 這樣的話我們可以透過uWSGI連進自己寫的server了,基本上跟前面用python指令啟動的感覺差不多,差別在於用python指令啟動的會自動偵測你檔案的修改,如果你修改了你的sourse code,他會自動重啟server,而透過uWSGI啟動的要自己手動關閉再打開才會生效。 ## 架站:Nginx 雖然我們現在有了比較高效能的interface能接入自己的server,但每次連進去都還是要指定port,你看過哪個網站這麼麻煩的嗎?所以這邊我們透過Nginx來做反向代理,把每個從port 80(HTTP預設port)連進來的request自動導向我們指定的port。 首先我們要先安裝Nginx,透過套件管理程式來安裝(homebrew, apt, yum...等等),以ubuntu16.04舉例來說如下: ``` $ sudo apt-get install nginx ``` 裝好後預設的設定檔會放在`/etc/nginx/sites-available`裡的`default`裡,我們直接把這個檔案蓋掉換成自己寫的檔案,檔案內容如下所示: ``` server { listen 80 default; server_name 1.2.3.4; location / { include uwsgi_params; uwsgi_pass unix:/tmp/short_url.sock; uwsgi_param UWSGI_PYHOME /home/jason/Desktop/short_url/env; uwsgi_param UWSGI_CHDIR /home/jason/Desktop/short_url; uwsgi_param UWSGI_SCRIPT start_server:app; } } ``` 主要要注意的就是`listen`與`server_name`,這邊就設定你要監聽的port跟你server的public IP或domain name,通常域名都是要錢的,所以我做個小專案就用IP而已,再來就是要在`uwsgi_pass`那邊指到剛剛在uWSGI設定的file socket,這樣才能跟uWSGI溝通,要特別注意的是這邊的設定都必須使用絕對路徑。 每次設定完Nginx後記得都要重啟Nginx的服務,透過以下指令執行: ``` $ sudo service nginx restart ``` 現在連到你的server再也不用指定port囉,感覺真舒服!透過以下的指令就能得到一個縮短後的網址,把得到的網址貼到瀏覽器就會連到google了。 ``` $ curl http://1.2.3.4/shortURL -d "http://google.com" ``` 或是使用指定的url_key,使用的方法如下: ``` $ curl http://1.2.3.4/specify/aaaaa -d "http://yahoo.com" ``` 如果aaaaa這個key沒有被使用過的話,我們就能得到連到yahoo的短網址就會是`http://1.2.3.4/aaaaa` ## 問題:沒辦法從外網連進你的Server該怎麼辦: 這邊紀錄了自己遇到的問題,因為最後的解答跟Flask,uWSGI,Nginx這些東西都沒有相關,所以遇到問題的時候真的是不知所措(因為正常會以為這三個東西有哪裡設定錯了)。 ### Nginx的檢查 首先當然要先檢查你的Nginx有沒有正常運作,所謂正常運作第一就是Nginx的state要是active的,再來就是Nginx要乖乖的監聽port80,我們可以藉由以下指令來確認Nginx的運作狀況 ``` $ service nginx status ``` 按照我前面uWSGI設定1個Master Process+4個Process,如果Nginx運作正常,應該會看到綠色的Active(Running)加上4+1個Process的圖示。 再來是確認有沒有乖乖監聽port80,透過`netstat`來確認: ``` $ sudo netstat -tanpl|grep nginx ``` 如果Nginx有在做事的話,會顯示如下: ``` tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 6827/nginx -g daemo ``` 如果以上確認都沒問題,那就不會是Nginx這邊的問題了,來看看其他可能。 ### 防火牆 接下來會想到的就是防火牆把外來的port80 request擋下了,先確定一下防火牆的狀態: ``` $ sudo ufw status ``` 如果有看到Nginx HTTP在裡面代表防火牆有開啟,如果沒有那要把他打開,聰明的防火牆已經知道HTTP就代表port80的請求,所以我們可以透過以下指令打開port80: ``` $ sudo ufw allow http ``` 打開後記得再用`status`確認一遍,如果打開了還是不能連入,那就是別的問題了 ### LAN Server port forwarding 因為我一開始是使用家裡的電腦架的,而家裡電腦都沒有固定的public IP,是透過種花的小烏龜接過來,所以我的電腦看到的IP都會是127開頭的內網,這邊我就查了一下要怎麼從外網連進LAN server,於是查到了一個名詞port forwarding,大概就是說讓router可以把請求轉發給目標的IP(不過要這個router有支援)。這邊的設定就全是在router裡面了,透過`192.168.0.1`連進你的router,開始接下來的設定。 由於家裡的router不是只有我一個人使用,還有我爸的電腦,Wifi等等,而每次重新連接時都會重新分配一個IP給user,我們可不希望每次要forward的目標IP都不同,所以要先做MAC address與IP的綁定,這個設定應該會在"ARP綁定"的選項中,他可以讓每個MAC每次拿到的IP位址是一樣的,設定完後我們就可以確定這台電腦每次拿到的內網IP是固定的。 接下來就是設定port forwarding了,我們到"通訊阜導向"去設定,把外部跟內部通訊阜設成80,導向的IP位址設為自己剛剛設定的固定內網IP這樣就完成了!就我自己的例子來說,完成port forwarding後就可以從外網連入了。 ## 心得 自己本身對這部份本來就完全沒有相關經驗,做專案的過程中也受到了很多高手的幫忙與啟發,雖然最後不一定能成功進入S公司,但學習的過程也是挺有趣的!這邊是[專案的github](https://github.com/jason88012/myShortURL),這份專案我自知還有很多可以改進的地方,例如分散式架構,分散系統的資料庫處理,分散系統的負載平衡等等,如果之後有機會再學起來。