實現簡單高效的網址(文本)縮短服務


項目中有一處需求,需要把長網址縮為短網址,把結果通過短信、微信等渠道推送給客戶。剛開始直接使用網上現成的開放服務,然后在某個周末突然手癢想自己動手實現一個別具特色的長網址(文本)縮短服務。

由於以前做過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     }
View Code

運行一次上面的方法,得到隨機序列:

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 }
View Code

 

本方案的優點:

把10進制的ID轉換為62進制的字符,6位數的62進制字符容量為 62^6約為568億,如果每次隨機遞增值為1~10(取平均值為5),6位字符的容量仍然能容納113.6億條!這個數據已經遠遠大於一般的數據庫承受能力。由於每次提交長網址采用Append方式寫入,因此寫入性能也不會差。在解析短網址時由於采用二分法查找,僅移動文件指針與讀取8字節的緩存,性能上依然非常優秀。

缺點:在高並發的情況下,可能會出現文件打開失敗等IO異常,如果改用單線程的Node.js來實現,或許可以杜絕這種情況。

廣告一下,短網址實際應用案例:http://urlj.cn,開放API,生成二維碼,可以在微信上面玩。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM