Spring Data JPA 之 Web MVC 开发的支持

15 JPA 对 Web MVC 开发的支持

我们使⽤ Spring Data JPA 的时候,⼀般都会⽤到 Spring MVC,Spring Data 对 Spring MVC 做了很好的⽀持,体现在以下⼏个⽅⾯:

  1. ⽀持在 Controller 层直接返回实体,⽽不使⽤其显式的调⽤⽅法;
  2. 对 MVC 层⽀持标准的分⻚和排序功能;
  3. 扩展的插件⽀持 Querydsl,可以实现⼀些通⽤的查询逻辑。

正常情况下,我们开启 Spring Data 对 Spring Web MVC ⽀持的时候需要在 @Configuration 的配置⽂件⾥⾯添加 @EnableSpringDataWebSupport 这⼀注解,如下⾯这种形式:

@Configuration
@EnableWebMvc
// 开启⽀持Spring Data Web的⽀持
@EnableSpringDataWebSupport
public class WebConfiguration { }

由于我们⽤了 Spring Boot,其有⾃动加载机制,会⾃动加载 SpringDataWebAutoConfiguration 类,发⽣如下变化:

@Configuration(proxyBeanMethods = false)
@EnableSpringDataWebSupport
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ PageableHandlerMethodArgumentResolver.class, WebMvcConfigurer.class })
@ConditionalOnMissingBean(PageableHandlerMethodArgumentResolver.class)
@EnableConfigurationProperties(SpringDataWebProperties.class)
@AutoConfigureAfter(RepositoryRestMvcAutoConfiguration.class)
public class SpringDataWebAutoConfiguration {}

从类上⾯可以看出来,@EnableSpringDataWebSupport 会⾃动开启,所以当我们⽤ Spring Boot + JPA + MVC 的时候,什么都不需要做,因为 Spring Boot 利⽤ Spring Data 对 Spring MVC 做了很多 Web 开发的天然⽀持。⽀持的组件有 DomainConverter、Page、Sort、Databinding、Dynamic Param 等。

那么我们先来看⼀下它对 DomainClassConverter 组件的⽀持。

15.1 DomainClassConverter 组件

这个组件的主要作⽤是帮我们把 Path 中 ID 的变量,或 Request 参数中的变量 ID 的参数值,直接转化成实体对象注册到 Controller ⽅法的参数⾥⾯。怎么理解呢?我们看个例⼦,就很好懂了

15.1.1 一个实例

⾸先,写⼀个 MVC 的 Controller,分别从 Path 和 Param 变量⾥⾯,根据 ID 转化成实体,代码如下:

@RestController
public class UserController {

    /**
     * 从 path 变量⾥⾯获得参数 ID 的值,然后直接转化成 user 实体
     */
    @GetMapping("/user/{id}")
    public User getUserFromPath(@PathVariable("id") User user) {
        return user;
    }

    /**
     * 将 request 的 param 中的 ID 变量值,转化成 user 实体
     */
    @GetMapping("/user")
    public User getUserFromRequestParam(@RequestParam("id") User user) {
        return user;
    }
}

然后,我们运⾏起来,看⼀下结果:

### 根据 id 获取用户信息
GET http://127.0.0.1:8080/user/1

### 根据 id 获取用户信息
GET http://127.0.0.1:8080/user?id=1

运行结果如下:

HTTP/1.1 200 
Content-Type: application/json

{
  "id": 1,
  "version": 0,
  "deleted": false,
  "createUserId": 245273790,
  "createdDate": "2022-07-31T08:32:38.915",
  "lastModifiedUserId": 245273790,
  "lastModifiedDate": "2022-07-31T08:32:38.915",
  "name": "zzn",
  "email": "973536793@qq.com",
  "sex": "BOY",
  "age": 18
}

从结果来看,Controller ⾥⾯的 getUserFromRequestParam ⽅法会⾃动根据 ID 查询实体对象 User,然后注⼊⽅法的参数⾥⾯。那它是怎么实现的呢?我们看⼀下源码。

15.1.2 源码分析

我们打开 DomainClassConverter 类,⾥⾯有个 ToEntityConverter 的内部转化类的 Matches ⽅法,它会判断参数的类型是不是实体,并且有没有对应的实体 Repositorie 存在。如果不存在,就会直接报错说找不到合适的参数转化器。

DomainClassConverter ⾥⾯的关键代码如下:

public class DomainClassConverter<T extends ConversionService & ConverterRegistry>
    implements ConditionalGenericConverter, ApplicationContextAware {
    
    @Nullable
		@Override
		public Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {

			if (source == null || !StringUtils.hasText(source.toString())) {
				return null;
			}

			if (sourceType.equals(targetType)) {
				return source;
			}

			Class<?> domainType = targetType.getType();
			RepositoryInvoker invoker = repositoryInvokerFactory.getInvokerFor(domainType);
			RepositoryInformation information = repositories.getRequiredRepositoryInformation(domainType);

			Object id = conversionService.convert(source, information.getIdType());
            // 调用 findById 执行查询
			return id == null ? null : invoker.invokeFindById(id).orElse(null);
		}

    @Override
    public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) {
        // 判断参数的类型是不是实体
        if (sourceType.isAssignableTo(targetType)) {
            return false;
        }

        Class<?> domainType = targetType.getType();
        // 有没有对应的实体的 Repository 存在
        if (!repositories.hasRepositoryFor(domainType)) {
            return false;
        }

        Optional<RepositoryInformation> repositoryInformation = repositories.getRepositoryInformationFor(domainType);

        return repositoryInformation.map(it -> {

            Class<?> rawIdType = it.getIdType();

            return sourceType.equals(TypeDescriptor.valueOf(rawIdType))
                || conversionService.canConvert(sourceType.getType(), rawIdType);
        }).orElseThrow(
            () -> new IllegalStateException(String.format("Couldn't find RepositoryInformation for %s!", domainType)));
    }

}		

所以,我们上⾯的例⼦其实是需要有 UserInfoRepository 的,否则会失败。通过源码我们也可以看到,如果 matches=true,那么就会执⾏ convert ⽅法,最终调⽤ findById 的⽅法帮我们执⾏查询动作。

SpringDataWebConfiguration 因为实现了 WebMvcConfigurer 的 addFormatters 所有加载了⾃定义参数转化器的功能,所以才有了 DomainClassConverter 组件的⽀持。关键代码如下:

@Configuration(proxyBeanMethods = false)
public class SpringDataWebConfiguration implements WebMvcConfigurer, BeanClassLoaderAware {
    
    @Override
	public void addFormatters(FormatterRegistry registry) {

		registry.addFormatter(DistanceFormatter.INSTANCE);
		registry.addFormatter(PointFormatter.INSTANCE);

		if (!(registry instanceof FormattingConversionService)) {
			return;
		}

		FormattingConversionService conversionService = (FormattingConversionService) registry;

		DomainClassConverter<FormattingConversionService> converter = new DomainClassConverter<>(conversionService);
		converter.setApplicationContext(context);
	}
    
}

从源码上我们也可以看到,DomainClassConverter 只会根据 ID 来查询实体,很有局限性,没有更加灵活的参数转化功能,不过你也可以根据源码⾃⼰进⾏扩展,我在这就不展示更多了。

下⾯来看⼀下JPA 对 Web MVC 分⻚和排序是如何⽀持的。

15.2 Page 和 Sort 的参数支持

15.2.1 一个实例

在我们之前的 UserController ⾥⾯添加如下两个⽅法,分别测试分⻚和排序。

@GetMapping("/users")
    public Page<User> queryByPage(Pageable pageable, User user) {
        return userRepository.findAll(Example.of(user), pageable);
    }

    @GetMapping("/users/sort")
    public HttpEntity<List<User>> queryBySort(Sort sort) {
        return new HttpEntity<>(userRepository.findAll(sort));
    }

其中,queryByPage ⽅法中,两个参数可以分别接收分⻚参数和查询条件,我们请求⼀下,看看效果:

### 分页查询用户信息
GET http://127.0.0.1:8080/users?size=2&page=0&ages=18&sort=id,desc

参数⾥⾯可以⽀持分⻚⼤⼩为 2、⻚码 0、排序(按照 ID 倒序)、参数 ages=18 的所有结果,如下所示:

{
  "content": [
    {
      "id": 5,
      "version": 0,
      "sex": "BOY",
      "age": 18
    },
    {
      "id": 4,
      "version": 0,
      "sex": "BOY",
      "age": 18
    }
  ],
  "pageable": {
    "sort": {
      "unsorted": false,
      "sorted": true,
      "empty": false
    },
    "pageNumber": 0,
    "pageSize": 2,
    "offset": 0,
    "unpaged": false,
    "paged": true
  },
  "totalPages": 3,
  "totalElements": 5,
  "last": false,
  "numberOfElements": 2,
  "first": true,
  "sort": {
    "unsorted": false,
    "sorted": true,
    "empty": false
  },
  "size": 2,
  "number": 0,
  "empty": false
}

上⾯的字段我就不⼀⼀介绍了,在第 4 课时(如何利⽤ Repository 中的⽅法返回值解决实际问题)我们已经讲过了,只不过现在应⽤到了 MVC 的 View 层。因此,我们可以得出结论:Pageable 既⽀持分⻚参数,也⽀持排序参数。并且从下⾯这⾏代码可以看出其也可以单独调⽤ Sort 参数。

### 排序查询
GET http://127.0.0.1:8080/users/sort?ages=18&sort=id,desc

那么它的实现原理是什么呢?

15.2.2 原理分析

和 DomainClassConverter 组件的⽀持是⼀样的,由于 SpringDataWebConfiguration 实现了 WebMvcConfigurer 接⼝,通过 addArgumentResolvers ⽅法,扩展了 Controller ⽅法的参数 HandlerMethodArgumentResolver 的解决者,从下⾯图⽚中你就可以看出来。

在这里插入图片描述

我们可以进去 SortHandlerMethodArgumentResolver 里面看一下源码:

在这里插入图片描述

这个类⾥⾯最关键的就是下⾯两个⽅法:

  1. supportsParameter,表示只处理类型为 Sort.class 的参数;
  2. resolveArgument,可以把请求⾥⾯参数的值,转换成该⽅法⾥⾯的参数 Sort 对象。

这⾥还要提到的是另外⼀个类:PageHandlerMethodArgumentResolver 类。

在这里插入图片描述

这个类⾥⾯也有两个最关键的⽅法:

  1. supportsParameter,表示我只处理类型是 Pageable.class 的参数;
  2. resolveArgument,把请求⾥⾯参数的值,转换成该⽅法⾥⾯的参数 Pageable 的实现类 PageRequest。

关于 Web 请求的分⻚和排序的⽀持就介绍到这⾥,那么如果返回的是⼀个 Projection 的接⼝,Spring 是怎么处理的呢?我们接着看。

15.3 Web MVC 的参数绑定

之前我们在讲 Projection 的时候提到过接⼝,Spring Data JPA ⾥⾯,也可以通过 @ProjectedPayload 和 @JsonPath 对接⼝进⾏注解⽀持,不过要注意这与前⾯所讲的 Jackson 注解的区别在于,此时我们讲的是接⼝。

15.3.1 一个实例

这⾥我依然结合⼀个实例来对这个接⼝进⾏讲解,请看下⾯的步骤。

第⼀步:如果要⽀持 Projection,必须要在 maven ⾥⾯引⼊ jsonpath 依赖才可以:

<dependency>
    <groupId>com.jayway.jsonpath</groupId>
    <artifactId>json-path</artifactId>
</dependency>

第⼆步:新建⼀个 UserForm 接⼝类,⽤来接收接⼝传递的 json 对象。

@ProjectedPayload
public interface UserForm {

    /**
     * 第⼀级参数 JSON ⾥⾯找 age 字段
     * // @JsonPath("$..age") $.. 代表任意层级找 age 字段
     */
    @JsonPath("$.age")
    Integer getAge();

    /**
     * 第⼀级找参数 JSON ⾥⾯的 telephone 字段
     * // @JsonPath({ "$.telephone", "$.user.telephone" })
     * 第⼀级或者 user 下⾯的 telephone 都可以
     */
    @JsonPath("$.telephone")
    String getTelephone();
    
}

第三步:在 Controller ⾥⾯新建⼀个 post ⽅法,通过接⼝获得 RequestBody 参数对象⾥⾯的值。

@PostMapping("/users/projected")
public UserForm saveUserInfo(@RequestBody UserForm form) {
    return form;
}

第四步:我们发送⼀个 post 请求,代码如下:

POST http://localhost:8080/users/projected
Content-Type: application/json

{
  "age": 18,
  "telephone": "12345678"
}

此时可以正常得到如下结果:

{
  "telephone": "12345678",
  "age": 18
}

这个响应结果说明了接⼝可以正常映射。现在你知道⽤法了,我们再通过源码分析⼀下其原理。

15.3.2 原理分析

很简单,我们还是直接看 SpringDataWebConfiguration,其中实现的 WebMvcConfigurer 接⼝⾥⾯有个 extendMessageConverters ⽅法,⽅法中加了⼀个 ProjectingJackson2HttpMessageConverter 的类,这个类会把带 ProjectedPayload.class 注解的接⼝进⾏ Converter。

我们看⼀下其中主要的两个⽅法:

  1. 加载 ProjectingJackson2HttpMessageConverter,⽤来做 Projecting 的接⼝转化。我们通过源码看⼀下是在哪⾥被加载进去的,如下:

    在这里插入图片描述

  2. ⽽ ProjectingJackson2HttpMessageConverter 主要是继承了 MappingJackson2HttpMessageConverter,并且实现了 HttpMessageConverter 的接⼝⾥⾯的两个重要⽅法,如下所示:

    public class ProjectingJackson2HttpMessageConverter extends MappingJackson2HttpMessageConverter
        implements BeanClassLoaderAware, BeanFactoryAware {
        
        
        @Override
        public boolean canRead(Type type, @Nullable Class<?> contextClass, @Nullable MediaType mediaType) {
    
            if (!canRead(mediaType)) {
                return false;
            }
    
            ResolvableType owner = contextClass == null ? null : ResolvableType.forClass(contextClass);
            Class<?> rawType = ResolvableType.forType(type, owner).resolve(Object.class);
            Boolean result = supportedTypesCache.get(rawType);
    
            if (result != null) {
                return result;
            }
    
            result = rawType.isInterface() && AnnotationUtils.findAnnotation(rawType, ProjectedPayload.class) != null;
            supportedTypesCache.put(rawType, result);
    
            return result;
        }
    
        @Override
        public Object read(Type type, @Nullable Class<?> contextClass, HttpInputMessage inputMessage)
            throws IOException, HttpMessageNotReadableException {
            return projectionFactory.createProjection(ResolvableType.forType(type).resolve(Object.class),
                                                      inputMessage.getBody());
        }
    }
    
    • canRead 通过判断参数的实体类型⾥⾯是否有接⼝,以及是否有 ProjectedPayload.class 注解后,才进⾏解析;
    • read ⽅法负责把 HttpInputMessage 转化成 Projected 的映射代理对象

现在你知道了 Spring ⾥⾯是如何通过 HttpMessageConverter 对 Projected 进⾏的⽀持,在使⽤过程中,希望你针对实际情况多去 Debug。不过这个不常⽤,你知道⼀下就可以了。

下⾯介绍⼀个通过 QueryDSL 对 Web 请求进⾏动态参数查询的⽅法。

15.4 Querydsl 的 Web MVC 支持

实际⼯作中,经常有⼈会⽤ Querydsl 做⼀些复杂查询,⽅便⽣成 Rest 的 API 接⼝,那么这种⽅法有什么好处,⼜会暴露什么缺点呢?我们先看⼀个实例。

15.4.1 一个实例

这是⼀个通过 QueryDSL 作为请求参数的使⽤案例,通过它你就可以体验⼀下 QueryDSL 的⽤法和使⽤场景,我们⼀步⼀步来看⼀下。

第⼀步:需要 maven 引⼊ querydsl 的依赖。

<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
</dependency>
<!--QueryDSL支持-->
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
</dependency>

第⼆步:UserRepository 继承 QuerydslPredicateExecutor 接⼝,就可以实现 QueryDSL 的查询⽅法了,代码如下:

public interface UserRepository extends BaseRepository<User, Long>, QuerydslPredicateExecutor<User> {}

第三步:Controller ⾥⾯直接利⽤ @QuerydslPredicate 注解接收 Predicate predicate 参数。

@GetMapping(value = "user/dsl")
public Page<User> queryByDsl(@QuerydslPredicate(root = User.class) Predicate predicate, Pageable pageable) {
    // 这⾥⾯我⽤的 userRepository ⾥⾯的 QuerydslPredicateExecutor ⾥⾯的⽅法
    return userRepository.findAll(predicate, pageable);
}

第四步:直接请求我们的 user/dsl 即可,这⾥利⽤ queryDsl 的语法 ,使 &ages=18 作为我们的请求参数。

{
    "content": [
        {
            "id": 5,
            "version": 0,
            "sex": "BOY",
            "age": 18
        },
        {
            "id": 4,
            "version": 0,
            "sex": "BOY",
            "age": 18
        }
    ],
    "pageable": {
        "sort": {
            "sorted": true,
            "unsorted": false,
            "empty": false
        },
        "pageNumber": 0,
        "pageSize": 2,
        "offset": 0,
        "paged": true,
        "unpaged": false
    },
    "last": false,
    "totalPages": 3,
    "totalElements": 5,
    "first": true,
    "numberOfElements": 2,
    "size": 2,
    "number": 0,
    "sort": {
        "sorted": true,
        "unsorted": false,
        "empty": false
    },
    "empty": false
}

现在我们可以得出结论:QuerysDSL 可以帮我们省去创建 Predicate 的过程,简化了操作流程。但是它依然存在⼀些局限性,⽐如多了⼀些模糊查询、范围查询、⼤⼩查询,它对这些⽅⾯的⽀持不是特别友好。可能未来会更新、优化,不过在这⾥你只要关注⼀下就可以了。

15.4.2 原理分析

QueryDSL 也是主要利⽤⾃定义 Spring MVC 的 HandlerMethodArgumentResolver 实现类,根据请求的参数字段,转化成 Controller ⾥⾯所需要的参数,请看⼀下源码。

public class QuerydslPredicateArgumentResolver extends QuerydslPredicateArgumentResolverSupport
      implements HandlerMethodArgumentResolver {
    
    @Nullable
	@Override
	public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
			NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {

		MultiValueMap<String, String> queryParameters = getQueryParameters(webRequest);
		Predicate result = getPredicate(parameter, queryParameters);

		return potentiallyConvertMethodParameterValue(parameter, result);
	}
    
}

在实际开发中,关于 insert 和 update 的接⼝我们是“逃不掉”的,但不是每次的字段都会全部传递过来,那这个时候我们应该怎么做呢?这就涉及了上述实例⾥⾯的两个注解 @DynamicUpdate 和 @DynamicInsert,下⾯来详细介绍⼀下。

15.5 @DynamicUpdate 和 @DynamicInsert 详解

15.5.1 通过语法快速了解

@DynamicInsert:这个注解表示 insert 的时候,会动态⽣产 insert SQL 语句,其⽣成 SQL

的规则是:只有⾮空的字段才能⽣成 SQL。代码如下:

@Target(TYPE)
@Retention(RUNTIME)
public @interface DynamicInsert {

    /**
     * 默认是 true,如果设置成 false,就表示空的字段也会⽣成 sql 语句;
     */
    boolean value() default true;

}

这个注解主要是⽤在 @Entity 的实体中,如果加上这个注解,就表示⽣成的 insert SQL 的 Columns 只包含⾮空的字段;如果实体中不加这个注解,默认的情况是空的,字段也会作为 insert 语句⾥⾯的 Columns。

@DynamicUpdate:和 insert 是⼀个意思,只不过这个注解指的是在 update 的时候,会动态产⽣ update SQL 语句,⽣成 SQL 的规则是:只有更新的字段才会⽣成到 update SQL 的 Columns ⾥⾯。 请看代码:

@Target( TYPE )
@Retention( RUNTIME )
public @interface DynamicUpdate {

    /**
     * 默认 true,如果设置成 false 和不添加这个注解的效果⼀样
     */
    boolean value() default true; 
}

和上⼀个注解的原理类似,这个注解也是⽤在 @Entity 的实体中,如果加上这个注解,就表示⽣成的 update SQL 的 Columns 只包含改变的字段;如果不加这个注解,默认的情况是所有的字段也会作为 update 语句⾥⾯的 Columns。

这样做的⽬的是提⾼ sql 的执⾏效率,默认更新所有字段,这样会导致⼀些到索引的字段也会更新,这样 sql 的执⾏效率就⽐较低了。需要注意的是:这种⽣效的前提是 select before-update 的触发机制。

15.5.2 使用案例

第⼀步:为了⽅便测试,我们修改⼀下 User 实体:加上 @DynamicInsert 和 @DynamicUpdate 注解。

@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(callSuper = true)
@DynamicInsert
@DynamicUpdate
public class User extends BaseEntity {

    private String name;
    private String email;
    @Enumerated(EnumType.STRING)
    private SexEnum sex;
    private Integer age;

}

第二步:准备测试用例

@Slf4j
@DataJpaTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Import(JpaConfiguration.class)
class UserRepositoryTest {

    @Test
    void test_dynamic_insert() {
        User user = User.builder().name("zzn").email("123456@126.com").build();
        userRepository.saveAndFlush(user);
    }
}

第三步:执行测试用例,查看日志输出

Hibernate: insert into user (create_user_id, created_date, deleted, last_modified_date, last_modified_user_id, version, email, name, id) values (?, ?, ?, ?, ?, ?, ?, ?, ?)

这时你会发现,除了 BaseEntity ⾥⾯的⼀些基础字段,⽽其他字段并没有⽣成到 insert 语句⾥⾯。

第四步:准备更新的测试用例

@Test
void test_dynamic_update() {
    User user = User.builder().name("zzn").email("123456@126.com").build();
    userRepository.saveAndFlush(user);
    User entity = userRepository.getById(user.getId());
    entity.setName("test_zzn");
    entity.setEmail(null);
    userRepository.saveAndFlush(entity);
    System.out.println(userRepository.findById(user.getId()));
}

第五步:执行测试用例,查看日志输出

Hibernate: update user set last_modified_date=?, last_modified_user_id=?, version=?, email=?, name=? where id=? and version=?

通过 SQL 可以看到,只更新了我们有进行变跟的字段,⽽包括了 null 的字段也更新了,如 email 字段中我们传递的是 null。

通过上⾯的两个例⼦你应该能弄清楚 @DynamicInsert 和 @DynamicUpdate 注解是做什么的了,我们在写 API 的时候就要考虑⼀下是否需要对 null 的字段进⾏操作,因为 JPA 是不知道字段为 null 的时候,是想更新还是不想更新,所以默认 JPA 会⽐较实例对象⾥⾯的所有包括 null 的字段,发现有变化也会更新。

15.6 本章小结

通过上⾯的讲解,你会发现 Spring Data 为我们做了不少⽀持 MVC 的⼯作,帮助我们提升了很多开发效率;并且通过原理分析,你也知道了⾃定义 HttpMessageConverter 和 HandlerMethodArgumentResolver 的⽅法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值