freewolf

freewolf 查看完整档案

北京编辑解放军电子工程学院  |  信息安全和网络管理 编辑天津金融资产交易所  |  技术研发部-副总经理 编辑 www.freewolf.me 编辑
编辑

Inspection & Adaptation
定的了战略,布的了架构,写的了代码,管得了全队!
永远的程序猿,攻城狮~

个人动态

freewolf 赞了文章 · 2019-11-18

moquette改造笔记(一):整合到SpringBoot

Moquette简介

Mqtt作为物联网比较流行的协议现在已经被大范围使用,其中也有很多开源的MQTT BROKEN。Moquette是用java基于netty实现的轻量级的MQTT BROKEN. Moquette基于Netty实现,性能问题至少前期可以不用考虑,在使用过程中还算稳定,没有出现过较大的问题。github地址:https://github.com/andsel/moq...

整合到SpringBoot

本文更加注重代码实践,对于配置相关的知识会一笔带过,不做过多的详解。
假设已经搭建好SpringBoot环境,下载完Moquette。至于怎么引用Moquette,可以在原项目上修改,也可以达成Jar包添加到lib调用,也可以上传到Maven私服后通过配置pom引用。笔者是上传到Maven私服,然后通过maven导入。


  1. 自定义包装类,实现io.moquette.server.Server的自动注入
@Slf4j
@Service
public class MoquetteServer {
    @Value("${mqtt-server.config-path}")
    private String configFilePath;
    @Autowired
    private IAuthorizator authorizator;

    /**
     * Safety相关的拦截器,如果有其它业务,可以再去实现一个拦截器处理其它业务
     */
    @Autowired
    @Qualifier("safetyInterceptHandler")
    private InterceptHandler safetyinterceptHandler;

    private Server mqttServer;

    public void startServer() throws IOException {
        IResourceLoader configFileResourceLoader = new ClasspathResourceLoader(configFilePath);
        final IConfig config = new ResourceLoaderConfig(configFileResourceLoader);

        mqttServer = new Server();

        /**添加处理Safety相关的拦截器,如果有其它业务,可以再去实现一个拦截器处理其它业务,然后也添加上即可*/
        List<InterceptHandler> interceptHandlers = Arrays.asList(safetyinterceptHandler);
        /**
         * Authenticator 不显示设置,Server会默认以password_file创建一个ResourceAuthenticator
         * 如果需要更灵活的连接验证方案,可以继承IAuthenticator接口,自定义实现
         */
        mqttServer.startServer(config, interceptHandlers, null, null, authorizator);
    }


    public void stop() {
        mqttServer.stopServer();
    }
    
}

解释:
(1)添加@Service
(2)configFilePath是Moquette需要的moquette.conf的配置文件路径,笔者将配置文件放到了resouces目录下,在application.xml配置的路径,因此通过@Value自动注入路径值。
(3)因为是将配置文件放在了resources目录下,所以用Moquette提供的ClasspathResourceLoader加载的配置文件
(4)IAuthorizator 是权限验证接口
(5)InterceptHandler是Moquette订阅、发布、建立连接等相关事件的拦截回调业务处理逻辑接口


2.实现IAuthorizator接口

@Component
public class PermitAllAuthorizator implements IAuthorizator {
    @Override
    public boolean canWrite(Topic topic, String user, String client) {
        /**可以控制某个用户的client,是否具有发布某个主题的权限,目前默认任何Client可以发布任主题*/
        return true;
    }

    @Override
    public boolean canRead(Topic topic, String user, String client) {
        /**可以控制某个用户的client,是否具有接收某个主题的权限,目前默认任何Client可以接收任何主题*/
        return true;
    }
}

解释:实现另一个很简单的授权接口,允许任何用户所有的读写请求


3.实现InterceptHandler接口

@Slf4j
@Component
public class SafetyInterceptHandler extends AbstractInterceptHandler{

    @Override
    public String getID() {
        return SafetyInterceptHandler.class.getName();
    }

    @Override
    public void onConnect(InterceptConnectMessage msg) {
        
    }

    @Override
    public void onConnectionLost(InterceptConnectionLostMessage msg) {
       
    }


    @Override
    public void onPublish(InterceptPublishMessage msg) {
       
    }


    @Override
    public void onMessageAcknowledged(InterceptAcknowledgedMessage msg) {
        
    }

}

解释:
1.简单实现InterceptHandler,继承自AbstractInterceptHandler,并重写了部分方法。可以根据业务需要实现不同的方法。InterceptHandler接口是Moquette预留给开发者根据不同事件处理业务逻辑的接口。


4.通过SpringBoot启动Moquette

@SpringBootApplication
public class MqttServiceApplication {

    public static void main(String[] args) throws IOException {
        SpringApplication application = new SpringApplication(MqttServiceApplication.class);
        final ApplicationContext context = application.run(args);
        MoquetteServer server = context.getBean(MoquetteServer.class);
        server.startServer();
        Runtime.getRuntime().addShutdownHook(new Thread() {
            @Override
            public void run() {
                server.stop();
                log.info("Moquette Server stopped");
            }
        });
    }



}

如果发现MoquetteServer无法启动,是否是SpringBoot默认的包扫描机制的问题,可以通过@ComponentScan解决。

通过以上操作,就可以在任何想要使用MoquetteServer的地方,通过@Autowired自动注入。


当然在MoquetteServer中笔者只是简单实现了封装,并没有实现其它方法,读者完全可以根据自己的需要在MoquetteServer中实现自己需要的功能,但前提是你要对Moquette的源码熟悉。

moquette改造笔记(二):优化BrokerInterceptor notifyTopicPublished()逻辑

查看原文

赞 1 收藏 0 评论 0

freewolf 关注了专栏 · 2019-09-03

阿里云栖号

汇集阿里技术精粹-yq.aliyun.com

关注 14872

freewolf 赞了文章 · 2019-09-03

阿里技术专家详解 DDD 系列- Domain Primitive

导读:对于一个架构师来说,在软件开发中如何降低系统复杂度是一个永恒的挑战,无论是 94 年 GoF 的 Design Patterns , 99 年的 Martin Fowler 的 Refactoring , 02 年的 P of EAA ,还是 03 年的 Enterprise Integration Patterns ,都是通过一系列的设计模式或范例来降低一些常见的复杂度。但是问题在于,这些书的理念是通过技术手段解决技术问题,但并没有从根本上解决业务的问题。所以 03 年 Eric Evans 的 Domain Driven Design 一书,以及后续 Vaughn Vernon 的 Implementing DDD , Uncle Bob 的 Clean Architecture 等书,真正的从业务的角度出发,为全世界绝大部分做纯业务的开发提供了一整套的架构思路。

前言

由于 DDD 不是一套框架,而是一种架构思想,所以在代码层面缺乏了足够的约束,导致 DDD 在实际应用中上手门槛很高,甚至可以说绝大部分人都对 DDD 的理解有所偏差。举个例子, Martin Fowler 在他个人博客里描述的一个 Anti-pattern,Anemic Domain Model (贫血域模型)在实际应用当中层出不穷,而一些仍然火热的 ORM 工具比如 Hibernate,Entity Framework 实际上助长了贫血模型的扩散。同样的,传统的基于数据库技术以及 MVC 的四层应用架构(UI、Business、Data Access、Database),在一定程度上和 DDD 的一些概念混淆,导致绝大部分人在实际应用当中仅仅用到了 DDD 的建模的思想,而其对于整个架构体系的思想无法落地。

我第一次接触 DDD 应该是 2012 年,当时除了大型互联网公司,基本上商业应用都还处于单机的时代,服务化的架构还局限于单机 +LB 用 MVC 提供 Rest 接口供外部调用,或者用 SOAP 或 WebServices 做 RPC 调用,但其实更多局限于对外部依赖的协议。让我关注到 DDD 思想的是一个叫 Anti-Corruption Layer(防腐层)的概念,特别是其在解决外部依赖频繁变更的情况下,如何将核心业务逻辑和外部依赖隔离的机制。到了 2014 年, SOA 开始大行其道,微服务的概念开始冒头,而如何将一个 Monolith 应用合理的拆分为多个微服务成为了各大论坛的热门话题,而 DDD 里面的 Bounded Context(限界上下文)的思想为微服务拆分提供了一套合理的框架。而在今天,在一个所有的东西都能被称之为“服务”的时代(XAAS), DDD 的思想让我们能冷静下来,去思考到底哪些东西可以被服务化拆分,哪些逻辑需要聚合,才能带来最小的维护成本,而不是简单的去追求开发效率。

所以今天,我开始这个关于 DDD 的一系列文章,希望能继续在总结前人的基础上发扬光大 DDD 的思想,但是通过一套我认为合理的代码结构、框架和约束,来降低 DDD 的实践门槛,提升代码质量、可测试性、安全性、健壮性。

未来会覆盖的内容包括:

  • 最佳架构实践:六边形应用架构 / Clean 架构的核心思想和落地方案
  • 持续发现和交付:Event Storming > Context Map > Design Heuristics > Modelling
  • 降低架构腐败速度:通过 Anti-Corruption Layer 集成第三方库的模块化方案
  • 标准组件的规范和边界:Entity, Aggregate, Repository, Domain Service, Application Service, Event, DTO Assembler 等
  • 基于 Use Case 重定义应用服务的边界
  • 基于 DDD 的微服务化改造及颗粒度控制
  • CQRS 架构的改造和挑战
  • 基于事件驱动的架构的挑战
  • 等等

今天先给大家带来一篇最基础,但极其有价值的Domain Primitive的概念。

Domain Primitive

就好像在学任何语言时首先需要了解的是基础数据类型一样,在全面了解 DDD 之前,首先给大家介绍一个最基础的概念: Domain Primitive(DP)。

Primitive 的定义是:

不从任何其他事物发展而来 
初级的形成或生长的早期阶段

就好像 Integer、String 是所有编程语言的Primitive一样,在 DDD 里, DP 可以说是一切模型、方法、架构的基础,而就像 Integer、String 一样, DP 又是无所不在的。所以,第一讲会对 DP 做一个全面的介绍和分析,但我们先不去讲概念,而是从案例入手,看看为什么 DP 是一个强大的概念。

1、案例分析

我们先看一个简单的例子,这个 case 的业务逻辑如下:

一个新应用在全国通过 地推业务员 做推广,需要做一个用户注册系统,同时希望在用户注册后能够通过用户电话(先假设仅限座机)的地域(区号)对业务员发奖金。

先不要去纠结这个根据用户电话去发奖金的业务逻辑是否合理,也先不要去管用户是否应该在注册时和业务员做绑定,这里我们看的主要还是如何更加合理的去实现这个逻辑。一个简单的用户和用户注册的代码实现如下:

public class User {
    Long userId;
    String name;
    String phone;
    String address;
    Long repId;
}

public class RegistrationServiceImpl implements RegistrationService {

    private SalesRepRepository salesRepRepo;
    private UserRepository userRepo;

    public User register(String name, String phone, String address) 
      throws ValidationException {
        // 校验逻辑
        if (name == null || name.length() == 0) {
            throw new ValidationException("name");
        }
        if (phone == null || !isValidPhoneNumber(phone)) {
            throw new ValidationException("phone");
        }
        // 此处省略address的校验逻辑

        // 取电话号里的区号,然后通过区号找到区域内的SalesRep
        String areaCode = null;
        String[] areas = new String[]{"0571", "021", "010"};
        for (int i = 0; i < phone.length(); i++) {
            String prefix = phone.substring(0, i);
            if (Arrays.asList(areas).contains(prefix)) {
                areaCode = prefix;
                break;
            }
        }
        SalesRep rep = salesRepRepo.findRep(areaCode);

        // 最后创建用户,落盘,然后返回
        User user = new User();
        user.name = name;
        user.phone = phone;
        user.address = address;
        if (rep != null) {
            user.repId = rep.repId;
        }

        return userRepo.save(user);
    }

    private boolean isValidPhoneNumber(String phone) {
        String pattern = "^0[1-9]{2,3}-?\\d{8}$";
        return phone.matches(pattern);
    }
}

我们日常绝大部分代码和模型其实都跟这个是类似的,乍一看貌似没啥问题,但我们再深入一步,从以下四个维度去分析一下:接口的清晰度(可阅读性)、数据验证和错误处理、业务逻辑代码的清晰度、和可测试性。

▍问题1 - 接口的清晰度

在Java代码中,对于一个方法来说所有的参数名在编译时丢失,留下的仅仅是一个参数类型的列表,所以我们重新看一下以上的接口定义,其实在运行时仅仅是:

User register(String, String, String);

所以以下的代码是一段编译器完全不会报错的,很难通过看代码就能发现的 bug :

service.register("殷浩", "浙江省杭州市余杭区文三西路969号", "0571-12345678");

当然,在真实代码中运行时会报错,但这种 bug 是在运行时被发现的,而不是在编译时。普通的 Code Review 也很难发现这种问题,很有可能是代码上线后才会被暴露出来。这里的思考是,有没有办法在编码时就避免这种可能会出现的问题?

另外一种常见的,特别是在查询服务中容易出现的例子如下:

User findByName(String name);
User findByPhone(String phone);
User findByNameAndPhone(String name, String phone);

在这个场景下,由于入参都是 String 类型,不得不在方法名上面加上 ByXXX 来区分,而 findByNameAndPhone 同样也会陷入前面的入参顺序错误的问题,而且和前面的入参不同,这里参数顺序如果输错了,方法不会报错只会返回 null,而这种 bug 更加难被发现。这里的思考是,有没有办法让方法入参一目了然,避免入参错误导致的 bug ?

▍问题2 - 数据验证和错误处理

在前面这段数据校验代码:

if (phone == null || !isValidPhoneNumber(phone)) {
    throw new ValidationException("phone");
}

在日常编码中经常会出现,一般来说这种代码需要出现在方法的最前端,确保能够 fail-fast 。但是假设你有多个类似的接口和类似的入参,在每个方法里这段逻辑会被重复。而更严重的是如果未来我们要拓展电话号去包含手机时,很可能需要加入以下代码:

if (phone == null || !isValidPhoneNumber(phone) || !isValidCellNumber(phone)) {
    throw new ValidationException("phone");
}

如果你有很多个地方用到了 phone 这个入参,但是有个地方忘记修改了,会造成 bug 。这是一个 DRY 原则被违背时经常会发生的问题。

如果有个新的需求,需要把入参错误的原因返回,那么这段代码就变得更加复杂:

if (phone == null) {
    throw new ValidationException("phone不能为空");
} else if (!isValidPhoneNumber(phone)) {
    throw new ValidationException("phone格式错误");
}

可以想像得到,代码里充斥着大量的类似代码块时,维护成本要有多高。

最后,在这个业务方法里,会(隐性或显性的)抛 ValidationException,所以需要外部调用方去try/catch,而业务逻辑异常和数据校验异常被混在了一起,是否是合理的?

在传统Java架构里有几个办法能够去解决一部分问题,常见的如BeanValidation注解或ValidationUtils类,比如:

// Use Bean Validation
User registerWithBeanValidation(
  @NotNull @NotBlank String name,
  @NotNull @Pattern(regexp = "^0?[1-9]{2,3}-?\\d{8}$") String phone,
  @NotNull String address
);

// Use ValidationUtils:
public User registerWithUtils(String name, String phone, String address) {
    ValidationUtils.validateName(name); // throws ValidationException
    ValidationUtils.validatePhone(phone);
    ValidationUtils.validateAddress(address);
    ...
}

但这几个传统的方法同样有问题,

BeanValidation:

  • 通常只能解决简单的校验逻辑,复杂的校验逻辑一样要写代码实现定制校验器
  • 在添加了新校验逻辑时,同样会出现在某些地方忘记添加一个注解的情况,DRY原则还是会被违背

ValidationUtils类:

  • 当大量的校验逻辑集中在一个类里之后,违背了Single Responsibility单一性原则,导致代码混乱和不可维护
  • 业务异常和校验异常还是会混杂

所以,有没有一种方法,能够一劳永逸的解决所有校验的问题以及降低后续的维护成本和异常处理成本呢?

▍问题3 - 业务代码的清晰度

在这段代码里:

String areaCode = null;
String[] areas = new String[]{"0571", "021", "010"};
for (int i = 0; i < phone.length(); i++) {
    String prefix = phone.substring(0, i);
    if (Arrays.asList(areas).contains(prefix)) {
        areaCode = prefix;
        break;
    }
}
SalesRep rep = salesRepRepo.findRep(areaCode);

实际上出现了另外一种常见的情况,那就是从一些入参里抽取一部分数据,然后调用一个外部依赖获取更多的数据,然后通常从新的数据中再抽取部分数据用作其他的作用。这种代码通常被称作“胶水代码”,其本质是由于外部依赖的服务的入参并不符合我们原始的入参导致的。比如,如果SalesRepRepository包含一个findRepByPhone的方法,则上面大部分的代码都不必要了。

所以,一个常见的办法是将这段代码抽离出来,变成独立的一个或多个方法:

private static String findAreaCode(String phone) {
    for (int i = 0; i < phone.length(); i++) {
        String prefix = phone.substring(0, i);
        if (isAreaCode(prefix)) {
            return prefix;
        }
    }
    return null;
}

private static boolean isAreaCode(String prefix) {
    String[] areas = new String[]{"0571", "021"};
    return Arrays.asList(areas).contains(prefix);
}

然后原始代码变为:

String areaCode = findAreaCode(phone);
SalesRep rep = salesRepRepo.findRep(areaCode);

而为了复用以上的方法,可能会抽离出一个静态工具类 PhoneUtils 。但是这里要思考的是,静态工具类是否是最好的实现方式呢?当你的项目里充斥着大量的静态工具类,业务代码散在多个文件当中时,你是否还能找到核心的业务逻辑呢?

▍问题4 - 可测试性

为了保证代码质量,每个方法里的每个入参的每个可能出现的条件都要有 TC 覆盖(假设我们先不去测试内部业务逻辑),所以在我们这个方法里需要以下的 TC :

假如一个方法有 N 个参数,每个参数有 M 个校验逻辑,至少要有 N * M 个 TC 。

如果这时候在该方法中加入一个新的入参字段 fax ,即使 fax 和 phone 的校验逻辑完全一致,为了保证 TC 覆盖率,也一样需要 M 个新的 TC 。

而假设有 P 个方法中都用到了 phone 这个字段,这 P 个方法都需要对该字段进行测试,也就是说整体需要:

P N M

个测试用例才能完全覆盖所有数据验证的问题,在日常项目中,这个测试的成本非常之高,导致大量的代码没被覆盖到。而没被测试覆盖到的代码才是最有可能出现问题的地方。

在这个情况下,降低测试成本 == 提升代码质量,如何能够降低测试的成本呢?

2、解决方案

我们回头先重新看一下原始的 use case,并且标注其中可能重要的概念:

一个新应用在全国通过 地推业务员 做推广,需要做一个用户的注册系统,在用户注册后能够通过用户电话号的区号对业务员发奖金。

在分析了 use case 后,发现其中地推业务员、用户本身自带 ID 属性,属于 Entity(实体),而注册系统属于 Application Service(应用服务),这几个概念已经有存在。但是发现电话号这个概念却完全被隐藏到了代码之中。我们可以问一下自己,取电话号的区号的逻辑是否属于用户(用户的区号?)?是否属于注册服务(注册的区号?)?如果都不是很贴切,那就说明这个逻辑应该属于一个独立的概念。所以这里引入我们第一个原则:

Make Implicit Concepts Explicit

将隐性的概念显性化

在这里,我们可以看到,原来电话号仅仅是用户的一个参数,属于隐形概念,但实际上电话号的区号才是真正的业务逻辑,而我们需要将电话号的概念显性化,通过写一个Value Object:

public class PhoneNumber {

    private final String number;
    public String getNumber() {
        return number;
    }

    public PhoneNumber(String number) {
        if (number == null) {
            throw new ValidationException("number不能为空");
        } else if (isValid(number)) {
            throw new ValidationException("number格式错误");
        }
        this.number = number;
    }

    public String getAreaCode() {
        for (int i = 0; i < number.length(); i++) {
            String prefix = number.substring(0, i);
            if (isAreaCode(prefix)) {
                return prefix;
            }
        }
        return null;
    }

    private static boolean isAreaCode(String prefix) {
        String[] areas = new String[]{"0571", "021", "010"};
        return Arrays.asList(areas).contains(prefix);
    }

    public static boolean isValid(String number) {
        String pattern = "^0?[1-9]{2,3}-?\\d{8}$";
        return number.matches(pattern);
    }

}

这里面有几个很重要的元素:

通过 private final String number 确保 PhoneNumber 是一个(Immutable)Value Object。(一般来说 VO 都是 Immutable 的,这里只是重点强调一下)

校验逻辑都放在了 constructor 里面,确保只要 PhoneNumber 类被创建出来后,一定是校验通过的。

之前的 findAreaCode 方法变成了 PhoneNumber 类里的 getAreaCode ,突出了 areaCode 是 PhoneNumber 的一个计算属性。

这样做完之后,我们发现把 PhoneNumber 显性化之后,其实是生成了一个 Type(数据类型)和一个 Class(类):

  • Type 指我们在今后的代码里可以通过 PhoneNumber 去显性的标识电话号这个概念
  • Class 指我们可以把所有跟电话号相关的逻辑完整的收集到一个文件里

这两个概念加起来,构造成了本文标题的 Domain Primitive(DP)。

我们看一下全面使用了 DP 之后效果:

public class User {
    UserId userId;
    Name name;
    PhoneNumber phone;
    Address address;
    RepId repId;
}

public User register(
  @NotNull Name name,
  @NotNull PhoneNumber phone,
  @NotNull Address address
) {
    // 找到区域内的SalesRep
    SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());

    // 最后创建用户,落盘,然后返回,这部分代码实际上也能用Builder解决
    User user = new User();
    user.name = name;
    user.phone = phone;
    user.address = address;
    if (rep != null) {
        user.repId = rep.repId;
    }

    return userRepo.saveUser(user);
}

我们可以看到在使用了 DP 之后,所有的数据验证逻辑和非业务流程的逻辑都消失了,剩下都是核心业务逻辑,可以一目了然。我们重新用上面的四个维度评估一下:

▍评估1 - 接口的清晰度

重构后的方法签名变成了很清晰的:

public User register(Name, PhoneNumber, Address)

而之前容易出现的bug,如果按照现在的写法

service.register(new Name("殷浩"), new Address("浙江省杭州市余杭区文三西路969号"), new PhoneNumber("0571-12345678"));

让接口 API 变得很干净,易拓展。

▍评估2 - 数据验证和错误处理

public User register(
  @NotNull Name name,
  @NotNull PhoneNumber phone,
  @NotNull Address address
) // no throws

如前文代码展示的,重构后的方法里,完全没有了任何数据验证的逻辑,也不会抛 ValidationException 。原因是因为 DP 的特性,只要是能够带到入参里的一定是正确的或 null(Bean Validation 或 lombok 的注解能解决 null 的问题)。所以我们把数据验证的工作量前置到了调用方,而调用方本来就是应该提供合法数据的,所以更加合适。

再展开来看,使用DP的另一个好处就是代码遵循了 DRY 原则和单一性原则,如果未来需要修改 PhoneNumber 的校验逻辑,只需要在一个文件里修改即可,所有使用到了 PhoneNumber 的地方都会生效。

▍评估3 - 业务代码的清晰度

SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
User user = xxx;
return userRepo.save(user);

除了在业务方法里不需要校验数据之外,原来的一段胶水代码 findAreaCode 被改为了 PhoneNumber 类的一个计算属性 getAreaCode ,让代码清晰度大大提升。而且胶水代码通常都不可复用,但是使用了 DP 后,变成了可复用、可测试的代码。我们能看到,在刨除了数据验证代码、胶水代码之后,剩下的都是核心业务逻辑。( Entity 相关的重构在后面文章会谈到,这次先忽略)

▍评估4 - 可测试性

当我们将 PhoneNumber 抽取出来之后,在来看测试的 TC :

  • 首先 PhoneNumber 本身还是需要 M 个测试用例,但是由于我们只需要测试单一对象,每个用例的代码量会大大降低,维护成本降低。
  • 每个方法里的每个参数,现在只需要覆盖为 null 的情况就可以了,其他的 case 不可能发生(因为只要不是 null 就一定是合法的)

所以,单个方法的 TC 从原来的 N * M 变成了今天的 N + M 。同样的,多个方法的 TC 数量变成了

N + M + P

这个数量一般来说要远低于原来的数量 N M P ,让测试成本极大的降低。

▍评估总结

3、进阶使用

在上文我介绍了 DP 的第一个原则:将隐性的概念显性化。在这里我将介绍 DP 的另外两个原则,用一个新的案例。

▍案例1 - 转账

假设现在要实现一个功能,让A用户可以支付 x 元给用户 B ,可能的实现如下:

public void pay(BigDecimal money, Long recipientId) {
    BankService.transfer(money, "CNY", recipientId);
}

如果这个是境内转账,并且境内的货币永远不变,该方法貌似没啥问题,但如果有一天货币变更了(比如欧元区曾经出现的问题),或者我们需要做跨境转账,该方法是明显的 bug ,因为 money 对应的货币不一定是 CNY 。

在这个 case 里,当我们说“支付 x 元”时,除了 x 本身的数字之外,实际上是有一个隐含的概念那就是货币“元”。但是在原始的入参里,之所以只用了 BigDecimal 的原因是我们认为 CNY 货币是默认的,是一个隐含的条件,但是在我们写代码时,需要把所有隐性的条件显性化,而这些条件整体组成当前的上下文。所以 DP 的第二个原则是:

Make Implicit Context Explicit

将 隐性的 上下文 显性化

所以当我们做这个支付功能时,实际上需要的一个入参是支付金额 + 支付货币。我们可以把这两个概念组合成为一个独立的完整概念:Money。

@Value
public class Money {
    private BigDecimal amount;
    private Currency currency;
    public Money(BigDecimal amount, Currency currency) {
        this.amount = amount;
        this.currency = currency;
    }
}

而原有的代码则变为:

public void pay(Money money, Long recipientId) {
    BankService.transfer(money, recipientId);
}

通过将默认货币这个隐性的上下文概念显性化,并且和金额合并为 Money ,我们可以避免很多当前看不出来,但未来可能会暴雷的bug。

▍案例2 - 跨境转账

前面的案例升级一下,假设用户可能要做跨境转账从 CNY 到 USD ,并且货币汇率随时在波动:

public void pay(Money money, Currency targetCurrency, Long recipientId) {
    if (money.getCurrency().equals(targetCurrency)) {
        BankService.transfer(money, recipientId);
    } else {
        BigDecimal rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
        BigDecimal targetAmount = money.getAmount().multiply(new BigDecimal(rate));
        Money targetMoney = new Money(targetAmount, targetCurrency);
        BankService.transfer(targetMoney, recipientId);
    }
}

在这个case里,由于 targetCurrency 不一定和 money 的 Curreny 一致,需要调用一个服务去取汇率,然后做计算。最后用计算后的结果做转账。

这个case最大的问题在于,金额的计算被包含在了支付的服务中,涉及到的对象也有2个 Currency ,2 个 Money ,1 个 BigDecimal ,总共 5 个对象。这种涉及到多个对象的业务逻辑,需要用 DP 包装掉,所以这里引出 DP 的第三个原则:

Encapsulate Multi-Object Behavior

封装 多对象 行为

在这个 case 里,可以将转换汇率的功能,封装到一个叫做 ExchangeRate 的 DP 里:

@Value
public class ExchangeRate {
    private BigDecimal rate;
    private Currency from;
    private Currency to;

    public ExchangeRate(BigDecimal rate, Currency from, Currency to) {
        this.rate = rate;
        this.from = from;
        this.to = to;
    }

    public Money exchange(Money fromMoney) {
        notNull(fromMoney);
        isTrue(this.from.equals(fromMoney.getCurrency()));
        BigDecimal targetAmount = fromMoney.getAmount().multiply(rate);
        return new Money(targetAmount, to);
    }
}

ExchangeRate 汇率对象,通过封装金额计算逻辑以及各种校验逻辑,让原始代码变得极其简单:

public void pay(Money money, Currency targetCurrency, Long recipientId) {
    ExchangeRate rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
    Money targetMoney = rate.exchange(money);
    BankService.transfer(targetMoney, recipientId);
}

4、讨论和总结

▍Domain Primitive 的定义

让我们重新来定义一下 Domain Primitive :Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object 。

  • DP是一个传统意义上的Value Object,拥有Immutable的特性
  • DP是一个完整的概念整体,拥有精准定义
  • DP使用业务域中的原生语言
  • DP可以是业务域的最小组成部分、也可以构建复杂组合

注:Domain Primitive的概念和命名来自于Dan Bergh Johnsson & Daniel Deogun的书 Secure by Design。

▍使用 Domain Primitive 的三原则

  • 让隐性的概念显性化
  • 让隐性的上下文显性化
  • 封装多对象行为

▍Domain Primitive 和 DDD 里 Value Object 的区别

在 DDD 中, Value Object 这个概念其实已经存在:

  • 在 Evans 的 DDD 蓝皮书中,Value Object 更多的是一个非 Entity 的值对象
  • 在Vernon的IDDD红皮书中,作者更多的关注了Value Object的Immutability、Equals方法、Factory方法等

Domain Primitive 是 Value Object 的进阶版,在原始 VO 的基础上要求每个 DP 拥有概念的整体,而不仅仅是值对象。在 VO 的 Immutable 基础上增加了 Validity 和行为。当然同样的要求无副作用(side-effect free)。

▍Domain Primitive 和 Data Transfer Object (DTO) 的区别

在日常开发中经常会碰到的另一个数据结构是 DTO ,比如方法的入参和出参。DP 和 DTO 的区别如下:

▍什么情况下应该用 Domain Primitive

常见的 DP 的使用场景包括:

  • 有格式限制的 String:比如Name,PhoneNumber,OrderNumber,ZipCode,Address等
  • 有限制的Integer:比如OrderId(>0),Percentage(0-100%),Quantity(>=0)等
  • 可枚举的 int :比如 Status(一般不用Enum因为反序列化问题)
  • Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等
  • 复杂的数据结构:比如 Map> 等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为

5、实战 - 老应用重构的流程

在新应用中使用 DP 是比较简单的,但在老应用中使用 DP 是可以遵循以下流程按部就班的升级。在此用本文的第一个 case 为例。

▍第一步 - 创建 Domain Primitive,收集所有 DP 行为

在前文中,我们发现取电话号的区号这个是一个可以独立出来的、可以放入 PhoneNumber 这个 Class 的逻辑。类似的,在真实的项目中,以前散落在各个服务或工具类里面的代码,可以都抽出来放在 DP 里,成为 DP 自己的行为或属性。这里面的原则是:所有抽离出来的方法要做到无状态,比如原来是 static 的方法。如果原来的方法有状态变更,需要将改变状态的部分和不改状态的部分分离,然后将无状态的部分融入 DP 。因为 DP 本身不能带状态,所以一切需要改变状态的代码都不属于 DP 的范畴。

(代码参考 PhoneNumber 的代码,这里不再重复)

▍第二步 - 替换数据校验和无状态逻辑

为了保障现有方法的兼容性,在第二步不会去修改接口的签名,而是通过代码替换原有的校验逻辑和根 DP 相关的业务逻辑。比如:

public User register(String name, String phone, String address)
        throws ValidationException {
    if (name == null || name.length() == 0) {
        throw new ValidationException("name");
    }
    if (phone == null || !isValidPhoneNumber(phone)) {
        throw new ValidationException("phone");
    }

    String areaCode = null;
    String[] areas = new String[]{"0571", "021", "010"};
    for (int i = 0; i < phone.length(); i++) {
        String prefix = phone.substring(0, i);
        if (Arrays.asList(areas).contains(prefix)) {
            areaCode = prefix;
            break;
        }
    }
    SalesRep rep = salesRepRepo.findRep(areaCode);
    // 其他代码...
}

通过 DP 替换代码后:

public User register(String name, String phone, String address)
        throws ValidationException {

    Name _name = new Name(name);
    PhoneNumber _phone = new PhoneNumber(phone);
    Address _address = new Address(address);

    SalesRep rep = salesRepRepo.findRep(_phone.getAreaCode());
    // 其他代码...
}

通过 new PhoneNumber(phone) 这种代码,替代了原有的校验代码。

通过 _phone.getAreaCode() 替换了原有的无状态的业务逻辑。

▍第三步 - 创建新接口

创建新接口,将DP的代码提升到接口参数层:

public User register(Name name, PhoneNumber phone, Address address) {
    SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
}

▍第四步 - 修改外部调用

外部调用方需要修改调用链路,比如:

service.register("殷浩", "0571-12345678", "浙江省杭州市余杭区文三西路969号");

改为:

service.register(new Name("殷浩"), new PhoneNumber("0571-12345678"), new Address("浙江省杭州市余杭区文三西路969号"));

通过以上 4 步,就能让你的代码变得更加简洁、优雅、健壮、安全。你还在等什么?今天就去尝试吧!



本文作者:殷浩

阅读原文

本文为云栖社区原创内容,未经允许不得转载。

查看原文

赞 16 收藏 9 评论 1

freewolf 发布了文章 · 2019-09-03

在Spring boot 2 微服务中使用 GRPC

在Spring boot 2 微服务中使用 GRPC

关于RPC

RPC(Remote Procedure Call)—远程过程调用,它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。RPC协议假定某些传输协议的存在,如TCP或UDP,为通信程序之间携带信息数据。在OSI网络通信模型中,RPC跨越了传输层和应用层。RPC使得开发包括网络分布式多程序在内的应用程序更加容易。

gRPC

gRPC是Google开源的通用高性能RPC框架,它支持的是使用Protocol Buffers来编写Service定义,支持较多语言扩平台并且拥有强大的二进制序列化工具集。与文章《RPC框架实践之:Apache Thrift》一文中实践的另一种通用RPC框架Thrift能通过Generator自动生成对应语言的Service接口类似,gRPC也能自动地生成Server和Client的Service存根(Stub),我们只需要一个命令就能快速搭建起RPC运行环境。

微服务和 RPC

微服务大行其道的今天,服务间通讯其实是个比较大的问题,REST调用及测试都很方便,RPC就显得有点繁琐,但是RPC的效率是毋庸置疑的,所以建议在多系统之间的内部调用采用RPC。对外提供的服务,Rest更加合适。

如果何用

  • 在一个 .proto 文件内定义服务。
  • 用 protocol buffer 编译器生成服务器和客户端代码。
  • 使用 gRPC 的 Java API 为你的服务实现一个简单的客户端和服务器。

Demo

创建一个Spring boot 2项目,选择Web StarterLombok就可以了。然后加入对grpc-spring-boot-starter的依赖。

<dependency>
    <groupId>io.github.lognet</groupId>
    <artifactId>grpc-spring-boot-starter</artifactId>
    <version>3.4.1</version>
</dependency>

编译需要修改,因为编译时需要先.proto文件进行代码生成,生成我们想要的java代码文件。生成的文件会在target下的generated-source下的protobuf文件夹下。编译配置如下:

  <build>
        <extensions>
            <extension>
                <groupId>kr.motd.maven</groupId>
                <artifactId>os-maven-plugin</artifactId>
                <version>${os-maven-plugin.version}</version>
            </extension>
        </extensions>
        <plugins>
            <plugin>
                <groupId>org.xolstice.maven.plugins</groupId>
                <artifactId>protobuf-maven-plugin</artifactId>
                <version>${protobuf-maven-plugin.version}</version>
                <configuration>
                    <protocArtifact>
                        com.google.protobuf:protoc:3.9.1:exe:${os.detected.classifier}
                    </protocArtifact>
                    <pluginId>grpc-java</pluginId>
                    <pluginArtifact>
                        io.grpc:protoc-gen-grpc-java:1.23.0:exe:${os.detected.classifier}
                    </pluginArtifact>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>compile</goal>
                            <goal>compile-custom</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

添加三个.proto文件,第一个是Member.proto文件,作为一个复合对象,作为参数使用。

syntax = "proto3";
option java_multiple_files = true;
package com.example.grpcdemo.grpc;

message Member {
    string username = 1;
    string password = 2;
    string info = 3;
}

然后就是Service文件,MemberListSerbice.proto文件中定义了,request和respone的内容,还有服务输入输出的message。这里需要引入之前定义的Member.proto

syntax = "proto3";
option java_multiple_files = true;
package com.example.grpcdemo.grpc;
import "Member.proto";

message MemberListRequest {
    int32 page = 1;
    int32 per_page = 2;
}

message MemberListResponse {
    repeated Member member = 1;
}

service MemberListService {
    rpc member_list(MemberListRequest) returns (MemberListResponse);
}

这里另一个Service详见源代码。下面添加一些本项目所需的RPC服务处理代码,先编译项目,省城所需java文件,然后就可以开始写RPC服务处理代码,需要从生成的抽象类继承。

@GRpcService
public class MemberLoginServiceImpl extends MemberLoginServiceGrpc.MemberLoginServiceImplBase {

    @Override
    public void memberLogin(MemberLoginRequest request, StreamObserver<MemberLoginResponse> responseObserver) {

        String token = "";
        if(request.getMember().getPassword().equals("123456")){
            token = "success";
        }

        MemberLoginResponse response = MemberLoginResponse
                .newBuilder()
                .setToken(token)
                .build();

        responseObserver.onNext(response);
        responseObserver.onCompleted();
    }
}

还需要一个Java类来定义客户端连接RPC所需的Bean

@Configuration
public class GrpcConfig {
    @Bean
    ManagedChannel channel(@Value("${app-config.grpc-server-name}") String name,
                           @Value("${app-config.grpc-server-port}") Integer port){
        return ManagedChannelBuilder.forAddress(name, port)
                .usePlaintext()
                .build();
    }

    @Bean
    MemberListServiceGrpc.MemberListServiceBlockingStub memberListServiceBlockingStub(ManagedChannel channel){
        return MemberListServiceGrpc.newBlockingStub(channel);
    }

    @Bean
    MemberLoginServiceGrpc.MemberLoginServiceBlockingStub memberLoginServiceStub(ManagedChannel channel){
        return MemberLoginServiceGrpc.newBlockingStub(channel);
    }
}

最后还有一个测试用的Controller,浏览器访问controller中的rest服务,rest服务连接rpc去访问真实服务,这里服务端和客户端并没有分开,实际controller应该在真正的客户端项目中。这里只是一个demo

@RestController
public class MemberController{

    private MemberListServiceGrpc.MemberListServiceBlockingStub memberListServiceBlockingStub;
    private MemberLoginServiceGrpc.MemberLoginServiceBlockingStub memberLoginServiceBlockingStub;

    public MemberController(MemberListServiceGrpc.MemberListServiceBlockingStub memberListServiceBlockingStub,
                            MemberLoginServiceGrpc.MemberLoginServiceBlockingStub memberLoginServiceBlockingStub) {
        this.memberListServiceBlockingStub = memberListServiceBlockingStub;
        this.memberLoginServiceBlockingStub = memberLoginServiceBlockingStub;
    }

    /**
     * curl -X POST -H "Content-type: application/json" -d "{\"username\":\"freewolf\", \"password\":\"123456\"}" http://localhost:8080/login
     * @param memberVO
     * @return
     */
    @PostMapping("/login")
    public String login(@RequestBody MemberVO memberVO){
        MemberLoginResponse response = this.memberLoginServiceBlockingStub.memberLogin(MemberLoginRequest.newBuilder()
                .setMember(Member.newBuilder()
                        .setPassword(memberVO.getPassword())
                        .setUsername(memberVO.getUsername())
                        .build())
                .build());
        return response.getToken();
    }

    @GetMapping("/list")
    public List<MemberVO> list(){
        MemberListResponse response = this.memberListServiceBlockingStub.memberList(MemberListRequest.newBuilder()
                .setPage(1)
                .setPerPage(2)
                .build());
        List<MemberVO> list = MemberVO.getList(response.getMemberList());
        return list;
    }
}
本文代码地址:https://github.com/freew01f/g...

资源

查看原文

赞 8 收藏 5 评论 0

freewolf 发布了文章 · 2019-05-04

在Mac上搭建本地的Kubenetes开发环境Minikube 1.0

在Mac上安装Minikube 1.0

最近这几年,软件开发翻天覆地的变化,当个软件行业从业者,不学习新东西肯定是不行的。从微服务到容器化服务开发,其实并没有多长时间,随着Docker和Kubenetes的大热,还必须要跟上脚步才行,做一个程序员真不容易,做一个老程序员更难。

Docker和Kubenetes是什么,我这里就不介绍了,比我了解这玩意的的人,大有人在,如果你想了解,请自我学习,本文结尾也推荐了《IBM微讲堂 Kubenetes》系列,一共十个视频,看一遍基本也就都懂了。

如果你开发微服务想控制和容器编排工具有互动,比如调用K8s的API之类,或者你想本Mac地装一个单机版本的K8s,我也走了一些弯路,没少浪费时间。K8s安装是一个不小的工程,但是我并不是一个很好的运维,也仅仅在开发层面了解K8s,所以装一个单机版本非常有必要,这里推荐你去安装Minikube,这就是一个单机版本的单节点K8s。其实本来就想一个安装记录,但是估计也有不少人会遇到安装的问题,那我就稍微写详细一点,帮助大家稍微少走一点弯路。

安装Minikube第一步,你先要越过GFW,至于什么是GFW怎么越过去这里就不详细说了。
Minikube需要你本地装有VirtualBox,推荐你下载一个比较新的版本,因为要在上面运行一个虚拟机来跑K8s的节点。

修改网络配置

我用的软件是ShadowsocksX-NG这个软件,早期版本并不支持代理,后期版本使用了privoxy实现了HTTP和Socks,但是本文只需要使用HTTP代理就好了。

首先修改进入Performance中,把Advance中的Socks5地址和HTTP中的监听地址都改为0.0,0,0就Ok了,这样你就能接收非本地的请求代理访问ss了。

安装

Mac下安装太容易了,别告诉我你没有brew

brew cask install minikube

大功告成。

下载安装kubectl ,需要手工该权限,这是K8s的命令行控制工具

curl -LO https://storage.googleapis.com/kubernetes-release/release/v1.14.0/bin/darwin/amd64/kubectl
sudo mv kubectl /usr/local/bin/
chmod +x /usr/local/bin/kubectl

安装完Minikube 先测试下网络环境,下面命令正常说明代理服务没问题

curl -x 192.168.99.1:1087 http://baidu.com

启动

用下面命令,让K8s节点的docker,使用下面代理访问,insecure-registry详解见本文末尾扩展阅读

minikube start --docker-env HTTP_PROXY=http://192.168.99.1:1087 --docker-env HTTPS_PROXY=http://192.168.99.1:1087 --docker-env NO_PROXY=127.0.0.1/24 --insecure-registry=192.168.99.1:5000

见到下面log为启动成功。

😄  minikube v1.0.0 on darwin (amd64)
🤹  Downloading Kubernetes v1.14.0 images in the background ...
🔥  Creating virtualbox VM (CPUs=2, Memory=2048MB, Disk=20000MB) ...
📶  "minikube" IP address is 192.168.99.105
🐳  Configuring Docker as the container runtime ...
    ▪ env HTTP_PROXY=http://192.168.99.1:1087
    ▪ env HTTPS_PROXY=http://192.168.99.1:1087
    ▪ env NO_PROXY=127.0.0.1/24
🐳  Version of container runtime is 18.06.2-ce
⌛  Waiting for image downloads to complete ...
E0503 17:26:45.170139    5798 start.go:209] Error caching images:  Caching images for kubeadm: caching images: caching image /Users/freewolf/.minikube/cache/images/k8s.gcr.io/k8s-dns-dnsmasq-nanny-amd64_1.14.13: fetching remote image: Get https://k8s.gcr.io/v2/: dial tcp 74.125.203.82:443: i/o timeout
✨  Preparing Kubernetes environment ...
❌  Unable to load cached images: loading cached images: loading image /Users/freewolf/.minikube/cache/images/gcr.io/k8s-minikube/storage-provisioner_v1.8.1: stat /Users/freewolf/.minikube/cache/images/gcr.io/k8s-minikube/storage-provisioner_v1.8.1: no such file or directory
🚜  Pulling images required by Kubernetes v1.14.0 ...
🚀  Launching Kubernetes v1.14.0 using kubeadm ... 
⌛  Waiting for pods: apiserver proxy etcd scheduler controller dns
🔑  Configuring cluster permissions ...
🤔  Verifying component health .....
💗  kubectl is now configured to use "minikube"
🏄  Done! Thank you for using minikube!

中间的错误信息说明本地的cache中没有所需文件都要连接互联网拉取。中间时间很长需要20分钟,因为要下载1g的内容。

如果你想知道都在下载哪些内容,你可以再开一个命令行行,用下面三个命令查看:
1 进入K8s节点
2 查看当前镜像,你会看到镜像不断增多
3 查看进程 你可以看到当前在pull什么镜像
4 查看启动容器

mimikube ssh
docker images
ps -aux
docker ps

插件配置

我又装了一个监控,一个反向代理两个插件

minikube addons  enable ingress
✅  ingress was successfully enabled

minikube addons  enable ingress
✅  ingress was successfully enabled

查看插件详情

minikube addons list
- addon-manager: enabled
- dashboard: enabled
- default-storageclass: enabled
- efk: disabled
- freshpod: disabled
- gvisor: disabled
- heapster: disabled
- ingress: enabled
- logviewer: disabled
- metrics-server: enabled
- nvidia-driver-installer: disabled
- nvidia-gpu-device-plugin: disabled
- registry: disabled
- registry-creds: disabled
- storage-provisioner: enabled
- storage-provisioner-gluster: disabled

启动Dashboard

输入minikube dashboard,当所需镜像下载完毕后会自动打开网页

minikube dashboard
🔌  Enabling dashboard ...
🤔  Verifying dashboard health ...
🚀  Launching proxy ...
🤔  Verifying proxy health ...
🎉  Opening http://127.0.0.1:60708/api/v1/namespaces/kube-system/services/http:kubernetes-dashboard:/proxy/ in your default browser...

查看镜像情况

进入容器minikube ssh查看镜像安装情况,可以看到装的东西不少。

docker images
REPOSITORY                                                       TAG                 IMAGE ID            CREATED             SIZE
k8s.gcr.io/kube-proxy                                            v1.14.0             5cd54e388aba        5 weeks ago         82.1MB
k8s.gcr.io/kube-controller-manager                               v1.14.0             b95b1efa0436        5 weeks ago         158MB
k8s.gcr.io/kube-apiserver                                        v1.14.0             ecf910f40d6e        5 weeks ago         210MB
k8s.gcr.io/kube-scheduler                                        v1.14.0             00638a24688b        5 weeks ago         81.6MB
quay.io/kubernetes-ingress-controller/nginx-ingress-controller   0.23.0              42d47fe0c78f        2 months ago        591MB
k8s.gcr.io/kube-addon-manager                                    v9.0                119701e77cbc        3 months ago        83.1MB
k8s.gcr.io/coredns                                               1.3.1               eb516548c180        3 months ago        40.3MB
k8s.gcr.io/kubernetes-dashboard-amd64                            v1.10.1             f9aed6605b81        4 months ago        122MB
k8s.gcr.io/etcd                                                  3.3.10              2c4adeb21b4f        5 months ago        258MB
k8s.gcr.io/pause                                                 3.1                 da86e6ba6ca1        16 months ago       742kB
k8s.gcr.io/metrics-server-amd64                                  v0.2.1              9801395070f3        16 months ago       42.5MB
gcr.io/k8s-minikube/storage-provisioner                          v1.8.1              4689081edb10        18 months ago       80.8MB
gcr.io/google_containers/defaultbackend                          1.4                 846921f0fe0e        18 months ago       4.84MB

最后

啰啰嗦嗦说一堆,我自己都看不下去了,其实只要保证K8s的安装节点网络没问题就好了,网络没问题,如果有错误就通过minikube delete,删除当前虚拟机,然后再运行带参数的start命令就好了,镜像下载最好一次性完成,镜像列表本文也有,希望能对你有所帮助。

资源推荐

查看原文

赞 5 收藏 4 评论 0

freewolf 关注了用户 · 2019-05-03

mercyblitz @mercyblitz

小马哥,Java 劝退师,《Spring Boot 编程思想》作者,Apache 和 Spring Cloud 等知名开源架构成员,点击查看详情。(交流QQ群:719291662

最新发布:
Spring Boot 2.0深度实践之核心技术篇
Java 微服务实践系列课堂
「一入 Java 深似海 」系列课程

关注 2838

freewolf 评论了文章 · 2019-04-09

使用 OAuth 2 和 JWT 为微服务提供安全保障

Part 1 - 理论相关

作者 freewolf

关键词

微服务Spring CloudOAuth 2.0JWTSpring SecuritySSOUAA

写在前面

作为从业了十多年的IT行业和程序的老司机,今天如果你说你不懂微服务,都不好意思说自己的做软件的。SOA喊了多年,无人不知,但又有多少系统开发真正的SOA了呢?但是好像一夜之间所有人都投入了微服务的怀抱。

作为目前最主流的“微服务框架”,Spring Cloud发展速度很快,成为了最全面的微服务解决方案。不管什么软件体系,什么框架,安全永远是不可能绕开的话题,我也把它作为我最近一段时间研究微服务的开篇。

老话题!“如何才能在微服务体系中保证安全?”,为了达成目标,这里采用一个简单而可行方式来保护Spring Cloud中服务的安全,也就是建立统一的用户授权中心。

这里补充说一下什么是Authentication(认证)Authorization(鉴权),其实很简单,认证关心你是谁,鉴权关心你能干什么。举个大家一致都再说的例子,如果你去机场乘机,你持有的护照代表你的身份,这是认证,你的机票就是你的权限,你能干什么。

学习微服务并不是一个简单的探索过程,这不得学习很多新的知识,其实不管是按照DDD(Domain Driven Design)领域驱动设计中领域模型的方式,还是将微服务拆分成更小的粒度。都会遇到很多新的问题和以前一直都没解决很好的问题。随着不断的思考,随着熟悉Facebook/GitHub/AWS这些机构是如何保护内部资源,答案也逐渐浮出水面。

为了高效的实现这个目标,这里采用OAuth 2JWT(JSON Web Tokens)技术作为解决方案,

为什么使用OAuth 2

尽管微服务在现代软件开发中还算一个新鲜事物,但是OAuth 2已经是一个广泛使用的授权技术,它让Web开发者在自己提供服务中,用一种安全的方式直访问Google/Facebook/GitHub平台用户信息。但在我开始阐述细节之前,我将揭开聚焦到本文真正的主题:云安全

那么在云服务中对用户访问资源的控制,我们一般都怎么做呢?然我举一些大家似乎都用过的但又不是很完美的例子。

我们可以设置边界服务器或者带认证功能的反向代理服务器,假设所有访问请求都发给它。通过认证后,转发给内部相应的服务器。一般在Spring MVC Security开发中几乎都会这样做的。但这并不安全,最重要的是,一旦是有人从内部攻击,你的数据毫无安全性。

其他方式:我们为所有服务建立统一的权限数据库,并在每次请求前对用户进行鉴权,听起来某些方面的确有点愚蠢,但实际上这确实是一个可行的安全方案。

更好的方式: 用户通过授权服务来实现鉴权,把用户访问Session映射成一个Token。所有远程访问资源服务器相关的API必须提供Token。然后资源服务器访问授权服务来识别Token,得知Token属于哪个用户,并了解通过这个Token可以访问什么资源。

这听起来是个不错的方案,对不?但是如何保证Token的安全传输?如何区分是用户访问还是其他服务访问?这肯定是我们关心的问题。

所以上述种种问题让我们选择使用OAuth 2,其实访问Facebook/Google的敏感数据和访问我们自己后端受保护数据没什么区别,并且他们已经使用这样的解决方案很多年,我们只要遵循这些方法就好了。

OAuth 2是如何工作的

如果你了解OAuth 2相关的原理,那么在部署OAuth 2是非常容易的。
让我们描述下这样一个场景,“某App希望获得TomFacebook上相关的数据”

OAuth 2 在整个流程中有四种角色:

  • 资源拥有者(Resource Owner) - 这里是Tom

  • 资源服务器(Resource Server) - 这里是Facebook

  • 授权服务器(Authorization Server) - 这里当然还是Facebook,因为Facebook有相关数据

  • 客户端(Client) - 这里是某App

Tom试图登录Facebook某App将他重定向到Facebook的授权服务器,当Tom登录成功,并且许可自己的Email和个人信息被某App获取。这两个资源被定义成一个Scope(权限范围),一旦准许,某App的开发者就可以申请访问权限范围中定义的这两个资源。

+--------+                               +---------------+
|        |--(A)- Authorization Request ->|   Resource    |
|        |                               |     Owner     |
|        |<-(B)-- Authorization Grant ---|               |
|        |                               +---------------+
|        |
|        |                               +---------------+
|        |--(C)-- Authorization Grant -->| Authorization |
| Client |                               |     Server    |
|        |<-(D)----- Access Token -------|               |
|        |                               +---------------+
|        |
|        |                               +---------------+
|        |--(E)----- Access Token ------>|    Resource   |
|        |                               |     Server    |
|        |<-(F)--- Protected Resource ---|               |
+--------+                               +---------------+

Tom允许了权限请求,再次通过重定向返回某App,重定向返回时携带了一个Access Token(访问令牌),接下来某App就可以通过这个Access TokenFacebook直接获取相关的授权资源(也就是Email和个人信息),而无需重新做Tom相关的鉴权。而且每当Tom登录了某App,都可以通过之前获得的Access Token,直接获取相关授权资源。

到目前为止,我们如何直接将以上内容用于实际的例子中?OAuth 2十分友好,并容易部署,所有交互都是关于客户端和权限范围的。

  • OAuth 2中的客户端权限范围和我们平时的用户和权限是否相同?

  • 我需要将授权映射到权限范围中或将用户映射到客户端中?

  • 为什么我需要客户端?

你也许在之前在类似的企业级开发案例中尝试映射过相关的角色。这会很棘手!

任何类型的应用都提供用户登录,登录结果是一个Access Token,所有的之后的API调用都将这个Access Token加入HTTP请求头中,被调用服务去授权服务器验证Access Token并获取该Token可访问的权限信息。这样一来,所有服务的访问都会请求另外的服务来完成鉴权。

权限范围和角色,客户端和用户

OAuth 2中,你可以定义哪个应用(网站、移动客户端、桌面应用、其他)可以访问那些资源。这里只有一个尺寸,来自哪里的哪个用户可以访问那些数据,当然也是哪个应用或者服务可以访问那些资源。换一种说法,权限范围就是控制那些端点对客户端可见,或者用户根据他的权限来获取相关的数据。

在一个在线商店中,前端可以看做一个客户端,可以访问商品、订单和客户信息,但后端可以关于物流和合同等,另一方面,用户可以访问一个服务但并不是全部的数据,这可以是因为用户正在使用Web应用,当他不能的时候,其他用户却可以。服务之间的访问时我们要讨论的另一个维度。如果你熟悉数学,我可以说在OAuth 2中,客户端-权限范围关系是线性独立于用户-权限关系。

为什么是JWT

OAuth 2并不关心去哪找Access Token和把它存在什么地方的,生成随机字符串并保存Token相关的数据到这些字符串中保存好。通过一个令牌端点,其他服务可能会关心这个Token是否有效,它可以通过哪些权限。这就是用户信息URL方法,授权服务器为了获取用户信息转换为资源服务器。

当我们谈及微服务时,我们需要找一个Token存储的方式,来保证授权服务器可以被水平扩展,尽管这是一个很复杂的任务。所有访问微服务资源的请求都在Http Header中携带Token,被访问的服务接下来再去请求授权服务器验证Token的有效性,目前这种方式,我们需要两次或者更多次的请求,但这是为了安全性也没什么其他办法。但扩展Token存储会很大影响我们系统的可扩展性,这是我们引入JWT(读jot)的原因。

+-----------+                                     +-------------+
|           |       1-Request Authorization       |             |
|           |------------------------------------>|             |
|           |     grant_type&username&password    |             |--+
|           |                                     |Authorization|  | 2-Gen
|  Client   |                                     |Service      |  |   JWT
|           |       3-Response Authorization      |             |<-+
|           |<------------------------------------| Private Key |
|           |    access_token / refresh_token     |             |
|           |    token_type / expire_in / jti     |             |
+-----------+                                     +-------------+

简短来说,响应一个用户请求时,将用户信息和授权范围序列化后放入一个JSON字符串,然后使用Base64进行编码,最终在授权服务器用私钥对这个字符串进行签名,得到一个JSON Web Token,我们可以像使用Access Token一样的直接使用它,假设其他所有的资源服务器都将持有一个RSA公钥。当资源服务器接收到这个在Http Header中存有Token的请求,资源服务器就可以拿到这个Token,并验证它是否使用正确的私钥签名(是否经过授权服务器签名,也就是验签)。验签通过,反序列化后就拿到OAuth 2的验证信息。

验证服务器返回的信息可以是以下内容:

  • access_token - 访问令牌,用于资源访问

  • refresh_token - 当访问令牌失效,使用这个令牌重新获取访问令牌

  • token_type - 令牌类型,这里是Bearer也就是基本HTTP认证

  • expire_in - 过期时间

  • jti - JWT ID

由于Access TokenBase64编码,反编码后就是下面的格式,标准的JWT格式。也就是HeaderPayloadSignature三部分。

{ 
  "alg":"RS256",
  "typ":"JWT"
}
{
  "exp": 1492873315,
  "user_name": "reader",
  "authorities": [
    "AURH_READ"
  ],
  "jti": "8f2d40eb-0d75-44df-a8cc-8c37320e3548",
  "client_id": "web_app",
  "scope": [
    "FOO"
  ]
}
&:lƧs)ۡ-[+
F"2"Kآ8ۓٞ:u9ٴ̯ޡ 9Q32Zƌ޿$ec{3mxJh0DF庖[!뀭N)㥔knVVĖV|夻ׄE㍫}Ŝf9>'<蕱굤Bۋеϵov虀DӨ8C4K}Emޢ    YVcaqIW&*uʝub!׏*Ť\՟-{ʖX܌WTq

使用JWT可以简单的传输Token,用RSA签名保证Token很难被伪造。Access Token字符串中包含用户信息和权限范围,我们所需的全部信息都有了,所以不需要维护Token存储,资源服务器也不必要求Token检查。

+-----------+                                    +-----------+
|           |       1-Request Resource           |           |
|           |----------------------------------->|           |
|           | Authorization: bearer Access Token |           |--+
|           |                                    | Resource  |  | 2-Verify
|  Client   |                                    | Service   |  |  Token
|           |       3-Response Resource          |           |<-+
|           |<-----------------------------------| Public Key|
|           |                                    |           |
+-----------+                                    +-----------+

所以,在微服务中使用OAuth 2,不会影响到整体架构的可扩展性。淡然这里还有一些问题没有涉及,例如Access Token过期后,使用Refresh Token到认证服务器重新获取Access Token等,后面会有具体的例子来展开讨论这些问题。

如果您感兴趣,后面还会有实现部分,敬请期待~

由于 http://asciiflow.com/ 流程图使用中文就无法对齐了,本文中流程图都是英文了~

查看原文

freewolf 评论了文章 · 2019-03-30

使用 OAuth 2 和 JWT 为微服务提供安全保障

Part 1 - 理论相关

作者 freewolf

关键词

微服务Spring CloudOAuth 2.0JWTSpring SecuritySSOUAA

写在前面

作为从业了十多年的IT行业和程序的老司机,今天如果你说你不懂微服务,都不好意思说自己的做软件的。SOA喊了多年,无人不知,但又有多少系统开发真正的SOA了呢?但是好像一夜之间所有人都投入了微服务的怀抱。

作为目前最主流的“微服务框架”,Spring Cloud发展速度很快,成为了最全面的微服务解决方案。不管什么软件体系,什么框架,安全永远是不可能绕开的话题,我也把它作为我最近一段时间研究微服务的开篇。

老话题!“如何才能在微服务体系中保证安全?”,为了达成目标,这里采用一个简单而可行方式来保护Spring Cloud中服务的安全,也就是建立统一的用户授权中心。

这里补充说一下什么是Authentication(认证)Authorization(鉴权),其实很简单,认证关心你是谁,鉴权关心你能干什么。举个大家一致都再说的例子,如果你去机场乘机,你持有的护照代表你的身份,这是认证,你的机票就是你的权限,你能干什么。

学习微服务并不是一个简单的探索过程,这不得学习很多新的知识,其实不管是按照DDD(Domain Driven Design)领域驱动设计中领域模型的方式,还是将微服务拆分成更小的粒度。都会遇到很多新的问题和以前一直都没解决很好的问题。随着不断的思考,随着熟悉Facebook/GitHub/AWS这些机构是如何保护内部资源,答案也逐渐浮出水面。

为了高效的实现这个目标,这里采用OAuth 2JWT(JSON Web Tokens)技术作为解决方案,

为什么使用OAuth 2

尽管微服务在现代软件开发中还算一个新鲜事物,但是OAuth 2已经是一个广泛使用的授权技术,它让Web开发者在自己提供服务中,用一种安全的方式直访问Google/Facebook/GitHub平台用户信息。但在我开始阐述细节之前,我将揭开聚焦到本文真正的主题:云安全

那么在云服务中对用户访问资源的控制,我们一般都怎么做呢?然我举一些大家似乎都用过的但又不是很完美的例子。

我们可以设置边界服务器或者带认证功能的反向代理服务器,假设所有访问请求都发给它。通过认证后,转发给内部相应的服务器。一般在Spring MVC Security开发中几乎都会这样做的。但这并不安全,最重要的是,一旦是有人从内部攻击,你的数据毫无安全性。

其他方式:我们为所有服务建立统一的权限数据库,并在每次请求前对用户进行鉴权,听起来某些方面的确有点愚蠢,但实际上这确实是一个可行的安全方案。

更好的方式: 用户通过授权服务来实现鉴权,把用户访问Session映射成一个Token。所有远程访问资源服务器相关的API必须提供Token。然后资源服务器访问授权服务来识别Token,得知Token属于哪个用户,并了解通过这个Token可以访问什么资源。

这听起来是个不错的方案,对不?但是如何保证Token的安全传输?如何区分是用户访问还是其他服务访问?这肯定是我们关心的问题。

所以上述种种问题让我们选择使用OAuth 2,其实访问Facebook/Google的敏感数据和访问我们自己后端受保护数据没什么区别,并且他们已经使用这样的解决方案很多年,我们只要遵循这些方法就好了。

OAuth 2是如何工作的

如果你了解OAuth 2相关的原理,那么在部署OAuth 2是非常容易的。
让我们描述下这样一个场景,“某App希望获得TomFacebook上相关的数据”

OAuth 2 在整个流程中有四种角色:

  • 资源拥有者(Resource Owner) - 这里是Tom

  • 资源服务器(Resource Server) - 这里是Facebook

  • 授权服务器(Authorization Server) - 这里当然还是Facebook,因为Facebook有相关数据

  • 客户端(Client) - 这里是某App

Tom试图登录Facebook某App将他重定向到Facebook的授权服务器,当Tom登录成功,并且许可自己的Email和个人信息被某App获取。这两个资源被定义成一个Scope(权限范围),一旦准许,某App的开发者就可以申请访问权限范围中定义的这两个资源。

+--------+                               +---------------+
|        |--(A)- Authorization Request ->|   Resource    |
|        |                               |     Owner     |
|        |<-(B)-- Authorization Grant ---|               |
|        |                               +---------------+
|        |
|        |                               +---------------+
|        |--(C)-- Authorization Grant -->| Authorization |
| Client |                               |     Server    |
|        |<-(D)----- Access Token -------|               |
|        |                               +---------------+
|        |
|        |                               +---------------+
|        |--(E)----- Access Token ------>|    Resource   |
|        |                               |     Server    |
|        |<-(F)--- Protected Resource ---|               |
+--------+                               +---------------+

Tom允许了权限请求,再次通过重定向返回某App,重定向返回时携带了一个Access Token(访问令牌),接下来某App就可以通过这个Access TokenFacebook直接获取相关的授权资源(也就是Email和个人信息),而无需重新做Tom相关的鉴权。而且每当Tom登录了某App,都可以通过之前获得的Access Token,直接获取相关授权资源。

到目前为止,我们如何直接将以上内容用于实际的例子中?OAuth 2十分友好,并容易部署,所有交互都是关于客户端和权限范围的。

  • OAuth 2中的客户端权限范围和我们平时的用户和权限是否相同?

  • 我需要将授权映射到权限范围中或将用户映射到客户端中?

  • 为什么我需要客户端?

你也许在之前在类似的企业级开发案例中尝试映射过相关的角色。这会很棘手!

任何类型的应用都提供用户登录,登录结果是一个Access Token,所有的之后的API调用都将这个Access Token加入HTTP请求头中,被调用服务去授权服务器验证Access Token并获取该Token可访问的权限信息。这样一来,所有服务的访问都会请求另外的服务来完成鉴权。

权限范围和角色,客户端和用户

OAuth 2中,你可以定义哪个应用(网站、移动客户端、桌面应用、其他)可以访问那些资源。这里只有一个尺寸,来自哪里的哪个用户可以访问那些数据,当然也是哪个应用或者服务可以访问那些资源。换一种说法,权限范围就是控制那些端点对客户端可见,或者用户根据他的权限来获取相关的数据。

在一个在线商店中,前端可以看做一个客户端,可以访问商品、订单和客户信息,但后端可以关于物流和合同等,另一方面,用户可以访问一个服务但并不是全部的数据,这可以是因为用户正在使用Web应用,当他不能的时候,其他用户却可以。服务之间的访问时我们要讨论的另一个维度。如果你熟悉数学,我可以说在OAuth 2中,客户端-权限范围关系是线性独立于用户-权限关系。

为什么是JWT

OAuth 2并不关心去哪找Access Token和把它存在什么地方的,生成随机字符串并保存Token相关的数据到这些字符串中保存好。通过一个令牌端点,其他服务可能会关心这个Token是否有效,它可以通过哪些权限。这就是用户信息URL方法,授权服务器为了获取用户信息转换为资源服务器。

当我们谈及微服务时,我们需要找一个Token存储的方式,来保证授权服务器可以被水平扩展,尽管这是一个很复杂的任务。所有访问微服务资源的请求都在Http Header中携带Token,被访问的服务接下来再去请求授权服务器验证Token的有效性,目前这种方式,我们需要两次或者更多次的请求,但这是为了安全性也没什么其他办法。但扩展Token存储会很大影响我们系统的可扩展性,这是我们引入JWT(读jot)的原因。

+-----------+                                     +-------------+
|           |       1-Request Authorization       |             |
|           |------------------------------------>|             |
|           |     grant_type&username&password    |             |--+
|           |                                     |Authorization|  | 2-Gen
|  Client   |                                     |Service      |  |   JWT
|           |       3-Response Authorization      |             |<-+
|           |<------------------------------------| Private Key |
|           |    access_token / refresh_token     |             |
|           |    token_type / expire_in / jti     |             |
+-----------+                                     +-------------+

简短来说,响应一个用户请求时,将用户信息和授权范围序列化后放入一个JSON字符串,然后使用Base64进行编码,最终在授权服务器用私钥对这个字符串进行签名,得到一个JSON Web Token,我们可以像使用Access Token一样的直接使用它,假设其他所有的资源服务器都将持有一个RSA公钥。当资源服务器接收到这个在Http Header中存有Token的请求,资源服务器就可以拿到这个Token,并验证它是否使用正确的私钥签名(是否经过授权服务器签名,也就是验签)。验签通过,反序列化后就拿到OAuth 2的验证信息。

验证服务器返回的信息可以是以下内容:

  • access_token - 访问令牌,用于资源访问

  • refresh_token - 当访问令牌失效,使用这个令牌重新获取访问令牌

  • token_type - 令牌类型,这里是Bearer也就是基本HTTP认证

  • expire_in - 过期时间

  • jti - JWT ID

由于Access TokenBase64编码,反编码后就是下面的格式,标准的JWT格式。也就是HeaderPayloadSignature三部分。

{ 
  "alg":"RS256",
  "typ":"JWT"
}
{
  "exp": 1492873315,
  "user_name": "reader",
  "authorities": [
    "AURH_READ"
  ],
  "jti": "8f2d40eb-0d75-44df-a8cc-8c37320e3548",
  "client_id": "web_app",
  "scope": [
    "FOO"
  ]
}
&:lƧs)ۡ-[+
F"2"Kآ8ۓٞ:u9ٴ̯ޡ 9Q32Zƌ޿$ec{3mxJh0DF庖[!뀭N)㥔knVVĖV|夻ׄE㍫}Ŝf9>'<蕱굤Bۋеϵov虀DӨ8C4K}Emޢ    YVcaqIW&*uʝub!׏*Ť\՟-{ʖX܌WTq

使用JWT可以简单的传输Token,用RSA签名保证Token很难被伪造。Access Token字符串中包含用户信息和权限范围,我们所需的全部信息都有了,所以不需要维护Token存储,资源服务器也不必要求Token检查。

+-----------+                                    +-----------+
|           |       1-Request Resource           |           |
|           |----------------------------------->|           |
|           | Authorization: bearer Access Token |           |--+
|           |                                    | Resource  |  | 2-Verify
|  Client   |                                    | Service   |  |  Token
|           |       3-Response Resource          |           |<-+
|           |<-----------------------------------| Public Key|
|           |                                    |           |
+-----------+                                    +-----------+

所以,在微服务中使用OAuth 2,不会影响到整体架构的可扩展性。淡然这里还有一些问题没有涉及,例如Access Token过期后,使用Refresh Token到认证服务器重新获取Access Token等,后面会有具体的例子来展开讨论这些问题。

如果您感兴趣,后面还会有实现部分,敬请期待~

由于 http://asciiflow.com/ 流程图使用中文就无法对齐了,本文中流程图都是英文了~

查看原文

freewolf 评论了文章 · 2019-03-17

使用JWT保护你的Spring Boot应用 - Spring Security实战

使用JWT保护你的Spring Boot应用 - Spring Security实战

作者 freewolf

原创文章转载请标明出处

关键词

Spring BootOAuth 2.0JWTSpring SecuritySSOUAA

写在前面

最近安静下来,重新学习一些东西,最近一年几乎没写过代码。整天疲于奔命的日子终于结束了。坐下来,弄杯咖啡,思考一些问题,挺好。这几天有人问我Spring Boot结合Spring Security实现OAuth认证的问题,写了个Demo,顺便分享下。Spring 2之后就没再用过Java,主要是xml太麻烦,就投入了Node.js的怀抱,现在Java倒是好过之前很多,无论是执行效率还是其他什么。感谢Pivotal团队在Spring boot上的努力,感谢Josh Long,一个有意思的攻城狮。

我又搞Java也是为了去折腾微服务,因为目前看国内就Java程序猿最好找,虽然水平好的难找,但是至少能找到,不像其他编程语言,找个会世界上最好的编程语言PHP的人真的不易。

Spring Boot

有了Spring Boot这样的神器,可以很简单的使用强大的Spring框架。你需要关心的事儿只是创建应用,不必再配置了,“Just run!”,这可是Josh Long每次演讲必说的,他的另一句必须说的就是“make jar not war”,这意味着,不用太关心是Tomcat还是Jetty或者Undertow了。专心解决逻辑问题,这当然是个好事儿,部署简单了很多。

创建Spring Boot应用

有很多方法去创建Spring Boot项目,官方也推荐用:

start.spring.io可以方便选择你要用的组件,命令行工具当然也可以。目前Spring Boot已经到了1.53,我是懒得去更新依赖,继续用1.52版本。虽然阿里也有了中央库的国内版本不知道是否稳定。如果你感兴趣,可以自己尝试下。你可以选Maven或者Gradle成为你项目的构建工具,Gradle优雅一些,使用了Groovy语言进行描述。

打开start.spring.io,创建的项目只需要一个Dependency,也就是Web,然后下载项目,用IntellJ IDEA打开。我的Java版本是1.8。

这里看下整个项目的pom.xml文件中的依赖部分:

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

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

所有Spring Boot相关的依赖都是以starter形式出现,这样你无需关心版本和相关的依赖,所以这样大大简化了开发过程。

当你在pom文件中集成了spring-boot-maven-plugin插件后你可以使用Maven相关的命令来run你的应用。例如mvn spring-boot:run,这样会启动一个嵌入式的Tomcat,并运行在8080端口,直接访问你当然会获得一个Whitelabel Error Page,这说明Tomcat已经启动了。

创建一个Web 应用

这还是一篇关于Web安全的文章,但是也得先有个简单的HTTP请求响应。我们先弄一个可以返回JSON的Controller。修改程序的入口文件:

@SpringBootApplication
@RestController
@EnableAutoConfiguration
public class DemoApplication {

    // main函数,Spring Boot程序入口
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    // 根目录映射 Get访问方式 直接返回一个字符串
    @RequestMapping("/")
    Map<String, String> hello() {
      // 返回map会变成JSON key value方式
      Map<String,String> map=new HashMap<String,String>();
      map.put("content", "hello freewolf~");
      return map;
    }
}

这里我尽量的写清楚,让不了解Spring Security的人通过这个例子可以了解这个东西,很多人都觉得它很复杂,而投向了Apache Shiro,其实这个并不难懂。知道主要的处理流程,和这个流程中哪些类都起了哪些作用就好了。

Spring Boot对于开发人员最大的好处在于可以对Spring应用进行自动配置。Spring Boot会根据应用中声明的第三方依赖来自动配置Spring框架,而不需要进行显式的声明。Spring Boot推荐采用基于Java注解的配置方式,而不是传统的XML。只需要在主配置 Java 类上添加@EnableAutoConfiguration注解就可以启用自动配置。Spring Boot的自动配置功能是没有侵入性的,只是作为一种基本的默认实现。

这个入口类我们添加@RestController@EnableAutoConfiguration两个注解。
@RestController注解相当于@ResponseBody@Controller合在一起的作用。

run整个项目。访问http://localhost:8080/就能看到这个JSON的输出。使用Chrome浏览器可以装JSON Formatter这个插件,显示更PL一些。

{
  "content": "hello freewolf~"
}

为了显示统一的JSON返回,这里建立一个JSONResult类进行,简单的处理。首先修改pom.xml,加入org.json相关依赖。

<dependency>
    <groupId>org.json</groupId>
    <artifactId>json</artifactId>
</dependency>

然后在我们的代码中加入一个新的类,里面只有一个结果集处理方法,因为只是个Demo,所有这里都放在一个文件中。这个类只是让返回的JSON结果变为三部分:

  • status - 返回状态码 0 代表正常返回,其他都是错误

  • message - 一般显示错误信息

  • result - 结果集

class JSONResult{
    public static String fillResultString(Integer status, String message, Object result){
        JSONObject jsonObject = new JSONObject(){{
            put("status", status);
            put("message", message);
            put("result", result);
        }};
        return jsonObject.toString();
    }
}

然后我们引入一个新的@RestController并返回一些简单的结果,后面我们将对这些内容进行访问控制,这里用到了上面的结果集处理类。这里多放两个方法,后面我们来测试权限和角色的验证用。

@RestController
class UserController {

    // 路由映射到/users
    @RequestMapping(value = "/users", produces="application/json;charset=UTF-8")
    public String usersList() {

        ArrayList<String> users =  new ArrayList<String>(){{
            add("freewolf");
            add("tom");
            add("jerry");
        }};

        return JSONResult.fillResultString(0, "", users);
    }

    @RequestMapping(value = "/hello", produces="application/json;charset=UTF-8")
    public String hello() {
        ArrayList<String> users =  new ArrayList<String>(){{ add("hello"); }};
        return JSONResult.fillResultString(0, "", users);
    }

    @RequestMapping(value = "/world", produces="application/json;charset=UTF-8")
    public String world() {
        ArrayList<String> users =  new ArrayList<String>(){{ add("world"); }};
        return JSONResult.fillResultString(0, "", users);
    }
}

重新run这个文件,访问http://localhost:8080/users就看到了下面的结果:

{
  "result": [
    "freewolf",
    "tom",
    "jerry"
  ],
  "message": "",
  "status": 0
}

如果你细心,你会发现这里的JSON返回时,Chrome的格式化插件好像并没有识别?这是为什么呢?我们借助curl分别看一下我们写的两个方法的Header信息.

curl -I http://127.0.0.1:8080/
curl -I http://127.0.0.1:8080/users

可以看到第一个方法hello,由于返回值是Map<String, String>,Spring已经有相关的机制自动处理成JSON:

Content-Type: application/json;charset=UTF-8

第二个方法usersList由于返回时String,由于是@RestControler已经含有了@ResponseBody也就是直接返回内容,并不模板。所以就是:

Content-Type: text/plain;charset=UTF-8

那怎么才能让它变成JSON呢,其实也很简单只需要补充一下相关注解:

@RequestMapping(value = "/users", produces="application/json;charset=UTF-8")

这样就好了。

使用JWT保护你的Spring Boot应用

终于我们开始介绍正题,这里我们会对/users进行访问控制,先通过申请一个JWT(JSON Web Token读jot),然后通过这个访问/users,才能拿到数据。

关于JWT,出门奔向以下内容,这些不在本文讨论范围内:

JWT很大程度上还是个新技术,通过使用HMAC(Hash-based Message Authentication Code)计算信息摘要,也可以用RSA公私钥中的私钥进行签名。这个根据业务场景进行选择。

添加Spring Security

根据上文我们说过我们要对/users进行访问控制,让用户在/login进行登录并获得Token。这里我们需要将spring-boot-starter-security加入pom.xml。加入后,我们的Spring Boot项目将需要提供身份验证,相关的pom.xml如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.7.0</version>
</dependency>

至此我们之前所有的路由都需要身份验证。我们将引入一个安全设置类WebSecurityConfig,这个类需要从WebSecurityConfigurerAdapter类继承。

@Configuration
@EnableWebSecurity
class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    // 设置 HTTP 验证规则
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 关闭csrf验证
        http.csrf().disable()
                // 对请求进行认证
                .authorizeRequests()
                // 所有 / 的所有请求 都放行
                .antMatchers("/").permitAll()
                // 所有 /login 的POST请求 都放行
                .antMatchers(HttpMethod.POST, "/login").permitAll()
                // 权限检查
                .antMatchers("/hello").hasAuthority("AUTH_WRITE")
                // 角色检查
                .antMatchers("/world").hasRole("ADMIN")
                // 所有请求需要身份认证
                .anyRequest().authenticated()
            .and()
                // 添加一个过滤器 所有访问 /login 的请求交给 JWTLoginFilter 来处理 这个类处理所有的JWT相关内容
                .addFilterBefore(new JWTLoginFilter("/login", authenticationManager()),
                        UsernamePasswordAuthenticationFilter.class)
                // 添加一个过滤器验证其他请求的Token是否合法
                .addFilterBefore(new JWTAuthenticationFilter(),
                        UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 使用自定义身份验证组件
        auth.authenticationProvider(new CustomAuthenticationProvider());

    }
}

先放两个基本类,一个负责存储用户名密码,另一个是一个权限类型,负责存储权限和角色。

class AccountCredentials {

    private String username;
    private String password;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

class GrantedAuthorityImpl implements GrantedAuthority{
    private String authority;

    public GrantedAuthorityImpl(String authority) {
        this.authority = authority;
    }

    public void setAuthority(String authority) {
        this.authority = authority;
    }

    @Override
    public String getAuthority() {
        return this.authority;
    }
}

在上面的安全设置类中,我们设置所有人都能访问/POST方式访问/login,其他的任何路由都需要进行认证。然后将所有访问/login的请求,都交给JWTLoginFilter过滤器来处理。稍后我们会创建这个过滤器和其他这里需要的JWTAuthenticationFilterCustomAuthenticationProvider两个类。

先建立一个JWT生成,和验签的类

class TokenAuthenticationService {
    static final long EXPIRATIONTIME = 432_000_000;     // 5天
    static final String SECRET = "P@ssw02d";            // JWT密码
    static final String TOKEN_PREFIX = "Bearer";        // Token前缀
    static final String HEADER_STRING = "Authorization";// 存放Token的Header Key

  // JWT生成方法
    static void addAuthentication(HttpServletResponse response, String username) {

    // 生成JWT
        String JWT = Jwts.builder()
                // 保存权限(角色)
                .claim("authorities", "ROLE_ADMIN,AUTH_WRITE")
                // 用户名写入标题
                .setSubject(username)
                // 有效期设置
                        .setExpiration(new Date(System.currentTimeMillis() + EXPIRATIONTIME))
                // 签名设置
                        .signWith(SignatureAlgorithm.HS512, SECRET)
                        .compact();

        // 将 JWT 写入 body
        try {
            response.setContentType("application/json");
            response.setStatus(HttpServletResponse.SC_OK);
            response.getOutputStream().println(JSONResult.fillResultString(0, "", JWT));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

  // JWT验证方法
    static Authentication getAuthentication(HttpServletRequest request) {
        // 从Header中拿到token
        String token = request.getHeader(HEADER_STRING);

        if (token != null) {
            // 解析 Token
            Claims claims = Jwts.parser()
                    // 验签
                    .setSigningKey(SECRET)
                    // 去掉 Bearer
                    .parseClaimsJws(token.replace(TOKEN_PREFIX, ""))
                    .getBody();

            // 拿用户名
            String user = claims.getSubject();

            // 得到 权限(角色)
            List<GrantedAuthority> authorities =  AuthorityUtils.commaSeparatedStringToAuthorityList((String) claims.get("authorities"));

            // 返回验证令牌
            return user != null ?
                    new UsernamePasswordAuthenticationToken(user, null, authorities) :
                    null;
        }
        return null;
    }
}

这个类就两个static方法,一个负责生成JWT,一个负责认证JWT最后生成验证令牌。注释已经写得很清楚了,这里不多说了。

下面来看自定义验证组件,这里简单写了,这个类就是提供密码验证功能,在实际使用时换成自己相应的验证逻辑,从数据库中取出、比对、赋予用户相应权限。

// 自定义身份认证验证组件
class CustomAuthenticationProvider implements AuthenticationProvider {

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 获取认证的用户名 & 密码
        String name = authentication.getName();
        String password = authentication.getCredentials().toString();

        // 认证逻辑
        if (name.equals("admin") && password.equals("123456")) {

            // 这里设置权限和角色
            ArrayList<GrantedAuthority> authorities = new ArrayList<>();
            authorities.add( new GrantedAuthorityImpl("ROLE_ADMIN") );
            authorities.add( new GrantedAuthorityImpl("AUTH_WRITE") );
            // 生成令牌
            Authentication auth = new UsernamePasswordAuthenticationToken(name, password, authorities);
            return auth;
        }else {
            throw new BadCredentialsException("密码错误~");
        }
    }

    // 是否可以提供输入类型的认证服务
    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

下面实现JWTLoginFilter 这个Filter比较简单,除了构造函数需要重写三个方法。

  • attemptAuthentication - 登录时需要验证时候调用

  • successfulAuthentication - 验证成功后调用

  • unsuccessfulAuthentication - 验证失败后调用,这里直接灌入500错误返回,由于同一JSON返回,HTTP就都返回200了

class JWTLoginFilter extends AbstractAuthenticationProcessingFilter {

    public JWTLoginFilter(String url, AuthenticationManager authManager) {
        super(new AntPathRequestMatcher(url));
        setAuthenticationManager(authManager);
    }

    @Override
    public Authentication attemptAuthentication(
            HttpServletRequest req, HttpServletResponse res)
            throws AuthenticationException, IOException, ServletException {

        // JSON反序列化成 AccountCredentials
        AccountCredentials creds = new ObjectMapper().readValue(req.getInputStream(), AccountCredentials.class);

        // 返回一个验证令牌
        return getAuthenticationManager().authenticate(
                new UsernamePasswordAuthenticationToken(
                        creds.getUsername(),
                        creds.getPassword()
                )
        );
    }

    @Override
    protected void successfulAuthentication(
            HttpServletRequest req,
            HttpServletResponse res, FilterChain chain,
            Authentication auth) throws IOException, ServletException {

        TokenAuthenticationService.addAuthentication(res, auth.getName());
    }


    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {

        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_OK);
        response.getOutputStream().println(JSONResult.fillResultString(500, "Internal Server Error!!!", JSONObject.NULL));
    }
}

再完成最后一个类JWTAuthenticationFilter,这也是个拦截器,它拦截所有需要JWT的请求,然后调用TokenAuthenticationService类的静态方法去做JWT验证。

class JWTAuthenticationFilter extends GenericFilterBean {

    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain filterChain)
            throws IOException, ServletException {
        Authentication authentication = TokenAuthenticationService
                .getAuthentication((HttpServletRequest)request);

        SecurityContextHolder.getContext()
                .setAuthentication(authentication);
        filterChain.doFilter(request,response);
    }
}

现在代码就写完了,整个Spring Security结合JWT基本就差不多了,下面我们来测试下,并说下整体流程。

开始测试,先运行整个项目,这里介绍下过程:

  • 先程序启动 - main函数

  • 注册验证组件 - WebSecurityConfigconfigure(AuthenticationManagerBuilder auth)方法,这里我们注册了自定义验证组件

  • 设置验证规则 - WebSecurityConfigconfigure(HttpSecurity http)方法,这里设置了各种路由访问规则

  • 初始化过滤组件 - JWTLoginFilterJWTAuthenticationFilter 类会初始化

首先测试获取Token,这里使用CURL命令行工具来测试。

curl -H "Content-Type: application/json" -X POST -d '{"username":"admin","password":"123456"}'  http://127.0.0.1:8080/login

结果:

{
  "result": "eyJhbGciOiJIUzUxMiJ9.eyJhdXRob3JpdGllcyI6IlJPTEVfQURNSU4sQVVUSF9XUklURSIsInN1YiI6ImFkbWluIiwiZXhwIjoxNDkzNzgyMjQwfQ.HNfV1CU2CdAnBTH682C5-KOfr2P71xr9PYLaLpDVhOw8KWWSJ0lBo0BCq4LoNwsK_Y3-W3avgbJb0jW9FNYDRQ",
  "message": "",
  "status": 0
}

这里我们得到了相关的JWT,反Base64之后,就是下面的内容,标准JWT

{"alg":"HS512"}{"authorities":"ROLE_ADMIN,AUTH_WRITE","sub":"admin","exp":1493782240}ͽ]BS`pS6~hCVH%
ܬ)֝ଖoE5р

整个过程如下:

  • 拿到传入JSON,解析用户名密码 - JWTLoginFilterattemptAuthentication 方法

  • 自定义身份认证验证组件,进行身份认证 - CustomAuthenticationProviderauthenticate 方法

  • 盐城成功 - JWTLoginFiltersuccessfulAuthentication 方法

  • 生成JWT - TokenAuthenticationServiceaddAuthentication方法

再测试一个访问资源的:

curl -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJhdXRob3JpdGllcyI6IlJPTEVfQURNSU4sQVVUSF9XUklURSIsInN1YiI6ImFkbWluIiwiZXhwIjoxNDkzNzgyMjQwfQ.HNfV1CU2CdAnBTH682C5-KOfr2P71xr9PYLaLpDVhOw8KWWSJ0lBo0BCq4LoNwsK_Y3-W3avgbJb0jW9FNYDRQ"  http://127.0.0.1:8080/users

结果:

{
  "result":["freewolf","tom","jerry"],
  "message":"",
  "status":0
}

说明我们的Token生效可以正常访问。其他的结果您可以自己去测试。再回到处理流程:

  • 接到请求进行拦截 - JWTAuthenticationFilter 中的方法

  • 验证JWT - TokenAuthenticationServicegetAuthentication 方法

  • 访问Controller

这样本文的主要流程就结束了,本文主要介绍了,如何用Spring Security结合JWT保护你的Spring Boot应用。如何使用RoleAuthority,这里多说一句其实在Spring Security中,对于GrantedAuthority接口实现类来说是不区分是Role还是Authority,二者区别就是如果是hasAuthority判断,就是判断整个字符串,判断hasRole时,系统自动加上ROLE_到判断的Role字符串上,也就是说hasRole("CREATE")hasAuthority('ROLE_CREATE')是相同的。利用这些可以搭建完整的RBAC体系。本文到此,你已经会用了本文介绍的知识点。

代码整理后我会上传到Github

本文代码

https://github.com/freew01f/s...

参考资源

查看原文

freewolf 评论了文章 · 2019-03-12

使用JWT保护你的Spring Boot应用 - Spring Security实战

使用JWT保护你的Spring Boot应用 - Spring Security实战

作者 freewolf

原创文章转载请标明出处

关键词

Spring BootOAuth 2.0JWTSpring SecuritySSOUAA

写在前面

最近安静下来,重新学习一些东西,最近一年几乎没写过代码。整天疲于奔命的日子终于结束了。坐下来,弄杯咖啡,思考一些问题,挺好。这几天有人问我Spring Boot结合Spring Security实现OAuth认证的问题,写了个Demo,顺便分享下。Spring 2之后就没再用过Java,主要是xml太麻烦,就投入了Node.js的怀抱,现在Java倒是好过之前很多,无论是执行效率还是其他什么。感谢Pivotal团队在Spring boot上的努力,感谢Josh Long,一个有意思的攻城狮。

我又搞Java也是为了去折腾微服务,因为目前看国内就Java程序猿最好找,虽然水平好的难找,但是至少能找到,不像其他编程语言,找个会世界上最好的编程语言PHP的人真的不易。

Spring Boot

有了Spring Boot这样的神器,可以很简单的使用强大的Spring框架。你需要关心的事儿只是创建应用,不必再配置了,“Just run!”,这可是Josh Long每次演讲必说的,他的另一句必须说的就是“make jar not war”,这意味着,不用太关心是Tomcat还是Jetty或者Undertow了。专心解决逻辑问题,这当然是个好事儿,部署简单了很多。

创建Spring Boot应用

有很多方法去创建Spring Boot项目,官方也推荐用:

start.spring.io可以方便选择你要用的组件,命令行工具当然也可以。目前Spring Boot已经到了1.53,我是懒得去更新依赖,继续用1.52版本。虽然阿里也有了中央库的国内版本不知道是否稳定。如果你感兴趣,可以自己尝试下。你可以选Maven或者Gradle成为你项目的构建工具,Gradle优雅一些,使用了Groovy语言进行描述。

打开start.spring.io,创建的项目只需要一个Dependency,也就是Web,然后下载项目,用IntellJ IDEA打开。我的Java版本是1.8。

这里看下整个项目的pom.xml文件中的依赖部分:

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

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

所有Spring Boot相关的依赖都是以starter形式出现,这样你无需关心版本和相关的依赖,所以这样大大简化了开发过程。

当你在pom文件中集成了spring-boot-maven-plugin插件后你可以使用Maven相关的命令来run你的应用。例如mvn spring-boot:run,这样会启动一个嵌入式的Tomcat,并运行在8080端口,直接访问你当然会获得一个Whitelabel Error Page,这说明Tomcat已经启动了。

创建一个Web 应用

这还是一篇关于Web安全的文章,但是也得先有个简单的HTTP请求响应。我们先弄一个可以返回JSON的Controller。修改程序的入口文件:

@SpringBootApplication
@RestController
@EnableAutoConfiguration
public class DemoApplication {

    // main函数,Spring Boot程序入口
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    // 根目录映射 Get访问方式 直接返回一个字符串
    @RequestMapping("/")
    Map<String, String> hello() {
      // 返回map会变成JSON key value方式
      Map<String,String> map=new HashMap<String,String>();
      map.put("content", "hello freewolf~");
      return map;
    }
}

这里我尽量的写清楚,让不了解Spring Security的人通过这个例子可以了解这个东西,很多人都觉得它很复杂,而投向了Apache Shiro,其实这个并不难懂。知道主要的处理流程,和这个流程中哪些类都起了哪些作用就好了。

Spring Boot对于开发人员最大的好处在于可以对Spring应用进行自动配置。Spring Boot会根据应用中声明的第三方依赖来自动配置Spring框架,而不需要进行显式的声明。Spring Boot推荐采用基于Java注解的配置方式,而不是传统的XML。只需要在主配置 Java 类上添加@EnableAutoConfiguration注解就可以启用自动配置。Spring Boot的自动配置功能是没有侵入性的,只是作为一种基本的默认实现。

这个入口类我们添加@RestController@EnableAutoConfiguration两个注解。
@RestController注解相当于@ResponseBody@Controller合在一起的作用。

run整个项目。访问http://localhost:8080/就能看到这个JSON的输出。使用Chrome浏览器可以装JSON Formatter这个插件,显示更PL一些。

{
  "content": "hello freewolf~"
}

为了显示统一的JSON返回,这里建立一个JSONResult类进行,简单的处理。首先修改pom.xml,加入org.json相关依赖。

<dependency>
    <groupId>org.json</groupId>
    <artifactId>json</artifactId>
</dependency>

然后在我们的代码中加入一个新的类,里面只有一个结果集处理方法,因为只是个Demo,所有这里都放在一个文件中。这个类只是让返回的JSON结果变为三部分:

  • status - 返回状态码 0 代表正常返回,其他都是错误

  • message - 一般显示错误信息

  • result - 结果集

class JSONResult{
    public static String fillResultString(Integer status, String message, Object result){
        JSONObject jsonObject = new JSONObject(){{
            put("status", status);
            put("message", message);
            put("result", result);
        }};
        return jsonObject.toString();
    }
}

然后我们引入一个新的@RestController并返回一些简单的结果,后面我们将对这些内容进行访问控制,这里用到了上面的结果集处理类。这里多放两个方法,后面我们来测试权限和角色的验证用。

@RestController
class UserController {

    // 路由映射到/users
    @RequestMapping(value = "/users", produces="application/json;charset=UTF-8")
    public String usersList() {

        ArrayList<String> users =  new ArrayList<String>(){{
            add("freewolf");
            add("tom");
            add("jerry");
        }};

        return JSONResult.fillResultString(0, "", users);
    }

    @RequestMapping(value = "/hello", produces="application/json;charset=UTF-8")
    public String hello() {
        ArrayList<String> users =  new ArrayList<String>(){{ add("hello"); }};
        return JSONResult.fillResultString(0, "", users);
    }

    @RequestMapping(value = "/world", produces="application/json;charset=UTF-8")
    public String world() {
        ArrayList<String> users =  new ArrayList<String>(){{ add("world"); }};
        return JSONResult.fillResultString(0, "", users);
    }
}

重新run这个文件,访问http://localhost:8080/users就看到了下面的结果:

{
  "result": [
    "freewolf",
    "tom",
    "jerry"
  ],
  "message": "",
  "status": 0
}

如果你细心,你会发现这里的JSON返回时,Chrome的格式化插件好像并没有识别?这是为什么呢?我们借助curl分别看一下我们写的两个方法的Header信息.

curl -I http://127.0.0.1:8080/
curl -I http://127.0.0.1:8080/users

可以看到第一个方法hello,由于返回值是Map<String, String>,Spring已经有相关的机制自动处理成JSON:

Content-Type: application/json;charset=UTF-8

第二个方法usersList由于返回时String,由于是@RestControler已经含有了@ResponseBody也就是直接返回内容,并不模板。所以就是:

Content-Type: text/plain;charset=UTF-8

那怎么才能让它变成JSON呢,其实也很简单只需要补充一下相关注解:

@RequestMapping(value = "/users", produces="application/json;charset=UTF-8")

这样就好了。

使用JWT保护你的Spring Boot应用

终于我们开始介绍正题,这里我们会对/users进行访问控制,先通过申请一个JWT(JSON Web Token读jot),然后通过这个访问/users,才能拿到数据。

关于JWT,出门奔向以下内容,这些不在本文讨论范围内:

JWT很大程度上还是个新技术,通过使用HMAC(Hash-based Message Authentication Code)计算信息摘要,也可以用RSA公私钥中的私钥进行签名。这个根据业务场景进行选择。

添加Spring Security

根据上文我们说过我们要对/users进行访问控制,让用户在/login进行登录并获得Token。这里我们需要将spring-boot-starter-security加入pom.xml。加入后,我们的Spring Boot项目将需要提供身份验证,相关的pom.xml如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.7.0</version>
</dependency>

至此我们之前所有的路由都需要身份验证。我们将引入一个安全设置类WebSecurityConfig,这个类需要从WebSecurityConfigurerAdapter类继承。

@Configuration
@EnableWebSecurity
class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    // 设置 HTTP 验证规则
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 关闭csrf验证
        http.csrf().disable()
                // 对请求进行认证
                .authorizeRequests()
                // 所有 / 的所有请求 都放行
                .antMatchers("/").permitAll()
                // 所有 /login 的POST请求 都放行
                .antMatchers(HttpMethod.POST, "/login").permitAll()
                // 权限检查
                .antMatchers("/hello").hasAuthority("AUTH_WRITE")
                // 角色检查
                .antMatchers("/world").hasRole("ADMIN")
                // 所有请求需要身份认证
                .anyRequest().authenticated()
            .and()
                // 添加一个过滤器 所有访问 /login 的请求交给 JWTLoginFilter 来处理 这个类处理所有的JWT相关内容
                .addFilterBefore(new JWTLoginFilter("/login", authenticationManager()),
                        UsernamePasswordAuthenticationFilter.class)
                // 添加一个过滤器验证其他请求的Token是否合法
                .addFilterBefore(new JWTAuthenticationFilter(),
                        UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 使用自定义身份验证组件
        auth.authenticationProvider(new CustomAuthenticationProvider());

    }
}

先放两个基本类,一个负责存储用户名密码,另一个是一个权限类型,负责存储权限和角色。

class AccountCredentials {

    private String username;
    private String password;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

class GrantedAuthorityImpl implements GrantedAuthority{
    private String authority;

    public GrantedAuthorityImpl(String authority) {
        this.authority = authority;
    }

    public void setAuthority(String authority) {
        this.authority = authority;
    }

    @Override
    public String getAuthority() {
        return this.authority;
    }
}

在上面的安全设置类中,我们设置所有人都能访问/POST方式访问/login,其他的任何路由都需要进行认证。然后将所有访问/login的请求,都交给JWTLoginFilter过滤器来处理。稍后我们会创建这个过滤器和其他这里需要的JWTAuthenticationFilterCustomAuthenticationProvider两个类。

先建立一个JWT生成,和验签的类

class TokenAuthenticationService {
    static final long EXPIRATIONTIME = 432_000_000;     // 5天
    static final String SECRET = "P@ssw02d";            // JWT密码
    static final String TOKEN_PREFIX = "Bearer";        // Token前缀
    static final String HEADER_STRING = "Authorization";// 存放Token的Header Key

  // JWT生成方法
    static void addAuthentication(HttpServletResponse response, String username) {

    // 生成JWT
        String JWT = Jwts.builder()
                // 保存权限(角色)
                .claim("authorities", "ROLE_ADMIN,AUTH_WRITE")
                // 用户名写入标题
                .setSubject(username)
                // 有效期设置
                        .setExpiration(new Date(System.currentTimeMillis() + EXPIRATIONTIME))
                // 签名设置
                        .signWith(SignatureAlgorithm.HS512, SECRET)
                        .compact();

        // 将 JWT 写入 body
        try {
            response.setContentType("application/json");
            response.setStatus(HttpServletResponse.SC_OK);
            response.getOutputStream().println(JSONResult.fillResultString(0, "", JWT));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

  // JWT验证方法
    static Authentication getAuthentication(HttpServletRequest request) {
        // 从Header中拿到token
        String token = request.getHeader(HEADER_STRING);

        if (token != null) {
            // 解析 Token
            Claims claims = Jwts.parser()
                    // 验签
                    .setSigningKey(SECRET)
                    // 去掉 Bearer
                    .parseClaimsJws(token.replace(TOKEN_PREFIX, ""))
                    .getBody();

            // 拿用户名
            String user = claims.getSubject();

            // 得到 权限(角色)
            List<GrantedAuthority> authorities =  AuthorityUtils.commaSeparatedStringToAuthorityList((String) claims.get("authorities"));

            // 返回验证令牌
            return user != null ?
                    new UsernamePasswordAuthenticationToken(user, null, authorities) :
                    null;
        }
        return null;
    }
}

这个类就两个static方法,一个负责生成JWT,一个负责认证JWT最后生成验证令牌。注释已经写得很清楚了,这里不多说了。

下面来看自定义验证组件,这里简单写了,这个类就是提供密码验证功能,在实际使用时换成自己相应的验证逻辑,从数据库中取出、比对、赋予用户相应权限。

// 自定义身份认证验证组件
class CustomAuthenticationProvider implements AuthenticationProvider {

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 获取认证的用户名 & 密码
        String name = authentication.getName();
        String password = authentication.getCredentials().toString();

        // 认证逻辑
        if (name.equals("admin") && password.equals("123456")) {

            // 这里设置权限和角色
            ArrayList<GrantedAuthority> authorities = new ArrayList<>();
            authorities.add( new GrantedAuthorityImpl("ROLE_ADMIN") );
            authorities.add( new GrantedAuthorityImpl("AUTH_WRITE") );
            // 生成令牌
            Authentication auth = new UsernamePasswordAuthenticationToken(name, password, authorities);
            return auth;
        }else {
            throw new BadCredentialsException("密码错误~");
        }
    }

    // 是否可以提供输入类型的认证服务
    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

下面实现JWTLoginFilter 这个Filter比较简单,除了构造函数需要重写三个方法。

  • attemptAuthentication - 登录时需要验证时候调用

  • successfulAuthentication - 验证成功后调用

  • unsuccessfulAuthentication - 验证失败后调用,这里直接灌入500错误返回,由于同一JSON返回,HTTP就都返回200了

class JWTLoginFilter extends AbstractAuthenticationProcessingFilter {

    public JWTLoginFilter(String url, AuthenticationManager authManager) {
        super(new AntPathRequestMatcher(url));
        setAuthenticationManager(authManager);
    }

    @Override
    public Authentication attemptAuthentication(
            HttpServletRequest req, HttpServletResponse res)
            throws AuthenticationException, IOException, ServletException {

        // JSON反序列化成 AccountCredentials
        AccountCredentials creds = new ObjectMapper().readValue(req.getInputStream(), AccountCredentials.class);

        // 返回一个验证令牌
        return getAuthenticationManager().authenticate(
                new UsernamePasswordAuthenticationToken(
                        creds.getUsername(),
                        creds.getPassword()
                )
        );
    }

    @Override
    protected void successfulAuthentication(
            HttpServletRequest req,
            HttpServletResponse res, FilterChain chain,
            Authentication auth) throws IOException, ServletException {

        TokenAuthenticationService.addAuthentication(res, auth.getName());
    }


    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {

        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_OK);
        response.getOutputStream().println(JSONResult.fillResultString(500, "Internal Server Error!!!", JSONObject.NULL));
    }
}

再完成最后一个类JWTAuthenticationFilter,这也是个拦截器,它拦截所有需要JWT的请求,然后调用TokenAuthenticationService类的静态方法去做JWT验证。

class JWTAuthenticationFilter extends GenericFilterBean {

    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain filterChain)
            throws IOException, ServletException {
        Authentication authentication = TokenAuthenticationService
                .getAuthentication((HttpServletRequest)request);

        SecurityContextHolder.getContext()
                .setAuthentication(authentication);
        filterChain.doFilter(request,response);
    }
}

现在代码就写完了,整个Spring Security结合JWT基本就差不多了,下面我们来测试下,并说下整体流程。

开始测试,先运行整个项目,这里介绍下过程:

  • 先程序启动 - main函数

  • 注册验证组件 - WebSecurityConfigconfigure(AuthenticationManagerBuilder auth)方法,这里我们注册了自定义验证组件

  • 设置验证规则 - WebSecurityConfigconfigure(HttpSecurity http)方法,这里设置了各种路由访问规则

  • 初始化过滤组件 - JWTLoginFilterJWTAuthenticationFilter 类会初始化

首先测试获取Token,这里使用CURL命令行工具来测试。

curl -H "Content-Type: application/json" -X POST -d '{"username":"admin","password":"123456"}'  http://127.0.0.1:8080/login

结果:

{
  "result": "eyJhbGciOiJIUzUxMiJ9.eyJhdXRob3JpdGllcyI6IlJPTEVfQURNSU4sQVVUSF9XUklURSIsInN1YiI6ImFkbWluIiwiZXhwIjoxNDkzNzgyMjQwfQ.HNfV1CU2CdAnBTH682C5-KOfr2P71xr9PYLaLpDVhOw8KWWSJ0lBo0BCq4LoNwsK_Y3-W3avgbJb0jW9FNYDRQ",
  "message": "",
  "status": 0
}

这里我们得到了相关的JWT,反Base64之后,就是下面的内容,标准JWT

{"alg":"HS512"}{"authorities":"ROLE_ADMIN,AUTH_WRITE","sub":"admin","exp":1493782240}ͽ]BS`pS6~hCVH%
ܬ)֝ଖoE5р

整个过程如下:

  • 拿到传入JSON,解析用户名密码 - JWTLoginFilterattemptAuthentication 方法

  • 自定义身份认证验证组件,进行身份认证 - CustomAuthenticationProviderauthenticate 方法

  • 盐城成功 - JWTLoginFiltersuccessfulAuthentication 方法

  • 生成JWT - TokenAuthenticationServiceaddAuthentication方法

再测试一个访问资源的:

curl -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJhdXRob3JpdGllcyI6IlJPTEVfQURNSU4sQVVUSF9XUklURSIsInN1YiI6ImFkbWluIiwiZXhwIjoxNDkzNzgyMjQwfQ.HNfV1CU2CdAnBTH682C5-KOfr2P71xr9PYLaLpDVhOw8KWWSJ0lBo0BCq4LoNwsK_Y3-W3avgbJb0jW9FNYDRQ"  http://127.0.0.1:8080/users

结果:

{
  "result":["freewolf","tom","jerry"],
  "message":"",
  "status":0
}

说明我们的Token生效可以正常访问。其他的结果您可以自己去测试。再回到处理流程:

  • 接到请求进行拦截 - JWTAuthenticationFilter 中的方法

  • 验证JWT - TokenAuthenticationServicegetAuthentication 方法

  • 访问Controller

这样本文的主要流程就结束了,本文主要介绍了,如何用Spring Security结合JWT保护你的Spring Boot应用。如何使用RoleAuthority,这里多说一句其实在Spring Security中,对于GrantedAuthority接口实现类来说是不区分是Role还是Authority,二者区别就是如果是hasAuthority判断,就是判断整个字符串,判断hasRole时,系统自动加上ROLE_到判断的Role字符串上,也就是说hasRole("CREATE")hasAuthority('ROLE_CREATE')是相同的。利用这些可以搭建完整的RBAC体系。本文到此,你已经会用了本文介绍的知识点。

代码整理后我会上传到Github

本文代码

https://github.com/freew01f/s...

参考资源

查看原文

认证与成就

  • 获得 998 次点赞
  • 获得 8 枚徽章 获得 1 枚金徽章, 获得 2 枚银徽章, 获得 5 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2015-02-11
个人主页被 4.2k 人浏览