重拾后端之Spring Boot(二):MongoDB的无缝集成

重拾后端之Spring Boot(一):REST API的搭建能够这样简单
重拾后端之Spring Boot(二):MongoDb的无缝集成
重拾后端之Spring Boot(三):找回熟悉的Controller,Service
重拾后端之Spring Boot(四):使用 JWT 和 Spring Security 保护 REST API
重拾后端之 Spring Boot(五) -- 跨域、自定义查询及分页
重拾后端之Spring Boot(六) -- 热加载、容器和多项目html

上一节,咱们作的那个例子有点太简单了,一般的后台都会涉及一些数据库的操做,而后在暴露的API中提供处理后的数据给客户端使用。那么这一节咱们要作的是集成MongoDB ( www.mongodb.com )。java

MongoDB是什么?

MongoDB是一个NoSQL数据库,是NoSQL中的一个分支:文档数据库。和传统的关系型数据库好比Oracle、SQLServer和MySQL等有很大的不一样。传统的关系型数据库(RDBMS)已经成为数据库的代名词超过20多年了。对于大多数开发者来讲,关系型数据库是比较好理解的,表这种结构和SQL这种标准化查询语言毕竟是很大一部分开发者已有的技能。那么为何又搞出来了这个什么劳什子NoSQL,并且看上去NoSQL数据库正在飞快的占领市场。ios

NoSQL的应用场景是什么?

假设说咱们如今要构建一个论坛,用户能够发布帖子(帖子内容包括文本、视频、音频和图片等)。那么咱们能够画出一个下图的表关系结构。git

论坛的简略ER图
论坛的简略ER图

这种状况下咱们想一下这样一个帖子的结构怎么在页面中显示,若是咱们但愿显示帖子的文字,以及关联的图片、音频、视频、用户评论、赞和用户的信息的话,咱们须要关联八个表取得本身想要的数据。若是咱们有这样的帖子列表,并且是随着用户的滚动动态加载,同时须要监听是否有新内容的产生。这样一个任务咱们须要太多这种复杂的查询了。github

NoSQL解决这类问题的思路是,干脆抛弃传统的表结构,你不是帖子有一个结构关系吗,那我就直接存储和传输一个这样的数据给你,像下面那样。web

{
    "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

在Spring中集成MongoDB

在咱们刚刚的项目中集成MongoDB简单到使人发指,只有三个步骤:sql

  1. build.gradle 中更改 compile('org.springframework.boot:spring-boot-starter-web')compile("org.springframework.boot:spring-boot-starter-data-rest")
  2. Todo.java 中给 private String id; 以前加一个元数据修饰 @Id 以便让Spring知道这个Id就是数据库中的Id
  3. 新建一个以下的 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 咱们会获得下面的的结果。mongodb

{
    _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是使用了一个叫 ALPSalps.io/spec/index.… 的专门描述应用语义的数据格式。摘出下面这一小段来分析一下,这个描述了一个get方法,类型是 SAFE 代表这个操做不会对系统状态产生影响(由于只是查询),并且这个操做返回的结果格式定义在 todo-representation 中了。 todo-representation

{
  "id" : "get-todo",
  "name" : "todo",
  "type" : "SAFE",
  "rt" : "#todo-representation"
}复制代码

仍是不太理解?不要紧,咱们再来作一个实验,启动 PostMan (不知道的同窗,能够去Chrome应用商店中搜索下载)。咱们用Postman构建一个POST请求:

用Postman构建一个POST请求添加一个Todo
用Postman构建一个POST请求添加一个Todo

执行后的结果以下,咱们能够看到返回的links中包括了刚刚新增的Todo的link http://localhost:8080/todo/588a01abc5d0e23873d4c1b8588a01abc5d0e23873d4c1b8 就是数据库自动为这个Todo生成的Id),这样客户端能够方便的知道指向刚刚生成的Todo的API连接。

执行添加Todo后的返回Json数据
执行添加Todo后的返回Json数据

再举一个现实一些的例子,咱们在开发一个“个人”页面时,通常状况下除了取得个人某些信息以外,由于在这个页面还会有一些能够连接到更具体信息的页面连接。若是客户端在取得比较概要信息的同时就获得这些详情的连接,那么客户端的开发就比较简单了,并且也更灵活了。

其实这个描述中还告诉咱们一些分页的信息,好比每页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…Byget…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 类中同时有 useruserEmail 两个属性的状况下,咱们若是想要指定的是 useremail ,那么须要写成 findByUser_Email

还有一个问题,我估计不少同窗如今已经在想了,那就是咱们的这个例子中并无使用 userid,这不科学啊。是的,之因此没有在上面使用 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),你会发现返回的结果是个空数组。缘由是虽然咱们在类中标识 idString 类型,但对于这种数据库本身生成维护的字段,它在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中描述的关键字,返回匹配的集合。这个需求咱们只需改动一行,这个以命名规则为基础的查询条件是能够加 AndOr 这种关联多个条件的关键字的。

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的查询进行。上面的例子中有几个地方须要说明一下

  1. ?0?1 是参数的占位符,?0 表示第一个参数,也就是 userId?1 表示第二个参数也就是 desc
  2. 使用user._id 而不是 user.id 是由于全部被 @Id 修饰的属性在Spring Data中都会被转换成 _id
  3. MongoDB中没有关系型数据库的Like关键字,须要以正则表达式的方式达成相似的功能。

其实,这种支持的力度已经可让咱们写出相对较复杂的查询了。但确定仍是不够的,对于开发人员来说,若是不给能够自定义的方式基本没人会用的,由于总有这样那样的缘由会致使咱们但愿能彻底掌控咱们的查询或存储过程。但这个话题展开感受就内容更多了,后面再讲吧。

本章代码:github.com/wpcfan/spri…

重拾后端之Spring Boot(一):REST API的搭建能够这样简单
重拾后端之Spring Boot(二):MongoDb的无缝集成
重拾后端之Spring Boot(三):找回熟悉的Controller,Service
重拾后端之Spring Boot(四):使用 JWT 和 Spring Security 保护 REST API

相关文章
相关标签/搜索