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 等依赖。
默认创建的项目结构只适合简单应用,对于业务逻辑比较复杂的应用,我们需要采取良好的设计来避免业务代码跟其它依赖代码紧密耦合。本项目采用了干净架构,能够保证业务代码的稳定性和可测试性,项目目录结构如下:
1. 2├── adapter # 适配层 3│ ├── controller # 控制器,将用例适配为 REST API 4│ ├── encoder # 编码器,实现 usecase/port/encoder 下的接口 5│ ├── generator # 生成器,实现 usecase/port/generator 下的接口 6│ ├── graphql # GraphQL Resolver,将用例适配为 GraphQL API 7│ ├── repository # 对象仓库,实现 usecase/port/repository 下的接口 8│ └── service # 第三方服务,实现 usecase/port/service 下的接口 9├── api # API 界面层 10│ ├── Application.java # Spring 应用 11│ ├── config # 应用配置 12│ ├── filter # 请求 Filter 13│ └── security # Spring Security 自定义 14├── entity # 实体层 15│ ├── ... 16│ └── UserEntity.java # 用户实体 17└── usecase # 用例层 18 ├── ... 19 ├── UserUsecase.java # 用户模块相关用例 20 ├── exception # 用例层异常 21 └── port # 用例层依赖的外部服务接口定义
四个层级从外到内依次为界面层、适配层、用例层和实体层,代码依赖关系遵循向内依赖原则,只有外层代码可以调用内层代码,不能反其道而行之。实体层和用例层保存着业务逻辑,它们是整个应用的核心,不受外层所用框架和工具的影响。用例层需要的外部依赖都通过接口进行了抽象,这些接口在适配层得以实现,以避免违反向内依赖原则。更多有关干净架构的内容可阅读此文 干净架构最佳实践。
认证和授权
关于授权,Spring Security 默认的基于角色的权限检查就能够满足 REST API 的需求。比较麻烦的是认证,因为 Spring Security 只提供了基于表单的认证方式,不适用于使用 JSON 来传递数据的 REST API,因此需要进行自定义。自定义有两种方式,一种是自定义表单认证流程,另外一种是手动设置登录状态。由于第一种方式改动地方较多,因此我们采取第二种方式。
在登录时调用 AuthenticationManager.authenticate() 方法来认证用户提交的用户名和密码,然后把认证结果更新到 SecurityContext 里。退出时更简单,只需清除认证信息就可以。
1package net.jaggerwang.sbip.adapter.controller; 2 3... 4 5abstract public class AbstractController { 6 ... 7 8 protected LoggedUser loginUser(String username, String password) { 9 var auth = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken( 10 username, password)); 11 var securityContext = SecurityContextHolder.getContext(); 12 securityContext.setAuthentication(auth); 13 return (LoggedUser) auth.getPrincipal(); 14 } 15 16 protected Optional<LoggedUser> logoutUser() { 17 var loggedUser = loggedUser(); 18 SecurityContextHolder.getContext().setAuthentication(null); 19 return loggedUser; 20 } 21 22 ... 23}
AuthenticationManager.authenticate() 方法会调用 UserDetailsService.loadUserByUsername() 方法来获取认证用户信息,由于如何加载用户信息只有应用知道,因此需要提供一个实现了 UserDetailsService 接口的 Bean 对象。
1package net.jaggerwang.sbip.api.security; 2 3... 4 5@Service 6public class CustomUserDetailsService implements UserDetailsService { 7 private UserUsecase userUsecase; 8 9 public CustomUserDetailsService(UserUsecase userUsecase) { 10 this.userUsecase = userUsecase; 11 } 12 13 @Override 14 public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { 15 Optional<UserEntity> userEntity; 16 if (username.matches("[0-9]+")) { 17 userEntity = userUsecase.infoByMobile(username); 18 } else if (username.matches("[a-zA-Z0-9_!#$%&’*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+")) { 19 userEntity = userUsecase.infoByEmail(username); 20 } else { 21 userEntity = userUsecase.infoByUsername(username); 22 } 23 if (userEntity.isEmpty()) { 24 throw new UsernameNotFoundException("用户未找到"); 25 } 26 27 List<GrantedAuthority> authorities = userUsecase.roles(username).stream() 28 .map(v -> new SimpleGrantedAuthority("ROLE_" + v.getName())) 29 .collect(Collectors.toList()); 30 31 return new LoggedUser(userEntity.get().getId(), userEntity.get().getUsername(), 32 userEntity.get().getPassword(), authorities); 33 } 34}
其中 loadUserByUsername() 方法返回的是自定义的 LoggedUser,它继承于 User,相比于 User 增加了 id 属性。
实现了认证之后,接下来是配置授权。
1package net.jaggerwang.sbip.api.config; 2 3... 4 5@Configuration 6public class SecurityConfig extends WebSecurityConfigurerAdapter { 7 @Bean 8 public PasswordEncoder passwordEncoder() { 9 return new BCryptPasswordEncoder(); 10 } 11 12 @Bean("authenticationManager") 13 @Override 14 public AuthenticationManager authenticationManagerBean() throws Exception { 15 return super.authenticationManagerBean(); 16 } 17 18 @Override 19 protected void configure(HttpSecurity http) throws Exception { 20 http 21 .csrf(csrf -> csrf.disable()) 22 .exceptionHandling(exceptionHandling -> exceptionHandling 23 .defaultAuthenticationEntryPointFor( 24 new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), 25 new AntPathRequestMatcher("/**")) 26 ) 27 .authorizeRequests(authorizeRequests -> authorizeRequests 28 .antMatchers("/favicon.ico", "/csrf", "/vendor/**", "/webjars/**", 29 "/actuator/**", "/v2/api-docs", "/swagger-ui.html", 30 "/swagger-resources/**", "/", "/graphql", "/login", "/logout", 31 "/auth/**", "/user/register", "/files/**").permitAll() 32 .anyRequest().authenticated() 33 ); 34 } 35}
上面的配置定义了前面手动设置登录状态需要的 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 添加了三个自定义方法:
1package net.jaggerwang.sbip.adapter.repository.jpa; 2 3... 4 5@Repository 6public interface UserJpaRepository extends JpaRepository<UserDo, Long> { 7 Optional<UserDo> findByUsername(String username); 8 9 Optional<UserDo> findByMobile(String mobile); 10 11 Optional<UserDo> findByEmail(String email); 12}
使用 @Query 注解
在 @Query 注解里指定要执行的语句,可以是 JPQL 或者原生 SQL。推荐后者,这样不用再额外去学习 JPQL 的语法。此外使用 JPQL 还要求先定义好实体之间的关联关系,否则不能使用关联查询。更多内容可阅读 Spring Data JPA 的参考文档 Using @Query。
假设我们要给 UserFollowJpaRepository 增加一个 isFollowing 方法来查询某个用户是否关注了另外一个用户,那么可以这样实现:
1package net.jaggerwang.sbip.adapter.repository.jpa; 2 3... 4 5@Repository 6public interface UserFollowJpaRepository extends JpaRepository<UserFollowDo, Long> { 7 @Query(value = "SELECT IF(COUNT(*)>0,'true','false') FROM user_follow uf " 8 + "WHERE uf.follower_id = :follower_id AND uf.following_id = :following_id", 9 nativeQuery = true) 10 boolean isFollowing(@Param("follower_id") Long followerId, 11 @Param("following_id") Long followingId); 12}
使用自定义接口
有的时候需要动态生成 SQL,那么前面两种方式就无法满足需求了,这种情况可以通过自定义接口来实现。假设我们要给 UserRepo 增加一个 following 方法来查询某个用户关注的用户,如果没有指定用户则查询所有被任意用户关注的用户。
首先定义一个接口:
1package net.jaggerwang.sbip.adapter.repository.jpa; 2 3... 4 5public interface UserJpaRepositoryCustom { 6 List<UserDo> following(Long followerId, Long limit, Long offset); 7}
然后实现该接口:
1package net.jaggerwang.sbip.adapter.repository.jpa; 2 3... 4 5public class UserJpaRepositoryCustomImpl implements UserJpaRepositoryCustom { 6 ... 7 8 public List<UserDo> following(Long followerId, Long limit, Long offset) { 9 var sql = "SELECT u.* FROM user_follow uf JOIN user u ON uf.following_id = u.id WHERE 1=1"; 10 if (followerId != null) { 11 sql += " AND uf.follower_id = :follower_id"; 12 } 13 sql += " ORDER BY uf.created_at DESC"; 14 if (limit != null) { 15 sql += " LIMIT :limit"; 16 } 17 if (offset != null) { 18 sql += " OFFSET :offset"; 19 } 20 21 var query = entityManager.createNativeQuery(sql, UserDo.class); 22 if (followerId != null) { 23 query.setParameter("follower_id", followerId); 24 } 25 if (limit != null) { 26 query.setParameter("limit", limit); 27 } 28 if (offset != null) { 29 query.setParameter("offset", offset); 30 } 31 32 @SuppressWarnings("unchecked") 33 var postEntities = (List<UserDo>) query.getResultList(); 34 return postEntities; 35 } 36}
最后让原有的接口 UserJpaRepository 同时再继承该自定义接口 UserJpaRepositoryCustomImpl 即可。
这里没有使用 CriteriaBuilder 来动态构建 SQL,而是直接使用了字符串拼接。这是因为 CriteriaBuilder 的 API 比较复杂,编写出来的代码可读性也很差,完全丢失了 SQL 的可读性。当然字符串拼接也不是什么好办法,无法保证类型安全,后面会有更好的办法。
使用 Querydsl 来动态构建 SQL
前面的 @Query 注解和自定义接口两种方式都需要直接编写 SQL(或者使用可读性很差的 CriteriaBuilder),它们均无法保障类型安全。Querydsl 正是为此而生,它在提供流畅舒适的 API 的同时,还能保障类型安全。
下面是查询某个用户关注的用户的 Querydsl 版本实现:
1package net.jaggerwang.sbip.adapter.repository; 2 3... 4 5@Component 6public class UserRepositoryImpl implements UserRepository { 7 ... 8 9 private JPAQuery<UserDo> followingQuery(Long followerId) { 10 var user = QUserDo.userDo; 11 var userFollow = QUserFollowDo.userFollowDo; 12 var query = jpaQueryFactory.selectFrom(user).join(userFollow).on(user.id.eq(userFollow.followingId)); 13 if (followerId != null) { 14 query.where(userFollow.followerId.eq(followerId)); 15 } 16 return query; 17 } 18 19 @Override 20 public List<UserEntity> following(Long followerId, Long limit, Long offset) { 21 var query = followingQuery(followerId); 22 var userFollow = QUserFollowDo.userFollowDo; 23 query.orderBy(userFollow.createdAt.desc()); 24 if (limit != null) { 25 query.limit(limit); 26 } 27 if (offset != null) { 28 query.offset(offset); 29 } 30 31 return query.fetch().stream().map(userDo -> userDo.toEntity()).collect(Collectors.toList()); 32 } 33 34 @Override 35 public Long followingCount(Long followerId) { 36 return followingQuery(followerId).fetchCount(); 37 } 38}
上面代码中的 QUserDo 和 QUserFollowDo 类是 Querydsl 从 JPA 实体类自动生成出来的,其中提供了各个实体字段的 Path 对象,以便使用它们来以类型安全的方式构建 SQL。可以看到其构建方式很自然,很容易看出实际执行的 SQL 语句是什么样子。有了 Querydsl,完全可以弃用手动编写 SQL,简单场景使用自定义方法,复杂场景使用 Querydsl。注意上面的代码我们并没有定义实体之间的关联关系,但仍然可以使用 Querydsl 来执行关联查询。
Querydsl 还提供了一些查询方法来辅助构建 SQL。类似于 JpaRepository,只需让 Repository 继承于 QuerydslPredicateExecutor,就可自动获得以下方法:
1package org.springframework.data.querydsl; 2 3... 4 5public interface QuerydslPredicateExecutor<T> { 6 Optional<T> findOne(Predicate predicate); 7 Iterable<T> findAll(Predicate predicate); 8 Iterable<T> findAll(Predicate predicate, Sort sort); 9 Iterable<T> findAll(Predicate predicate, OrderSpecifier<?>... orders); 10 Iterable<T> findAll(OrderSpecifier<?>... orders); 11 Page<T> findAll(Predicate predicate, Pageable pageable); 12 long count(Predicate predicate); 13 boolean exists(Predicate predicate); 14}
通过提供动态生成的 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 为例:
1package net.jaggerwang.sbip.adapter.controller; 2 3... 4 5@RestController 6@RequestMapping("/user") 7@Api(tags = "User Apis") 8public class UserController extends AbstractController { 9 ... 10 11 @PostMapping("/register") 12 @ApiOperation("Register user") 13 public RootDto register(@RequestBody UserDto userDto) { 14 var userEntity = userUsecase.register(userDto.toEntity()); 15 16 loginUser(userDto.getUsername(), userDto.getPassword()); 17 18 metricUsecase.increment("registerCount", 1L); 19 20 return new RootDto().addDataEntry("user", UserDto.fromEntity(userEntity)); 21 } 22 23 @GetMapping("/info") 24 @ApiOperation("Get user info") 25 public RootDto info(@RequestParam Long id) { 26 var userEntity = userUsecase.info(id); 27 if (userEntity.isEmpty()) { 28 throw new NotFoundException("用户未找到"); 29 } 30 31 return new RootDto().addDataEntry("user", fullUserDto(userEntity.get())); 32 } 33}
通过使用 @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:
1scalar JSON 2 3type Query { 4 authLogout: User 5 authLogged: User 6 userInfo(id: Int!): User! 7 userFollowing(userId: Int, limit: Int, offset: Int): [User!]! 8 userFollowingCount(userId: Int): Int! 9 userFollower(userId: Int, limit: Int, offset: Int): [User!]! 10 userFollowerCount(userId: Int): Int! 11 12 postInfo(id: Int!): Post! 13 postPublished(userId: Int, limit: Int, offset: Int): [Post!]! 14 postPublishedCount(userId: Int): Int! 15 postLiked(userId: Int, limit: Int, offset: Int): [Post!]! 16 postLikedCount(userId: Int): Int! 17 postFollowing(limit: Int, beforeId: Int, afterId: Int): [Post!]! 18 postFollowingCount: Int! 19 20 fileInfo(id: Int!): File! 21} 22 23type Mutation { 24 authLogin(user: UserInput!): User! 25 userRegister(user: UserInput!): User! 26 userModify(user: UserInput!, code: String): User! 27 userSendMobileVerifyCode(type: String!, mobile: String!): String! 28 userFollow(userId: Int!): Boolean! 29 userUnfollow(userId: Int!): Boolean! 30 31 postPublish(post: PostInput!): Post! 32 postDelete(id: Int!): Boolean! 33 postLike(postId: Int!): Boolean! 34 postUnlike(postId: Int!): Boolean! 35} 36 37type User { 38 id: Int! 39 username: String! 40 mobile: String 41 email: String 42 avatarId: Int 43 intro: String! 44 createdAt: String! 45 updatedAt: String 46 avatar: File 47 stat: UserStat! 48 following: Boolean! 49} 50 51type Post { 52 id: Int! 53 userId: Int! 54 type: PostType! 55 text: String! 56 imageIds: [Int!] 57 videoId: Int 58 createdAt: String! 59 updatedAt: String 60 user: User! 61 images: [File!] 62 video: File 63 stat: PostStat! 64 liked: Boolean! 65} 66 67enum PostType { 68 TEXT 69 IMAGE 70 VIDEO 71} 72 73type File { 74 id: Int! 75 userId: Int! 76 region: String! 77 bucket: String! 78 path: String! 79 meta: FileMeta! 80 createdAt: String! 81 updatedAt: String 82 user: User! 83 url: String! 84 thumbs: JSON 85} 86 87type FileMeta { 88 name: String! 89 size: Int! 90 type: String! 91} 92 93type UserStat { 94 id: Int! 95 userId: Int! 96 postCount: Int! 97 likeCount: Int! 98 followingCount: Int! 99 followerCount: Int! 100 createdAt: String! 101 updatedAt: String 102 user: User! 103} 104 105type PostStat { 106 id: Int! 107 postId: Int! 108 likeCount: Int! 109 createdAt: String! 110 updatedAt: String 111 post: Post! 112} 113 114input UserInput { 115 username: String 116 password: String 117 mobile: String 118 email: String 119 avatarId: Int 120 intro: String 121} 122 123input PostInput { 124 type: String 125 text: String 126 imageIds: [Int!] 127 videoId: Int 128}
其中 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 类分别如下:
1package net.jaggerwang.sbip.adapter.graphql.datafetcher.mutation; 2 3... 4 5@Component 6public class MutationUserRegisterDataFetcher extends AbstractDataFetcher implements DataFetcher { 7 @Override 8 @PermitAll 9 public Object get(DataFetchingEnvironment env) { 10 var userInput = objectMapper.convertValue(env.getArgument("user"), UserEntity.class); 11 var userEntity = userUsecase.register(userInput); 12 13 loginUser(userInput.getUsername(), userInput.getPassword()); 14 15 return userEntity; 16 } 17}
1package net.jaggerwang.sbip.adapter.graphql.datafetcher.query; 2 3... 4 5@Component 6public class QueryUserInfoDataFetcher extends AbstractDataFetcher implements DataFetcher { 7 @Override 8 public Object get(DataFetchingEnvironment env) { 9 var id = Long.valueOf((Integer) env.getArgument("id")); 10 var userEntity = userUsecase.info(id); 11 if (userEntity.isEmpty()) { 12 throw new NotFoundException("用户未找到"); 13 } 14 return userEntity.get(); 15 } 16}
1package net.jaggerwang.sbip.adapter.graphql.datafetcher.user; 2 3... 4 5@Component 6public class UserAvatarDataFetcher extends AbstractDataFetcher implements DataFetcher { 7 @Override 8 public Object get(DataFetchingEnvironment env) { 9 UserEntity userEntity = env.getSource(); 10 if (userEntity.getAvatarId() == null) { 11 return Optional.empty(); 12 } 13 return fileUsecase.info(userEntity.getAvatarId()); 14 } 15}
对于顶层的 Query 和 Mutation 类型,需要为其每一个字段创建 DataFetcher,而对于其它类型,则只需为父对象中不存在的字段创建。比如上面的 Query.userInfo 字段,查询结果的类型为 User,其 id、username 等字段可直接从父对象中得到,而像 avatar、stat 和 following 这些需要进一步查询的字段,则需要再分别为它们创建 DataFetcher。
每个 DataFetcher 在定义时都是相互独立的,最终需要将它们按照 Schema 的结构组装在一起。为了方便后面组装,这里给Schema 里的每种类型都定义了一个对应的类。以 Query 类型为例:
1package net.jaggerwang.sbip.adapter.graphql.type; 2 3... 4 5@Component 6public class QueryType implements Type { 7 ... 8 9 @Override 10 public Map<String, DataFetcher> dataFetchers() { 11 var m = new HashMap<String, DataFetcher>(); 12 m.put("authLogout", authLogoutDataFetcher); 13 m.put("authLogged", authLoggedDataFetcher); 14 m.put("userInfo", userInfoDataFetcher); 15 m.put("userFollowing", userFollowingDataFetcher); 16 m.put("userFollowingCount", userFollowingCountDataFetcher); 17 m.put("userFollower", userFollowerDataFetcher); 18 m.put("userFollowerCount", userFollowerCountDataFetcher); 19 m.put("postInfo", postInfoDataFetcher); 20 m.put("postPublished", postPublishedDataFetcher); 21 m.put("postPublishedCount", postPublishedCountDataFetcher); 22 m.put("postLiked", postLikedDataFetcher); 23 m.put("postLikedCount", postLikedCountDataFetcher); 24 m.put("postFollowing", postFollowingDataFetcher); 25 m.put("postFollowingCount", postFollowingCountDataFetcher); 26 m.put("fileInfo", fileInfoDataFetcher); 27 return m; 28 } 29}
配置 GraphQL
1package net.jaggerwang.sbip.api.config; 2 3... 4 5@Configuration(proxyBeanMethods = false) 6public class GraphQLConfig { 7 private GraphQL graphQL; 8 9 @Value("classpath:schema.graphqls") 10 private Resource schema; 11 12 @Autowired 13 QueryType queryType; 14 @Autowired 15 MutationType mutationType; 16 @Autowired 17 UserType userType; 18 @Autowired 19 PostType postType; 20 @Autowired 21 FileType fileType; 22 @Autowired 23 UserStatType userStatType; 24 @Autowired 25 PostStatType postStatType; 26 27 @PostConstruct 28 public void init() throws IOException { 29 var reader = new InputStreamReader(schema.getInputStream(), StandardCharsets.UTF_8); 30 var sdl = FileCopyUtils.copyToString(reader); 31 var graphQLSchema = buildSchema(sdl); 32 var executionStrategy = new AsyncExecutionStrategy( 33 new CustomDataFetchingExceptionHandler()); 34 this.graphQL = GraphQL.newGraphQL(graphQLSchema) 35 .queryExecutionStrategy(executionStrategy) 36 .mutationExecutionStrategy(executionStrategy) 37 .build(); 38 } 39 40 private GraphQLSchema buildSchema(String sdl) { 41 var typeRegistry = new SchemaParser().parse(sdl); 42 var runtimeWiring = buildWiring(); 43 return new SchemaGenerator().makeExecutableSchema(typeRegistry, runtimeWiring); 44 } 45 46 private RuntimeWiring buildWiring() { 47 return RuntimeWiring.newRuntimeWiring() 48 .scalar(ExtendedScalars.Json) 49 .type(newTypeWiring("Query").dataFetchers(queryType.dataFetchers())) 50 .type(newTypeWiring("Mutation").dataFetchers(mutationType.dataFetchers())) 51 .type(newTypeWiring("User").dataFetchers(userType.dataFetchers())) 52 .type(newTypeWiring("Post").dataFetchers(postType.dataFetchers())) 53 .type(newTypeWiring("File").dataFetchers(fileType.dataFetchers())) 54 .type(newTypeWiring("UserStat").dataFetchers(userStatType.dataFetchers())) 55 .type(newTypeWiring("PostStat").dataFetchers(postStatType.dataFetchers())) 56 .build(); 57 } 58 59 @Bean 60 public GraphQL graphQL() { 61 return graphQL; 62 } 63}
上面的配置中有几点值得注意:
- 通过显示创建
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)来统一执行权限检查:
1package net.jaggerwang.sbip.api.security; 2 3... 4 5@Component 6@Aspect 7public class SecureGraphQLAspect { 8 @Before("allDataFetchers() && isInApplication() && !isPermitAll()") 9 public void doSecurityCheck() { 10 var auth = SecurityContextHolder.getContext().getAuthentication(); 11 if (auth == null || auth instanceof AnonymousAuthenticationToken || 12 !auth.isAuthenticated()) { 13 throw new UnauthenticatedException("未认证"); 14 } 15 } 16 17 @Pointcut("target(graphql.schema.DataFetcher)") 18 private void allDataFetchers() { 19 } 20 21 @Pointcut("within(net.jaggerwang.sbip.adapter.graphql.datafetcher..*)") 22 private void isInApplication() { 23 } 24 25 @Pointcut("@annotation(net.jaggerwang.sbip.api.security.annotation.PermitAll)") 26 private void isPermitAll() { 27 } 28}
这个切面会在应用内的(isInApplication())所有 DataFetcher(allDataFetchers())的方法执行之前进行检查(doSecurityCheck()),除非该方法使用了 @PermitALL 注解(isPermitAll())。其中 @PermitALL 注解是我们自定义的注解:
1package net.jaggerwang.sbip.api.security.annotation; 2 3... 4 5@Retention(RetentionPolicy.RUNTIME) 6@Target(ElementType.METHOD) 7public @interface PermitALL { 8}
参考资料
- 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/,如有侵权,请联系删除。
