項目中有一處需求,需要把長網址縮為短網址,把結果通過短信、微信等渠道推送給客戶。剛開始直接使用網上現成的開放服務,然后在某個周末突然手癢想自己動手實現一個別具特色的長網址(文本)縮短服務。
由於以前做過socket服務,對數據包的封裝排列還有些印象,因此,短網址服務我第一反應是先設計數據的存儲格式,我這里沒有采用數據庫,而是使用2個文件來實現:
Url.db存儲用戶提交的長網址文本,Url.idx 存儲數據索引,記錄每次提交數據的位置(Begin)與長度(Length),還有一些附帶信息(Hits,DateTime)。由於每次添加長網址,對兩個文件都是進行Append操作,因此即使這兩個文件體積很大(比如若干GB),也沒有太大的IO壓力。
再看看Url.idx文件的結構,ID是主鍵,設為Int64類型,轉換為字節數組后的長度為8,緊跟的是Begin,該值是把長網址數據續寫到Url.db文件之前,Url.db文件的長度,同樣設為Int64類型。長網址的字符串長度有限,Int16足夠使用了,Int16.MaxValue==65536,比Url規范定義的4Kb長度還大,Int16轉換為字節數組后長度為2字節。Hits表示短網址的解析次數,設為Int32,字節長度為4,DateTime 設為Int64,長度8。由於ID不會像數據庫那樣自動遞增,因此需要手工實現。因此在開始寫入Url.idx前,需要預先讀取最后一行(行是虛的,其實就是最后30字節)中的的ID值,遞增后才開始寫入新的一行。
也就是說每次提交一個長網址,不管數據有多長(最大不能超過65536字節),Url.idx 文件都固定增加 30 字節。
數據結構一旦明確下來,整個網址縮短服務就變得簡單明了。例如連續兩次提交長網址,可能得到的短網址為http://域名/1000,與http://域名/1001,結果顯然很丑陋,域名后面的ID全是數字,而且遞增關系明顯,很容易暴力枚舉全部的數據。而且10進制的數字容量有限,一次提交100萬條的長網址,產生的短網址越來越長,失去意義。
因此下面就開始對ID進行改造,改造的目標有2:
1、增加混淆機制,相鄰兩個ID表面上看不出區別。
2、增加容量,一次性提交100萬條長網址,ID的長度不能有明顯變化。
最簡單最直接的混淆機制,就是把10進制轉換為62進制(0-9a-zA-Z),由於順序的abcdef...也很容易猜到下一個ID,因此62進制字符序列隨機排列一次:

1 /// <summary> 2 /// 生成隨機的0-9a-zA-Z字符串 3 /// </summary> 4 /// <returns></returns> 5 public static string GenerateKeys() 6 { 7 string[] Chars = "0,1,2,3,4,5,6,7,8,9,A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z".Split(','); 8 int SeekSeek = unchecked((int)DateTime.Now.Ticks); 9 Random SeekRand = new Random(SeekSeek); 10 for (int i = 0; i < 100000; i++) 11 { 12 int r = SeekRand.Next(1, Chars.Length); 13 string f = Chars[0]; 14 Chars[0] = Chars[r - 1]; 15 Chars[r - 1] = f; 16 } 17 return string.Join("", Chars); 18 }
運行一次上面的方法,得到隨機序列:
string Seq = "s9LFkgy5RovixI1aOf8UhdY3r4DMplQZJXPqebE0WSjBn7wVzmN2Gc6THCAKut";
用這個序列字符串替代0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ,具有很強的混淆特性。一個10進制的數字按上面的序列轉換為62進制,將變得面目全非,附轉換方法:
1 /// <summary> 2 /// 10進制轉換為62進制 3 /// </summary> 4 /// <param name="id"></param> 5 /// <returns></returns> 6 private static string Convert(long id) 7 { 8 if (id < 62) 9 { 10 return Seq[(int)id].ToString(); 11 } 12 int y = (int)(id % 62); 13 long x = (long)(id / 62); 14 15 return Convert(x) + Seq[y]; 16 } 17 18 /// <summary> 19 /// 將62進制轉為10進制 20 /// </summary> 21 /// <param name="Num"></param> 22 /// <returns></returns> 23 private static long Convert(string Num) 24 { 25 long v = 0; 26 int Len = Num.Length; 27 for (int i = Len - 1; i >= 0; i--) 28 { 29 int t = Seq.IndexOf(Num[i]); 30 double s = (Len - i) - 1; 31 long m = (long)(Math.Pow(62, s) * t); 32 v += m; 33 } 34 return v; 35 }
例如執行 Convert(123456789) 得到 RYswX,執行 Convert(123456790) 得到 RYswP。
如果通過分析大量的連續數值,還是可以暴力算出上面的Seq序列值,進而猜測到某個ID左右兩邊的數值。下面進一步強化混淆,ID每次遞增的單位不是固定的1,而是一個隨機值,比如1000,1005,1013,1014,1020,毫無規律可言。
private static Int16 GetRnd(Random seekRand) { Int16 s = (Int16)seekRand.Next(1, 11); return s; }
即使把62進制的值逆向計算出10進制的ID值,也難於猜測到左右兩邊的值,大大增加暴力枚舉的難度。難度雖然增加,但是連續產生的2個62進制值如前面的RyswX與RyswP,僅個位數不同,還是很像,因此我們再進行第三次簡單的混淆,把62進制字符向左(右)旋轉一定次數(解析時反向旋轉同樣的次數):
1 /// <summary> 2 /// 混淆id為字符串 3 /// </summary> 4 /// <param name="id"></param> 5 /// <returns></returns> 6 private static string Mixup(long id) 7 { 8 string Key = Convert(id); 9 int s = 0; 10 foreach (char c in Key) 11 { 12 s += (int)c; 13 } 14 int Len = Key.Length; 15 int x = (s % Len); 16 char[] arr = Key.ToCharArray(); 17 char[] newarr = new char[arr.Length]; 18 Array.Copy(arr, x, newarr, 0, Len - x); 19 Array.Copy(arr, 0, newarr, Len - x, x); 20 string NewKey = ""; 21 foreach (char c in newarr) 22 { 23 NewKey += c; 24 } 25 return NewKey; 26 } 27 28 /// <summary> 29 /// 解開混淆字符串 30 /// </summary> 31 /// <param name="Key"></param> 32 /// <returns></returns> 33 private static long UnMixup(string Key) 34 { 35 int s = 0; 36 foreach (char c in Key) 37 { 38 s += (int)c; 39 } 40 int Len = Key.Length; 41 int x = (s % Len); 42 x = Len - x; 43 char[] arr = Key.ToCharArray(); 44 char[] newarr = new char[arr.Length]; 45 Array.Copy(arr, x, newarr, 0, Len - x); 46 Array.Copy(arr, 0, newarr, Len - x, x); 47 string NewKey = ""; 48 foreach (char c in newarr) 49 { 50 NewKey += c; 51 } 52 return Convert(NewKey); 53 }
執行 Mixup(123456789)得到wXRYs,假如隨機遞增值為7,則下一條記錄的ID執行 Mixup(123456796)得到swWRY,肉眼上很難再聯想到這兩個ID值是相鄰的。
以上講述了數據結構與ID的混淆機制,下面講述的是短網址的解析機制。
得到了短網址,如wXRYs,我們可以通過上面提供的UnMixup()方法,逆向計算出ID值,由於ID不是遞增步長為1的數字,因此不能根據ID馬上計算出記錄在索引文件中的位置(如:ID * 30)。由於ID是按小到大的順序排列,因此在索引文件中定位ID,非二分查找法莫屬。
1 //二分法查找的核心代碼片段 2 FileStream Index = new FileStream(IndexFile, FileMode.OpenOrCreate, FileAccess.ReadWrite); 3 long Id =;//解析短網址得到的真實ID 4 long Left = 0; 5 long Right = (long)(Index.Length / 30) - 1; 6 long Middle = -1; 7 while (Left <= Right) 8 { 9 Middle = (long)(Math.Floor((double)((Right + Left) / 2))); 10 if (Middle < 0) break; 11 Index.Position = Middle * 30; 12 Index.Read(buff, 0, 8); 13 long val = BitConverter.ToInt64(buff, 0); 14 if (val == Id) break; 15 if (val < Id) 16 { 17 Left = Middle + 1; 18 } 19 else 20 { 21 Right = Middle - 1; 22 } 23 } 24 25 Index.Close();
二分法查找的核心是不斷移動指針,讀取中間的8字節,轉換為數字后再與目標ID比較的過程。這是一個非常高速的算法,如果有接近43億條短網址記錄,查找某一個ID,最多只需要移動32次指針(上面的while循環32次)就能找到結果,因為2^32=4294967296。
用二分法查找是因為前面使用了隨機遞增步長,如果遞增步長設為1,則二分法可免,直接從 ID*30 就能一次性精准定位到索引文件中的位置。
下面是完整的代碼,封裝了一個ShortenUrl類:

1 using System; 2 using System.Linq; 3 using System.Web; 4 using System.IO; 5 using System.Text; 6 7 /// <summary> 8 /// ShortenUrl 的摘要說明 9 /// </summary> 10 public class ShortenUrl 11 { 12 const string Seq = "s9LFkgy5RovixI1aOf8UhdY3r4DMplQZJXPqebE0WSjBn7wVzmN2Gc6THCAKut"; 13 14 private static string DataFile 15 { 16 get { return HttpContext.Current.Server.MapPath("/Url.db"); } 17 } 18 19 private static string IndexFile 20 { 21 get { return HttpContext.Current.Server.MapPath("/Url.idx"); } 22 } 23 24 /// <summary> 25 /// 批量添加網址,按順序返回Key。如果輸入的一組網址中有不合法的元素,則返回數組的相同位置(下標)的元素將為null。 26 /// </summary> 27 /// <param name="Url"></param> 28 /// <returns></returns> 29 public static string[] AddUrl(string[] Url) 30 { 31 FileStream Index = new FileStream(IndexFile, FileMode.OpenOrCreate, FileAccess.ReadWrite); 32 FileStream Data = new FileStream(DataFile, FileMode.Append, FileAccess.Write); 33 Data.Position = Data.Length; 34 DateTime Now = DateTime.Now; 35 byte[] dt = BitConverter.GetBytes(Now.ToBinary()); 36 int _Hits = 0; 37 byte[] Hits = BitConverter.GetBytes(_Hits); 38 string[] ResultKey = new string[Url.Length]; 39 int seekSeek = unchecked((int)Now.Ticks); 40 Random seekRand = new Random(seekSeek); 41 string Host = HttpContext.Current.Request.Url.Host.ToLower(); 42 byte[] Status = BitConverter.GetBytes(true); 43 //index: ID(8) + Begin(8) + Length(2) + Hits(4) + DateTime(8) = 30 44 for (int i = 0; i < Url.Length && i<1000; i++) 45 { 46 if (Url[i].ToLower().Contains(Host) || Url[i].Length ==0 || Url[i].Length > 4096) continue; 47 long Begin = Data.Position; 48 byte[] UrlData = Encoding.UTF8.GetBytes(Url[i]); 49 Data.Write(UrlData, 0, UrlData.Length); 50 byte[] buff = new byte[8]; 51 long Last; 52 if (Index.Length >= 30) //讀取上一條記錄的ID 53 { 54 Index.Position = Index.Length - 30; 55 Index.Read(buff, 0, 8); 56 Index.Position += 22; 57 Last = BitConverter.ToInt64(buff, 0); 58 } 59 else 60 { 61 Last = 1000000; //起步ID,如果太小,生成的短網址會太短。 62 Index.Position = 0; 63 } 64 long RandKey = Last + (long)GetRnd(seekRand); 65 byte[] BeginData = BitConverter.GetBytes(Begin); 66 byte[] LengthData = BitConverter.GetBytes((Int16)(UrlData.Length)); 67 byte[] RandKeyData = BitConverter.GetBytes(RandKey); 68 69 Index.Write(RandKeyData, 0, 8); 70 Index.Write(BeginData, 0, 8); 71 Index.Write(LengthData, 0, 2); 72 Index.Write(Hits, 0, Hits.Length); 73 Index.Write(dt, 0, dt.Length); 74 ResultKey[i] = Mixup(RandKey); 75 } 76 Data.Close(); 77 Index.Close(); 78 return ResultKey; 79 } 80 81 /// <summary> 82 /// 按順序批量解析Key,返回一組長網址。 83 /// </summary> 84 /// <param name="Key"></param> 85 /// <returns></returns> 86 public static string[] ParseUrl(string[] Key) 87 { 88 FileStream Index = new FileStream(IndexFile, FileMode.OpenOrCreate, FileAccess.ReadWrite); 89 FileStream Data = new FileStream(DataFile, FileMode.Open, FileAccess.Read); 90 byte[] buff = new byte[8]; 91 long[] Ids = Key.Select(n => UnMixup(n)).ToArray(); 92 string[] Result = new string[Ids.Length]; 93 long _Right = (long)(Index.Length / 30) - 1; 94 for (int j = 0; j < Ids.Length; j++) 95 { 96 long Id = Ids[j]; 97 long Left = 0; 98 long Right = _Right; 99 long Middle = -1; 100 while (Left <= Right) 101 { 102 Middle = (long)(Math.Floor((double)((Right + Left) / 2))); 103 if (Middle < 0) break; 104 Index.Position = Middle * 30; 105 Index.Read(buff, 0, 8); 106 long val = BitConverter.ToInt64(buff, 0); 107 if (val == Id) break; 108 if (val < Id) 109 { 110 Left = Middle + 1; 111 } 112 else 113 { 114 Right = Middle - 1; 115 } 116 } 117 string Url = null; 118 if (Middle != -1) 119 { 120 Index.Position = Middle * 30 + 8; //跳過ID 121 Index.Read(buff, 0, buff.Length); 122 long Begin = BitConverter.ToInt64(buff, 0); 123 Index.Read(buff, 0, buff.Length); 124 Int16 Length = BitConverter.ToInt16(buff, 0); 125 byte[] UrlTxt = new byte[Length]; 126 Data.Position = Begin; 127 Data.Read(UrlTxt, 0, UrlTxt.Length); 128 int Hits = BitConverter.ToInt32(buff, 2);//跳過2字節的Length 129 byte[] NewHits = BitConverter.GetBytes(Hits + 1);//解析次數遞增, 4字節 130 Index.Position -= 6;//指針撤回到Length之后 131 Index.Write(NewHits, 0, NewHits.Length);//覆蓋老的Hits 132 Url = Encoding.UTF8.GetString(UrlTxt); 133 } 134 Result[j] = Url; 135 } 136 Data.Close(); 137 Index.Close(); 138 return Result; 139 } 140 141 /// <summary> 142 /// 混淆id為字符串 143 /// </summary> 144 /// <param name="id"></param> 145 /// <returns></returns> 146 private static string Mixup(long id) 147 { 148 string Key = Convert(id); 149 int s = 0; 150 foreach (char c in Key) 151 { 152 s += (int)c; 153 } 154 int Len = Key.Length; 155 int x = (s % Len); 156 char[] arr = Key.ToCharArray(); 157 char[] newarr = new char[arr.Length]; 158 Array.Copy(arr, x, newarr, 0, Len - x); 159 Array.Copy(arr, 0, newarr, Len - x, x); 160 string NewKey = ""; 161 foreach (char c in newarr) 162 { 163 NewKey += c; 164 } 165 return NewKey; 166 } 167 168 /// <summary> 169 /// 解開混淆字符串 170 /// </summary> 171 /// <param name="Key"></param> 172 /// <returns></returns> 173 private static long UnMixup(string Key) 174 { 175 int s = 0; 176 foreach (char c in Key) 177 { 178 s += (int)c; 179 } 180 int Len = Key.Length; 181 int x = (s % Len); 182 x = Len - x; 183 char[] arr = Key.ToCharArray(); 184 char[] newarr = new char[arr.Length]; 185 Array.Copy(arr, x, newarr, 0, Len - x); 186 Array.Copy(arr, 0, newarr, Len - x, x); 187 string NewKey = ""; 188 foreach (char c in newarr) 189 { 190 NewKey += c; 191 } 192 return Convert(NewKey); 193 } 194 195 /// <summary> 196 /// 10進制轉換為62進制 197 /// </summary> 198 /// <param name="id"></param> 199 /// <returns></returns> 200 private static string Convert(long id) 201 { 202 if (id < 62) 203 { 204 return Seq[(int)id].ToString(); 205 } 206 int y = (int)(id % 62); 207 long x = (long)(id / 62); 208 209 return Convert(x) + Seq[y]; 210 } 211 212 /// <summary> 213 /// 將62進制轉為10進制 214 /// </summary> 215 /// <param name="Num"></param> 216 /// <returns></returns> 217 private static long Convert(string Num) 218 { 219 long v = 0; 220 int Len = Num.Length; 221 for (int i = Len - 1; i >= 0; i--) 222 { 223 int t = Seq.IndexOf(Num[i]); 224 double s = (Len - i) - 1; 225 long m = (long)(Math.Pow(62, s) * t); 226 v += m; 227 } 228 return v; 229 } 230 231 /// <summary> 232 /// 生成隨機的0-9a-zA-Z字符串 233 /// </summary> 234 /// <returns></returns> 235 public static string GenerateKeys() 236 { 237 string[] Chars = "0,1,2,3,4,5,6,7,8,9,A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z".Split(','); 238 int SeekSeek = unchecked((int)DateTime.Now.Ticks); 239 Random SeekRand = new Random(SeekSeek); 240 for (int i = 0; i < 100000; i++) 241 { 242 int r = SeekRand.Next(1, Chars.Length); 243 string f = Chars[0]; 244 Chars[0] = Chars[r - 1]; 245 Chars[r - 1] = f; 246 } 247 return string.Join("", Chars); 248 } 249 250 /// <summary> 251 /// 返回隨機遞增步長 252 /// </summary> 253 /// <param name="SeekRand"></param> 254 /// <returns></returns> 255 private static Int16 GetRnd(Random SeekRand) 256 { 257 Int16 Step = (Int16)SeekRand.Next(1, 11); 258 return Step; 259 } 260 }
本方案的優點:
把10進制的ID轉換為62進制的字符,6位數的62進制字符容量為 62^6約為568億,如果每次隨機遞增值為1~10(取平均值為5),6位字符的容量仍然能容納113.6億條!這個數據已經遠遠大於一般的數據庫承受能力。由於每次提交長網址采用Append方式寫入,因此寫入性能也不會差。在解析短網址時由於采用二分法查找,僅移動文件指針與讀取8字節的緩存,性能上依然非常優秀。
缺點:在高並發的情況下,可能會出現文件打開失敗等IO異常,如果改用單線程的Node.js來實現,或許可以杜絕這種情況。
廣告一下,短網址實際應用案例:http://urlj.cn,開放API,生成二維碼,可以在微信上面玩。