重拾後端之Spring Boot(一):REST API的搭建可以這樣簡單
重拾後端之Spring Boot(二):MongoDb的無縫整合
重拾後端之Spring Boot(三):找回熟悉的Controller,Service
重拾後端之Spring Boot(四):使用 JWT 和 Spring Security 保護 REST API
重拾後端之 Spring Boot(五) -- 跨域、自定義查詢及分頁
重拾後端之Spring Boot(六) -- 熱載入、容器和多專案
上一節,我們做的那個例子有點太簡單了,通常的後臺都會涉及一些資料庫的操作,然後在暴露的API中提供處理後的資料給客戶端使用。那麼這一節我們要做的是整合MongoDB ( www.mongodb.com )。
MongoDB是什麼?
MongoDB是一個NoSQL資料庫,是NoSQL中的一個分支:文件資料庫。和傳統的關係型資料庫比如Oracle、SQLServer和MySQL等有很大的不同。傳統的關係型資料庫(RDBMS)已經成為資料庫的代名詞超過20多年了。對於大多數開發者來說,關係型資料庫是比較好理解的,表這種結構和SQL這種標準化查詢語言畢竟是很大一部分開發者已有的技能。那麼為什麼又搞出來了這個什麼勞什子NoSQL,而且看上去NoSQL資料庫正在飛快的佔領市場。
NoSQL的應用場景是什麼?
假設說我們現在要構建一個論壇,使用者可以釋出帖子(帖子內容包括文字、視訊、音訊和圖片等)。那麼我們可以畫出一個下圖的表關係結構。
這種情況下我們想一下這樣一個帖子的結構怎麼在頁面中顯示,如果我們希望顯示帖子的文字,以及關聯的圖片、音訊、視訊、使用者評論、贊和使用者的資訊的話,我們需要關聯八個表取得自己想要的資料。如果我們有這樣的帖子列表,而且是隨著使用者的滾動動態載入,同時需要監聽是否有新內容的產生。這樣一個任務我們需要太多這種複雜的查詢了。
NoSQL解決這類問題的思路是,乾脆拋棄傳統的表結構,你不是帖子有一個結構關係嗎,那我就直接儲存和傳輸一個這樣的資料給你,像下面那樣。
{
"id":"5894a12f-dae1-5ab0-5761-1371ba4f703e",
"title":"2017年的Spring發展方向",
"date":"2017-01-21",
"body":"這篇文章主要探討如何利用Spring Boot整合NoSQL",
"createdBy":User,
"images":["http://dev.local/myfirstimage.png","http://dev.local/mysecondimage.png"],
"videos":[
{"url":"http://dev.local/myfirstvideo.mp4", "title":"The first video"},
{"url":"http://dev.local/mysecondvideo.mp4", "title":"The second video"}
],
"audios":[
{"url":"http://dev.local/myfirstaudio.mp3", "title":"The first audio"},
{"url":"http://dev.local/mysecondaudio.mp3", "title":"The second audio"}
]
}複製程式碼
NoSQL一般情況下是沒有Schema這個概念的,這也給開發帶來較大的自由度。因為在關係型資料庫中,一旦Schema確定,以後更改Schema,維護Schema是很麻煩的一件事。但反過來說Schema對於維護資料的完整性是非常必要的。
一般來說,如果你在做一個Web、物聯網等型別的專案,你應該考慮使用NoSQL。如果你要面對的是一個對資料的完整性、事務處理等有嚴格要求的環境(比如財務系統),你應該考慮關係型資料庫。
在Spring中整合MongoDB
在我們剛剛的專案中整合MongoDB簡單到令人髮指,只有三個步驟:
- 在
build.gradle
中更改compile('org.springframework.boot:spring-boot-starter-web')
為compile("org.springframework.boot:spring-boot-starter-data-rest")
- 在
Todo.java
中給private String id;
之前加一個後設資料修飾@Id
以便讓Spring知道這個Id就是資料庫中的Id - 新建一個如下的
TodoRepository.java
package dev.local.todo;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;
@RepositoryRestResource(collectionResourceRel = "todo", path = "todo")
public interface TodoRepository extends MongoRepository<Todo, String>{
}複製程式碼
此時我們甚至不需要Controller了,所以暫時註釋掉 TodoController.java
中的程式碼。然後我們 ./gradlew bootRun
啟動應用。訪問 http://localhost:8080/todo
我們會得到下面的的結果。
{
_embedded: {
todo: [ ]
},
_links: {
self: {
href: "http://localhost:8080/todo"
},
profile: {
href: "http://localhost:8080/profile/todo"
}
},
page: {
size: 20,
totalElements: 0,
totalPages: 0,
number: 0
}
}複製程式碼
我勒個去,不光是有資料集的返回結果 todo: [ ]
,還附贈了一個links物件和page物件。如果你瞭解 Hypermedia
的概念,就會發現這是個符合 Hypermedia
REST API返回的資料。
說兩句關於 MongoRepository<Todo, String>
這個介面,前一個引數型別是領域物件型別,後一個指定該領域物件的Id型別。
Hypermedia REST
簡單說兩句Hypermedia是什麼。簡單來說它是可以讓客戶端清晰的知道自己可以做什麼,而無需依賴伺服器端指示你做什麼。原理呢,也很簡單,通過返回的結果中包括不僅是資料本身,也包括指向相關資源的連結。拿上面的例子來說(雖然這種預設狀態生成的東西不是很有代表性):links中有一個profiles,我們看看這個profile的連結 http://localhost:8080/profile/todo
執行的結果是什麼:
{
"alps" : {
"version" : "1.0",
"descriptors" : [
{
"id" : "todo-representation",
"href" : "http://localhost:8080/profile/todo",
"descriptors" : [
{
"name" : "desc",
"type" : "SEMANTIC"
},
{
"name" : "completed",
"type" : "SEMANTIC"
}
]
},
{
"id" : "create-todo",
"name" : "todo",
"type" : "UNSAFE",
"rt" : "#todo-representation"
},
{
"id" : "get-todo",
"name" : "todo",
"type" : "SAFE",
"rt" : "#todo-representation",
"descriptors" : [
{
"name" : "page",
"doc" : {
"value" : "The page to return.",
"format" : "TEXT"
},
"type" : "SEMANTIC"
},
{
"name" : "size",
"doc" : {
"value" : "The size of the page to return.",
"format" : "TEXT"
},
"type" : "SEMANTIC"
},
{
"name" : "sort",
"doc" : {
"value" : "The sorting criteria to use to calculate the content of the page.",
"format" : "TEXT"
},
"type" : "SEMANTIC"
}
]
},
{
"id" : "patch-todo",
"name" : "todo",
"type" : "UNSAFE",
"rt" : "#todo-representation"
},
{
"id" : "update-todo",
"name" : "todo",
"type" : "IDEMPOTENT",
"rt" : "#todo-representation"
},
{
"id" : "delete-todo",
"name" : "todo",
"type" : "IDEMPOTENT",
"rt" : "#todo-representation"
},
{
"id" : "get-todo",
"name" : "todo",
"type" : "SAFE",
"rt" : "#todo-representation"
}
]
}
}複製程式碼
這個物件雖然我們暫時不是完全的理解,但大致可以猜出來,這個是todo這個REST API的後設資料描述,告訴我們這個API中定義了哪些操作和接受哪些引數等等。我們可以看到todo這個API有增刪改查等對應功能。
其實呢,Spring是使用了一個叫 ALPS
(alps.io/spec/index.… 的專門描述應用語義的資料格式。摘出下面這一小段來分析一下,這個描述了一個get方法,型別是 SAFE
表明這個操作不會對系統狀態產生影響(因為只是查詢),而且這個操作返回的結果格式定義在 todo-representation
中了。 todo-representation
{
"id" : "get-todo",
"name" : "todo",
"type" : "SAFE",
"rt" : "#todo-representation"
}複製程式碼
還是不太理解?沒關係,我們再來做一個實驗,啟動 PostMan (不知道的同學,可以去Chrome應用商店中搜尋下載)。我們用Postman構建一個POST請求:
執行後的結果如下,我們可以看到返回的links中包括了剛剛新增的Todo的link http://localhost:8080/todo/588a01abc5d0e23873d4c1b8
( 588a01abc5d0e23873d4c1b8
就是資料庫自動為這個Todo生成的Id),這樣客戶端可以方便的知道指向剛剛生成的Todo的API連結。
再舉一個現實一些的例子,我們在開發一個“我的”頁面時,一般情況下除了取得我的某些資訊之外,因為在這個頁面還會有一些可以連結到更具體資訊的頁面連結。如果客戶端在取得比較概要資訊的同時就得到這些詳情的連結,那麼客戶端的開發就比較簡單了,而且也更靈活了。
其實這個描述中還告訴我們一些分頁的資訊,比如每頁20條記錄(size: 20
)、總共幾頁(totalPages:1
)、總共多少個元素(totalElements: 1
)、當前第幾頁(number: 0
)。當然你也可以在傳送API請求時,指定page、size或sort引數。比如 http://localhost:8080/todos?page=0&size=10
就是指定每頁10條,當前頁是第一頁(從0開始)。
魔法的背後
這麼簡單就生成一個有資料庫支援的REST API,這件事看起來比較魔幻,但一般這麼魔幻的事情總感覺不太託底,除非我們知道背後的原理是什麼。首先再來回顧一下 TodoRepository
的程式碼:
@RepositoryRestResource(collectionResourceRel = "todo", path = "todo")
public interface TodoRepository extends MongoRepository<Todo, String>{
}複製程式碼
Spring是最早的幾個IoC(控制反轉或者叫DI)框架之一,所以最擅長的就是依賴的注入了。這裡我們寫了一個Interface,可以猜到Spring一定是有一個這個介面的實現在執行時注入了進去。如果我們去 spring-data-mongodb
的原始碼中看一下就知道是怎麼回事了,這裡只舉一個小例子,大家可以去看一下 SimpleMongoRepository.java
( 原始碼連結 ),由於原始碼太長,我只擷取一部分:
public class SimpleMongoRepository<T, ID extends Serializable> implements MongoRepository<T, ID> {
private final MongoOperations mongoOperations;
private final MongoEntityInformation<T, ID> entityInformation;
/**
* Creates a new {@link SimpleMongoRepository} for the given {@link MongoEntityInformation} and {@link MongoTemplate}.
*
* @param metadata must not be {@literal null}.
* @param mongoOperations must not be {@literal null}.
*/
public SimpleMongoRepository(MongoEntityInformation<T, ID> metadata, MongoOperations mongoOperations) {
Assert.notNull(mongoOperations);
Assert.notNull(metadata);
this.entityInformation = metadata;
this.mongoOperations = mongoOperations;
}
/*
* (non-Javadoc)
* @see org.springframework.data.repository.CrudRepository#save(java.lang.Object)
*/
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null!");
if (entityInformation.isNew(entity)) {
mongoOperations.insert(entity, entityInformation.getCollectionName());
} else {
mongoOperations.save(entity, entityInformation.getCollectionName());
}
return entity;
}
...
public T findOne(ID id) {
Assert.notNull(id, "The given id must not be null!");
return mongoOperations.findById(id, entityInformation.getJavaType(), entityInformation.getCollectionName());
}
private Query getIdQuery(Object id) {
return new Query(getIdCriteria(id));
}
private Criteria getIdCriteria(Object id) {
return where(entityInformation.getIdAttribute()).is(id);
}
...
}複製程式碼
也就是說其實在執行時Spring將這個類或者其他具體介面的實現類注入了應用。這個類中有支援各種資料庫的操作。我瞭解到這步就覺得ok了,有興趣的同學可以繼續深入研究。
雖然不想在具體類上繼續研究,但我們還是應該多瞭解一些關於 MongoRepository
的東西。這個介面繼承了 PagingAndSortingRepository
(定義了排序和分頁) 和 QueryByExampleExecutor
。而 PagingAndSortingRepository
又繼承了 CrudRepository
(定義了增刪改查等)。
第二個魔法就是 @RepositoryRestResource(collectionResourceRel = "todo", path = "todo")
這個後設資料的修飾了,它直接對MongoDB中的集合(本例中的todo)對映到了一個REST URI(todo)。因此我們連Controller都沒寫就把API搞出來了,而且還是個Hypermedia REST。
其實呢,這個第二個魔法只在你需要變更對映路徑時需要。本例中如果我們不加 @RepositoryRestResource
這個修飾符的話,同樣也可以生成API,只不過其路徑按照預設的方式變成了 todoes
,大家可以試試把這個後設資料修飾去掉,然後重啟服務,訪問 http://localhost:8080/todoes
看看。
說到這裡,順便說一下REST的一些約定俗成的規矩。一般來說如果我們定義了一個領域物件 (比如我們這裡的Todo),那麼這個物件的集合(比如Todo的列表)可以使用這個物件的命名的複數方式定義其資源URL,也就是剛剛我們訪問的 http://localhost:8080/todoes
,對於新增一個物件的操作也是這個URL,但Request的方法是POST。
而這個某個指定的物件(比如指定了某個ID的Todo)可以使用 todoes/:id
來訪問,比如本例中 http://localhost:8080/todoes/588a01abc5d0e23873d4c1b8
。對於這個物件的修改和刪除使用的也是這個URL,只不過HTTP Request的方法變成了PUT(或者PATCH)和DELETE。
這個裡面預設採用的這個命名 todoes
是根據英語的語法來的,一般來說複數是加s即可,但這個todo,是子音+o結尾,所以採用的加es方式。 todo
其實並不是一個真正意義上的單詞,所以我認為更合理的命名方式應該是 todos
。所以我們還是改成 @RepositoryRestResource(collectionResourceRel = "todos", path = "todos")
無招勝有招
剛才我們提到的都是開箱即用的一些方法,你可能會想,這些東西看上去很炫,但沒有毛用,實際開發過程中,我們要使用的肯定不是這麼簡單的增刪改查啊。說的有道理,我們來試試看非預設方法。那麼我們就來增加一個需求,我們可以通過查詢Todo的描述中的關鍵字來搜尋符合的專案。
顯然這個查詢不是預設的操作,那麼這個需求在Spring Boot中怎麼實現呢?非常簡單,只需在 TodoRepository
中新增一個方法:
...
public interface TodoRepository extends MongoRepository<Todo, String>{
List<Todo> findByDescLike(@Param("desc") String desc);
}複製程式碼
太不可思議了,這樣就行?不信可以啟動服務後,在瀏覽器中輸入 http://localhost:8080/todos/search/findByDescLike?desc=swim
去看看結果。是的,我們甚至都沒有寫這個方法的實現就已經完成了該需求(題外話,其實 http://localhost:8080/todos?desc=swim
這個URL也起作用)。
你說這裡肯定有鬼,我同意。那麼我們試試把這個方法改個名字 findDescLike
,果然不好用了。為什麼呢?這套神奇的療法的背後還是那個我們在第一篇時提到的 Convention over configuration
,要神奇的療效就得遵循Spring的配方。這個配方就是方法的命名是有講究的:Spring提供了一套可以通過命名規則進行查詢構建的機制。這套機制會把方法名首先過濾一些關鍵字,比如 find…By
, read…By
, query…By
, count…By
和 get…By
。系統會根據關鍵字將命名解析成2個子語句,第一個 By
是區分這兩個子語句的關鍵詞。這個 By
之前的子語句是查詢子語句(指明返回要查詢的物件),後面的部分是條件子語句。如果直接就是 findBy…
返回的就是定義Respository時指定的領域物件集合(本例中的Todo組成的集合)。
一般到這裡,有的同學可能會問 find…By
, read…By
, query…By
, get…By
到底有什麼區別啊?答案是。。。木有區別,就是別名,從下面的定義可以看到這幾個東東其實生成的查詢是一樣的,這種讓你不用查文件都可以寫對的方式也比較貼近目前流行的自然語言描述風格(類似各種DSL)。
private static final String QUERY_PATTERN = "find|read|get|query|stream";複製程式碼
剛剛我們實驗了模糊查詢,那如果要是精確查詢怎麼做呢,比如我們要篩選出已完成或未完成的Todo,也很簡單:
List<Todo> findByCompleted(@Param("completed") boolean completed);複製程式碼
巢狀物件的查詢怎麼搞?
看到這裡你會問,這都是簡單型別,如果複雜型別怎麼辦?嗯,好的,我們還是增加一個需求看一下:現在需求是要這個API是多使用者的,每個使用者看到的Todo都是他們自己建立的專案。我們新建一個User領域物件:
package dev.local.user;
import org.springframework.data.annotation.Id;
public class User {
@Id private String id;
private String username;
private String email;
//此處為節省篇幅省略屬性的getter和setter
}複製程式碼
為了可以新增User資料,我們需要一個User的REST API,所以新增一個 UserRepository
package dev.local.user;
import org.springframework.data.mongodb.repository.MongoRepository;
public interface UserRepository extends MongoRepository<User, String> {
}複製程式碼
然後給 Todo
領域物件新增一個User屬性。
package dev.local.todo;
//省略import部分
public class Todo {
//省略其他部分
private User user;
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
}複製程式碼
接下來就可以來把 TodoRepository
新增一個方法定義了,我們先實驗一個簡單點的,根據使用者的email來篩選出這個使用者的Todo列表:
public interface TodoRepository extends MongoRepository<Todo, String>{
List<Todo> findByUserEmail(@Param("userEmail") String userEmail);
}複製程式碼
現在需要構造一些資料了,你可以通過剛剛我們建立的API使用Postman工具來構造:我們這裡建立了2個使用者,以及一些Todo專案,分別屬於這兩個使用者,而且有部分專案的描述是一樣的。接下來就可以實驗一下了,我們在瀏覽器中輸入 http://localhost:8080/todos/search/findByUserEmail?userEmail=peng@gmail.com
,我們會發現返回的結果中只有這個使用者的Todo專案。
{
"_embedded" : {
"todos" : [ {
"desc" : "go swimming",
"completed" : false,
"user" : {
"username" : "peng",
"email" : "peng@gmail.com"
},
"_links" : {
"self" : {
"href" : "http://localhost:8080/todos/58908a92c5d0e2524e24545a"
},
"todo" : {
"href" : "http://localhost:8080/todos/58908a92c5d0e2524e24545a"
}
}
}, {
"desc" : "go for walk",
"completed" : false,
"user" : {
"username" : "peng",
"email" : "peng@gmail.com"
},
"_links" : {
"self" : {
"href" : "http://localhost:8080/todos/58908aa1c5d0e2524e24545b"
},
"todo" : {
"href" : "http://localhost:8080/todos/58908aa1c5d0e2524e24545b"
}
}
}, {
"desc" : "have lunch",
"completed" : false,
"user" : {
"username" : "peng",
"email" : "peng@gmail.com"
},
"_links" : {
"self" : {
"href" : "http://localhost:8080/todos/58908ab6c5d0e2524e24545c"
},
"todo" : {
"href" : "http://localhost:8080/todos/58908ab6c5d0e2524e24545c"
}
}
}, {
"desc" : "have dinner",
"completed" : false,
"user" : {
"username" : "peng",
"email" : "peng@gmail.com"
},
"_links" : {
"self" : {
"href" : "http://localhost:8080/todos/58908abdc5d0e2524e24545d"
},
"todo" : {
"href" : "http://localhost:8080/todos/58908abdc5d0e2524e24545d"
}
}
} ]
},
"_links" : {
"self" : {
"href" : "http://localhost:8080/todos/search/findByUserEmail?userEmail=peng@gmail.com"
}
}
}複製程式碼
看到結果後我們來分析這個 findByUserEmail
是如何解析的:首先在 By
之後,解析器會按照 camel
(每個單詞首字母大寫)的規則來分詞。那麼第一個詞是 User
,這個屬性在 Todo
中有沒有呢?有的,但是這個屬性是另一個物件型別,所以緊跟著這個詞的 Email
就要在 User
類中去查詢是否有 Email
這個屬性。聰明如你,肯定會想到,那如果在 Todo
類中如果還有一個屬性叫 userEmail
怎麼辦?是的,這種情況下 userEmail
會被優先匹配,此時請使用 _
來顯性分詞處理這種混淆。也就是說如果我們的 Todo
類中同時有 user
和 userEmail
兩個屬性的情況下,我們如果想要指定的是 user
的 email
,那麼需要寫成 findByUser_Email
。
還有一個問題,我估計很多同學現在已經在想了,那就是我們的這個例子中並沒有使用 user
的 id
,這不科學啊。是的,之所以沒有在上面使用 findByUserId
是因為要引出一個易錯的地方,下面我們來試試看,將 TodoRepository
的方法改成
public interface TodoRepository extends MongoRepository<Todo, String>{
List<Todo> findByUserId(@Param("userId") String userId);
}複製程式碼
你如果開啟瀏覽器輸入 http://localhost:8080/todos/search/findByUserId?userId=589089c3c5d0e2524e245458
(這裡的Id請改成你自己mongodb中的user的id),你會發現返回的結果是個空陣列。原因是雖然我們在類中標識 id
為 String
型別,但對於這種資料庫自己生成維護的欄位,它在MongoDB中的型別是ObjectId,所以在我們的介面定義的查詢函式中應該標識這個引數是 ObjectId
。那麼我們只需要改動 userId
的型別為 org.bson.types.ObjectId
即可。
package dev.local.todo;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;
import java.util.List;
@RepositoryRestResource(collectionResourceRel = "todos", path = "todos")
public interface TodoRepository extends MongoRepository<Todo, String>{
List<Todo> findByUserId(@Param("userId") ObjectId userId);
}複製程式碼
再複雜一些行不行?
好吧,到現在我估計還有一大波攻城獅表示不服,實際開發中需要的查詢比上面的要複雜的多,再複雜一些怎麼辦?還是用例子來說話吧,那麼現在我們想要模糊搜尋指定使用者的Todo中描述的關鍵字,返回匹配的集合。這個需求我們只需改動一行,這個以命名規則為基礎的查詢條件是可以加 And
、Or
這種關聯多個條件的關鍵字的。
List<Todo> findByUserIdAndDescLike(@Param("userId") ObjectId userId, @Param("desc") String desc);複製程式碼
當然,還有其他操作符:Between
(值在兩者之間), LessThan
(小於), GreaterThan
(大於), Like
(包含), IgnoreCase
(b忽略大小寫), AllIgnoreCase
(對於多個引數全部忽略大小寫), OrderBy
(引導排序子語句), Asc
(升序,僅在 OrderBy
後有效) 和 Desc
(降序,僅在 OrderBy
後有效)。
剛剛我們談到的都是對於查詢條件子語句的構建,其實在 By
之前,對於要查詢的物件也可以有限定的修飾詞 Distinct
(去重,如有重複取一個值)。比如有可能返回的結果有重複的記錄,可以使用 findDistinctTodoByUserIdAndDescLike
。
我可以直接寫查詢語句嗎?幾乎所有碼農都會問的問題。當然可以咯,也是同樣簡單,就是給方法加上一個後設資料修飾符 @Query
public interface TodoRepository extends MongoRepository<Todo, String>{
@Query("{ 'user._id': ?0, 'desc': { '$regex': ?1} }")
List<Todo> searchTodos(@Param("userId") ObjectId userId, @Param("desc") String desc);
}複製程式碼
採用這種方式我們就不用按照命名規則起方法名了,可以直接使用MongoDB的查詢進行。上面的例子中有幾個地方需要說明一下
?0
和?1
是引數的佔位符,?0
表示第一個引數,也就是userId
;?1
表示第二個引數也就是desc
。- 使用
user._id
而不是user.id
是因為所有被@Id
修飾的屬性在Spring Data中都會被轉換成_id
- MongoDB中沒有關係型資料庫的Like關鍵字,需要以正規表示式的方式達成類似的功能。
其實,這種支援的力度已經可以讓我們寫出相對較複雜的查詢了。但肯定還是不夠的,對於開發人員來講,如果不給可以自定義的方式基本沒人會用的,因為總有這樣那樣的原因會導致我們希望能完全掌控我們的查詢或儲存過程。但這個話題展開感覺就內容更多了,後面再講吧。
本章程式碼:github.com/wpcfan/spri…
重拾後端之Spring Boot(一):REST API的搭建可以這樣簡單
重拾後端之Spring Boot(二):MongoDb的無縫整合
重拾後端之Spring Boot(三):找回熟悉的Controller,Service
重拾後端之Spring Boot(四):使用 JWT 和 Spring Security 保護 REST API