SpringBoot實戰分析-MongoDB操作

聞人的技術部落格發表於2018-10-21

前言

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

用 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-mongodbspring-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

我們也可以在MongoDB 伺服器裡查到這條記錄:

image-20181020210805972

從記錄中看到多了個 _ 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

到這裡我們對資料的增,改,查都已經試過了,刪除其實也很簡單,只要呼叫 postRepositorydelete 方法即可,現在最主要還是探究 PostRepository僅通過繼承MongoRepository如何實現資料增刪改查的呢?

實戰分析

postRepository的執行底層

實現了基本的資料操作之後,我們現在就來看下這一切是怎麼做到的呢? 首先我們對測試用例 testUpdate 中的postRepository#save 進行斷點除錯,觀察程式的執行路徑.在單步進入 save 方法內部,程式碼執行到了JdkDynamicAopProxy型別下, 此時程式碼呼叫鏈如下圖所示

image-20181020215752763

很顯然這裡是用到 SpringJDK 動態代理,而invoke方法內這個 proxy物件十分引人注意, 方法執行時實際呼叫的 proxysave 方法,而這個 proxy 則是 org.springframework.data.mongodb.repository.support.SimpleMongoRepository@8deb645

, 是 SimpleMongoRepository 類的例項.那麼最後呼叫就會落到SimpleMongoRepository# save 方法中,我們在這個方法裡再次進行斷點然後繼續執行.

image-20181020220728719

從這裡可以看出,save 方法內部有兩個操作: 如果是傳入的實體是新紀錄則執行 insert,否則執行 save更新操作.顯然現在要執行的是後者.

而要完成操作跟兩個物件 entityInformationmongoOperations有著密切關係,他們又是幹什麼的呢,什麼時候初始化的呢.

image-20181020221604857

首先我們看下 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

從一層層原始碼可以跟蹤到 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

在呼叫鏈的下半截裡,我們再看下發生著一切的來源在哪, 找到 org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory#createBean方法,內部建立Bean 例項的 doCreateBean呼叫引數為postRepositoryMongoRepositoryFactoryBean例項,也就是在建立postRepository例項的時候完成的.

image-20181020230418830

而建立postRepository對應實體物件實際為 MongoRepositoryFactoryBean這個工廠 Bean

image-20181020232644057

當需要使用 postRepository物件時,實際就是使用工廠物件的方法MongoRepositoryFactoryBean#getObject返回的 SimpleMongoRepository物件,詳見當類AbstractBeanFactorydoGetBean方法,當引數 namepostRepository時程式碼呼叫鏈.

image-20181021000306776

好了,到這裡基本說完 postRepository是如何完成MongoDB資料庫操作的,還有個問題就是僅定義了介面方法 findByTitle,如何實現根據 title 欄位查詢的.

findByTitle的查詢實現

斷點到執行 findByTitle 方法的地方,除錯進去跟之前一樣在 JdkDynamicAopProxy 類中執行,而在獲取呼叫鏈時

,這個代理物件的所擁有的攔截器中一個攔截器類org.springframework.data.repository.core.support.RepositoryFactorySupport.QueryExecutorMethodInterceptor引起了我的注意.從命名上看是專門處理查詢方法的攔截器.我嘗試在這個攔截的invoke方法進行斷點,果然執行findByTitle時,程式執行到了這裡.

image-20181021085124017

然後在攔截器方法中判斷該方法是否為查詢方法,如果是就會攜帶引數呼叫 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

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符合框架預設的正則要求,所以能自動提取到Posttitle 欄位作為查詢欄位. 除此之外,使用類似queryBy,getBy等等也可以達到同樣效果, 這裡體現的就是 Spring Framework 約定由於配置的思想, 如果我們隨意定義方法名,那框架就無法直接識別出查詢欄位了.

好了到這裡, 我們再次總結一下原始碼分析成果:

  • 定義postRepository實現MongoRepository介面,操作MongoDB 資料的底層使用的 MongoDBTemplate, 而實際使用時通過JDK 動態代理和 AOP 攔截器方式層層呼叫.
  • postRepository中自定義查詢方法是要符合spring-boot-data-mongodb框架的方法命名規則,才能達到完全自動處理的效果.

結語

到這裡,我們的 Spring BootMongoDB 的實戰分析就結束了,細看內部原始碼,雖然結構層次清晰,但由於模組間複雜呼叫關係,也往往容易迷失於原始碼中,這時候耐心和明確的目標就至關重要.這算也是本次原始碼分析的收穫吧,希望這篇文章能有更多收穫,我們下篇再見吧.???

參考

相關文章