2013年5月30日 星期四

精進你的程式碼 - 從取得用戶端 IP 的函式談起

http://www.jaceju.net/blog/archives/1913/

我想多數從事程式開發的朋友,都曾經在遇到問題時,直接上相關的論壇去詢問前輩,或是想辦法去找尋有沒有人碰過同樣的問題。而幸運的是,通常在網路上也可以找到很多前人所提供的解決方案,甚至有寫好的程式碼供後人參考。 但是很多人都是直接把這些程式碼直接複製過來使用,很少會去探討其中的原理。只要能解決問題,有誰會去在乎程式碼到底是圓是扁呢?然而這就是許多程式開發者無法精進自己能力的主要原因之一。

當然我本身也是曾經是這種人,在不瞭解原理的狀況下,對於能夠運作的程式碼,也曾經抱持著能不改就別改的心態。但是在技術這條路上,如果一直用這種態度去處理問題,那麼跟沒有思考能力的機器人又有什麼兩樣呢? 來吧!我們就從一個最常見的問題開始,一起來瞭解怎樣才能寫出一個所謂「好」的程式碼。

如何取得用戶端 IP ?

大部份從事 Web 開發的朋友,一定都會遇到需要取得用戶端 IP 的狀況。而在 PHP 中,透過 Google 去搜尋的話,也可以找到很多解決方案。 以下的程式碼應該是最常見的解法:
1
2
3
4
function get_client_ip()
{
return $_SERVER['REMOTE_ADDR'];
}
然而這個方法有個問題,那就是 $SERVER['REMOTE_ADDR'] 有可能不是真實的用戶端 IP 。 原因是用戶可能是在防火牆之後,也可能是透過 Proxy 上網,而使得 $SERVER['REMOTE_ADDR'] 的值可能不是用戶真實的 IP ;總而言之,我們並不能完全相信 $_SERVER['REMOTE_ADDR'] 所回傳的值。 那該怎麼辦呢?網路上又有了這樣的解決方案:
1
2
3
4
5
6
7
8
9
10
11
function get_client_ip()
{
if ($_SERVER['HTTP_CLIENT_IP']) { // check ip from share internet
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif ($_SERVER['HTTP_X_FORWARDED_FOR']) { // to check ip is pass from proxy
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
} else {
$ip = $_SERVER['REMOTE_ADDR'];
}
return $ip;
}
其中 HTTP_CLIENT_IP 這個 HTTP Header 表示用戶可能是在防火牆之後,或是在透過 IP 分享器來上網;而 HTTP_X_FORWARDED_FOR 則表示用戶可能是透過 Proxy 過來的。這兩個 HTTP Headers 都會將用戶真實的 IP 記錄下來,轉交給 Web Server 處理。 而在這兩個 HTTP Headers 都沒有的狀況下,我們就只好選擇 $_SERVER['REMOTE_ADDR'] 這個一定會有的值,因為它可能就是用戶真實的 IP 了。

PHP 嚴格開發模式

一般在開發時,我們都會把 PHP 設定成嚴格開發模式來讓程式儘可能不要有基本的錯誤發生,像是變數未定義就使用,陣列索引不存在等等,以確保我們的程式在任何設定下都可以順利執行。 設定嚴格開發模式的方法如下:
1
error_reporting(E_ALL | E_STRICT);
身為一個合格的 PHP 開發人員所開發出來的程式,至少要在這種嚴格的環境中執行,而不會發生基本的錯誤。 然而在打開 PHP 嚴格開發模式後,前面的範例在 PHP 嚴格開發模式下可能會出現 “Undefined index: HTTP_CLIENT_IP in xxx.php on line xx” 的警告訊息,這是因為 HTTP_CLIENT_IP 及 HTTP_X_FORWARDED_FOR 這兩個 HTTP Headers 其實是有可能不會設定在 $SERVER 中的。 可是我們不是有判斷這兩個 HTTP Headers 的值是否存在嗎?是的,只是我們是判斷它們的「值」是否存在,而不是判斷它們是否存在於 $SERVER 陣列之中。正確的方法是要以 empty 敘述來判斷這兩個 HTTP Headers 的索引是否存在於 $_SERVER 陣列裡:
1
2
3
4
5
6
7
8
9
10
11
function get_client_ip()
{
if (!empty($_SERVER['HTTP_CLIENT_IP'])) { // check ip from share internet
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { // to check ip is pass from proxy
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
} else {
$ip = $_SERVER['REMOTE_ADDR'];
}
return $ip;
}
註:至於為什麼 $_SERVER 中有可能不會包含 HTTP_CLIENT_IP 及 HTTP_X_FORWARDED_FOR 這兩個 HTTP Headers 呢?這其實跟 Web/Proxy Server 與網路設備的運作方式有關,這裡就不再深入討論。

其他有用的 HTTP Headers

事實上,除了 HTTP_CLIENT_IP 與 HTTP_X_FORWARDED_FOR 之外,還有其實可能會是用戶實際 IP 的 HTTP Headers ,分別是:
  • HTTP_X_FORWARDED
  • HTTP_X_CLUSTER_CLIENT_IP
  • HTTP_FORWARDED_FOR
  • HTTP_FORWARDED
因此我們就必須得再多判斷這些 HTTP Headers :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function get_client_ip()
{
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED'])) {
$ip = $_SERVER['HTTP_X_FORWARDED'];
} elseif (!empty($_SERVER['HTTP_X_CLUSTER_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_X_CLUSTER_CLIENT_IP'];
} elseif (!empty($_SERVER['HTTP_FORWARDED_FOR'])) {
$ip = $_SERVER['HTTP_FORWARDED_FOR'];
} elseif (!empty($_SERVER['HTTP_FORWARDED'])) {
$ip = $_SERVER['HTTP_FORWARDED'];
} else {
$ip = $_SERVER['REMOTE_ADDR'];
}
return $ip;
}
但現實上它們的值還是有可能並非用戶實際的 IP ,因此僅供參考用。
註:很可惜的是,我還沒找到這些 HTTP Headers 的完整解釋,只能從字面上去猜測。如果大家有找到它們所代表的意義,還請不吝告知。

判斷是否為合法的 IP

基於從用戶端所傳過來的值都是不可信任的原則,我們還是要對上述的這些 IP 做合法性驗證;在 PHP 5.2 之後,我們可以透過 filter_var 方法來處理:
1
2
3
4
5
6
7
8
function validate_ip($ip)
{
return (bool) filter_var($ip, FILTER_VALIDATE_IP,
FILTER_FLAG_IPV4 |
FILTER_FLAG_NO_PRIV_RANGE |
FILTER_FLAG_NO_RES_RANGE
);
}
其中 FILTER_VALIDATE_IP 表示我們要過濾的是 IP 值,而 FILTER_FLAG_* 的意義可以參考 Filter flagsfilter_var 函式會回傳驗證過後的值,如果驗證不過,就回傳 false 。因此我們就可以改寫我們的 get_client_ip 函式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function get_client_ip()
{
if (!empty($_SERVER['HTTP_CLIENT_IP']) && validate_ip($_SERVER['HTTP_CLIENT_IP'])) {
return $_SERVER['HTTP_CLIENT_IP'];
}
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']) && validate_ipSERVER['HTTP_X_FORWARDED_FOR'])) {
return $_SERVER['HTTP_X_FORWARDED_FOR'];
}
if (!empty($_SERVER['HTTP_X_FORWARDED']) && validate_ip($_SERVER['HTTP_X_FORWARDED'])) {
return $_SERVER['HTTP_X_FORWARDED'];
}
if (!empty($_SERVER['HTTP_X_CLUSTER_CLIENT_IP']) && validate_ip($_SERVER['HTTP_X_CLUSTER_CLIENT_IP'])) {
return $_SERVER['HTTP_X_CLUSTER_CLIENT_IP'];
}
if (!empty($_SERVER['HTTP_FORWARDED_FOR']) && validate_ip($_SERVER['HTTP_FORWARDED_FOR'])) {
return $_SERVER['HTTP_FORWARDED_FOR'];
}
if (!empty($_SERVER['HTTP_FORWARDED']) && validate_ip($_SERVER['HTTP_FORWARDED'])) {
return $_SERVER['HTTP_FORWARDED'];
}
return $_SERVER['REMOTE_ADDR'];
}
這裡另外改寫的部份就是直接把驗證無誤的 IP 值回傳給函式呼叫者,這樣一來我們就不需要再加上一個暫存變數 $ip 了。
註:也許有人會對一個函式中有多個 return 出口感到不舒服,但在這裡確實是個避免後續程式繼續執行的好方法,這在重構中稱為 Guard Clauses

HTTP_X_FORWARDED_FOR 可能的格式

前面的程式碼看起來很長對吧?沒關係,晚點我們會回頭過來處理它;現在要先來看看一個比較重要的問題。 根據 Wiki 對 X-Forwarded-For 這個 HTTP Header 的描述,它的格式有可能是:
1
client1, proxy1, proxy2
換句話說,我們必須對它預先以處理才能得到用戶的 IP ,方法是以 explode 來拆開每組 IP ,程式碼如下:
1
2
3
4
5
6
7
8
9
// 略
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
foreach (explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']) as $ip) {
$ip = trim($ip); // just to be safe
if (validate_ip($ip)) {
return $ip;
}
}
}
將上面的片段加到原有的程式碼後,整個函式就更顯得落落長;雖然可以用,但是如果這樣就滿足,那我們真的就只是程式碼轉貼手了。

精簡的寫法

接下來我們要想辦法把一長串的 if…elseif 簡化,也可以避免寫一堆的陣列元素。既然這些值都是在 $_SERVER 中,那麼我們就可以用迴圈來一一將它們代入:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function get_client_ip()
{
foreach (array(
'HTTP_CLIENT_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED',
'HTTP_X_CLUSTER_CLIENT_IP',
'HTTP_FORWARDED_FOR',
'HTTP_FORWARDED',
'REMOTE_ADDR') as $key) {
if (array_key_exists($key, $_SERVER)) {
// 處理 IP
}
}
return null;
}
相信這樣的轉換應該不難懂,這樣的程式碼雖然比不上其他程式語言的語法來的漂亮,但已經比我們原來的那段範例好看太多了。 至於怎麼處理 IP 呢?除了 HTTP_X_FORWARDED_FOR 需要以 explode 函式處理,其他的值應該都不需要處理呀?我們可以把它們放在一起嗎? 其實是可以的,我們可以把非 HTTP_X_FORWARDED_FOR 的其他值也用 explode 函式過個水,然後會得到只有一個元素的陣列;這樣一來,我們就不需要再用 if…else 判斷格式上的差異,直接都以陣列來處理即可。 最後完整的程式碼如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function get_client_ip()
{
foreach (array(
'HTTP_CLIENT_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED',
'HTTP_X_CLUSTER_CLIENT_IP',
'HTTP_FORWARDED_FOR',
'HTTP_FORWARDED',
'REMOTE_ADDR') as $key) {
if (array_key_exists($key, $_SERVER)) {
foreach (explode(',', $_SERVER[$key]) as $ip) {
$ip = trim($ip);
if ((bool) filter_var($ip, FILTER_VALIDATE_IP,
FILTER_FLAG_IPV4 |
FILTER_FLAG_NO_PRIV_RANGE |
FILTER_FLAG_NO_RES_RANGE)) {
return $ip;
}
}
}
}
return null;
}
跟前面的程式碼比起來,你是否也覺得最後的這段程式碼比較容易瞭解與維護呢?
註:其實最後這段程式碼也是網路上找得到的,而我在這裡只是讓大家瞭解為什麼高手要這樣精簡程式碼。

心得

在取得用戶實際 IP 這個很常見的功能中,其實也蘊涵了很重要的網路知識,雖然不見得我們能完全瞭解這些 HTTP Headers 的由來與運作方式,但至少也要知道為什麼要把它們加進來的原因。 而在語法上的重構,也是我們程式精進上一個很重要的技能;它能反映出你是否真正瞭解這門程式語言,並且知道該怎麼善用它們來增加你工作上的效率。 手邊擁有的工具越多,就更應該熟悉它們;這樣才能適其位盡所能,發揮它們真正的價值。

沒有留言:

張貼留言