前言
MongoDB
作為一個基於分散式檔案儲存的資料庫,在微服務領域中廣泛使用.本篇文章將學習 Spring Boot
程式如何執行 MongoDB
操作以及底層實現方式的原始碼分析,來更好地幫助我們理解Spring程式操作 MongoDB
資料庫的行為.以下兩點是原始碼分析的收穫,讓我們一起來看下這些是怎麼發現的吧.
- Spring 框架操作MongoDB 資料的底層使用的 MongoDBTemplate, 而實際使用時通過JDK 動態代理和
AOP
攔截器方式層層呼叫. - 在自己的DAO物件中自定義查詢方法是要符合
spring-boot-data-mongodb
框架的方法命名規則,才能達到完全自動處理的效果.
正文
本文使用
MongoDB
伺服器版本為4.0.0
MongoDB
伺服器的安裝可以參考我的另一篇部落格:後端架構搭建系列之MonogDB
下載示例工程
首先在SPRING INITIALIZR網站上下載示例工程,Spring Boot 版本為1.5.17,僅依賴一個 MongoDB.
![image-20181020201332247](https://i.iter01.com/images/2e7ccc190f48296bc2fc4ba178ace930ee9e15bbea7f11ff06d7e0c8da879993.jpg)
用 IDE 匯入工程後開啟POM 檔案,就可以看到 MongoDB 依賴對應的Maven 座標和對應第三方庫為 spring-boot-starter-data-mongodb
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
複製程式碼
那以後我們要在Spring Boot
專案使用 MongoDB
時就可以在主 POM
檔案中引入這個庫的座標就OK 了.
而 spring-boot-starter-data-mongodb
是 spring-data
的子專案, 其作用就是針對 MongoDB
的訪問提供豐富的操作和簡化.
配置 MongoDB連線
要操作 MongoDB
資料庫, 首先要讓程式連線到 MongoDB
伺服器,由於 Spring Boot
強大的簡化配置特性, 想要連線 MongoDB
伺服器, 我們只需在資原始檔夾下的 application.properties
檔案裡新增一行配置即可.
spring.data.mongodb.uri=mongodb://localhost:27017/test
複製程式碼
如果連線有使用者驗證的 MongoDB 伺服器,則uri 形式為
mongodb://name:password@ip:port/dbName
編寫程式碼
配置後之後,接下來我們先建立一個實體 Post
, 包含屬性: id
,title
,content
,createTime
public class Post {
@Id
private Long id;
private String title;
private String content;
private Date createTime;
public Post() {
}
public Post(Long id, String title, String content) {
this.id = id;
this.title = title;
this.content = content;
this.createTime = new Date();
}
// 省略 setter,getter 方法
}
複製程式碼
這裡用 註解@Id
表示該實體屬性對應為資料庫記錄的主鍵.
然後再提供對Post的資料訪問的儲存物件 PostRepository
, 繼承 官方提供的MongoRepository
介面
public interface PostRepository extends MongoRepository<Post,Long> {
void findByTitle(String title);
}
複製程式碼
到這裡 對 Post
實體的 CRUD
操作程式碼就完成. What !!! 我們還沒寫什麼程式碼就結束了麼? 我們現在就來寫個測試用例來看看吧.
@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringbootMongodbApplicationTests {
@Autowired
private PostRepository postRepository;
@Test
public void testInsert() {
Post post = new Post(1L,"sayhi", "hi,mongodb");
postRepository.insert(post);
List<Post> all = postRepository.findAll();
System.out.println(all);
// [Post{id=1, title='sayhi', content='hi,mongodb',
//createTime=Sat Oct 20 20:55:15 CST 2018}]
Assert.assertEquals(all.size(),1); // true
}
}
複製程式碼
執行測試用例,執行結果如下, Post
資料成功地儲存到了 MongoDB
資料庫中,並且能夠查詢出來了.
![image-20181020205843907](https://i.iter01.com/images/be81010df471650e43fca08747bd94ffbcd358fe03e7c223f78f5a4574be76c9.jpg)
我們也可以在MongoDB
伺服器裡查到這條記錄:
![image-20181020210805972](https://i.iter01.com/images/7e8a211b3fb0bcf028b553878b21f043bd741017df16b4cd0e6307f9387ee7b3.jpg)
從記錄中看到多了個 _ class
欄位 的值,其實是由 MongoRepository
自動幫我們設定的,用來表示這條記錄對應的實體型別,但底層是什麼時候操作的呢,期待在我們後續分析的時候揭曉答案.
新增之後,我們再嘗試下更新操作,這裡用的也是用繼承而來的 save
方法,除此之外我們還使用了自己寫的介面方法 findByTitle
來根據 title
欄位查詢出 Post 實體.
@Test
public void testUpdate() {
Post post = new Post();
post.setId(1L);
post.setTitle("sayHi");
post.setContent("hi,springboot");
post.setCreateTime(new Date());
postRepository.save(post); // 更新 post 物件
Post updatedPost = postRepository.findByTitle("sayHi"); // 根據 title 查詢
Assert.assertEquals(updatedPost.getId(),post.getId());
Assert.assertEquals(updatedPost.getTitle(),post.getTitle());
Assert.assertEquals(updatedPost.getContent(),"hi,springboot");
}
複製程式碼
執行這個測試用例,結果也是通過.但這裡也有個疑問: 自己提供的方法,沒有寫如何實現,程式怎麼就能依照我們所想要的:根據title
欄位的值去查詢到匹配到的記錄呢 ? 這樣也在下面實戰分析裡看個明白吧.
![image-20181020212654792](https://i.iter01.com/images/4535e2e8200658b5317c38fde03f7af1e508d34b1e6ad0824b7ff4747d24c5a2.jpg)
到這裡我們對資料的增,改,查都已經試過了,刪除其實也很簡單,只要呼叫 postRepository
的delete
方法即可,現在最主要還是探究 PostRepository
僅通過繼承MongoRepository
如何實現資料增刪改查的呢?
實戰分析
postRepository的執行底層
實現了基本的資料操作之後,我們現在就來看下這一切是怎麼做到的呢? 首先我們對測試用例 testUpdate
中的postRepository#save
進行斷點除錯,觀察程式的執行路徑.在單步進入 save
方法內部,程式碼執行到了JdkDynamicAopProxy
型別下, 此時程式碼呼叫鏈如下圖所示
![image-20181020215752763](https://i.iter01.com/images/c3313320097b53b13c3e99ac3bc47519b7147f830bb439ce22dadbc11728d464.jpg)
很顯然這裡是用到 Spring
的 JDK
動態代理,而invoke
方法內這個 proxy
物件十分引人注意, 方法執行時實際呼叫的 proxy
的 save
方法,而這個 proxy
則是 org.springframework.data.mongodb.repository.support.SimpleMongoRepository@8deb645
, 是 SimpleMongoRepository
類的例項.那麼最後呼叫就會落到SimpleMongoRepository# save
方法中,我們在這個方法裡再次進行斷點然後繼續執行.
![image-20181020220728719](https://i.iter01.com/images/f66a46c2f0e463ec4eb0d3fe8695530de27ef2d2d7a9148f82b19d92331a187d.jpg)
從這裡可以看出,save 方法內部有兩個操作: 如果是傳入的實體是新紀錄則執行 insert
,否則執行 save
更新操作.顯然現在要執行的是後者.
而要完成操作跟兩個物件 entityInformation
和mongoOperations
有著密切關係,他們又是幹什麼的呢,什麼時候初始化的呢.
![image-20181020221604857](https://i.iter01.com/images/083e5057bdea01c7b4983c501cf898e7bda7a703693558c0674d2b37081ea49d.jpg)
首先我們看下 mongoOperations
這個物件,利用IDEA
除錯工具可以看到 mongoOperations
其實就是 MongoTemplate
物件, 類似 JDBCTemplate
,針對MongoDB
資料的增刪改查, Spring
也採用相似的名稱方式和 API
.所以真正操作MongoDB
資料庫底層就是這個MongoTemplate
物件.
至於entityInformation
物件所屬的類 MappingMongoEntityInformation
,儲存著Mongo
資料實體資訊,如集合名稱,主鍵型別,一些所對映的實體後設資料等.
再來看下他們的初始化時機,在SimpleMongoRepository
類, 可以找到他們都在的構造方法中初始化
public SimpleMongoRepository(MongoEntityInformation<T, ID> metadata, MongoOperations mongoOperations) {
Assert.notNull(metadata, "MongoEntityInformation must not be null!");
Assert.notNull(mongoOperations, "MongoOperations must not be null!");
this.entityInformation = metadata;
this.mongoOperations = mongoOperations;
}
複製程式碼
以同樣的方式,在SimpleMongoRepository
構造器中進行斷點,重新允許觀察初始化 SimpleMongoRepository
物件時的呼叫鏈.發現整個鏈路如下,從執行測試用例到這裡很長的執行鏈路,這裡只標識出了我們所需要關注的那些類和方法.
![image-20181020224041366](https://i.iter01.com/images/eb230fe16ad8e6dead7396b71695d7402e8bb031383c2f0c9a32ebfc69d85476.jpg)
從一層層原始碼可以跟蹤到 SimpleMongoRepository
類的建立和初始化是由 工廠類MongoRepositoryFactory
完成,
public <T> T getRepository(Class<T> repositoryInterface, Object customImplementation) {
RepositoryMetadata metadata = getRepositoryMetadata(repositoryInterface);
Class<?> customImplementationClass = null == customImplementation ? null : customImplementation.getClass();
RepositoryInformation information = getRepositoryInformation(metadata, customImplementationClass);
validate(information, customImplementation);
Object target = getTargetRepository(information); // 獲取初始化後的SimpleMongoRepository物件.
// Create proxy
ProxyFactory result = new ProxyFactory();
result.setTarget(target);
result.setInterfaces(new Class[] { repositoryInterface, Repository.class });
// 對 repositoryInterface介面類進行 AOP 代理
result.addAdvice(SurroundingTransactionDetectorMethodInterceptor.INSTANCE);
result.addAdvisor(ExposeInvocationInterceptor.ADVISOR);
return (T) result.getProxy(classLoader);
}
複製程式碼
下圖就是MongoRepositoryFactory
的類圖,而MongoRepositoryFactory
又是在MongoRepositoryFactoryBean
類裡構造的.
![image-20181020224811986](https://i.iter01.com/images/26fd214141960ed559a36df1bf9cdd923370718a71813d496a0be2974ffe9438.jpg)
在呼叫鏈的下半截裡,我們再看下發生著一切的來源在哪, 找到 org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory#createBean
方法,內部建立Bean
例項的 doCreateBean
呼叫引數為postRepository
和MongoRepositoryFactoryBean
例項,也就是在建立postRepository
例項的時候完成的.
![image-20181020230418830](https://i.iter01.com/images/4c238c87864b32cc5e63a674d5891ccd459a7369260e3b303a72b6b9591f3433.jpg)
而建立postRepository
對應實體物件實際為 MongoRepositoryFactoryBean
這個工廠 Bean
![image-20181020232644057](https://i.iter01.com/images/a891a8c80765516b25b5ebde7be2f72b205670f3f2b84bdf215c927bfc941b78.jpg)
當需要使用 postRepository
物件時,實際就是使用工廠物件的方法MongoRepositoryFactoryBean#getObject
返回的 SimpleMongoRepository
物件,詳見當類AbstractBeanFactory
的doGetBean
方法,當引數 name
為 postRepository
時程式碼呼叫鏈.
![image-20181021000306776](https://i.iter01.com/images/9eb611b96b7045a4b7970ae6d3b59adc22d4d7fc10cd63c3d0a65268eac31776.jpg)
好了,到這裡基本說完 postRepository
是如何完成MongoDB
資料庫操作的,還有個問題就是僅定義了介面方法 findByTitle
,如何實現根據 title
欄位查詢的.
findByTitle的查詢實現
斷點到執行 findByTitle
方法的地方,除錯進去跟之前一樣在 JdkDynamicAopProxy
類中執行,而在獲取呼叫鏈時
,這個代理物件的所擁有的攔截器中一個攔截器類org.springframework.data.repository.core.support.RepositoryFactorySupport.QueryExecutorMethodInterceptor
引起了我的注意.從命名上看是專門處理查詢方法的攔截器.我嘗試在這個攔截的invoke
方法進行斷點,果然執行findByTitle
時,程式執行到了這裡.
![image-20181021085124017](https://i.iter01.com/images/d3b38c0c81ea6eee31180c6b2f410292a3b06cf9590911be15848e86e27ab9cc.jpg)
然後在攔截器方法中判斷該方法是否為查詢方法,如果是就會攜帶引數呼叫 PartTreeMongoQuery
物件繼承而來的AbstractMongoQuery#execute
方法.
// AbstractMongoQuery
public Object execute(Object[] parameters) {
MongoParameterAccessor accessor = new MongoParametersParameterAccessor(method, parameters);
// 構建查詢物件 Query: { "title" : "sayHi"}, Fields: null, Sort: null
Query query = createQuery(new ConvertingParameterAccessor(operations.getConverter(), accessor));
applyQueryMetaAttributesWhenPresent(query);
ResultProcessor processor = method.getResultProcessor().withDynamicProjection(accessor);
String collection = method.getEntityInformation().getCollectionName();
// 構建查詢執行物件
MongoQueryExecution execution = getExecution(query, accessor,new ResultProcessingConverter(processor, operations, instantiators));
return execution.execute(query, processor.getReturnedType().getDomainType(), collection);
}
複製程式碼
而 MongoQueryExecution#execute
方法裡經過層層地呼叫實際執行而以下程式碼:
// AbstractMongoQuery#execute =>
// MongoQueryExecution.ResultProcessingExecution#execute =>
// MongoQueryExecution.SingleEntityExecution#execute
@Override
public Object execute(Query query, Class<?> type, String collection) {
return operations.findOne(query, type, collection);
}
複製程式碼
這裡的 operations
就是我們之前提到的 MongoDBTemplate
例項.所以當執行 自定義方法findByTitile
查詢時底層呼叫的還是MongoDBTemplate#findOne
.
而這裡也有個疑問:構建Query
物件時能獲取到引數值為sayHi
,如何是獲取對應查詢欄位為title
的呢?
在方法createQuery
是一個模板方法,真正執行在``PartTreeMongoQuery`類上.
@Override
protected Query createQuery(ConvertingParameterAccessor accessor) {
MongoQueryCreator creator = new MongoQueryCreator(tree, accessor, context, isGeoNearQuery);
Query query = creator.createQuery();
//...
return query
}
複製程式碼
這裡在構建MongoQueryCreator
時有個 tree
屬性,這個物件就是構建條件查詢的關係.
![image-20181021092719052](https://i.iter01.com/images/3dd85d7cd0e54b31c2dfc5f6b45c0e390eafd6d4690d0100f7bda988308a58f2.jpg)
而 tree
物件的初始化在PartTreeMongoQuery
這個類的構造器中完成的, 根據方法名, PartTree
又是如何構造出來的呢.
//PartTree.java
public PartTree(String source, Class<?> domainClass) {
Assert.notNull(source, "Source must not be null");
Assert.notNull(domainClass, "Domain class must not be null");
Matcher matcher = PREFIX_TEMPLATE.matcher(source);
if (!matcher.find()) {
this.subject = new Subject(null);
this.predicate = new Predicate(source, domainClass);
} else {
this.subject = new Subject(matcher.group(0));
// 構造查詢欄位的關鍵
this.predicate = new Predicate(source.substring(matcher.group().length()), domainClass);
}
}
複製程式碼
從上面程式碼可以看到 , 用正則方式匹配方法名,其中 PREFIX_TEMPLATE
表示著 ^(find|read|get|query|stream|count|exists|delete|remove)((\p{Lu}.*?))??By
, 如果匹配到了就將 By 後面緊跟的單詞提取出來,內部再根據該名稱去匹配對應類的屬性,找到構建完成後就會放在一個 ArrayList
集合裡存放,等待後續查詢的時候使用.
所以也可以看出 我們自定義的方法 findByTitle
符合框架預設的正則要求,所以能自動提取到Post
的 title
欄位作為查詢欄位. 除此之外,使用類似queryBy
,getBy
等等也可以達到同樣效果, 這裡體現的就是 Spring Framework
約定由於配置的思想, 如果我們隨意定義方法名,那框架就無法直接識別出查詢欄位了.
好了到這裡, 我們再次總結一下原始碼分析成果:
- 定義
postRepository
實現MongoRepository
介面,操作MongoDB 資料的底層使用的 MongoDBTemplate, 而實際使用時通過JDK 動態代理和AOP
攔截器方式層層呼叫. - 在
postRepository
中自定義查詢方法是要符合spring-boot-data-mongodb
框架的方法命名規則,才能達到完全自動處理的效果.
結語
到這裡,我們的 Spring Boot
與 MongoDB
的實戰分析就結束了,細看內部原始碼,雖然結構層次清晰,但由於模組間複雜呼叫關係,也往往容易迷失於原始碼中,這時候耐心和明確的目標就至關重要.這算也是本次原始碼分析的收穫吧,希望這篇文章能有更多收穫,我們下篇再見吧.???