Spring Boot 大大简化了使用 Spring 框架开发 Web 应用时的配置工作,使用它只需添加相关依赖包,即可通过零配置或少量配置来运行一个 Web 应用。本文将使用 Spring Boot 来开发一个 API 服务,同时支持 REST 和 GraphQL 两种协议。内容包括使用 Querydsl 来替换 JPQL 以便以类型安全的方式动态构建 SQL,配置 Spring Security 以支持 REST API 认证授权,使用切面来保障 GraphQL API 的安全性,以及使用干净架构来保障业务代码的稳定性和可测试性。
Spring Boot 简介
Java 平台的 Web 技术从 Servlet 升级到 Spring 和 Spring MVC,使得开发 Web 应用变得越来越容易。但是 Spring 和 Spring MVC 的众多配置却让人望而却步,有过 Spring MVC 开发经验的人应该体会过这一痛苦。即便是开发一个超级简单的 Hello-World 应用,都需要我们在 pom 文件中导入各种依赖,编写 web.xml、spring.xml、springmvc.xml 等配置文件。特别是当需要导入大量 jar 包依赖时,我们需要在网上查找各种 jar 包,由于各个 jar 包之间存在依赖关系,导致又得去下载相关依赖 jar 包。各个 jar 包之间还存在着版本要求,一不小心就会出现版本冲突。在开始编写第一行业务代码之前,我们需要花费许多时间在编写配置文件和准备 jar 包上,这极大地影响了开发效率。为了简化 Spring 繁杂的配置,Spring Boot 应运而生。正如 Spring Boot 名称所示,Spring Boot 能够让我们“一键启动”应用开发。通过其自动配置功能,可以零配置或很少配置就可以启动一个 Spring 应用,从而使得我们将重心放在业务逻辑开发上。Spring Boot 和 Spring、Spring MVC 不是竞争关系,其底层还是使用的 Spring 和 Spring MVC,只不过让我们用起来更加的容易。
本文将通过一个实际的 API 服务来讲解 Spring Boot 应用开发中经常用到的一些技术,完整代码可从 GitHub 获取 Spring Boot in Practice。本 API 服务同时提供了 REST 和 GraphQL 两种风格的 API,接下来将分别讲解它们的技术实现关键点。
REST API
项目结构
Spring Boot 提供了 Initializr 来简化创建应用,只需要选择和填写构建工具、开发语言、Spring Boot 版本、依赖包等信息即可创建一个立即可运行的 Spring 应用。各大支持 Java 开发的 IDE 也都提供了对这个工具的集成,在 IDE 里即可创建,无需访问网站。我们的项目选择了 Maven 作为构建工具,Java 开发语言,以及 spring-boot-starter-web、spring-boot-starter-data-jpa、spring-boot-starter-data-redis、spring-boot-starter-security 等依赖。
默认创建的项目结构只适合简单应用,对于业务逻辑比较复杂的应用,我们需要采取良好的设计来避免业务代码跟其它依赖代码紧密耦合。本项目采用了干净架构,能够保证业务代码的稳定性和可测试性,项目目录结构如下:
.
├── adapter # 适配层
│ ├── controller # 控制器,将用例适配为 REST API
│ ├── encoder # 编码器,实现 usecase/port/encoder 下的接口
│ ├── generator # 生成器,实现 usecase/port/generator 下的接口
│ ├── graphql # GraphQL Resolver,将用例适配为 GraphQL API
│ ├── repository # 对象仓库,实现 usecase/port/repository 下的接口
│ └── service # 第三方服务,实现 usecase/port/service 下的接口
├── api # API 界面层
│ ├── Application.java # Spring 应用
│ ├── config # 应用配置
│ ├── filter # 请求 Filter
│ └── security # Spring Security 自定义
├── entity # 实体层
│ ├── ...
│ └── UserEntity.java # 用户实体
└── usecase # 用例层
├── ...
├── UserUsecase.java # 用户模块相关用例
├── exception # 用例层异常
└── port # 用例层依赖的外部服务接口定义 四个层级从外到内依次为界面层、适配层、用例层和实体层,代码依赖关系遵循向内依赖原则,只有外层代码可以调用内层代码,不能反其道而行之。实体层和用例层保存着业务逻辑,它们是整个应用的核心,不受外层所用框架和工具的影响。用例层需要的外部依赖都通过接口进行了抽象,这些接口在适配层得以实现,以避免违反向内依赖原则。更多有关干净架构的内容可阅读此文 干净架构最佳实践。
认证和授权
关于授权,Spring Security 默认的基于角色的权限检查就能够满足 REST API 的需求。比较麻烦的是认证,因为 Spring Security 只提供了基于表单的认证方式,不适用于使用 JSON 来传递数据的 REST API,因此需要进行自定义。自定义有两种方式,一种是自定义表单认证流程,另外一种是手动设置登录状态。由于第一种方式改动地方较多,因此我们采取第二种方式。
在登录时调用 AuthenticationManager.authenticate() 方法来认证用户提交的用户名和密码,然后把认证结果更新到 SecurityContext 里。退出时更简单,只需清除认证信息就可以。
package net.jaggerwang.sbip.adapter.controller;
...
abstract public class AbstractController {
...
protected LoggedUser loginUser(String username, String password) {
var auth = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
username, password));
var securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(auth);
return (LoggedUser) auth.getPrincipal();
}
protected Optional<LoggedUser> logoutUser() {
var loggedUser = loggedUser();
SecurityContextHolder.getContext().setAuthentication(null);
return loggedUser;
}
...
} AuthenticationManager.authenticate() 方法会调用 UserDetailsService.loadUserByUsername() 方法来获取认证用户信息,由于如何加载用户信息只有应用知道,因此需要提供一个实现了 UserDetailsService 接口的 Bean 对象。
package net.jaggerwang.sbip.api.security;
...
@Service
public class CustomUserDetailsService implements UserDetailsService {
private UserUsecase userUsecase;
public CustomUserDetailsService(UserUsecase userUsecase) {
this.userUsecase = userUsecase;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<UserEntity> userEntity;
if (username.matches("[0-9]+")) {
userEntity = userUsecase.infoByMobile(username);
} else if (username.matches("[a-zA-Z0-9_!#$%&’*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+")) {
userEntity = userUsecase.infoByEmail(username);
} else {
userEntity = userUsecase.infoByUsername(username);
}
if (userEntity.isEmpty()) {
throw new UsernameNotFoundException("用户未找到");
}
List<GrantedAuthority> authorities = userUsecase.roles(username).stream()
.map(v -> new SimpleGrantedAuthority("ROLE_" + v.getName()))
.collect(Collectors.toList());
return new LoggedUser(userEntity.get().getId(), userEntity.get().getUsername(),
userEntity.get().getPassword(), authorities);
}
} 其中 loadUserByUsername() 方法返回的是自定义的 LoggedUser,它继承于 User,相比于 User 增加了 id 属性。
实现了认证之后,接下来是配置授权。
package net.jaggerwang.sbip.api.config;
...
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean("authenticationManager")
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.exceptionHandling(exceptionHandling -> exceptionHandling
.defaultAuthenticationEntryPointFor(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
new AntPathRequestMatcher("/**"))
)
.authorizeRequests(authorizeRequests -> authorizeRequests
.antMatchers("/favicon.ico", "/csrf", "/vendor/**", "/webjars/**",
"/actuator/**", "/v2/api-docs", "/swagger-ui.html",
"/swagger-resources/**", "/", "/graphql", "/login", "/logout",
"/auth/**", "/user/register", "/files/**").permitAll()
.anyRequest().authenticated()
);
}
} 上面的配置定义了前面手动设置登录状态需要的 AuthenticationManager Bean。为了在未登录时给客户端响应正确的 HTTP 状态码,配置了默认的 AuthenticationEntryPoint 为 HttpStatusServerEntryPoint。当前权限规则配置比较简单,除了少数路径可公开访问 permitAll(),其余均要求登录 authenticated()。
访问数据库
用例层不直接访问数据库,而是把自己需要的功能抽象成为了各种接口,放在 net.jaggerwang.sbip.usecase.port.repository 这个包下,在适配层的 net.jaggerwang.sbip.adapter.repository 包里实现了这些接口。这些实现利用了 Spring Data JPA Repository 来访问数据库,并且在 JPA 的实体类型跟业务实体类型之间进行转换,不能直接将 JPA 实体对象返回用例层。除了使用 JPA,也可以使用 JdbcTemplate 和 MyBatis 等其它技术来实现接口。因为有接口约定,这些技术选择对用例层来说是透明的。
每个 JPA Repository 都继承了 JpaRepository 提供的增删改查基本方法,如果这些方法无法满足需求,可以采取下面这些方式来自定义查询:
添加自定义方法
这些方法的名字需要遵循一定的规范,比如 Optional<UserDo> findByUsername(String username) 会去查询 username 属性的值等于指定值的用户对象。更多内容可查阅 Spring Data JPA 的参考文档 Query Creation。
下面的 UserJpaRepository 添加了三个自定义方法:
package net.jaggerwang.sbip.adapter.repository.jpa;
...
@Repository
public interface UserJpaRepository extends JpaRepository<UserDo, Long> {
Optional<UserDo> findByUsername(String username);
Optional<UserDo> findByMobile(String mobile);
Optional<UserDo> findByEmail(String email);
} 使用 @Query 注解
在 @Query 注解里指定要执行的语句,可以是 JPQL 或者原生 SQL。推荐后者,这样不用再额外去学习 JPQL 的语法。此外使用 JPQL 还要求先定义好实体之间的关联关系,否则不能使用关联查询。更多内容可阅读 Spring Data JPA 的参考文档 Using @Query。
假设我们要给 UserFollowJpaRepository 增加一个 isFollowing 方法来查询某个用户是否关注了另外一个用户,那么可以这样实现:
package net.jaggerwang.sbip.adapter.repository.jpa;
...
@Repository
public interface UserFollowJpaRepository extends JpaRepository<UserFollowDo, Long> {
@Query(value = "SELECT IF(COUNT(*)>0,'true','false') FROM user_follow uf "
+ "WHERE uf.follower_id = :follower_id AND uf.following_id = :following_id",
nativeQuery = true)
boolean isFollowing(@Param("follower_id") Long followerId,
@Param("following_id") Long followingId);
} 使用自定义接口
有的时候需要动态生成 SQL,那么前面两种方式就无法满足需求了,这种情况可以通过自定义接口来实现。假设我们要给 UserRepo 增加一个 following 方法来查询某个用户关注的用户,如果没有指定用户则查询所有被任意用户关注的用户。
首先定义一个接口:
package net.jaggerwang.sbip.adapter.repository.jpa;
...
public interface UserJpaRepositoryCustom {
List<UserDo> following(Long followerId, Long limit, Long offset);
} 然后实现该接口:
package net.jaggerwang.sbip.adapter.repository.jpa;
...
public class UserJpaRepositoryCustomImpl implements UserJpaRepositoryCustom {
...
public List<UserDo> following(Long followerId, Long limit, Long offset) {
var sql = "SELECT u.* FROM user_follow uf JOIN user u ON uf.following_id = u.id WHERE 1=1";
if (followerId != null) {
sql += " AND uf.follower_id = :follower_id";
}
sql += " ORDER BY uf.created_at DESC";
if (limit != null) {
sql += " LIMIT :limit";
}
if (offset != null) {
sql += " OFFSET :offset";
}
var query = entityManager.createNativeQuery(sql, UserDo.class);
if (followerId != null) {
query.setParameter("follower_id", followerId);
}
if (limit != null) {
query.setParameter("limit", limit);
}
if (offset != null) {
query.setParameter("offset", offset);
}
@SuppressWarnings("unchecked")
var postEntities = (List<UserDo>) query.getResultList();
return postEntities;
}
} 最后让原有的接口 UserJpaRepository 同时再继承该自定义接口 UserJpaRepositoryCustomImpl 即可。
这里没有使用 CriteriaBuilder 来动态构建 SQL,而是直接使用了字符串拼接。这是因为 CriteriaBuilder 的 API 比较复杂,编写出来的代码可读性也很差,完全丢失了 SQL 的可读性。当然字符串拼接也不是什么好办法,无法保证类型安全,后面会有更好的办法。
使用 Querydsl 来动态构建 SQL
前面的 @Query 注解和自定义接口两种方式都需要直接编写 SQL(或者使用可读性很差的 CriteriaBuilder),它们均无法保障类型安全。Querydsl 正是为此而生,它在提供流畅舒适的 API 的同时,还能保障类型安全。
下面是查询某个用户关注的用户的 Querydsl 版本实现:
package net.jaggerwang.sbip.adapter.repository;
...
@Component
public class UserRepositoryImpl implements UserRepository {
...
private JPAQuery<UserDo> followingQuery(Long followerId) {
var user = QUserDo.userDo;
var userFollow = QUserFollowDo.userFollowDo;
var query = jpaQueryFactory.selectFrom(user).join(userFollow).on(user.id.eq(userFollow.followingId));
if (followerId != null) {
query.where(userFollow.followerId.eq(followerId));
}
return query;
}
@Override
public List<UserEntity> following(Long followerId, Long limit, Long offset) {
var query = followingQuery(followerId);
var userFollow = QUserFollowDo.userFollowDo;
query.orderBy(userFollow.createdAt.desc());
if (limit != null) {
query.limit(limit);
}
if (offset != null) {
query.offset(offset);
}
return query.fetch().stream().map(userDo -> userDo.toEntity()).collect(Collectors.toList());
}
@Override
public Long followingCount(Long followerId) {
return followingQuery(followerId).fetchCount();
}
} 上面代码中的 QUserDo 和 QUserFollowDo 类是 Querydsl 从 JPA 实体类自动生成出来的,其中提供了各个实体字段的 Path 对象,以便使用它们来以类型安全的方式构建 SQL。可以看到其构建方式很自然,很容易看出实际执行的 SQL 语句是什么样子。有了 Querydsl,完全可以弃用手动编写 SQL,简单场景使用自定义方法,复杂场景使用 Querydsl。注意上面的代码我们并没有定义实体之间的关联关系,但仍然可以使用 Querydsl 来执行关联查询。
Querydsl 还提供了一些查询方法来辅助构建 SQL。类似于 JpaRepository,只需让 Repository 继承于 QuerydslPredicateExecutor,就可自动获得以下方法:
package org.springframework.data.querydsl;
...
public interface QuerydslPredicateExecutor<T> {
Optional<T> findOne(Predicate predicate);
Iterable<T> findAll(Predicate predicate);
Iterable<T> findAll(Predicate predicate, Sort sort);
Iterable<T> findAll(Predicate predicate, OrderSpecifier<?>... orders);
Iterable<T> findAll(OrderSpecifier<?>... orders);
Page<T> findAll(Predicate predicate, Pageable pageable);
long count(Predicate predicate);
boolean exists(Predicate predicate);
} 通过提供动态生成的 Predicate、OrderSpecifier 等对象就可自定义查询条件和排序规则,无需从零开始构建 SQL,更加省事。如果这些方法还无法满足需求,则只能从零开始构建了。
通过结合使用 Spring Data JPA 和 Querydsl,既能满足简单场景快捷查询需求,又能满足复杂场景自定义查询需求。很多人弃用 Spring Data JPA 转用 MyBatis(或者结合两者)就是因为 MyBatis 对自定义查询支持得更好,不过使用 MyBatis 需要编写 XML 映射文件(虽然支持注解方式但不完善),这就大大降低了其易用性。相比于 Spring Data JPA + MyBatis,Spring Data JPA + Querydsl 的解决方案更加轻量,也更易使用,因此推荐后者。
题外话:该不该使用 ORM?
Spring Data JPA 是一个 ORM 框架,ORM 框架一般都非常重型。它们提供的功能很全面,但想要完全掌握需要花费很多的时间和精力。使用 ORM 完成简单的增删改查操作非常方便,但一旦牵扯到复杂的查询使用起来就非常麻烦,还不如直接编写 SQL 来得方便和灵活。ORM 只能帮你解决 80~90% 的映射问题,剩下的部分还是需要你能够真正理解关系数据库是如何工作的。连 Martin Folwer 都早已诟病过这个问题 OrmHate。
那么 ORM 是否就真的一无是处?笔者的建议是简单的增删改查场景可以使用 ORM,这确实可以节省不少工作,但对于复杂的场景完全可以自己构建 SQL 来实现,这样的性价比是最高的。具体到 Spring Data JPA,建议只有单表查询使用它提供的高级 API,多表关联查询还是自己构建 SQL。这样就不用在代码里去维护实体之间的关系(ORM 的复杂性大多由此导致),这个交给关系数据库就可以了。另外也不推荐使用 JPQL,虽然它跟 SQL 类似,但还是有许多细微差别,并且使用上还有一些限制。既然已经有了标准 SQL,何苦再去学习一种新的“SQL 方言”。
开发控制器
Spring MVC 的控制器(Controller)用来处理 HTTP 请求,每个请求会路由分发给某个控制器的某个方法。通过使用注解,可以不用显示去定义路由规则。
以 UserController 为例:
package net.jaggerwang.sbip.adapter.controller;
...
@RestController
@RequestMapping("/user")
@Api(tags = "User Apis")
public class UserController extends AbstractController {
...
@PostMapping("/register")
@ApiOperation("Register user")
public RootDto register(@RequestBody UserDto userDto) {
var userEntity = userUsecase.register(userDto.toEntity());
loginUser(userDto.getUsername(), userDto.getPassword());
metricUsecase.increment("registerCount", 1L);
return new RootDto().addDataEntry("user", UserDto.fromEntity(userEntity));
}
@GetMapping("/info")
@ApiOperation("Get user info")
public RootDto info(@RequestParam Long id) {
var userEntity = userUsecase.info(id);
if (userEntity.isEmpty()) {
throw new NotFoundException("用户未找到");
}
return new RootDto().addDataEntry("user", fullUserDto(userEntity.get()));
}
} 通过使用 @RequestMapping 注解,指定了本控制器会处理所有以路径 /user 打头的请求,然后进一步在控制器的方法上使用 @GetMapping 或 @PostMapping 注解来指定了该方法会处理的请求的具体路径和请求方式。在控制器方法的参数上使用 @RequestBody 注解来获取整个请求内容,或者使用 @RequestParam 注解来获取单个 Query 参数或表单字段。控制器方法返回的 Java 对象会自动编码为 JSON 对象响应给客户端。
GraphQL API
GraphQL:API 的未来
随着 API 设计越来越复杂,传统的 REST API 越来越难以满足多样性的客户端对于 API 的需求,GraphQL 以其良好的可定制性成为了越来越多开发人员的选择。
那么什么是 GraphQL?简单来说,GraphQL 是一个开源的查询语言和协议。GraphQL 允许客户端根据其需求请求特定部分的数据,而 REST 始终返回固定的数据,哪怕其中有些是当前客户端用不上的。GraphQL 消除了发布的内容和可消费的内容之间的差距。GraphQL 是基于图来创建的,而 REST 是基于文件而创建的。GraphQL 跟 REST 一样使用 HTTP 协议来传输数据,因此很容易接入到现有的基于 REST 的系统中。更多内容可浏览官方文档 Learn GraphQL.
定义 Schema
开发 GraphQL 的第一步就是设计 Schema,其中定义了可返回给客户端的字段,如果某个字段的值是一个对象,那么还需要进一步定义该对象包含的字段。依次类推,直到所有叶子节点的类型都已是标量(Scalar,字符串、整数、浮点数、布尔等)。Schema 实际上是定义了各类型的对象节点之间的网状图形关系,最顶层的字段可以看做是各个 API 的入口。客户端在请求的时候需要指定返回对象的哪些字段,包括嵌套对象的字段,与定义 Schema 时类似。
下面是本 API 服务的 Schema:
scalar JSON
type Query {
authLogout: User
authLogged: User
userInfo(id: Int!): User!
userFollowing(userId: Int, limit: Int, offset: Int): [User!]!
userFollowingCount(userId: Int): Int!
userFollower(userId: Int, limit: Int, offset: Int): [User!]!
userFollowerCount(userId: Int): Int!
postInfo(id: Int!): Post!
postPublished(userId: Int, limit: Int, offset: Int): [Post!]!
postPublishedCount(userId: Int): Int!
postLiked(userId: Int, limit: Int, offset: Int): [Post!]!
postLikedCount(userId: Int): Int!
postFollowing(limit: Int, beforeId: Int, afterId: Int): [Post!]!
postFollowingCount: Int!
fileInfo(id: Int!): File!
}
type Mutation {
authLogin(user: UserInput!): User!
userRegister(user: UserInput!): User!
userModify(user: UserInput!, code: String): User!
userSendMobileVerifyCode(type: String!, mobile: String!): String!
userFollow(userId: Int!): Boolean!
userUnfollow(userId: Int!): Boolean!
postPublish(post: PostInput!): Post!
postDelete(id: Int!): Boolean!
postLike(postId: Int!): Boolean!
postUnlike(postId: Int!): Boolean!
}
type User {
id: Int!
username: String!
mobile: String
email: String
avatarId: Int
intro: String!
createdAt: String!
updatedAt: String
avatar: File
stat: UserStat!
following: Boolean!
}
type Post {
id: Int!
userId: Int!
type: PostType!
text: String!
imageIds: [Int!]
videoId: Int
createdAt: String!
updatedAt: String
user: User!
images: [File!]
video: File
stat: PostStat!
liked: Boolean!
}
enum PostType {
TEXT
IMAGE
VIDEO
}
type File {
id: Int!
userId: Int!
region: String!
bucket: String!
path: String!
meta: FileMeta!
createdAt: String!
updatedAt: String
user: User!
url: String!
thumbs: JSON
}
type FileMeta {
name: String!
size: Int!
type: String!
}
type UserStat {
id: Int!
userId: Int!
postCount: Int!
likeCount: Int!
followingCount: Int!
followerCount: Int!
createdAt: String!
updatedAt: String
user: User!
}
type PostStat {
id: Int!
postId: Int!
likeCount: Int!
createdAt: String!
updatedAt: String
post: Post!
}
input UserInput {
username: String
password: String
mobile: String
email: String
avatarId: Int
intro: String
}
input PostInput {
type: String
text: String
imageIds: [Int!]
videoId: Int
} 其中 type 定义的是类型,最顶层的两个类型是 Query 和 Mutation,它们分别表示查询和修改,其中的字段可以理解为 API 入口。input 定义的是输入数据的类型,客户端可以发送 JSON 对象到服务端,这里的类型限定了可以发送的数据的结构。其它的类型,比如 Int、String、Boolean,是 GraphQL 内置的标量类型,类型后添加的 ! 表示其值不能为 Null。
GraphQL 标准只定义了 Int、Float、String、Boolean 这几种标量类型,如果需要可以增加新的。比如这里通过 scalar JSON 声明了新的标量类型 JSON,用于 File 文件类型的 thumbs 缩略图字段。文件的缩略图有多种规格,因此存放在一个 Map 对象中,key 为缩略图规格,value 为缩略图 URL。这里没必要为缩略图去定义一种新类型,对外输出 JSON 对象即可。Schema 文件里声明的标量类型需要有对应的 Java 类型,这里我们使用了 Extended Scalars for graphql-java,它提供了一些常用的标量类型,比如 DateTime、JSON 等。
开发 DataFetcher
Schema 只定义了类型,每个类型的每个字段的数据怎么得到,需要为每个字段创建一个 DataFetcher 来查询。除开父对象里已经存在的字段,其它所有字段都需要创建一个 DataFetcher 来处理该字段的查询请求。这里我们直接使用了 GraphQL Java,而没有使用 GraphQL Spring Boot Starters 这样更高级的库,以便在使用上更灵活,比如自定义异常处理。
每个字段都需要一个对应的 DataFetcher 对象,它是一个实现了 DataFetcher 接口的类的实例。为了避免定义过多的类,可以使用匿名类,结合 Java 8 Lambda 表达式,创建一个 DataFetcher 就相当于定义一个函数。不过为了方便后面使用注解来给各个 DataFetcher 统一增加认证授权功能,我们还是采取为每个字段定义一个常规类的方式。比如 Mutation.userRegister、Query.userInfo 和 User.avatar 字段对应的 DataFetcher 类分别如下:
package net.jaggerwang.sbip.adapter.graphql.datafetcher.mutation;
...
@Component
public class MutationUserRegisterDataFetcher extends AbstractDataFetcher implements DataFetcher {
@Override
@PermitAll
public Object get(DataFetchingEnvironment env) {
var userInput = objectMapper.convertValue(env.getArgument("user"), UserEntity.class);
var userEntity = userUsecase.register(userInput);
loginUser(userInput.getUsername(), userInput.getPassword());
return userEntity;
}
} package net.jaggerwang.sbip.adapter.graphql.datafetcher.query;
...
@Component
public class QueryUserInfoDataFetcher extends AbstractDataFetcher implements DataFetcher {
@Override
public Object get(DataFetchingEnvironment env) {
var id = Long.valueOf((Integer) env.getArgument("id"));
var userEntity = userUsecase.info(id);
if (userEntity.isEmpty()) {
throw new NotFoundException("用户未找到");
}
return userEntity.get();
}
} package net.jaggerwang.sbip.adapter.graphql.datafetcher.user;
...
@Component
public class UserAvatarDataFetcher extends AbstractDataFetcher implements DataFetcher {
@Override
public Object get(DataFetchingEnvironment env) {
UserEntity userEntity = env.getSource();
if (userEntity.getAvatarId() == null) {
return Optional.empty();
}
return fileUsecase.info(userEntity.getAvatarId());
}
} 对于顶层的 Query 和 Mutation 类型,需要为其每一个字段创建 DataFetcher,而对于其它类型,则只需为父对象中不存在的字段创建。比如上面的 Query.userInfo 字段,查询结果的类型为 User,其 id、username 等字段可直接从父对象中得到,而像 avatar、stat 和 following 这些需要进一步查询的字段,则需要再分别为它们创建 DataFetcher。
每个 DataFetcher 在定义时都是相互独立的,最终需要将它们按照 Schema 的结构组装在一起。为了方便后面组装,这里给Schema 里的每种类型都定义了一个对应的类。以 Query 类型为例:
package net.jaggerwang.sbip.adapter.graphql.type;
...
@Component
public class QueryType implements Type {
...
@Override
public Map<String, DataFetcher> dataFetchers() {
var m = new HashMap<String, DataFetcher>();
m.put("authLogout", authLogoutDataFetcher);
m.put("authLogged", authLoggedDataFetcher);
m.put("userInfo", userInfoDataFetcher);
m.put("userFollowing", userFollowingDataFetcher);
m.put("userFollowingCount", userFollowingCountDataFetcher);
m.put("userFollower", userFollowerDataFetcher);
m.put("userFollowerCount", userFollowerCountDataFetcher);
m.put("postInfo", postInfoDataFetcher);
m.put("postPublished", postPublishedDataFetcher);
m.put("postPublishedCount", postPublishedCountDataFetcher);
m.put("postLiked", postLikedDataFetcher);
m.put("postLikedCount", postLikedCountDataFetcher);
m.put("postFollowing", postFollowingDataFetcher);
m.put("postFollowingCount", postFollowingCountDataFetcher);
m.put("fileInfo", fileInfoDataFetcher);
return m;
}
} 配置 GraphQL
package net.jaggerwang.sbip.api.config;
...
@Configuration(proxyBeanMethods = false)
public class GraphQLConfig {
private GraphQL graphQL;
@Value("classpath:schema.graphqls")
private Resource schema;
@Autowired
QueryType queryType;
@Autowired
MutationType mutationType;
@Autowired
UserType userType;
@Autowired
PostType postType;
@Autowired
FileType fileType;
@Autowired
UserStatType userStatType;
@Autowired
PostStatType postStatType;
@PostConstruct
public void init() throws IOException {
var reader = new InputStreamReader(schema.getInputStream(), StandardCharsets.UTF_8);
var sdl = FileCopyUtils.copyToString(reader);
var graphQLSchema = buildSchema(sdl);
var executionStrategy = new AsyncExecutionStrategy(
new CustomDataFetchingExceptionHandler());
this.graphQL = GraphQL.newGraphQL(graphQLSchema)
.queryExecutionStrategy(executionStrategy)
.mutationExecutionStrategy(executionStrategy)
.build();
}
private GraphQLSchema buildSchema(String sdl) {
var typeRegistry = new SchemaParser().parse(sdl);
var runtimeWiring = buildWiring();
return new SchemaGenerator().makeExecutableSchema(typeRegistry, runtimeWiring);
}
private RuntimeWiring buildWiring() {
return RuntimeWiring.newRuntimeWiring()
.scalar(ExtendedScalars.Json)
.type(newTypeWiring("Query").dataFetchers(queryType.dataFetchers()))
.type(newTypeWiring("Mutation").dataFetchers(mutationType.dataFetchers()))
.type(newTypeWiring("User").dataFetchers(userType.dataFetchers()))
.type(newTypeWiring("Post").dataFetchers(postType.dataFetchers()))
.type(newTypeWiring("File").dataFetchers(fileType.dataFetchers()))
.type(newTypeWiring("UserStat").dataFetchers(userStatType.dataFetchers()))
.type(newTypeWiring("PostStat").dataFetchers(postStatType.dataFetchers()))
.build();
}
@Bean
public GraphQL graphQL() {
return graphQL;
}
} 上面的配置中有几点值得注意:
- 通过显示创建
AsyncExecutionStrategy对象,指定了自定义异常处理器CustomDataFetchingExceptionHandler,其中使用了自定义的错误类型CustomDataFetchingError,以便通过extensions返回业务错误码code。 - 通过
scalar(ExtendedScalars.Json)来添加新的标量类型JSON。 - 为了简化为每个字段手动绑定 DataFetcher,使用了前面为各个 Schema 类型定义的类。
认证和授权
Spring Security 暂不支持 GraphQL API,不过可以通过自定义来支持。对于认证,可以采取跟 REST API 类似的机制,在登录和退出 API 里手动设置登录状态,这里就不再重复。
对于授权,Spring Security 默认开启的是基于路径(代表资源)的授权,而 GraphQL API 对外只有一个端点 /graphql,没法使用这种方式。不过 Spring Security 支持基于方法的授权,可以通过 @EnableGlobalMethodSecurity(prePostEnabled = true, jsr250Enabled = true) 注解来开启,其中 prePostEnabled 使得可以在方法执行的前后检查权限,而 jsr250Enabled 使得可以基于角色来来检查权限。
我们的 API 权限验证比较简单,除了少量 API,比如注册、登录,其它都需要登录,暂时没有按角色划分权限。使用 Spring Security 需要在每个 DataFetcher 上去增加注解,所以这里没有使用 Spring Security,而是定义了一个切面(Aspect)来统一执行权限检查:
package net.jaggerwang.sbip.api.security;
...
@Component
@Aspect
public class SecureGraphQLAspect {
@Before("allDataFetchers() && isInApplication() && !isPermitAll()")
public void doSecurityCheck() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth instanceof AnonymousAuthenticationToken ||
!auth.isAuthenticated()) {
throw new UnauthenticatedException("未认证");
}
}
@Pointcut("target(graphql.schema.DataFetcher)")
private void allDataFetchers() {
}
@Pointcut("within(net.jaggerwang.sbip.adapter.graphql.datafetcher..*)")
private void isInApplication() {
}
@Pointcut("@annotation(net.jaggerwang.sbip.api.security.annotation.PermitAll)")
private void isPermitAll() {
}
} 这个切面会在应用内的(isInApplication())所有 DataFetcher(allDataFetchers())的方法执行之前进行检查(doSecurityCheck()),除非该方法使用了 @PermitALL 注解(isPermitAll())。其中 @PermitALL 注解是我们自定义的注解:
package net.jaggerwang.sbip.api.security.annotation;
...
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface PermitALL {
} 参考资料
- Spring Boot Web framework and server
- Spring Data JPA Access database
- Querydsl JPA Type safe dynamic sql builder
- Spring Data Redis Cache data
- Spring Security Authenticate and authrorize
- Spring Session Manage session
- GraphQL Java Graphql for java
- Extended Scalars Extended scalars for graphql java
- Flyway Database migration
- Swagger Api documentation
本文转自 https://blog.jaggerwang.net/spring-boot-api-service-develop-tour/,如有侵权,请联系删除。



