Spring Boot API 服务开发指南

亚瑟
• 阅读 1527

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-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 # 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 状态码,配置了默认的 AuthenticationEntryPointHttpStatusServerEntryPoint。当前权限规则配置比较简单,除了少数路径可公开访问 permitAll(),其余均要求登录 authenticated()

访问数据库

用例层不直接访问数据库,而是把自己需要的功能抽象成为了各种接口,放在 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")
@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 定义的是类型,最顶层的两个类型是 QueryMutation,它们分别表示查询和修改,其中的字段可以理解为 API 入口。input 定义的是输入数据的类型,客户端可以发送 JSON 对象到服务端,这里的类型限定了可以发送的数据的结构。其它的类型,比如 IntStringBoolean,是 GraphQL 内置的标量类型,类型后添加的 ! 表示其值不能为 Null。

GraphQL 标准只定义了 IntFloatStringBoolean 这几种标量类型,如果需要可以增加新的。比如这里通过 scalar JSON 声明了新的标量类型 JSON,用于 File 文件类型的 thumbs 缩略图字段。文件的缩略图有多种规格,因此存放在一个 Map 对象中,key 为缩略图规格,value 为缩略图 URL。这里没必要为缩略图去定义一种新类型,对外输出 JSON 对象即可。Schema 文件里声明的标量类型需要有对应的 Java 类型,这里我们使用了 Extended Scalars for graphql-java,它提供了一些常用的标量类型,比如 DateTimeJSON 等。

开发 DataFetcher

Schema 只定义了类型,每个类型的每个字段的数据怎么得到,需要为每个字段创建一个 DataFetcher 来查询。除开父对象里已经存在的字段,其它所有字段都需要创建一个 DataFetcher 来处理该字段的查询请求。这里我们直接使用了 GraphQL Java,而没有使用 GraphQL Spring Boot Starters 这样更高级的库,以便在使用上更灵活,比如自定义异常处理。

每个字段都需要一个对应的 DataFetcher 对象,它是一个实现了 DataFetcher 接口的类的实例。为了避免定义过多的类,可以使用匿名类,结合 Java 8 Lambda 表达式,创建一个 DataFetcher 就相当于定义一个函数。不过为了方便后面使用注解来给各个 DataFetcher 统一增加认证授权功能,我们还是采取为每个字段定义一个常规类的方式。比如 Mutation.userRegisterQuery.userInfoUser.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());
    }
} 

对于顶层的 QueryMutation 类型,需要为其每一个字段创建 DataFetcher,而对于其它类型,则只需为父对象中不存在的字段创建。比如上面的 Query.userInfo 字段,查询结果的类型为 User,其 idusername 等字段可直接从父对象中得到,而像 avatarstatfollowing 这些需要进一步查询的字段,则需要再分别为它们创建 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 {
} 

参考资料

  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. Extended Scalars Extended scalars for graphql java
  9. Flyway Database migration
  10. Swagger Api documentation

本文转自 https://blog.jaggerwang.net/spring-boot-api-service-develop-tour/,如有侵权,请联系删除。

点赞
收藏
评论区
推荐文章
blmius blmius
2年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Jacquelyn38 Jacquelyn38
2年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Wesley13 Wesley13
2年前
Java获得今日零时零分零秒的时间(Date型)
publicDatezeroTime()throwsParseException{    DatetimenewDate();    SimpleDateFormatsimpnewSimpleDateFormat("yyyyMMdd00:00:00");    SimpleDateFormatsimp2newS
Stella981 Stella981
2年前
KVM调整cpu和内存
一.修改kvm虚拟机的配置1、virsheditcentos7找到“memory”和“vcpu”标签,将<namecentos7</name<uuid2220a6d1a36a4fbb8523e078b3dfe795</uuid
Wesley13 Wesley13
2年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
2年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Stella981 Stella981
2年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Wesley13 Wesley13
2年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Python进阶者 Python进阶者
3个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这