啤酒炖鸭

  • 配料表:

    1. 半片鸭

    2. 鸭腿 2 只

    3. 青椒 2 只

    4. 八角 1 个

    5. 桂皮 1 块

    6. 大葱两段

    7. 生姜 8 片

    8. 小米椒 5 个

    9. 生抽

    10. 老抽

    11. 青岛啤酒 500 mL 一罐

    12. 白胡椒

  • 注意点:

    1. 鸭子切块洗净。

    2. 鸭肉冷水入锅加入料酒葱姜飞水。

    3. 炒锅中加入少量油,加入葱姜、香料。

    4. 加入鸭肉上糖色并炒至鸭肉出油,加入生抽、老抽和糖。

    5. 将炒锅中的鸭肉转移至铸铁锅内,加入葱姜和啤酒炖煮,先大火煮开然后小火慢炖。

    6. 炖煮 30 分钟以上,加入青椒和小米椒。

    7. 出国前撒入适量白胡椒。

  • 经验总结:

    1. 鸭肉尽量还是选用冰鲜鸭肉,冷冻的解冻比较花时间,而且味道无法保证。

    2. 香料不能多加,加了一片桂皮和大约 6 个八角结果香料味道太重盖住了鸭肉的香味。

    3. 鸭肉入锅前没有先炒制香料和葱姜蒜。

    4. 生抽和老抽没有在炒鸭肉的时候加,在炖煮的时候再加晚了。

  • 参考资料:

    啤酒烧鸭

泡椒凤爪

  • 配料表:

    1. 凤爪 400g

    2. 大葱两段

    3. 白酒若干

    4. 八角两颗

    5. 桂皮两片

    6. 柠檬两片

    7. 干辣椒 15g

    8. 花椒 10g + 若干散装

    9. 洋葱 100g

    10. 胡萝卜半根 切条状

    11. 尖椒一根切环

    12. 小米辣若干

    13. 白醋 50mL

    14. 冰糖 30g

    15. 盐 30g

    16. 姜片 50g + 飞水用若干

    17. 大蒜 50g

    18. 野山椒 180g

  • 注意点:

    1. 鸡爪洗净,剪掉指甲。

    2. 鸡爪冷水入锅加料酒、葱姜、一小把花椒。

    3. 大火煮沸,然后保持沸腾状态 15 分钟,期间不断捞除血沫。

    4. 捞出鸡爪,倒入白酒搅拌。搅拌均匀并且鸡爪开始降温后再用冷开水或者矿泉水(最好是冰水)清洗一遍。

    5. 依次放入野山椒 180g、大蒜 50g、姜片 50g、冰糖 30g、盐 30g、花椒 10g、白醋 50 mL、干辣椒 15g、八角、桂皮、鸡爪、胡萝卜、洋葱、尖椒、小米辣、、柠檬。

    6. 注入适量的矿泉水淹没鸡爪。

    7. 腌制 24 小时。

  • 经验总结:

    1. 使用白酒的目的有三点:(1)可以洗去鸡爪表面剩余的油腻;(2)有助于降温;(3)为鸡爪增加一些白酒的风味。使用白酒和清水清洗两次可以使得鸡爪的异味清除的更加彻底。

    2. 鸡爪不宜放的太多,鸡爪摆置后空隙较大,如果鸡爪太多需要注入大量水,反而会稀释泡椒的味道。

  • 使用器具:

    1. 2000 mL 密封瓶

  • 参考资料:

    1. 如何制作泡椒凤爪——灰子美食实验室

    2. 怎么做泡椒凤爪

耙牛肉

  • 配料表:

    1. 牛腹肉 500g

    2. 牛蹄筋 500g

    3. 火锅底料 250g

    4. 梅花花椒若干

    5. 八角

    6. 桂皮

    7. 干辣椒

    8. 生抽、蚝油

    9. 葱姜蒜若干

  • 注意点:

    1. 牛腹肉和牛筋切大块,在水中浸泡泡出血水。

    2. 加入葱姜和料酒飞水,捞出血沫。

    3. 飞水完成捞出葱姜和牛肉,尽量保持肉汤清澈作为牛肉高汤。

    4. 牛肉捞出后温水洗净,沥干水分。

    5. 起油锅放入八角、桂皮、花椒、大蒜、姜片爆香,放入火锅底料炒出红油。

    6. 放入牛肉进行翻炒均匀上色。

    7. 将高汤移到珐琅锅内,将炒锅中的肉放入珐琅锅。

    8. 加入几片生姜、两段大葱、适量生抽和蚝油炖煮 2 小时。

  • 经验总结:

    1. 锅底太辣的话放的量需要斟酌,如果牛油太多的话可以放凉冷却以后去掉表面的牛油和辣油。

    2. 火锅锅底的香料如果太浓烈会掩盖牛肉本身的原味,如何平衡锅底的香料和牛肉的原味是个问题。

  • 参考资料:

    耙牛肉火锅

虫草枸杞乌鸡汤

  • 配料表

    1. 湘佳 丝乌鸡(900g)一只

    2. 红枣 10 颗

    3. 枸杞一小把

    4. 虫草花 75g

    5. 葱姜若干

  • 注意点:

    1. 鸡肉斩件。

    2. 鸡肉飞水可以温水下锅,加入姜蒜和料酒。

    3. 尽量捞干净血沫,飞水后捞出洗净。

    4. 将鸡肉、姜片(3片)和泡好的红枣、枸杞、虫草一起下炖锅,炖锅里注入适量冷水(冷水慢慢升温炖出鸡肉内部的风味,中途不可加水)。

    5. 大火煮开后加入适量冰糖,小火炖煮两个小时。

    6. 起锅之前加入适量的盐调味。

  • 经验总结:

    1. 汤锅里的水不宜太满,否则沸腾之后容易溢出。

    2. 冰糖不宜加太多,冰糖可以赋予汤头更深的层次感,但是太多了会偏甜。

    3. 枸杞、红枣和虫草会赋予鸡汤琥珀色的颜色,如果鸡汤可以过一遍滤筛的话效果会更好。

  • 参考资料

    香菇鸡汤

Spring Bean Validation

1. 前言

平时写接口的时候难免有要对外开放接口调用,如果接口请求体的结构比较简单还好,但是不可避免会遇到一些包含嵌套对象的请求体。

这个时候最烦的就是做参数校验了,一堆的 Objects.isNull 或者 Strings.isNullOrEmpty。冗长的一堆很不雅观而且基本不太可能复用。

最近突然发现 JSR303 (Bean Validation) 的存在,结合 @ControllerAdvice 定制全局异常可以大大减轻参数校验的负担呢。

简单来说,从参数校验的内容来看可以分为两种:

  • 业务无关的参数校验:NotNull、NotEmpty 等等。

  • 业务相关的校验:ID 值的有效性等。

对于业务无关的参数校验,如果校验不通过则会抛出相应的异常。对于与业务有关的参数我们可以在校验不通过后抛出运行时异常。

这些异常可以在 @ControllerAdvice 注解的类中统一处理。

2. 代码实例

2.1 依赖引入

pom.xml 文件中首先引入依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

2.2 简单使用

我们首先定义一个接口:

@GetMapping("jobs")
public Response<String> get(@Valid GetRequest request) {
    return Responses.successResponse(request.getKeyword());
}

然后是请求体格式:

package com.avaloninc.springvalidationexample.request;

import com.avaloninc.webapi.common.request.base.BaseRequest;
import lombok.Data;
import org.hibernate.validator.constraints.NotBlank;

@Data
public class GetRequest extends BaseRequest {
  @NotBlank
  private String keyword;
}

简单来说就是一个只包含一个参数的 GET 请求。请求体的 keyword 字段上面通过 @NotBlank 注解修饰了参数。

Controller 上使用 @Valid 参数触发对参数的校验。

2.3 嵌套结构

通常的使用场景不会有这么简单,请求体往往是嵌套的结构。即请求体的实例成员变量往往是一个集合对象或者另一个类的实例:

package com.avaloninc.springvalidationexample.request;

import com.avaloninc.webapi.common.request.base.BaseRequest;
import lombok.Data;
import org.hibernate.validator.constraints.NotBlank;
import org.hibernate.validator.constraints.NotEmpty;

import java.util.List;

import javax.validation.Valid;
import javax.validation.constraints.NotNull;

@Data
public class CreateRequest extends BaseRequest {
  @NotBlank
  private String       name;
  @NotEmpty
  private List<String> alarmReceivers;
  @NotNull
  @Valid
  private Resource     resource;
  @NotEmpty
  @Valid
  private List<File>   files;

  @Data
  public static class Resource {
    @NotEmpty
    private String account;
    @NotEmpty
    private String queue;
  }

  @Data
  public static class File {
    @NotEmpty
    private String fileName;
    @NotEmpty
    private String filePath;
  }
}

这种情况下记得在这些成员变量之上同样加上 @Valid 注解即可!

2.3 全局异常处理

对于参数违反的约束都会以异常的形式抛出来,我们可以在一个类中进行全局的异常处理:

package com.avaloninc.springvalidationexample.advice;

import com.avaloninc.webapi.common.response.Response;
import com.avaloninc.webapi.common.response.Responses;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import java.util.stream.Collectors;

@ControllerAdvice
@Slf4j
public class GlobalControllerAdvice {

  @ExceptionHandler(BindException.class)
  @ResponseBody
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  public Response handle(final BindException ex) {
    String errorMsg = ex.getFieldErrorCount() > 0
        ? ex.getFieldErrors().stream()
        .map(this::getFieldErrorMessage).collect(Collectors.joining(" "))
        : ex.getMessage();
    return Responses.errorResponse(HttpStatus.BAD_REQUEST.value(), errorMsg);
  }

  @ExceptionHandler(MethodArgumentNotValidException.class)
  @ResponseBody
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  public Response handle(final MethodArgumentNotValidException ex) {
    String errorMsg = ex.getBindingResult().getFieldErrorCount() > 0
        ? ex.getBindingResult().getFieldErrors().stream()
        .map(this::getFieldErrorMessage).collect(Collectors.joining(" "))
        : ex.getMessage();
    return Responses.errorResponse(HttpStatus.BAD_REQUEST.value(), errorMsg);
  }

  private String getFieldErrorMessage(FieldError err) {
    return err.getField() + " " + err.getDefaultMessage() + "!";
  }

  @ExceptionHandler(IllegalArgumentException.class)
  @ResponseBody
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  public Response handle(final IllegalArgumentException ex) {
    return Responses.errorResponse(HttpStatus.BAD_REQUEST.value(), ex.getMessage());
  }

  @ExceptionHandler(Exception.class)
  @ResponseBody
  @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
  public Response handle(Exception ex) {
    Response response = Responses.errorResponse(HttpStatus.INTERNAL_SERVER_ERROR.value(), ex.getMessage());
    log.error("Internal Server Error with trace id {}.", response.getMeta().getTraceId(), ex);
    return response;
  }
}

如上面的代码所示,参数校验不通过抛出的异常基本可以总结为:BindExceptionMethodArgumentNotValidException 两种。我们可以从 FieldError 中取出违反约束的参数名以及对应的错误提示!

对于业务相关的参数我们可以用 IllegalArgumentException 来进行异常的抛出和捕获。

最后使用了一个 Exception 来捕获所有没有处理的异常,进行统一的错误日志记录和错误信息的返回。

3. 总结

其实这里只是做了一个最简单的罗列,具体的注解的功能以及使用的类型大家可以从参考资料进一步学习。

借助于 Bean Validation 的确可以大大简化参数的校验,但是还没有想好怎么在工程实践中大规模地使用。

毕竟参数校验可能包含了一定的业务逻辑,全部放在注解中是否合适还有待商榷。

4. 参考资料

GitLab API 踩坑小结

1. 前言

今年上半年一直在做一个离线作业的调度系统。这个季度为了更好的用户体验,避免用户手动上传和管理文件,做了与公司内部 GitLab 打通的功能。

一方面通过 GitLab 提供的 API,可以很方便地选定某个脚本文件作为数据加工的执行脚本。每次升级的时候选定不同 commit 的版本即可快速地发布任务。

另一种场景下,通过打通 GitLab 的 WebHook 可以在用户向 master 分支推送代码的时候自动触发构建和上传构建好的 artifact,避免用户手动上传几百兆的 JAR 包的等待时间。

2. 实现方案

2.1 文件获取与存储

先说一下文件存储,由于作业调度在分布式环境下执行,所以文件的存储也必须是一个高可用的分布式的文件存储。

作为一个离线作业的调度系统,第一个想到的自然是 HDFS。毕竟如果 HDFS 挂了,基本离线计算任务你也别想跑了。

文件的获取方式分为两种:

第一种是直接从 GitLab 的 Repository 里面拉取文件,这种只适合 SQL、Shell、Python 纯文本的脚本文件(相关接口见:Get file from repository)。

第二种则是面向诸如 Spark 程序的 JAR 包,动辄上百兆。具体做法是与 Jenkins 这种打包构建的工具相结合,master 分支的代码更新后触发 WebHook,由构建工具将源代码打包并通过接口回传。接口可以根据项目的 Project id 和 commit id 组合成一个确定的路径写入 HDFS。

2.2 账号与权限

说到 GitLab API 的调用就不得不提 GitLab 的认证的过程。GitLab 提供了三种验证方式

本着猛糙快的互联网精神,果断选择了第二种方式:使用账户生成一个 TOKEN,通过 HTTP 请求的 Header 参数或者 URL 中的查询参数传递给服务器。简单粗暴!

那么第一个问题来了,这个账号用谁的账号呢?

从代码安全性的角度考虑,个人账号不合适。原因有两点:首先私有项目如果要使用该功能必须把这个账号加入到项目的 Members 中,意味着个人账号可以看到别人(或者别的组)私有项目的内容。本着不粘不背锅的精神,能不碰的就不碰。第二点,GitLab 的账号与企业 LDAP 账号是互通的,一旦离职很可能直接导致 TOKEN 失效,API 无法调用(负责 GitLab 的小姐姐一直强调交接的问题,一种明天就要离职的感觉)。

解决方案是申请一个应用账号,这个账号默认不带有任何项目的权限,用户需要使用这个功能的时候将应用账号加入到 Members 中,赋予 Reporter 角色(至少是 Reporter 角色,否则无法获取文件内容)。

第二个问题:如果共享一个应用账号如防止用户窥探无权限的项目呢?

答案是每次涉及项目信息时通过 GitLab 的 Get project users 接口获取有权限用户的用户名列表,与请求用户的用户名对照。

2.3 交互过程

无论是通过 GitLab 获取文件还是构建好的 artifact,第一步都是先确定一个项目以及其版本信息。搜索 GitLab 项目、选取 Master 分支的特定 commit 流程可以用下图来简单描述一下:

接下来首先说一下 GitLab 文件的上传过程。要唯一的确定 GitLab 中的一个文件需要三个要素:GitLab Project id、Commit id 以及文件在项目中相对路径。因此,后续的交互过程可以用下图来描述:

然后说一下 Git artifact 的上传。使用 WebHook 之后 Jenkins 会把所有 artifact 通过接口回传,我们要做的只是权限和文件的验证即可:

3. 踩过的坑

3.1 分页参数

先从 GitLab 的 API 返回格式说起吧。在过往的工作经历中,后端返回 JSON 一半分为三个部分:

  • Response 元信息,比如返回的状态码、错误提示、请求的唯一标识等等。

  • Response 数据题,真正承载数据的部分。

  • Response 分页信息,主要针对列表查询。

但是 GitLab API 的风格完全不一样,没有响应的元信息和分页信息,直接使用 HTTP 的 Status Code 描述请求的异常。

这倒也罢了,但是类似于列表的接口完全没有分页信息,请求的参数里也没有提到分页参数的设置。一开始还自作聪明地以为 GitLab 的 API 返回了全量的数据,结果在通过关键字搜索 Git 仓库的时候竟然搜不到自己的项目!之后查阅文档才发现 GitLab 的分页信息是写在 Header 里面的(详情请见 Pagination)!

3.2 URL 参数转义

Get file from repository 这个接口的参数定义中,file_path 的说明是 Url encoded full path to new file. Ex. lib%2Fclass%2Erb。意思是说 lib/class.rub 这种文件的相对路径要进行转义,在使用 RestTemplate 的时候猜到了 URL 参数转义的坑。

首先看如下代码:

  @Test
  public void testForUriTemplateWithRawPathParam() {
    String url = "https://gitlab.example.com/api/v4/projects/{project_id}/repository/files/{file_path}"
        + "?private_token={token}";
    UriTemplate uriTemplate = new UriTemplate(url);
    URI expand = uriTemplate.expand(ImmutableMap.of("project_id", 1,
                                                    "file_path", "lib/class.rb",
                                                    "token", "abc"));
    System.out.println("expand = " + expand.toString());
  }

标准输出流的结果是:

expand = https://gitlab.example.com/api/v4/projects/1/repository/files/lib/class.rb?private_token=abc

看来 UriTemplate(org.springframework.web.util.UriTemplate) 并不会主动为参数进行转义,那么我们手动为参数进行转义试试:

  @Test
  public void testForUriTemplateWithEncodedPathParam() throws UnsupportedEncodingException {
    String url = "https://gitlab.example.com/api/v4/projects/{project_id}/repository/files/{file_path}"
        + "?private_token={token}";
    UriTemplate uriTemplate = new UriTemplate(url);

    String encode = UriUtils.encode("lib/class.rb", "UTF-8");
    System.out.println("encode = " + encode);
    URI expand = uriTemplate.expand(ImmutableMap.of("project_id", 1, "file_path", encode, "token", "abc"));
    System.out.println("expand = " + expand.toString());
  }

输出的结果为:

encode = lib%2Fclass.rb
expand = https://gitlab.example.com/api/v4/projects/1/repository/files/lib%252Fclass.rb?private_token=abc

看来 UriTemplate 把我们手动转义的参数中的 % 又进行了一次转义变成了 %25

看来 UriTemplate 要么不转义,要么把结果再给转一次,反正是没法用了。

那么到底如何才能得到正确的参数呢?

我们需要使用 UriComponentsBuilder(org.springframework.web.util.UriComponentsBuilder)

虽然 UriTemplate 底层也是使用的 UriComponentsBuilder,但是我们需要更加精细的控制:

 @Test
  public void testForUriComponentsBuilder() throws UnsupportedEncodingException {

    URI    uri;
    String filePath = "lib/class.rb";

    // Illegal for path, it should use pathSegment
    //uri = UriComponentsBuilder.fromUriString("https://gitlab.example.com/api/v4/projects/")
    //    .path(String.valueOf(1)).path("repository").path("files").path("lib/class.rb")
    //    .queryParam("private_token", "abc").build(false).toUri();
    //System.out.println("uri = " + uri.toString());
    // result: uri = https://gitlab.example.com/api/v4/projects/1repositoryfileslib/class.rb?private_token=abc
    //
    //uri = UriComponentsBuilder.fromUriString("https://gitlab.example.com/api/v4/projects/")
    //    .path(String.valueOf(1)).path("repository").path("files").path("lib/class.rb")
    //    .queryParam("private_token", "abc").build(true).toUri();
    //System.out.println("uri = " + uri.toString());
    // result: uri = https://gitlab.example.com/api/v4/projects/1repositoryfileslib/class.rb?private_token=abc

    // exception for build true parameter because 'lib/class.rb' contains '/'
    //uri = UriComponentsBuilder.fromUriString("https://gitlab.example.com/api/v4/projects/")
    //    .pathSegment(String.valueOf(1), "repository", "files", "lib/class.rb")
    //    .queryParam("private_token", "abc").build(true).toUri();
    //System.out.println("uri = " + uri.toString());

    String encode = UriUtils.encode(filePath, "UTF-8");
    System.out.println("encode = " + encode);
    String encodePath = UriUtils.encodePath(filePath, "UTF-8");
    System.out.println("encodePath = " + encodePath);
    String encodePathSegment = UriUtils.encodePathSegment(filePath, "UTF-8");
    System.out.println("encodePathSegment = " + encodePathSegment);

    uri = UriComponentsBuilder.fromUriString("https://gitlab.example.com/api/v4/projects/")
        .pathSegment(String.valueOf(1), "repository", "files", encodePathSegment)
        .queryParam("private_token", "abc").build(true).toUri();
    System.out.println("uri = " + uri.toString());
  }

使用上述单元测试里的代码即可构造出访问 GitLab 所需要的 URI 了!

4. 小结

本文所有的参考资料全部来源于 GitLab 的 API 文档,涉及到的 API 有:

以上。

Spring与Cache

1. 前言

在做项目中往往遇到这样一些场景:从外部系统获取一些配置信息,它们被频繁读取但是却不经常修改,甚至是只读的数据。它们不要求非常强的实时性却有着非常高的访问频率。

如果每次都重新发起一次 HTTP 请求无疑是对系统资源的一种浪费。常见的做法是将这些外部数据以缓存的形式存储下来。

下面介绍一下 Spring 自带的 Cache 功能。

2. 几种基本注解

  • @Cacheable:开启缓存,指定命名空间参数(可以指定多个)。

  • @CacheEvict:指定缓存的清除,如果使用 allEntries = true 则会删除所有缓存。

  • @CachePut:首先运行目标方法然后将返回结果加入缓存。

  • @CacheConfig:在类的级别指定缓存的配置信息,比如缓存的名字或者 CacheManager

  • @Caching:由于一个方法上可以指定多个缓存,而 Java 不允许同一个注解在同一个地方出现多次,因此 @Caching 注解来组织。

3. 常见的缓存存储

Spring 虽然提供了缓存的框架,但是缓存的具体实现还分为很多种,常见的有 ConcurrentMapCacheGuavaCache 以及基于 Redis 的实现。

3.1 ConcurrentMapCache

ConcurrentMapCache 是一种简单的缓存实现,不能进行配置。那么 ConcurrentMapCache 缓存的生命周期是永远,除非使用 @CacheEvict 或者 @CachePut 否则缓存不会失效或者更新。

3.2 GuavaCache

这是基于 Guava 缓存的一种实现,我们可以像使用 Guava 中的 Cache 一样进行配置,比如缓存的失效时间、缓存的最大容量等等。

3.3 基于 Redis 的缓存

上文无论 ConcurrentMapCache 也好、GuavaCache 也好,都是基于本机的内存,无法在多个节点共享。基于 Redis 的缓存才能实现分布式缓存。

4. 代码示例

首先给出代码的依赖:

  <dependencies>
      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
      </dependency>
      <dependency>
          <groupId>com.avalon-inc</groupId>
          <artifactId>web-api-common</artifactId>
          <version>1.0-SNAPSHOT</version>
      </dependency>
      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-cache</artifactId>
      </dependency>
      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-data-redis</artifactId>
      </dependency>
  </dependencies>

然后是 Redis 的配置,见 application.properties

  spring.redis.host=localhost
  spring.redis.port=6379

接着进入代码配置的正文,使用 Cache 的第一件事是开启对缓存的支持:

  package com.avaloninc.springcacheexample ;
  
  import org.springframework.boot.SpringApplication;
  import org.springframework.boot.autoconfigure.SpringBootApplication;
  import org.springframework.cache.annotation.EnableCaching;
  
  /**
   * @Author: wuzhiyu.
   * @Date: 2018-05-18 02:46:01.
   * @Description:
   */
  @SpringBootApplication
  @EnableCaching
  public class Main {
    public static void main(String[] args) {
      SpringApplication.run(Main.class);
    }
  }

然后我们执行最小化的设置,注册三个 CacheManager

  package com.avaloninc.springcacheexample;
  
  import com.google.common.cache.CacheBuilder;
  
  import org.springframework.beans.factory.annotation.Value;
  import org.springframework.cache.CacheManager;
  import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
  import org.springframework.cache.guava.GuavaCacheManager;
  import org.springframework.context.annotation.Bean;
  import org.springframework.context.annotation.Configuration;
  import org.springframework.context.annotation.Primary;
  import org.springframework.data.redis.cache.RedisCacheManager;
  import org.springframework.data.redis.connection.RedisConnectionFactory;
  import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
  import org.springframework.data.redis.core.RedisTemplate;
  import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
  import org.springframework.data.redis.serializer.StringRedisSerializer;
  
  import java.util.concurrent.TimeUnit;
  
  /**
   * @Author: wuzhiyu.
   * @Date: 2018-06-12 01:44:20.
   * @Description:
   */
  @Configuration
  public class Config {
    @Bean("concurrentMapCacheManager")
    @Primary
    public CacheManager getDefaultCacheManager() {
      return new ConcurrentMapCacheManager();
    }
  
    @Bean("guavaCacheManager")
    public CacheManager getGuavaCacheManager() {
      GuavaCacheManager guavaCacheManager = new GuavaCacheManager();
      guavaCacheManager.setCacheBuilder(
          CacheBuilder.newBuilder().maximumSize(1000).expireAfterWrite(5, TimeUnit.SECONDS));
      return guavaCacheManager;
    }
  
    @Bean
    public JedisConnectionFactory getRedisConnectionFactory(@Value("${spring.redis.host}") String host,
                                                            @Value("${spring.redis.port}") int port) {
      JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory();
      jedisConnectionFactory.setHostName(host);
      jedisConnectionFactory.setPort(port);
      return jedisConnectionFactory;
    }
  
    @Bean
    public RedisTemplate<String, Object> getRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
      RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
      redisTemplate.setConnectionFactory(redisConnectionFactory);
      redisTemplate.setKeySerializer(new StringRedisSerializer());
      redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
      return redisTemplate;
    }
  
    @Bean("redisCacheManager")
    public CacheManager getRedisCacheManager(RedisTemplate<String, Object> redisTemplate) {
      RedisCacheManager redisCacheManager = new RedisCacheManager(redisTemplate);
      redisCacheManager.setDefaultExpiration(3);
      return redisCacheManager;
    }
  }

注意上面的 new ConcurrentMapCacheManager(),实例的构造函数有两种签名版本:

  • public ConcurrentMapCacheManager() {}

  • public ConcurrentMapCacheManager(String... cacheNames) { setCacheNames(Arrays.asList(cacheNames));}

如果使用无参构造,那么允许动态构造缓存,缓存数量可变。如果使用有参版本那么只能以固定个数的命名空间来创建固定个数的缓存。GuavaCacheRedisCache 同上。

接着我们编写使用缓存的具体代码:

  package com.avaloninc.springcacheexample ;
  
  import com.avaloninc.webapi.common.response.Response;
  import com.avaloninc.webapi.common.response.Responses;
  import lombok.extern.slf4j.Slf4j;
  import org.joda.time.DateTime;
  import org.springframework.boot.SpringApplication;
  import org.springframework.boot.autoconfigure.SpringBootApplication;
  import org.springframework.cache.annotation.CacheConfig;
  import org.springframework.cache.annotation.CacheEvict;
  import org.springframework.cache.annotation.CachePut;
  import org.springframework.cache.annotation.Cacheable;
  import org.springframework.cache.annotation.Caching;
  import org.springframework.cache.annotation.EnableCaching;
  import org.springframework.web.bind.annotation.DeleteMapping;
  import org.springframework.web.bind.annotation.GetMapping;
  import org.springframework.web.bind.annotation.PathVariable;
  import org.springframework.web.bind.annotation.PutMapping;
  import org.springframework.web.bind.annotation.RestController;
  
  /**
   * @Author: wuzhiyu.
   * @Date: 2018-05-18 02:46:01.
   * @Description:
   */
  @SpringBootApplication
  @EnableCaching
  @RestController
  @Slf4j
  @CacheConfig(cacheNames = "caches")
  public class Main {
    public static void main(String[] args) {
      SpringApplication.run(Main.class);
    }
  
    @GetMapping("/obj/{key}")
    @Caching(cacheable = {
        @Cacheable(cacheManager = "concurrentMapCacheManager"),
        @Cacheable(cacheManager = "guavaCacheManager"),
        @Cacheable(cacheManager = "redisCacheManager")
    })
    public Response<String> get(@PathVariable("key") String key) {
      String value = key.concat(" ").concat(DateTime.now().toString());
      log.info("Get the real value of " + key);
      return Responses.sucessResponse(value);
    }
  
    @DeleteMapping("/obj/{key}")
    @Caching(evict = {
        @CacheEvict(cacheManager = "concurrentMapCacheManager"),
        @CacheEvict(cacheManager = "guavaCacheManager"),
        @CacheEvict(cacheManager = "redisCacheManager")
    })
    public Response<String> del(@PathVariable("key") String key) {
      log.info("Evict the key " + key);
      return Responses.sucessResponse("success");
    }
  
    @PutMapping("/obj/{key}")
    @Caching(put = {
        @CachePut(cacheManager = "concurrentMapCacheManager"),
        @CachePut(cacheManager = "guavaCacheManager"),
        @CachePut(cacheManager = "redisCacheManager")
    })
    public Response<String> put(@PathVariable("key") String key) {
      String value = key.concat(" ").concat(DateTime.now().toString());
      log.info("Put the real value of " + key);
      return Responses.sucessResponse(value);
    }
  }

注意上文是为了演示 @Caching 才同时使用了三种缓存。实验显示多个缓存下 @Cacheable 只要有一个缓存没有失效那么就不会执行具体的方法体获取的新的值!如果其他缓存和 ConcurrentMapCache 一起使用而不调用 @CacheEvict 或者 @CachePut,那么其他缓存可能永远不会更新!

5. 总结

本文只是简单介绍了一下 SpringBoot 集成缓存以及几种常见的存储方式,具体的选择按需求场景确定即可。但是正确的缓存使用姿势或者更新策略可以结合现有的设计模式,灵活运用这几种注解实现选定的模式。

6. 参考资料

Spring 与配置获取方式

1. 前言

Spring 在配置文件方面的支持非常强大,本文不再赘述,有需求可以查看 Spring Boot 的官方文档。本文的叙述内容是如何在程序中使用 Spring Boot 配置文件中的参数值。常见的手法有三种:

  • @Value 注解

  • org.springframework.core.env.Environment 对象

  • @ConfigurationProperties 注解

2. @Value

先从 @Value 说起,通过配置完整名称的形式即可获取需要的值,如:

@Value("${myConfig.name}")
private String name;

但是需要注意的一点是 @Value 注入的时间一般在 Bean 构造完成之后。如果构造 Bean 的方法需要使用到配置文件里的参数,那么可以把这些参数作为构造函数或者 @Bean 注解修饰的方法的传入参数,并以 @Value 注解来指定注入的参数。如:

@Bean("client1")
public Client getClient(@Value("${endPoint.beijing}") String endPoint) {
    Client client = new Client();
    client.setEndPoint(endPoint);
    return client;
}

3. org.springframework.core.env.Environment

@Value 注解在获取少量配置的时候还是相当方便的,但是如果我们需要从配置文件中获取大量配置的时候往往需要定义大量的实例变量,就不如直接从 Environment 获取来的方便。Environment 对象可以直接通过 @Autowired 注解注入得到。从 Environment 获取配置的方式也相当简单,如:

String myConfigNameOfEnv = environment.getProperty("myConfig.name");

getProperty 方法还有可以指定默认值和参数类型的重载方法,此处不展开。

4. @ConfigurationProperties

通过 @ConfigurationProperties 注解获取配置有两种形式:

下面给出一个示例,对于如下配置:

myConfig:
  name: myConfig
  list:
    - a
    - b
yourConfig:
  name: yourConfig
  list:
    - c
    - d

如果我们想使用 myConfig 前缀下的所有配置,那么两种方案分别可以按照如下形式获取。

方案一:

@Configuration
@ConfigurationProperties(prefix = "myConfig")
@Data
public class MyConfig {
  private String       name;
  private List<String> list;
}

这里通过前缀和字段的名字来映射配置。

方案二:

@Configuration
@ConfigurationProperties(prefix = "")
@Data
public class ConfigAsMap {
  private Map<String, String> myConfig;
  private Map<String, String> yourConfig;
}

这里我们将所有所有配置映射为一个对象,每一个 namespace 下的配置以 Map 的形式来存储。使用的时候可以按照如下方式获取特定配置:

Map<String, String> myConfigMap     = configAsMap.getMyConfig();
String              nameOfConfigMap = myConfigMap.get("name");

这里还要记述一下 Environment 对象和 @ConfigurationProperties 映射为 Map 后两者在获取列表时的区别。

对于 Environemnt 对象,我们要获取 myConfig.list 对象时的方式如下:

List<String> myConfigListOfEnv = Lists.newArrayList(environment.getProperty("myConfig.list[0]"),
                                            environment.getProperty("myConfig.list[1]"));

而通过 @ConfigurationProperties 映射为 Map 后获取的方式则是:

List<String> listOfConfigMap = Lists.newArrayList(myConfigMap.get("list.0"),
                                                  myConfigMap.get("list.1"));

两者在处理列表的索引时不一致,使用时需要我们注意。

5. 跨 Module 引用配置

最近开始接手改造一个项目,改造过程中有这样一个问题:该项目原来所有的配置都采用集中管理的方式,每一个项目的配置都从配置服务器的接口获取然后初始化 Spring 的容器。该项目将从配置服务器获取配置的部分抽出为一个单独的 Maven Module。

但是这个配置集中管理的项目即将下线,于是配置本地化的改造就迎面而来了。

我的改造方案是以不同的 profile 的形式将配置固化到配置 Module 的配置文件中。在线上环境和测试环境通过指定不同的 profile 达到配置的切换。

对于运行时指定的 profile 能否作用到依赖的 Maven 模块并没有绝对把握,而改造的范围比较广,所以还是捏了一把汗的。于是写了一个小的 Demo 作为原型验证。

首先给出配置文件:

application.yaml

myConfig:
  name: myConfig
  list:
    - a
    - b
yourConfig:
  name: yourConfig
  list:
    - c
    - d

application-test.yaml

hisConfig:
  name: hisConfig-test
  list:
    - g
    - h

application-prod.yaml

hisConfig:
  name: hisConfig-prod
  list:
    - e
    - f

两种通过 @ConfigurationProperties 获取配置的方式:

package com.avaloninc.springconfigexample.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import java.util.List;

@Configuration
@ConfigurationProperties(prefix = "myConfig")
@Data
public class MyConfig {
  private String       name;
  private List<String> list;
}

以及:

package com.avaloninc.springconfigexample.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import java.util.Map;

@Configuration
@ConfigurationProperties(prefix = "")
@Data
public class ConfigAsMap {
  private Map<String, String> myConfig;
  private Map<String, String> yourConfig;
  private Map<String, String> hisConfig;
}

先在当前 Module 测试一下:

package com.avaloninc.springconfigexample;

import com.avaloninc.springconfigexample.config.ConfigAsMap;
import com.avaloninc.springconfigexample.config.MyConfig;
import junit.framework.TestCase;
import org.assertj.core.util.Lists;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.env.Environment;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.List;
import java.util.Map;

@SpringBootTest
@RunWith(SpringRunner.class)
@ActiveProfiles(value = "test")
public class MainTest extends TestCase {

  @Value("${myConfig.name}")
  private String name;
  @Autowired
  private Environment environment;
  @Autowired
  private ConfigAsMap configAsMap;
  @Autowired
  private MyConfig    myConfig;

  @Test
  public void test() {
    System.out.println("this.name = " + this.name);

    String myConfigNameOfEnv = environment.getProperty("myConfig.name");
    List<String> myConfigListOfEnv = Lists.newArrayList(environment.getProperty("myConfig.list[0]"),
                                                        environment.getProperty("myConfig.list[1]"));
    System.out.println("myConfigNameOfEnv = " + myConfigNameOfEnv);
    System.out.println("myConfigListOfEnv = " + myConfigListOfEnv);

    String       myConfigName = myConfig.getName();
    List<String> myConfigList = myConfig.getList();

    Map<String, String> myConfigMap     = configAsMap.getMyConfig();
    String              nameOfConfigMap = myConfigMap.get("name");
    List<String> listOfConfigMap = Lists.newArrayList(myConfigMap.get("list.0"),
                                                      myConfigMap.get("list.1"));

    System.out.println("myConfig = " + myConfig);
    System.out.println("configAsMap = " + configAsMap);

    assertEquals(this.name, myConfigNameOfEnv);
    assertEquals(myConfigNameOfEnv, myConfigName);
    assertEquals(myConfigName, nameOfConfigMap);
    assertEquals(myConfigListOfEnv, myConfigList);
    assertEquals(myConfigList, listOfConfigMap);
  }
}

然后我们在另一个 Module 中引用这里的配置,下面是单元测试:

package com.avaloninc.springtestexample;

import com.avaloninc.springconfigexample.config.ConfigAsMap;
import com.avaloninc.springconfigexample.config.MyConfig;
import junit.framework.TestCase;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;

@SpringBootTest
@RunWith(SpringRunner.class)
@ActiveProfiles("test")
@Import({ConfigAsMap.class, MyConfig.class})
public class MainTest extends TestCase {

  @Autowired
  private ConfigAsMap configAsMap;
  @Autowired
  private MyConfig  myConfig;

  @Test
  public void test() {
    System.out.println("configAsMap = " + configAsMap);
    assertEquals(configAsMap.getHisConfig().get("name"), "hisConfig-test");
    System.out.println("myConfig = " + myConfig);
  }
}

注意:跨模块引用配置的时候记得使用 @Import 注解,否则启动的时候会报错:org.springframework.beans.factory.UnsatisfiedDependencyException。

HDFS API 后记关于 FileSystem 缓存

HDFS API 这篇文章中,简单介绍了一下通过 Hadoop client 的 API 读写 HDFS 的方法。但是在实际使用过程中也发现了一个 FileSystem 的问题。

1. 问题描述

在程序运行中时不时地会抛出 IOException,内容提示 filesystem closed。一开始也很疑惑,因为不确定 org.apache.hadoop.fs.FileSystem 实例的线程安全性,所以每次调用的时候都通过 FileSystem.get(conf) 方法来获取一个新的对象。然后查阅了资料发现 FileSystem.get 方法会得到的并不是一个全新的对象,而是一个缓存过的对象。

源码如下:

  /** Returns the FileSystem for this URI's scheme and authority.  The scheme
   * of the URI determines a configuration property name,
   * <tt>fs.<i>scheme</i>.class</tt> whose value names the FileSystem class.
   * The entire URI is passed to the FileSystem instance's initialize method.
   */
  public static FileSystem get(URI uri, Configuration conf) throws IOException {
    String scheme = uri.getScheme();
    String authority = uri.getAuthority();

    if (scheme == null && authority == null) {     // use default FS
      return get(conf);
    }

    if (scheme != null && authority == null) {     // no authority
      URI defaultUri = getDefaultUri(conf);
      if (scheme.equals(defaultUri.getScheme())    // if scheme matches default
          && defaultUri.getAuthority() != null) {  // & default has authority
        return get(defaultUri, conf);              // return default
      }
    }
    
    String disableCacheName = String.format("fs.%s.impl.disable.cache", scheme);
    if (conf.getBoolean(disableCacheName, false)) {
      return createFileSystem(uri, conf);
    }

    return CACHE.get(uri, conf);
  }

可以看到 FileSyste.get 方法的确是取缓存的。

2. 解决方案

为了保障线程安全性,我们可以每次调用的时候都创建一个新的实例,FileSystem.newInstance 方法可以满足我们的需求。

package com.avaloninc.hdfsapi.service.impl;

import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;

import com.avaloninc.hdfsapi.service.HdfsService;
import org.apache.commons.io.IOUtils;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FSDataOutputStream;
import org.apache.hadoop.fs.FileStatus;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

/**
 * @Author: wuzhiyu.
 * @Date: 2018-03-19 14:39.
 * @Description:
 */
@Service
public class HdfsServiceImpl implements HdfsService {
    @Autowired
    private Configuration config;

    @Override
    public String read(String path) throws IOException {
        try (FileSystem fileSystem = FileSystem.newInstance(config)) {
            Path hdfsPath = new Path(path);
            List<String> lines = IOUtils.readLines(fileSystem.open(hdfsPath));
            return Joiner.on("\n").join(lines);
        }
    }

    @Override
    public void write(String path, InputStream inputStream) throws IOException {
        FileSystem         fileSystem   = null;
        FSDataOutputStream outputStream = null;
        try {
            Path hdfsPath = new Path(path);
            fileSystem = FileSystem.newInstance(config);
            outputStream = fileSystem.create(hdfsPath, true);

            byte[] bytes    = new byte[1024];
            int    numBytes = 0;
            while ((numBytes = inputStream.read(bytes)) > 0) {
                outputStream.write(bytes, 0, numBytes);
            }
        } finally {
            IOUtils.closeQuietly(inputStream);
            IOUtils.closeQuietly(outputStream);
            IOUtils.closeQuietly(fileSystem);
        }
    }

    @Override
    public boolean rename(String src, String dest) throws IOException {
        try (FileSystem fileSystem = FileSystem.newInstance(config)) {
            Path srcPath = new Path(src);
            Path destPath = new Path(dest);

            if (!fileSystem.exists(srcPath)) {
                throw new IOException("Path " + src + " do not exists.");
            }

            if (!fileSystem.exists(destPath.getParent())) {
                fileSystem.mkdirs(destPath.getParent());
            }

            return fileSystem.rename(srcPath, destPath);
        }
    }

    @Override
    public boolean delete(String path) throws IOException {
        try (FileSystem fileSystem = FileSystem.newInstance(config)) {
            return fileSystem.delete(new Path(path), true);
        }
    }

    @Override
    public List<String> ls(String path) throws IOException {
        try (FileSystem fileSystem = FileSystem.newInstance(config)) {
            Path hdfsPath = new Path(path);
            if (!fileSystem.exists(hdfsPath)) {
                throw new IllegalArgumentException(
                    "Path " + path + " do not exist or is not a dir.");
            }

            if (fileSystem.isDirectory(hdfsPath)) {
                return Arrays.stream(fileSystem.listStatus(hdfsPath))
                    .map(FileStatus::getPath)
                    .map(Path::getName)
                    .collect(Collectors.toList());
            } else {
                FileStatus status = fileSystem.getFileStatus(hdfsPath);
                return ImmutableList.of(status.getPath().getName());
            }
        }
    }
}

3. 参考资料