一、问题背景
工作中,需要开发一个第三方接口用来同步数据,接口要求数据 ID 为字符串,且最长为16个字符,但是我公司保存数据使用的是Mongodb,其ID是ObjectId类型的,将其实例化为String时是24个字符,显然这个问题需要解决。
解决方法想到了几个:
1.比如再增加一个ID字段专门用于接口,而原有业务仍使用ObjectId;
2.或者另外再增加一个ID映射表。
3.将24位OjbectId截取某一段。
无疑前两种解决方案都需要在原有代码上进行修改,不管是额外维护一个字段还是一张表,都是非常麻烦的。而第三种方案会遇到一个问题就是两种ID无法互相转换。完美的解决方案是将24位的ObjectId压缩为一个16位或更少的ID,并且可以转换回来。
要压缩OjbectId,就要先了解OjbectId是什么以及是怎么生成的:(参考:http://www.cnblogs.com/xjk15082/archive/2011/09/18/2180792.html )
====================我是引用开始分割线========================
举个例子:OjbectId(“4e7020cb7cac81af7136236b”)这个24位的字符串,实际上它是由一组十六进制的字符构成,每个字节两位的十六进制数字,总共用了12字节的存储空间。官网中对ObjectId的规范,如图所示。
1) Time
时间戳。将objectid的前4位进行提取“4e7020cb”,然后按照十六进制转为十进制,变为“1315971275”,这个数字就是一个时间戳。通过时间戳的转换,就成了易看清的时间格式,如图3所示。
图3 时间戳的转换
2) Machine
机器。接下来的三个字节就是“7cac81”,这三个字节是所在主机的唯一标识符,一般是机器主机名的散列值,这样就确保了不同主机生成不同的机器hash值,确保在分布式中不造成冲突,这也就是在同一台机器生成的objectId中间的字符串都是一模一样的原因。
3) PID
进程ID。上面的Machine是为了确保在不同机器产生的objectId不冲突,而pid就是为了在同一台机器不同的mongodb进程产生了objectId不冲突,接下来的“af71”两位就是产生objectId的进程标识符。
4) INC
自增计数器。前面的九个字节是保证了一秒内不同机器不同进程生成objectId不冲突,这后面的三个字节“36236b”是一个自动增加的计数器,用来确保在同一秒内产生的objectId也不会发现冲突,允许256的3次方等于16777216条记录的唯一性。
总的来看,objectId的前4个字节时间戳,记录了文档创建的时间;接下来3个字节代表了所在主机的唯一标识符,确定了不同主机间产生不同的objectId;后2个字节的进程id,决定了在同一台机器下,不同mongodb进程产生不同的objectId;最后通过3个字节的自增计数器,确保同一秒内产生objectId的唯一性。ObjectId的这个主键生成策略,很好地解决了在分布式环境下高并发情况主键唯一性问题,值得学习借鉴。
====================我是引用结束分割线========================
上面引用了不少,其中有一个非常重要的信息是它是由一组十六进制的字符构成,24个字符,换句话说就是总共占用12字节,也就是96bit,每一个字符占用4bit。
由此,反正总共是96bit,假如用32进制来表示,也就是每5个bit用一个字符来表示,不就可以缩短字符串长度了么,可以缩短到 96 / 5 = 19.2 个字符,悲剧,还是不够。正面算得算到什么时候啊,还是反过来吧,要求是用16个字符来表示这96bit信息,那么一个字符就要能表示 96 / 16 = 6bit 的内容,仔细看看,一个字符表示6bit内容这不就是 2 ^ 6bit = 64 进制么,就像 2 ^ 1bit = 2 进制是用一个字符(‘0’或者‘1’)来表示1bit内容,2 ^ 4bit = 16 进制用一个字符来表示4bit内容么。
16进制需要(0~9 a~f)16个符号来表示,64进制就需要64个符号,(0~9 a~z A~Z)加起来才62个符号,还少两个,可以用标点符号么,这个就要参考Base64编码(Wiki:http://zh.wikipedia.org/wiki/Base64 )了,一句话解释就是“Base64是一种基于64个可打印字符来表示二进制数据的表示方法。”。
还没完,我们的接口中id是放在URL中传输的,标准的Base64并不适合直接放在URL里传输,因为URL编码器会把标准Base64中的“/”和“+”字符变为形如“%XX”的形式,而这些“%”号在存入数据库时还需要再进行转换,因为ANSI SQL中已将“%”号用作通配符。
因此,可采用一种用于URL的改进Base64编码,将标准Base64中的“+”和“/”分别改成了“-”和“_”,这样就免去了在URL编解码和数据库存储时所要作的转换,避免了编码信息长度在此过程中的增加,并统一了数据库、表单等处对象标识符的格式。
二、具体实现
还是以上面的例子来看,ObjectId为“4e7020cb7cac81af7136236b”,由于是将24个字符压缩成16个字符,也就是每3个字符压缩成2个字符,因此我们取前3个字符作为示例:
1、16进制:'4' 'e' '7'
2、其中e表示的是14,所以三个字符转换为2进制形式就是:0100,1110,0111
3、上面每4bit便表示了一个字符,现在要转换成6bit表示一个字符,也就是 (0100<<2)+(1110>>2)=010011,(1110&0x3)<<4+0111=100111
4、通过移位和相加操作,就将3个4bit的信息保存到2个6bit中了,现在将这两个6bit用10进制来表示就是19,39
5、将19,39转换为64进制的字符表示:‘j',’D‘
通过以上步骤就将“4e7”转换为“jD”了,同样的道理,将以上步骤反过来运行就可以将“jD”转回“4e7”了。
重复以上步骤,可以将“4e7020cb7cac81af7136236b”转换为“jD0wOTOIwqZNdydH”。
我相信程序君们更喜欢直接看代码,下面就贴上上述算法的java实现:
/**
* @author xumeng
*
*/
public class ObjectIdConverter {
/**
* 将 64进制编码长度为16位的ID 转换为 16进制编码的长度为24位的ID
* @param qunarHotelId
* @return
*/
public static String unCompressObjectId(String shortId){
if(shortId == null || shortId.length() != 16){
throw new IllegalArgumentException();
}
StringBuilder res = new StringBuilder(24);
char[] str = shortId.toCharArray();
for(int i = 0; i < str.length; i += 2){
int pre = char2Int(str[i]),end = char2Int(str[i+1]);
res.append(int2Char( (pre >> 2) ));
res.append(int2Char( ((pre & 3) << 2) + (end >> 4) ));
res.append(int2Char( end & 15 ));
}
return res.toString();
}
/**
* 将 16进制编码的长度为24位的ID 转换为 64进制编码长度为16位的ID
* @param tdxHotelId
* @return
*/
public static String compressObjectId(String objectId){
if(objectId == null || objectId.length() != 24){
throw new IllegalArgumentException();
}
StringBuilder res = new StringBuilder(16);
char[] str = objectId.toCharArray();
for(int i = 0; i < str.length; i += 3){
int pre = char2Int(str[i]),mid = char2Int(str[i+1]),end = char2Int(str[i+2]);
res.append(int2Char( (pre << 2) + (mid >> 2) ));
res.append(int2Char( ((mid & 3) << 4) + end ));
}
return res.toString();
}
/**
* 支持64进制bit转字符
* 0~9,a~z,A~Z,-,_
* @param i
*/
private static char int2Char(int i){
if(i >= 0 && i <= 9){
return (char) ('0'+i);
}else if(i >= 10 && i <= 35){
return (char) ('a'+i-10);
}else if(i >= 36 && i <= 61){
return (char) ('A'+i-36);
}else if(i == 62){
return '-';
}else if(i == 63){
return '_';
}else{
throw new IllegalArgumentException();
}
}
/**
* 支持64进制字符转bit
* 0~9,a~z,A~Z,-,_
* @param c
*/
private static int char2Int(char c){
if(c >= '0' && c <= '9'){
return c-'0';
}else if(c >= 'a' && c <= 'z'){
return 10+c-'a';
}else if(c >= 'A' && c <= 'Z'){
return 36+c-'A';
}else if(c=='-'){
return 62;
}else if(c=='_'){
return 63;
}else{
throw new IllegalArgumentException();
}
}
}
三、附:位操作符
或操作符:| ,示例:5 | 3 = 0101b | 0011b = 0111b = 7
非操作符:~ ,示例: ~5 = ~0000...0101b = 1111...1010b = -6
异或操作符: ^ ,示例: 5 ^ 3 = 0101b ^ 0011b = 0110b = 6
与操作符: & ,示例: 5 & 3 = 0101b & 0011b = 0001b = 1
左移操作符:<< ,示例:5 << 35 = 0101b << ( 35%32) = 0101b << 3 = 0010 1000 = 40
算数右移:>> ,示例1: 5 >> 2 = 0101b >> 2 = 0001b = 1 ;示例2:-5 >> 2 = 1111...1011b >> 2 = 1111...1110b = -2
逻辑右移:>>> ,示例:-5 >>> 2 = 1111...1011b >>> 2 = 0011...1110b = 1073741822