1.了解MongoDB的ObjectId
MongoDB的文檔固定是使用“_id”作為主鍵的,它可以是任何類型的,默認是個ObjectId對象(在Java中則表現為字符串),那么為什么MongoDB沒有采用其他比較常規的做法(比如MySql的自增主鍵),而是采用了ObjectId的形式來實現?別着急,咱們看看ObjectId的生成方式便可知悉。
ObjectId使用12字節的存儲空間,每個字節兩位十六進制數字,是一個24位的字符串。由於看起來很長,不少人會覺得難以處理,其實不然。ObjectId是由客戶端生成的,按照如下方式生成:
前4位是一個從標准紀元開始的時間戳,是一個int類別,只不過從十進制轉換為了十六進制。這意味着這4個字節隱含了文檔的創建時間,將會帶來一些有用的屬性。並且時間戳處於字符的最前面,同時意味着ObjectId大致會按照插入順序進行排序,這對於某些方面起到很大作用,如作為索引提高搜索效率等等。使用時間戳還有一個好處是,某些客戶端驅動可以通過ObjectId解析出該記錄是何時插入的,這也解答了我們平時快速連續創 建多個Objectid時,會發現前幾位數字很少發現變化的現實,因為使用的是當前時間,很多用戶擔心要對服務器進行時間同步,其實這個時間戳的真實值並 不重要,只要其總不停增加就好。
接下來的3個字節,是所在主機的唯一標識符,一般是機器主機名的散列值,這樣就確保了不同主機生成不同的機器hash值,確保在分布式中不造成沖突,這也就是在同一台機器生成的objectid中間的字符串都是一模一樣的原因。
上面的機器字節是為了確保在不同機器產生的ObjectId不沖突,而PID就是為了在同一台機器不同的mongodb進程產生了ObjectId不沖突。
前面的9個字節是保證了一秒內不同機器不同進程生成ObjectId不沖突,最后的3個字節是一個自動增加的計數器,用來確保在同一秒內產生的ObjectId也不會沖突,允許256的3次方等於16777216條記錄的唯一性。
因此,MongoDB不使用自增主鍵,而是使用ObjectId。在分布式環境中,多個機器同步一個自增ID不但費時且費力,MongoDB從一開始就是設計用來做分布式數據庫的,處理多個節點是一個核心要求,而ObjectId在分片環境中要容易生成的多。
2.手動實現自增ID
ObjectId確實是有很大的好處,但有時候由於某些不可抗力的因素或需求,我們仍需要實現一個自增的數值ID,筆者查閱了網上的資料,大多都是一個套路:使用一個單獨的集合A來記錄每個集合中的ID最大值,然后每次向集合B中插入文檔時,去查找集合A中集合B所對應的ID最大值,取出來並+1,然后更新集合A、根據這個ID再插入文檔。下面筆者通過網上一種自認為好點的方式來實現,因為筆者用的是Spring Data MongoDB……
2.1 定義序列實體類SeqInfo
我們需要用這個集合來存儲每個集合的ID記錄自增到了多少,如下代碼:
package com.jastar.autokey.entity;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
/**
* 模擬序列類
*
* @author Jastar·Wang
* @date 2017年5月27日
*/
@Document(collection = "sequence")
public class SeqInfo {
@Id
private String id;// 主鍵
@Field
private String collName;// 集合名稱
@Field
private Long seqId;// 序列值
// 省略getter、setter
}
2.2 定義注解AutoIncKey
我們需要通過這個注解標識主鍵ID需要自動增長,如下代碼:
package com.jastar.autokey.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 自定義注解,標識主鍵字段需要自動增長
* <p>
* ClassName: AutoIncKey
* </p>
* <p>
* Copyright: (c)2017 Jastar·Wang,All rights reserved.
* </p>
*
* @author jastar-wang
* @date 2017年5月27日
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoIncKey {
}
2.3 定義業務實體類Student
package com.jastar.autokey.entity;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import com.jastar.autokey.annotation.AutoIncKey;
@Document(collection = "student")
public class Student {
@AutoIncKey
@Id
private Long id = 0L;// 為什么賦了默認值?文章后說明
@Field
private String name;
// 省略getter、setter
}
2.4 定義監聽類SaveEventListener
→2017年7月26日更新:
注意下面代碼中重寫的onBeforeConvert方法在1.8版本開始就廢棄了,不過官方推薦: Please use onBeforeConvert(BeforeConvertEvent),各位猿友可以研究下這個方法如何使用,我想 BeforeConvertEvent 對象里面應該會有所需要的參數信息,在此我就不再親測了。
因為使用的是Spring Data MongoDB,所以可以重寫監聽事件里面的方法,而進行某些處理,該類需要繼承AbstractMongoEventListener類,並且需交由Spring管理,如下代碼:
package com.jastar.autokey.listener;
import java.lang.reflect.Field;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.FindAndModifyOptions;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.mapping.event.AbstractMongoEventListener;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;
import com.jastar.autokey.annotation.AutoIncKey;
import com.jastar.autokey.entity.SeqInfo;
/**
* 保存文檔監聽類<br>
* 在保存對象時,通過反射方式為其生成ID
* <p>
* ClassName: SaveEventListener
* </p>
* <p>
* Copyright: (c)2017 Jastar·Wang,All rights reserved.
* </p>
*
* @author jastar-wang
* @date 2017年5月27日
*/
@Component
public class SaveEventListener extends AbstractMongoEventListener<Object> {
@Autowired
private MongoTemplate mongo;
@Override
public void onBeforeConvert(BeforeConvertEvent<Object> event) {
final Object source = event.getSource();
if (source != null) {
ReflectionUtils.doWithFields(source.getClass(), new ReflectionUtils.FieldCallback() {
/** Perform an operation using the given field.
* @param field the field to operate on */
@Override
public void doWith(Field field) throws IllegalArgumentException, IllegalAccessException {
ReflectionUtils.makeAccessible(field);
// 如果字段添加了我們自定義的AutoIncKey注解
if (field.isAnnotationPresent(AutoIncKey.class)
//判斷注解的字段是否為number類型且值是否等於0.如果大於0說明有ID不需要生成ID
&& field.get(source) instanceof Number
&& field.getLong(source) == 0) {
// 設置自增ID
field.set(source, getNextId(source.getClass().getSimpleName()));
logger.debug("increase key, source = {} , nextId = {}", source, field.get(source));
}
}
});
}
}
/**
* 獲取下一個自增ID
*
* @param collName
* 集合(這里用類名,就唯一性來說最好還是存放長類名)名稱
* @return 序列值
*/
private Long getNextId(String collName) {
Query query = new Query(Criteria.where("collName").is(collName));
Update update = new Update();
update.inc("seqId", 1);
FindAndModifyOptions options = new FindAndModifyOptions();
options.upsert(true);
options.returnNew(true);
SeqInfo seq = mongo.findAndModify(query, update, options, SeqInfo.class);
return seq.getSeqId();
}
}
2.5 單元測試
@Test
public void save() {
Student stu = new Student();
stu.setName("張三");
service.save(stu);
// service.update(stu);
System.out.println("已生成ID:" + stu.getId());
}
2.6 總結
經過測試,以上流程沒有問題,會得到期望的結果,但是有以下幾點需要注意:
(1)為什么我在Student類中為主鍵賦了一個默認值0L?
答:我在此自增方式原作者文章中發現這么一句,“注意自增ID的類型不要定義成Long這種包裝類,mongotemplate的源碼里面對主鍵ID的類型有限制”。測試后發現,如果ID定義為原生類型確實是沒有問題的。當ID定義為包裝類的情況下,如果在onBeforeConvert方法之前沒有給ID設置值,是會報錯的,我猜測可能是因為內部轉換類型時如果ID是空值而無法轉換引起的,因此,我賦了一個默認值,這樣就不會報錯了,包裝類也可以使用(不過這樣好像跟原生類型就沒什么區別了,沒什么意義)。
(2)這個監聽器會不會影響修改操作?
答:測試發現,不會影響,水平有限,本人也不知作何解釋,不要打我……
(3)這種方式會有並發問題嗎?
答:不會的!根據官方文檔說明,findAndModify一個原子性操作,不過有這么一句“When the findAndModify command includes the upsert: true option and the query field(s) is not uniquely indexed, the command could insert a document multiple times in certain circumstances.”,大概意思是說當查詢和更新兩個操作都存在時,如果查詢的字段沒有唯一索引的話,該命令可能會在某些情況下更新/插入 文檔多次,參考鏈接:戳我戳我。以上演示的是只存儲了集合所對應的實體類的短名稱,短名稱是會重復的,所以這種方法不妥,還是記錄長名稱吧。