本文棣属于 Spring API 服务开发系列。

  1. Spring Boot API 服务开发指南
  2. Spring Boot API 服务测试指南
  3. Spring Cloud 微服务开发指南

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-webspring-boot-starter-data-jpaspring-boot-starter-data-redisspring-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 # 界面层
│   ├── SbipApplication.java # Spring 应用
│   ├── config # 应用配置
│   ├── exception # 界面层异常
│   └── security # Spring Security 自定义
├── entity # 实体层
│   ├── ...
│   └── UserEntity.java # 用户实体
└── usecase # 用例层
    ├── ...
    ├── UserUsecases.java # 用户模块相关用例
    ├── exception # 用例层异常
    └── port # 用例层依赖的外部服务接口定义

四个层级从外到内依次为界面层、适配层、用例层和实体层,代码依赖关系遵循向内依赖原则,只有外层代码可以调用内层代码,不能反其道而行之。实体层和用例层保存着业务逻辑,它们是整个应用的核心,不受外层所用框架和工具的影响。用例层需要的外部依赖都通过接口进行了抽象,这些接口在适配层得以实现,以避免违反向内依赖原则。更多有关干净架构的内容可阅读此文 干净架构最佳实践

处理认证和授权

关于授权,Spring Security 默认的基于角色的权限检查就能够满足 REST API 的需求。比较麻烦的是认证,因为 Spring Security 默认提供的是基于表单的网页认证方式,不适用于使用 JSON 来传递数据的 REST API,因此需要进行自定义。自定义有两种方式,一种在 Spring Security 默认的认证流程上去自定义,另外一种是抛开这个流程,手动设置登录状态。其中第二种会更简单一些,我们来分别看一下。

自定义认证流程

首先需要配置 Spring Security:

package net.jaggerwang.sbip.api.config;

...

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true, jsr250Enabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    ...

    private void responseJson(HttpServletResponse response, HttpStatus status, JsonDto data)
            throws IOException {
        response.setStatus(status.value());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(data));
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()

                .exceptionHandling()
                .authenticationEntryPoint((request, response, authException) -> {
                    responseJson(response, HttpStatus.UNAUTHORIZED,
                            new JsonDto("unauthenticated", "未认证"));
                }).accessDeniedHandler((request, response, accessDeniedException) -> {
                    responseJson(response, HttpStatus.FORBIDDEN,
                            new JsonDto("unauthorized", "未授权"));
                })

                .and().logout().logoutSuccessHandler((request, response, authentication) -> {
                    responseJson(response, HttpStatus.OK, new JsonDto());
                })

                .and().addFilterBefore(authFilter(), UsernamePasswordAuthenticationFilter.class)

                .authorizeRequests()
                .antMatchers("/graphql", "/subscriptions", "/graphiql", "/voyager", "/vendor/**",
                        "/actuator/**", "/files/**", "/user/register", "/user/login",
                        "/user/logged")
                .permitAll().anyRequest().authenticated();
    }

    @Bean
    public JsonUsernamePasswordAuthenticationFilter authFilter() throws Exception {
        var authFilter = new JsonUsernamePasswordAuthenticationFilter();
        authFilter.setAuthenticationManager(authenticationManagerBean());
        authFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
            var loggedUser = (LoggedUser) authentication.getPrincipal();
            var userEntity = userUsecases.info(loggedUser.getId());

            responseJson(response, HttpStatus.OK,
                    new JsonDto().addDataEntry("user", UserDto.fromEntity(userEntity)));
        });
        authFilter.setAuthenticationFailureHandler((request, response, exception) -> {
            if (exception instanceof UsernameNotFoundException
                    || exception instanceof BadCredentialsException) {
                responseJson(response, HttpStatus.OK, new JsonDto("fail", "用户名或密码错误"));
            } else {
                responseJson(response, HttpStatus.INTERNAL_SERVER_ERROR,
                        new JsonDto("fail", exception.toString()));
            }
        });
        return authFilter;
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

上面的代码主要是配置未认证、未授权和退出成功时返回 JSON 结果,以及使用自定义的 Authentication Filter 来修改登录成功和失败时的返回结果为 JSON 格式。其中需要注意的有以下几点:

  • 通过 authenticationEntryPoint() 方法配置检测到未登录时的处理函数,此种情况将响应 401 状态码和对应错误信息,而不是默认的重定向到登录页;
  • 通过 accessDeniedHandler() 方法配置检测到没有权限时的处理函数,此种情况将响应 403 状态码和对应错误信息;
  • 通过 logoutSuccessHandler() 方法配置退出登录时的处理函数,此种情况将响应 200 状态码和成功结果;
  • 使用 addFilterBefore() 方法在默认的 UsernamePasswordAuthenticationFilter 之前添加了自定义的 JsonUsernamePasswordAuthenticationFilter。这个 Filter 继承于 UsernamePasswordAuthenticationFilter,并且覆盖了其 attemptAuthentication 方法,以便从请求的 JSON 数据里解析用户名和密码来生成 UsernamePasswordAuthenticationToken,并提交给 AuthenticationManager 去认证。此外还分别通过这个 Filter 的 setAuthenticationSuccessHandlersetAuthenticationFailureHandler 方法设置了认证成功和失败时的处理函数;

另外还自定义了 UserDetailsService,以便调用用例层接口来获得需要认证的用户对象:

package net.jaggerwang.sbip.api.security;

...

@Service
public class CustomUserDetailsService implements UserDetailsService {
    ...

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity userEntity;
        try {
            if (username.matches("[0-9]+")) {
                userEntity = userUsecases.infoByMobile(username);
            } else if (username.matches("[a-zA-Z0-9_!#$%&’*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+")) {
                userEntity = userUsecases.infoByEmail(username);
            } else {
                userEntity = userUsecases.infoByUsername(username);
            }
        } catch (NotFoundException e) {
            throw new UsernameNotFoundException(e.getMessage());
        }

        List<GrantedAuthority> authorities = authorityUsecases.rolesOfUser(username).stream()
                .map(v -> new SimpleGrantedAuthority("ROLE_" + v.getName()))
                .collect(Collectors.toList());

        return new LoggedUser(userEntity.getId(), userEntity.getUsername(),
                userEntity.getPassword(), authorities);
    }
}

其中返回的用户对象类型是自定义的 LoggedUser,相比于默认的 User,增加了 id 属性。

手动设置登录状态

这种方式抛开了整个认证流程,登录时只需要在 API 里调用 AuthenticationManagerauthenticate 方法来验证用户提交的用户名和密码,然后把验证结果更新到 SecurityContext 里即可。退出时更简单,只需清除验证信息就可以了。参考代码如下:

package net.jaggerwang.sbip.adapter.controller;

...

abstract public class AbstractController {
    ...
    
    protected void loginUser(String username, String password) {
        var auth = authManager
                .authenticate(new UsernamePasswordAuthenticationToken(username, password));
        var sc = SecurityContextHolder.getContext();
        sc.setAuthentication(auth);
    }

    protected void logoutUser() {
        SecurityContextHolder.getContext().setAuthentication(null);
    }
}

访问数据库

用例层不直接访问数据库,而是把自己需要的功能抽象成为了各种接口,放在 net.jaggerwang.sbip.usecase.port.repository 这个包下,在适配层的 net.jaggerwang.sbip.adapter.repository 包里实现了这些接口。这些实现利用了 Spring Data JPA Repository 来访问数据库,并且在 JPA 的实体类型跟业务实体类型之间进行转换,不能直接将 JPA 实体对象返回用例层。除了使用 JPA,也可以使用 JdbcTemplateMyBatis 等其它技术来实现接口。因为有接口约定,这些技术选择对用例层来说是透明的。

每个 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();
    }
}

上面代码中的 QUserDoQUserFollowDo 类是 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);
}

通过提供动态生成的 PredicateOrderSpecifier 等对象就可实现自定义查询,无需从零开始构建 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")
public class UserController extends AbstractController {
    ...
    
    @PostMapping("/register")
    public JsonDto register(@RequestBody UserDto userDto) {
        var userEntity = userUsecases.register(userDto.toEntity());

        login(userDto.getUsername(), userDto.getPassword());

        metricUsecases.increment("registerCount", 1L);

        return new JsonDto().addDataEntry("user", UserDto.fromEntity(userEntity));
    }
    
    @GetMapping("/info")
    public JsonDto info(@RequestParam Long id) {
        var userEntity = userUsecases.info(id);

        return new JsonDto().addDataEntry("user", fullUserDto(userEntity));
    }
}

通过使用 @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 {
    userLogout: User!
    userLogged: 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!

    ...
}

type Mutation {
    userRegister(user: UserInput!): User!
    userLogin(user: UserInput!): User!
    userModify(user: UserInput!, code: String): User!
    userSendMobileVerifyCode(type: String!, mobile: String!): String!
    userFollow(userId: Int!): Boolean!
    userUnfollow(userId: 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 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!
}

input UserInput {
    username: String
    password: String
    mobile: String
    email: String
    avatarId: Int
    intro: String
}

...

其中 type 定义的是类型,最顶层的两个类型是 QueryMutation,它们分别表示查询和修改,其中的字段可以理解为 API 入口。input 定义的是输入数据的类型,客户端可以发送 JSON 对象到服务端,这里的类型限定了可以发送的数据的结构。其它的类型,比如 IntStringBoolean,是 GraphQL 内置的标量类型,类型后添加的 ! 表示其值不能为 Null。

GraphQL 标准只定义了 IntFloatStringBoolean 这几种标量类型,如果需要可以增加新的。比如这里我们通过 scalar JSON 新增了 JSON 标量类型,用于 File 文件类型的 thumbs 缩略图字段。文件的缩略图有多种规格,因此存放在一个 Map 对象中,key 为缩略图规格,value 为缩略图 URL。这里没必要为缩略图去定义一种新类型,对外输出 JSON 对象即可。要想让新增的标量类型生效,除了在 Schema 里声明之外,同时还需实现对应的 Java 类型。我们使用了 GraphQL Spring Boot Starters,只需在配置里添加一个 GraphQLScalarType 类型的 Bean 即可。

package net.jaggerwang.sbip.api.config;

...

@Configuration
public class GraphQLConfig {
    ...

    @Bean
    public GraphQLScalarType jsonType() {
        return GraphQLScalarType.newScalar().name("JSON").description("A json scalar")
                .coercing(new Coercing<Object, Object>() {
                    @Override
                    public Object serialize(Object input) throws CoercingSerializeException {
                        return input;
                    }

                    @Override
                    public Object parseValue(Object input) throws CoercingParseValueException {
                        return input;
                    }

                    @Override
                    public Object parseLiteral(Object input) throws CoercingParseLiteralException {
                        return parseLiteral(input, Collections.emptyMap());
                    }

                    @Override
                    public Object parseLiteral(Object input, Map<String, Object> variables)
                            throws CoercingParseLiteralException {
                        if (!(input instanceof Value)) {
                            throw new CoercingParseLiteralException(
                                    "Expected AST type 'StringValue' but was '"
                                            + (input == null ? "null"
                                                    : input.getClass().getSimpleName())
                                            + "'.");
                        }
                        if (input instanceof NullValue) {
                            return null;
                        }
                        if (input instanceof FloatValue) {
                            return ((FloatValue) input).getValue();
                        }
                        if (input instanceof StringValue) {
                            return ((StringValue) input).getValue();
                        }
                        if (input instanceof IntValue) {
                            return ((IntValue) input).getValue();
                        }
                        if (input instanceof BooleanValue) {
                            return ((BooleanValue) input).isValue();
                        }
                        if (input instanceof EnumValue) {
                            return ((EnumValue) input).getName();
                        }
                        if (input instanceof VariableReference) {
                            var varName = ((VariableReference) input).getName();
                            return variables.get(varName);
                        }
                        if (input instanceof ArrayValue) {
                            var values = ((ArrayValue) input).getValues();
                            return values.stream().map(v -> parseLiteral(v, variables))
                                    .collect(Collectors.toList());
                        }
                        if (input instanceof ObjectValue) {
                            var values = ((ObjectValue) input).getObjectFields();
                            var parsedValues = new LinkedHashMap<String, Object>();
                            values.forEach(fld -> {
                                var parsedValue = parseLiteral(fld.getValue(), variables);
                                parsedValues.put(fld.getName(), parsedValue);
                            });
                            return parsedValues;
                        }
                        return Assert.assertShouldNeverHappen("We have covered all Value types");
                    }
                }).build();
    }
}

自定义一个标量类型还是有一些工作量,如果需要自定义的标量类型比较多,可以使用 Extended Scalars for Graphql Java 这个包,它提供了 DateTimeJSONRegexLocale 等标量类型。

开发 Resolver

Schema 只是一个空壳子,每个类型的每个字段的数据怎么来,需要为每个类型定义一个 Resolver 来求解。除了父对象属性里已经存在的字段,其它所有字段都要在 Resolver 里定义一个方法来查询该字段的值。这里我们使用了 GraphQL Java Tools 来简化 Schema 的构建,而没有直接使用 GraphQL Java

QueryMutation 类型比较特殊,它们需要分别继承于 GraphQLQueryResolverGraphQLMutationResolver,其它类型都继承于 GraphQLResolver。先来看 QueryResolver

package net.jaggerwang.sbip.adapter.graphql;

...

@Component
public class QueryResolver extends AbstractResolver implements GraphQLQueryResolver {
    ...
    
    public UserEntity userInfo(Long id) {
        return userUsecases.info(id);
    }
}

其中 userInfo() 方法对应 Schema 里的 Query.userInfo 字段。QueryMutation 类型的每个字段都需要为之定义一个查询或修改方法,因为这里没有父对象。userInfo() 方法返回类型为 UserEntity,这是一个非标量类型,因此需要再为其定义 Resolver:

package net.jaggerwang.sbip.adapter.graphql;

...

@Component
public class UserResolver extends AbstractResolver implements GraphQLResolver<UserEntity> {
    ...
    
    public Optional<FileEntity> avatar(UserEntity userEntity) {
        if (userEntity.getAvatarId() == null) {
            return Optional.empty();
        }

        var fileEntity = fileUsecases.info(userEntity.getAvatarId());
        return Optional.of(fileEntity);
    }

    public UserStatEntity stat(UserEntity userEntity) {
        return statUsecases.userStatInfoByUserId(userEntity.getId());
    }

    public boolean following(UserEntity userEntity) {
        return userUsecases.isFollowing(loggedUserId(), userEntity.getId());
    }
}

除了 avatarstatfollowing 这几个字段,其它字段的值 UserEntity 已经提供,因此无需为它们定义方法。依次类推,直到所有类型的所有字段都得到了求解。

客户端可以发送如下的内容来查询 userInfo 这个字段:

query {
  userInfo(id: 1) {
      id
      username
      avatar {
          url
      }
  }
}

将得到如下的响应:

{
    "data": {
        "userInfo": {
            "id": 1,
            "username": "jaggerwang",
            "avatar": {
                "url": "http://localhost:8080/files/avatar/5def28780b6636329346ed2d.jpg"
            }
        }
    }
}

处理认证和授权

Spring Security 默认的认证同样不适合 GraphQ API,不过可以采取跟 REST API 类似的机制,在登录和退出 API 里手动设置登录状态,这里就不再重复。

Spring Security 默认开启的是基于请求路径(代表资源)的授权,而我们的 GraphQL API 对外只有一个端点 /graphql,没法使用。好在 Spring Security 支持基于方法的授权,可以通过 @EnableGlobalMethodSecurity(prePostEnabled = true, jsr250Enabled = true) 注解来开启。其中 prePostEnabled 使得可以在方法执行的前后检查权限,而 jsr250Enabled 使得可以基于角色来来检查权限。

我们的 API 权限验证比较简单,除了少量 API,比如注册、登录,其它都需要登录,暂时没有按角色划分权限。可以定义一个切面(Aspect)来实施统一的权限规则:

package net.jaggerwang.sbip.api.security.aspect;

...

@Component
@Aspect
@Order(1)
public class SecureGraphQLAspect {
    ...
    
    @Before("allGraphQLResolverMethods() && isInApplication() && !isPermitAllMethod()")
    public void doSecurityCheck() {
        var auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth instanceof AnonymousAuthenticationToken || !auth.isAuthenticated()) {
            throw new UnauthenticatedException("未认证");
        }
    }

    @Pointcut("target(com.coxautodev.graphql.tools.GraphQLResolver)")
    private void allGraphQLResolverMethods() {
    }

    @Pointcut("within(net.jaggerwang.sbip.adapter.graphql..*)")
    private void isInApplication() {
    }

    @Pointcut("@annotation(net.jaggerwang.sbip.api.security.annotation.PermitALL)")
    private void isPermitAllMethod() {
    }
}

这个切面会在应用内的(isInApplication())所有 GraphQL Resolver(allGraphQLResolverMethods())的方法执行之前检查是否已登录(doSecurityCheck()),除非该方法使用了 @PermitALL 注解(isPermitAllMethod())。其中 @PermitALL 注解是我们自定义的注解:

package net.jaggerwang.sbip.api.security.annotation;

...

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface PermitALL {
}

如果需要进一步按角色验证权限,那么可以在 Resolver 的方法上添加 @RolesAllowed 注解。

欢迎大家留言交流!

参考资料

  1. Spring Boot Web framework and server
  2. Spring Data JPA Access database
  3. Querydsl JPA Type safe dynamic sql builder
  4. Spring Data Redis Cache data
  5. Spring Security Authenticate and authrorize
  6. Spring Session Manage session
  7. GraphQL Java Graphql for java
  8. GraphQL Spring Boot Starters Graphql for spring boot
  9. Flyway Database migration