不为有趣之事,何遣有涯之生
不失其所者久,死而不亡者寿

AMQ简明教程(12)

hzqiuxm阅读(61)评论(0)

AMQ集群

Queue consumer clusters

ActiveMQ支持Consumer对消息高可靠性的负载平衡消费,如果一个Consumer死掉,该消息会转发到其它的Consumer消费的Queue上。
如果一个Consumer获得消息比其它Consumer快,那么他将获得更多的消息。
因此推荐ActiveMQ的Broker和Client使用failover://transport的方式来配置链接

Broker clusters

大部情况下是使用一系列的Broker和Client链接到一起。如果一个Broker死掉了,Client可以自动链接到其它Broker上。实现以上行为需要用failover协议作为Client。

如果启动了多个Broker,Client可以使用static discover或者 Dynamic discovery容易的从一个broker到另一个broker直接链接。

这样当一个broker上没有Consumer的话,那么它的消息不会被消费的,然而该broker会通过存储和转发的策略来把该消息发到其它broker上。

特别注意:ActiveMQ默认的两个broker,static链接后是单方向的,broker-A可以访问消费broker-B的消息,如果要支持双向通信,需要在netWorkConnector配置的时候,设置duplex=true 就可以了。

消息会较为平均的分配给2个集群,而不是每个消费者。即使某个消费者集群的消费者比其他集群中多,它获得的消息总数仍然差不多。不适合机器性能不均等的架构。

原因:networkConnector配置的可用属性conduitSubscriptions :默认true,标示是否把同一个broker的多个consumer当做一个来处理

负载均衡的时候一般设置为false, 设置为false后,会按照消费者个数来分配。

Master Slave

在5.9的版本里面,废除了Pure Master Slave的方式,目前支持:

1:Shared File System Master Slave:基于共享储存的Master-Slave:多个broker实例使用一个存储文件,谁拿到文件锁就是master,其他处于待启动状态,如果master挂掉了,某个抢到文件锁的slave变成master

2:JDBC Master Slave:基于JDBC的Master-Slave:使用同一个数据库,拿到LOCK表的写锁的broker成为master

3:Replicated LevelDB Store:基于ZooKeeper复制LevelDB存储的Master-Slave机制,这个是5.9新加的
具体的可以到官方察看: http://activemq.apache.org/masterslave.html

JDBC Master Slave的方式

利用数据库作为数据源,采用Master/Slave模式,其中在启动的时候Master首先获
得独有锁,其它Slaves Broker则等待获取独有锁。
推荐客户端使用Failover来链接Brokers。
具体如下图所示:

Master失败
如果Master失败,则它释放独有锁,其他Slaver则获取独有锁,其它Slaver立即获得独有锁后此时它将变成Master,并且启动所有的传输链接。同时,Client将停止链接之
前的Master和将会轮询链接到其他可以利用的Broker即新Master。如上中图所示

Master重启
任何时候去启动新的Broker,即作为新的Slave来加入集群,如上右图所示

JDBC Master Slave的配置

使用来配置消息的持久化,自动就会使用JDBC MasterSlave的方式。

Cucumber简明教程

hzqiuxm阅读(610)评论(0)

Cucumber简明教程

入门篇

简单介绍

  • 用途:BDD(行为驱动开发)自动化测试产品,可以和目前很多语言结合在一起。
  • 有明确的可执行规范,自动化测试,记录系统的实际行为

  • 特点:它使用自然语言来描述测试,使得非程序员可以理解他们
  • 官方安装地址:https://cucumber.io/docs/installation/
  • 依赖包:
dependencies {
    testCompile 'io.cucumber:cucumber-java8:4.3.1'
    testCompile 'io.cucumber:cucumber-junit:4.3.1'
    testCompile 'info.cukes: cucumber-java:1.2.5' //2016年之前的包
}
  • 如果你用的是IEAD可以检查是否自动安装了该插件:Cucumber for Java plugin,如果没有自己手动安装下

Gherkin语法部分

概念介绍

  • Feature:一个测试用例集
  • SCENARIOS:类似一个具体测试用例或场景
  • STEPS:测试步骤,每个SCENARIOS包含多个STEPS,STEPS可以使用如下关键词:Gievn,When,Then,But,And等
  • Given:创建测试环境需要的前提条件
  • When:触发某个业务事件
  • Then:验证事件产生的结果
  • And:多个前提条件时使用,连接前一个条件,作为正向条件
  • But:多个前提条件时使用,连接前一个条件,作为反向条件
  • Background:执行SCENARIOS之前会执行Bankground,在一个Feature里只能有一个,作用上有点SCENARIOS,作为公共的步骤
  • arguments:步骤中传入参数,支持单个参数和复杂参数datatable
  • SCENARIOS Outline:重复场景数据

基本原理

对国家语言支持

  • 支持40多种语言
  • https://github.com/cucumber/cucumber/blob/master/gherkin/gherkin-languages.json

几个feature文件的例子

  • 一个加减法的例子
Feature: Basic Add Test

  Background: give x and y value
    Given x and y value

  Scenario: Addition
    Given x is 4 and y is 5
    When invoke add Method
    Then the result is 9

  Scenario: autoX
    Given x is 1
    When invoke autoX Method
    Then the result is 2

  Scenario: Sub
    And sub operation
    When invoke calculate button
    Then the result is x-y
  • 一个复杂参数例子
Feature: Complex data type

  Scenario: multiple then keywords
    Given the user account infomation
    Then we can found user "hzqiuxm", with password "123456", phone "13989461462"
    Then we can found user "linjiangxian", with password "123456", phone "13989461462"
    Then we can found user "queqiaoxian", with password "123456", phone "13989461462"
  Scenario:
    Given use complex data
    Then 验证下面的一些用户账号信息
      | name    | password | phone |
      | hzqiuxm | 123456   | 13989461462 |
      | linjiangxian | 123456   | 13989461462 |
      | queqiaoxian | 123456   | 13989461462 |

  • 一个公共场景和中文支持的例子
Feature: With Scentrio Outline

  Background: 公共的登录操作
    Given 进行用户登录来测试Scentrio Outline

  Scenario Outline: 用户名或密码错误
    When 使用错误用户名 "<UserName>" 和密码 "<Password>" 来登录
    Then 不正确的用户名或密码

    Examples:
      | UserName | Password |
      | hzqiuxm  | 123321   |
      | simon    | 123321   |

  Scenario: 正确的用户名密码登录测试
    When 使用正确用户名 "hzqiuxm", 密码 "123456" 来登录
    Then 用户名密码正确,登录成功

操作步骤详情部分

Step Definitions

  • 不要定义重复的或模棱两可(正则匹配多个符合)的steps
  • 正则表达式的使用(参数要使用是小括号分组,字符串要用双引号)
  • 想多种情况下匹配又不想抓取参数时,使用?:正则表达式来进行说明
  • DataTable数据格式:dataTable类型,用户自定义类型(https://github.com/cucumber/cucumber/tree/master/datatable),list map类型,list list类型
  • DataTable的compare来做一些结果对比,用于CURD或其它操作结果的一些检测

Tagging

  • 可以按照标签来执行场景用例,使用"@"符号,例子:@v1.0.0 @hzqiuxm
  • 使用~ 取反,放在一个不同""里表示and关系,放在同一个""里表示or关系
tags = {"@v1.0.0","not @santai"}  //执行v1.0.0标签并且不包含santai标签的场景用例

Hooks

  • 类似Junit中的before,after作用,执行顺序:Before Hook, Background,Scenario,After Hook
  • @Before代表之前,@After代表之后
  • 多个Before后After可以使用Order属性值来控制,默认是按照代码中顺序来执行
  • Before和After可以结合tag属性,控制其作用范围,默认情况下是所有feature都有效的,tag属性也只是and和or的关系

Options

  • 作用在启动类上,用来控制测试报告report输出、plugin插件、标签Tag选择、环境配置、Feature文件选择、严格模式等
@CucumberOptions(plugin = {"pretty","json:target/cucumber-report3.json"},tags = {"@v1.0.0","~@santai"})
  • Feature:只执行指定路径下的feature
  • gule:指定feature对应的测试类路径
  • tags:指定执行的标签规范
  • dryRun:不会真正的去执行steps,但会检查哪个feature没被实现
  • strict:严格模式下出现未实现的steps或断言失败就会失败报错
  • monochrome:影响控制台输出的样式效果
  • 支持的输出格式:html:target/Cucumber;json:target_json/Cucumber.json;junit:target_json/Cucumber_junit.xml

第三方整合

  • Jenkins整合,在Jenkins中安装cucumber 插件

自动构建后就会生产相关的报告

  • 结合Assured进行RESTful API测试
http://rest-assured.io/

https://www.baeldung.com/rest-assured-tutorial/

  • 结合selenium进行自动化测试
// https://mvnrepository.com/artifact/org.seleniumhq.selenium/selenium-java
compile group: 'org.seleniumhq.selenium', name: 'selenium-java', version: '3.14.0'

几个gradle下的cucumber插件

  • https://github.com/samueltbrown/gradle-cucumber-plugin
  • https://plugins.gradle.org/plugin/se.thinkcode.cucumber-runner
  • https://plugins.gradle.org/plugin/com.github.spacialcircumstances.gradle-cucumber-reporting
  • https://github.com/awbdallas/gradle-cucumber-jvm-plugin

个人总结

  • cucumber是敏捷开发团队常用的一种测试框架,它鼓励了系统开发环节中各个参与者来进行协作,其中也包括非技术人员
  • cucumber中的测试场景一般由纯自然语言来进行描述,很易懂,因此,非技术人员也可以来编写测试用例,然后通过技术人员来进行实现它。
  • Cucumber可以让人们用近似自然的语言去描述Feature和场景,根据Feature驱动开发。用作软件技术人员和非技术之间验收测试的桥梁。
  • 如果刚开始践行BDD,通常最好让开发人员编写
  • 如果只是用作测试自动化工具,可以由测试人员和开发人员编写
  • 特性(Feature)文件应该描述特性,而不是应用程序的组成部分,每个特性文件应有一个好的命名,并保持特性的专注
  • 避免特性与领域逻辑的不一致性,确保使用客户的领域语言。这一活动的最佳做法是让客户也参与编写故事。
  • 用组织代码的思想来组织你的特性与场景(Scenary)
  • 灵活使用标签Tag
  • 您的方案应该描述系统的预期行为,而不是实现。换句话说,它应该描述什么,而不是如何描述

Redis之路系列应用篇(2)

hzqiuxm阅读(31)评论(0)

2 应用篇—纸上得来终觉浅

大量键值对保存

场景案例

有这么一个需求场景,一个图片存储系统,图片的ID刚好可以对应和存储对象ID,所以采用了String类型来保存,键值的长度都是10位数。

刚开始,保存了大约1亿张图片,发现占用内存约6.4G。此时还算正常,当图片数量继续增加到2亿时开始出现由于要生成RDB而导致响应变慢了。
请问如何优化?

首先我们得找到变慢的原因,直接原因是RDB持久化文件太大所致
按道理来说,10位数整型无法存储,使用long型8个字节绝对够了,键值加一起也就16个字节;1个字节1B,16个字节16B;

1亿张图片为16亿B,约等于1.6G,为什么实际情况是6.4G?

那我们不禁要问,理论与实际为何相差这么大?
要回答这个问题,就需要对String的类型做个彻底的解剖。

疑团解析

之前介绍过Redis值中的String类型底层存储结构是简单动态字符串(SDS),其实它的存储结构如下:

比如上图中,key1和key2两个元素,它们都通过a、b、c三个无偏哈希函数进行计算落到不同的位置上,记为1。

当向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都位 1,只要有一个位为 0,那么说明布隆过滤器中这个 key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其它的 key 存在所致(这就是误判)。

如果这个位数组比较大,错误率就会很低,如果这个位数组比较小,错误率就会比较大。当然位数组太大,所需要的hash函数就越多,会影响计算效率的。

我们发现为了存储已用长度、实际分配长度、结束符号信息需要额外的9个字节!比我们实际数据本身还要大。

这还没完,对于 String 类型来说,除了 SDS 的额外开销,还有一个来自于 RedisObject 结构体的开销。

因为 Redis 的数据类型有很多,而且,不同数据类型都有些相同的元数据要记录(比如最后一次访问的时间、被引用的次数等),所以,Redis 会用一个 RedisObject 结构体来统一记录这些元数据,同时指向实际数据。

一个 RedisObject 包含了 8 字节的元数据和一个 8 字节指针,这个指针再进一步指向具体数据类型的实际数据所在,当SDS是Int或Long类型时,指针就不需要了,指针位置可以直接存放数据,节省空间开销。

本场景中采用的是后一种:数值类型的。所以又额外多出来8个字节信息要存储。
但是好像加起来也不到64个字节呀?没错,这还没有完!因为在全局哈希表中,还要存储指针,entry值,链式指针

虽然三个指针只占了24个字节,但是Redis通常会多分配一些内存,一般往上按照2的幂次数取到32字节,以此来减少分配次数。

所以虽然数据本身只占了16个字节,但是还需要额外的48个字节存储其它信息。

这就是1亿个10位键值对真实存储情况。

优化手段

明白了简单动态字符串存储原理后,也理解了为什么1.6G的数据为什么占用了6.4G内存。那能换成哪种结构来节约内存呢?

回顾下存储方式的底层数据结构,我们可以发现有个叫压缩列表的数据结构,它是一种非常节省内存的结构。
表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量,以及列表中的 entry 个数。压缩列表尾还有一个 zlend,表示列表结束。

  • prev_len,表示前一个 entry 的长度。prev_len 有两种取值情况:1 字节或 5 字节。取值 1 字节时,表示上一个 entry 的长度小于 254 字节。虽然 1 字节的值能表示的数值范围是 0 到 255,但是压缩列表中 zlend 的取值默认是 255,因此,就默认用 255 表示整个压缩列表的结束,其他表示长度的地方就不能再用 255 这个值了。所以,当上一个 entry 长度小于 254 字节时,prev_len 取值为 1 字节,否则,就取值为 5 字节。

这些 entry 会挨个儿放置在内存中,不需要再用额外的指针进行连接,这样就可以节省指针所占用的空间。
在上述场景中每个对象大小都是固定的8字节,所以prev_len占1个字节,加上编码方式1字节,自身长度4字节,实际数据8字节,一个entry大约14字节就够了
String的额外开销最大的部分为dictEntry部分,大约占了32个字节,因为每一个键值对entry都对应一个dictEntry。而压缩列表将entey都按顺序放在一起,若干个entry只需要对应一个dictEntry,可以节省下许多额外的空间。

不过redis官方一直在优化各种数据结构的保存机制,不同的版本得到的值可能不太一样,大家千万不要拘泥与值的大小,而是通过存储机制的不同,选择适合的数据结构。这里有一个小工具,可以在线计算各种键值对长度和使用数据类型的内存消耗大小,在实际应用中一定要先测试下所用版本的真实情况,版本不同差别很大。
推荐的神奇小工具:Redis容量预估-极数云舟

海量keys统计

场景1 聚合计算

  • 统计每天的新增用户数和第二天的留存用户数

需要系统中保存所有登录过的用户ID和每一天登录过的用户ID;所有登录过的用户ID可以使用Set来保存,key为user:id,value就是具体的userId值

每天的登录用户也可以使用SET来保存,不过需要有时间戳,可以把时间戳设计在key中,比如key设计为:user:id:20210202就表示2021年2月2号登录的用户ID集合

每天的新增用户,其实就是在当前的用户集合中把存在在累计用户集合中的用户剔除掉,所以只要用日用户Set与累计用户set做差集即可

第二天的留存用户,相当于在前一天的新增用户数还存在于当天的日用户Set中,很明显就是求二者的交集

场景2 排序计算

  • 获取商品评论评论中的最新评论

出现最XXX的字眼时,我们很容易联想到包含了排序,所以使用ZSet方式存储,我们自己来决定每个元素的权重值,比如时间戳先后。

实现起来就很简单了,因为ZSet就是按照权重自动排序的,我们要做的就是使用命令直接来获取即可

比如:ZRANGEBYSCORE comments N-99 N 假设最新评论权重是N,那么这个语句就代表获取最新的100条评论

场景3 二值状态统计

  • 统计一亿用户中连续10天打卡签到的数量

打卡的状态很有限,只有两种情况,要不打了,要不没打,所以我们称之为二值状态的统计问题。
在签到统计时,每个用户一天的签到用 1 个 bit 位就能表示,一年的签到也只需要用 365 个 bit 位,根本不用太复杂的集合类型。

我们就可以选择Redis 提供的扩展数据类型 Bitmap ,它的底层数据结构实现其实也用String类型,我们可以把它看出是一个bit数组。

多少个1,就表示有多少个用户连续10天打卡签到。

有人看到有1亿位的bitmap是不是被吓了一跳,是不是担心内存开销过大,能存的下吗?我们尝试来计算下,1亿除以8转换为字节,然后除以1024转换为KB,再除以1024转换为MB,大约等于12,那么10天的数据也就占了120M的内存,如果再结合TTL的使用,及时释放掉不需要的签到记录,内存压力不算太大。

只要是能够转换为二值状态统计的问题,我们都可以使用bitmap去做,这样可以有效节省内存空间。

场景4 基数统计

  • 统计1000个千万级访问量网页的UV

统计网页的UV我们需要进行去重操作,那么第一反应就是使用Set了,然后使用SCARD命令很方便就得到UV数了。

但是由于网页的访问量实在是打,是千万级别,那么一个Set就要记录千万个用户ID了,这样的页面有1000个,那内存消耗就有点大了。

我们再进一步思考,我们统计UV是否需要特别精准呢?一般是不需要特别精准的,只要差值别超过一个数量级,就有它的统计学意义了。

此时,我们可以使用Redis 提供的 HyperLogLog 了,是一种用于统计基数的数据集合类型,它的最大优势就在于,当集合元素数量非常多时,它计算基数所需的空间总是固定的,而且还很小。

在 Redis 中,每个 HyperLogLog 只需要花费 12 KB 内存,就可以计算接近 2^64 个元素的基数。你看,和元素越多就越耗费内存的 Set 和 Hash 类型相比,HyperLogLog 就非常节省空间。

HyperLogLog 的统计规则是基于概率完成的,所以它给出的统计结果是有一定误差的,标准误算率是 0.81%。这也就意味着,你使用 HyperLogLog 统计的 UV 是 100 万,但实际的 UV 可能是 101 万。
虽然误差率不算大,但是,如果你的业务场景需要精确统计结果的话,最好还是继续用 Set 或 Hash 类型,计算好你需要的内存。

统计计算宝典

该表只是提供一些常规场景的思路和建议,并不能涵盖Redis各种统计场景,大家可以不断更新此表。

大数据过滤

需求场景

某平台需要给用户不断推荐新的新闻内容,它每次推荐时要进行去重,去掉那些已经看过的内容,请问怎么去做?

大部分人的第一反应是通过Set来存储新闻的唯一编号,利用set天然去重。

当然我们的解决方案不可能这么简单,试想下如此多的历史记录全部缓存起来,那得浪费掉多大存储空间呀?且访问是按照线性增长的,时间越久性能就越差,宝贵内存资源浪费也越严重。此时我们就可以使用Redis中的布隆过滤器来解决。

布隆过滤器就是专门用来解决这种去重问题的,可以节约大量的空间,但存在一定的误判。

我们可以把布隆过滤器看成一个不那么精确的set,当它说某个值存在时,可能是不存在的;但是当它说不存在,那肯定是不存在的。用在上面场景里就是:当它说新闻是新的时候,那一定是新的;当它说新闻不是新的时候,有可能是新的,这个结果满足了上述场景要求。

插件安装使用

布隆过滤器是以插件的形式发挥作用的,可以前往Releases · RedisBloom/RedisBloom · GitHub进行下载源码编译安装,遵从以下步骤

  • 解压缩后使用make编译生成动态链接库redisbloom.so
  • 拷贝动态链接库: cp redisbloom.so usr/local/bin/
  • redis.conf配置文件中添加动态链接库:loadmodule redisbloom.so
  • 重启redis

布隆过滤器主要操作:

模拟新闻编号判断:

好像很准确啊,一个也没有误判,那是因为我们数据量太小。根据笔者实验,一般几百数据量的情况下就会出现误判。

另外其实我们是可以通过bf.reserve参数来重新设置过滤器的误判率的:

在我们实际的互联网环境下,有很多技术都利用了布隆过滤器的原理,比如爬虫系统判断URL是否爬过。

还有NOSQL领域的: HBase、Cassandra 还有 LevelDB、RocksDB 内部都有布隆过滤器结构,布隆过滤器可以显著降低数据库的 IO 请求数量。当用户来查询某个 row 时,可以先通过内存中的布隆过滤器过滤掉大量不存在的 row 请求,然后再去磁盘进行查询。

邮箱系统的垃圾邮件过滤功能也普遍用到了布隆过滤器,因为用了这个过滤器,所以平 时也会遇到某些正常的邮件被放进了垃圾邮件目录中,这个就是误判所致,概率很低。

分布式锁设计

基于Redis的分布式锁设计一般分为单机的redis和集群的redis

单个Redis分布式锁

业界通用的设计方案就是利用SETNX 和 DEL 命令组合来实现加锁和释放锁操作。伪代码如下:

// 加锁
SETNX lock_key 1
// 业务逻辑
DO THINGS
// 释放锁
DEL lock_key
//超时释放
Expire lock_key

上述伪代码还存在两个潜在风险:1 业务逻辑发生异常或宕机,导致释放机制失效;2 业务逻辑执行时间过长,锁超时或被其它线程释放了,导致并发问题

第一个风险我们可以加上一个异常捕获,先解决异常情况下的锁释放问题,然后利用redis提供的新方法:

SET key value [EX seconds | PX milliseconds]  [NX]
命令示例:Set lock:bizxxx true ex 5 nx

让redis保证SETNX和Expire指令的原子性;

第二个风险我们可以引入随机数(客户端唯一标识也行),验证随机数保证了锁不会被其它线程释放掉,由于随机数的判断和删除缺乏原子性,我们还需要引入LUA脚本,保证随机数品判断匹配和删除的逻辑的原子性。

整个过程伪代码:

//unlock.script是Lua脚本
redis-cli  --eval  unlock.script lock_key , unique_value 

//释放锁 比较unique_value是否相等,避免误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

Redis集群分布式锁

当我们要实现高可靠的分布式锁时,就不能只依赖单个的命令操作了,我们需要按照一定的步骤和规则(分布式锁算法)进行加解锁操作,否则,就可能会出现锁无法工作的情况。

Redis 的开发者 Antirez 提出了分布式锁算法 Redlock。

Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。

这样一来,即使有单个 Redis 实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。
整个分布式加锁可以分为3个步骤:

  • 1 客户端获取当前时间
  • 2 客户端按顺序依次向 N 个 Redis 实例执行加锁操作
  • 3 一旦客户端完成了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时

加锁成功要满足两个条件:1 客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;2 客户端获取锁的总耗时没有超过锁的有效时间。

在满足了这两个条件后,我们需要重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。

当然不推荐大家去根据算法去实现分布式锁,可以的话还是采用开源或已成熟的解决方案

分布式锁小结

我个人认为Redis的分布式锁是一个比较轻量的解决方案,可以满足我们99%的业务场景,但是如果你的业务要求是99.99%,甚至更高,那么你应该采用其它分布式锁技术比如:zk、etcd等,确保万无一失。

附录一:位数与存储大小计算

要计算多少位数需要多少个字节来保存,我们首先要清楚2的多少次方的计算结果能覆盖我们要保存的数据位数。

比如我们要保存一个10位数,10位数在数值大小上是十亿,我们要保证覆盖最大十亿数,那么到达百亿就能满足了。

根据2的指数运算:

2的34次方的时候达到了百亿,也就是2的34次方对应需要多少个字节呢?我们都知道1个字节占8位,34位大约是4.25个字节。

但是我们要知道在计算机语言中,数据是有类型的,int型最大的能表示的数为2的32次方,也就是十亿级别的,既然int型数据不能满足要求,那就只能采用long类型了

long的类型能表示的数为2的64次方,这个数太大了,足足有20位,满足10位数绰绰有余,而64位,占了8个字节。

附录二:HyperLogLog 原理

HyperLogLog使用非常简单,其实现依据说起来也很简单,但是证明理解起来就没那么简单了,需要用到概率学的知识

先讲下依据,HyperLogLog 是利用概率结果来估算实验次数,是不是看起来有点神奇和懵逼?

举个具体的例子:某天吃完饭,你和你女朋友玩抛硬币游戏,你女朋友负责抛硬币,她抛了的轮数记为n,每一次都会记录正面是在本轮中第K次出现的。然后她告诉你K的最大值,让你猜n的值。
作为理工男的你马上意识到这是个伯努利过程在脑海里进行了概率计算:

算了下kmax在回合出现的概率是(1/2)^k * max,得到: n = 2^k * max,当你女朋友告诉你最大K=3,你胸有成竹的脱口而出:8!

结局是她只抛了1次,于是你输了,负责刷碗。

并不是你的计算不对,而是单次的概率不符合大数定律,而Philippe Flajolet教授吸取了你的教训,引入了桶的概念,再利用调和平均数减少误差。

其中m是桶的数量,const是修正常数,它的取值会根据m而变化。
笔者写了一个简化版测试程序,计算了下误差值,真实算法更复杂,误差值也更低

100000 96673.07 误差:0.03
200000 196276.45 误差:0.02
300000 295135.37 误差:0.02
400000 396655.24 误差:0.01
500000 506963.14 误差:0.01
600000 603743.24 误差:0.01
700000 759724.06 误差:0.09
800000 803806.54 误差:0.00
900000 892418.41 误差:0.01

上面代码用了1024个桶,而在 Redis 的 HyperLogLog 实现中用到的是 16384 个桶,也就是 2^14,每个桶的 maxbits 需要 6 个 bits 来存储,最大可以表示 maxbits=63,于是总共占用内存就是 2^14 * 6 / 8 = 12k字节。

帮助我们理解原理的工具:Sketch of the Day: HyperLogLog — Cornerstone of a Big Data Infrastructure – AK Tech Blog (neustar.biz)

附录三:布隆过滤器原理

每个布隆过滤器对应到 Redis 的数据结构里面就是一个大型的位数组和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的hash值算得比较均匀。

向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个hash函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。

Redis之路系列基础篇(1)

hzqiuxm阅读(36)评论(0)

1 基础篇—千里之行始于足下

基于redis6

安装与运行

无论你一名极客还是一名工程师,Redis安装我都推荐源码安装,请前往官方下载地址:http://redis.io/download 进行源码下载,偶数为稳定版 奇数为不稳定版。

如果你是类linux系统,使用wget命令直接远程下载源码 wget https://download.redis.io/releases/redis-{具体版本号}.tar.gz

编译与安装命令:make && make install
默认安装地址:/usr/local/bin,准备好redis.conf配置文件,以下几个配置建议修改:

daemonize yes  后台启动
port xxxx  修改默认端口
requireoass xxxx   添加访问密码
bind 127.0.0.1 如果想其它服务器可以访问,注释掉

服务端启动运行命令: ./redis-server ../conf/redis.conf

服务端停止运行命令:pkill redis-server 或者使用客户端发出关闭命令: ./redis-cli shutdown

客户端链接命令:./redis-cli -h 服务器IP -p 端口 -u 用户 -a 密码,建议更换默认端口,添加访问认证密码

redis版本号查看命令:redis-server -v

redis自带工具集

  • redis-benchmark:性能测试工具,测试redis在你的系统及配置下的读写性能
  • redis-check-aof:用于修复出问题的AOF
  • redis-check-rdb:用于修复出问题的rdb
  • redis-sentinel:redis的集群管理工具

两种线程模型

  • 单线程模型:socket读写、解析数据、执行处理、返回数据等操作都是由一个主线程来完成的。通过对epoll函数的包装来做到。

    单线程原因:瓶颈在内存和网络不在cpu、多线程可能不安全、复杂度增加、线程上下文切换性能损耗等

  • 多线程模型:redis6开始支持I/O多线程,因为之前的瓶颈主要在I/O数据读写性能

高性能根本原因:抽象了一套事件模型,使用多路复用机制(epoll),使得I/O读写都是非阻塞的,从而具备高性能的网络处理能力;同时基于内存进行数据处理。

value存储形式

我们日常中所提到的String(字符串)、List(列表)、Hash(哈希)、Set(集合)和 Sorted Set(有序集合)都只是Redis 键值对中值的数据类型,也就是数据的保存形式。

严格来说并不是Redis数据结构,Redis底层数据结构实现其实一共有6种:分别是简单动态字符串(SDS)、双向链表、压缩列表、哈希表、跳表和整数数组。具体对应关系,后面介绍底层存储结构时会进一步介绍。

先理解和掌握value的5种基本存储形式

String

key是字符串,如果存在空格必须加上双引号,最大的容量是512M

类似Memcache的键值存储,

  • 使用场景:缓存、计数、共享会话、限速

List

底层实现是链表

类似数据结构中的队列,不过支持双向操作。

  • 使用场景:消息队列、文章列表

Hash

按hash的方式存放字符串

相当于关系数据的行数据

  • 使用场景:存储类似于关系数据库的行

Set

是通过hashTable实现的

存储很多数据名单又不重复,判断某个元素是否存在相当方便,聚合运算效率也很高(存放好友,联系人,共同好友)

  • 使用场景:标签,分类,社交

Zet

是通过散列表和跳跃表来实现的

在Set基础上增加了有序的特点,增加了一个double类型的分数作为权重,用于排序

  • 使用场景:排行榜、社交点赞

存储基本结构

整体上看,无论是哪种存储形式,Redis都是键-值的形式,为了实现这种从键到值的快速访问,Redis使用了一个哈希表来保存所有的键值对。

一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。所以,我们常说,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。

每个键值对包含了键部分和值部分,键部分都是String类型,值部分由大家所熟知的(上面介绍的)5种存储结构组成,而底层用来实现的数据结构有6种,它们之间的对应关系如下:

上面这个哈希表保存了所有的键值对,也称作全局哈希表。当Hash表查询数据的时间复杂度是O(1),操作又是内存级别的,所以数据会非常快。

不过当redis存储的数据越来越大的时候,难免就会产生hash冲突,形成链式hash,hash链上的元素只能逐个查找,时间复杂度是O(n),所以效率就会下降。

当链式hash过程的时候,redis会进行rehash操作,保证元素分布均匀。rehahs的过程是一个渐进式的过程。可以分为3个步骤:
1 先构造一个更大的全局哈希表;
2 映射元素到新的全局哈希表;
3 释放掉旧的hash表。

这里的关键是第2步,redis不是一次性完成所有元素拷贝的,而是在某索引位置发生请求或空闲时,才进行拷贝。

巧妙的把一次性大量拷贝分摊到了多次请求的过程中,既不影响正常请求也能保证数据快速拷贝(后续AOF重写技术也利用了这个设计)。

熟悉了底层数据结构的实现,对我们如何使用redis提供了技术指导,操作的复杂度取决于数据结构的复杂度:

  • 对Hash和Set的单元素操作,其实现是哈希表,复杂度都是O(1),它们现在也支持了多元素操作,M个元素复杂度就是O(M)
  • 对List、Hash、Set遍历操作时其实现是通过链表或数组实现,复杂度一般为O(N),我们尽量避免这种操作
  • 对集合类型元素统计操作(LLEN,SCARD),一般会有单独的字段来存储这个值,所以复杂度为O(1)
  • 特别注意对与压缩列表和双向列表都会记录头尾指针偏移量,对与头尾类操作(LPOP,RPUSH),复杂度为O(1)

通用规则

  • List、Hash、Set、Zset被称作容器型数据
  • 不存在就创建原则:容器型数据不存在,就会自动创建一个,再进行操作
  • 没有就释放原则:容器型数据中如果没有元素了,就删除掉并释放内存
  • 能有过期时间:所有数据结构都可以设置过期时间
  • 过期时间擦除:对有过期时间的字符串进行修改操作会抹除掉过期时间

过期机制

设计理念基于性能与效率的折中,采用定期删除与惰性删除机制

定期删除:Redis会在后台,默认每秒10次的执行如下操作: 随机 选取100个key校验是否过期,如果有25个以上的key过期了,立刻 额外随机选取下100个key(不计算在10次之内)。也就是说,如果过 期的key不多,Redis最多每秒回收200条左右,如果有超过25%的 key过期了,它就会做得更多,这样即使从不被访问的数据,过期 了也会被删除掉。

惰性删除:当client主动访问key时,会先对key进行超时判断,过 时的key会立刻删除
处理过期keys的相关命令:

  • expire:设置过期时间,格式是expire key值 秒数
  • expireat:设置过期时间,格式是expireat key值 到秒的时间戳
  • ttl:查看还有多少秒过期,格式是ttl key值,-1表示永不过期,-2表示已 过期
  • persist:设置成永不过期,格式是persist key值,删除key的过期设置;另 外使用set或者getset命令为键赋值的时候,也会清除键的过期时间
  • pttl:查看还有多少毫秒过期,格式是pttl key值
  • pexpire:设置过期时间,格式是pexpire key值 毫秒数
  • pexpireat:设置过期时间,格式是pexpireat key值 到毫秒的时间戳

附录一:常用命令

1 连接操作命令

  • quit:关闭连接(connection)
  • auth:简单密码认证
  • help cmd: 查看cmd帮助,例如:help quit

2 持久化

  • save:将数据同步保存到磁盘
  • bgsave:将数据异步保存到磁盘
  • lastsave:返回上次成功将数据保存到磁盘的Unix时戳
  • shundown:将数据同步保存到磁盘,然后关闭服务

3 远程服务控制

  • info:提供服务器的信息和统计
  • monitor:实时转储收到的请求
  • slaveof:改变复制策略设置
  • config:在运行时配置Redis服务器

4 对key操作的命令

  • exists(key):确认一个key是否存在
  • del(key):删除一个key
  • type(key):返回值的类型
  • keys(pattern):返回满足给定pattern的所有key(?、*、[]、\x等通配符)
  • randomkey:随机返回key空间的一个
  • keyrename(oldname, newname):重命名key
  • dbsize:返回当前数据库中key的数目
  • expire:设定一个key的活动时间(s)
  • ttl:获得一个key的活动时间
  • select(index):切换到某个数据库(更像是命名空间)
  • move(key, dbindex):移动当前数据库中的key到dbindex数据库
  • flushdb:删除当前选择数据库中的所有key
  • flushall:删除所有数据库中的所有key

5 String

  • set(key, value):给数据库中名称为key的string赋予值value
  • get(key):返回数据库中名称为key的string的value
  • getset(key, value):给名称为key的string赋予上一次的value
  • mget(key1, key2,…, key N):返回库中多个string的value
  • setnx(key, value):添加string,名称为key,值为value
  • setex(key, time, value):向库中添加string,设定过期时间time
  • mset(key N, value N):批量设置多个string的值
  • msetnx(key N, value N):如果所有名称为key i的string都不存在
  • incr(key):名称为key的string增1操作
  • incrby(key, integer):名称为key的string增加integer
  • decr(key):名称为key的string减1操作
  • decrby(key, integer):名称为key的string减少integer
  • append(key, value):名称为key的string的值附加value
  • substr(key, start, end):返回名称为key的string的value的子串

6 List

  • rpush(key, value):在名称为key的list尾添加一个值为value的元素
  • lpush(key, value):在名称为key的list头添加一个值为value的 元素

  • lpushx/rpushx:只有当list存在时才会从左/右边依次追加元素

  • linsert:插入元素,格式是linsert list的key before|after 定 位查找的值 添加的值

  • llen(key):返回名称为key的list的长度
  • lrange(key, start, end):返回名称为key的list中start至end之间的元素
  • ltrim(key, start, end):截取名称为key的list
  • lindex(key, index):返回名称为key的list中index位置的元素
  • lset(key, index, value):给名称为key的list中index位置的元素赋值
  • lrem(key, count, value):删除count个key的list中值为value的元素,0表示全部
  • lpop(key):返回并删除名称为key的list中的首元素
  • rpop(key):返回并删除名称为key的list中的尾元素
  • blpop(key1, key2,… key N, timeout):lpop命令的block版本。
  • brpop(key1, key2,… key N, timeout):rpop的block版本。
  • rpoplpush(srckey, dstkey):返回并删除名称为srckey的list的尾元素,并将该元素添加到名称为dstkey的list的头部

7 Set

  • sadd(key, member):向名称为key的set中添加元素member,member可以有多个

  • smembers(key) :返回名称为key的set的所有元素

  • srem(key, member) :删除名称为key的set中的元素member
  • spop(key) :随机返回并删除名称为key的set中一个元素
  • smove(srckey, dstkey, member) :移到集合元素
  • scard(key) :返回名称为key的set的基数
  • sismember(key, member) :member是否是名称为key的set的元素

  • srandmember(key) :随机返回名称为key的set的一个元素

  • sinter(key1, key2,…key N) :求交集
  • sinterstore(dstkey, (keys)) :求交集并将交集保存到dstkey的集合
  • sunion(key1, (keys)) :求并集
  • sunionstore(dstkey, (keys)) :求并集并将并集保存到dstkey的集合
  • sdiff(key1, (keys)) :求差集,保留key1独有
  • sdiffstore(dstkey, (keys)) :求差集并将差集保存到dstkey的集合

8 Hash

  • hset(key, field, value):向名称为key的hash中添加元素field
  • hget(key, field):返回名称为key的hash中field对应的value
  • hmget(key, (fields)):返回名称为key的hash中field i对应的value
  • hmset(key, (fields)):向名称为key的hash中添加元素field

  • hsetnx:如果项不存在则赋值,存在时什么都不做,格式是 hsetnx Hash的Key 项的key 项的值

  • hexists(key, field):名称为key的hash中是否存在键为field的域
  • hdel(key, field):删除名称为key的hash中键为field的域
  • hlen(key):返回名称为key的hash中元素个数
  • hkeys(key):返回名称为key的hash中所有键
  • hvals(key):返回名称为key的hash中所有键对应的value
  • hgetall(key):返回名称为key的hash中所有的键(field)及其对应的value

  • hincrby(key, field, integer):将名称为key的hash中field的value增加integer

  • hincrbyfloat:增减Float数值,格式是hincrbyfloat Hash的Key 项的key 正负float

9 Zset

  • zadd:添加元素,格式是zadd zset的key score值 项的值,Score 和项可以是多对,score可以是整数,也可以是浮点数,还可以是 +inf表示正无穷大,-inf表示负无穷大
  • zrange:获取索引区间内的元素,格式是zrange zset的key 起始 索引 终止索引 (withscores)
  • zrangebyscore:获取分数区间内的元素,格式是zrangebyscore zset的key 起始score 终止score (withscores),默认是包含端点 值的,如果加上“(”表示不包含;后面还可以加上limit来限制
  • zrem:删除元素,格式是zrem zset的key 项的值,项的值可以是 多个
  • zcard:获取集合中元素个数,格式是zcard zset的key
  • zincrby:增减元素的Score,格式是zincrby zset的key 正负数 字 项的值
  • zcount:获取分数区间内元素个数,格式是zcount zset的key 起 始score 终止score
  • zrank:获取项在zset中的索引,格式是zrank zset的key 项的值
  • zscore:获取元素的分数,格式是zscore zset的key 项的值,返 回项在zset中的score
  • zrevrank:获取项在zset中倒序的索引,格式是zrevrank zset的 key 项的值
  • zrevrange:获取索引区间内的元素,格式是zrevrange zset的 key 起始索引 终止索引 (withscores)
  • zrevrangebyscore:获取分数区间内的元素,格式是 zrevrangebyscore zset的key 终止score 起始score (withscores)
  • zpopmax:从集合中弹出分数最高的成员,返回该成员和分值,然后从集合 中将其移出
  • zpopmin:从集合中弹出分数最低的成员,返回该成员和分值,然后从集合 中将其移出
  • bzpopmax:在参数中的所有集合均为空的情况下,阻塞连接。参数中包含多 个有序集合时,按照参数中key的顺序,返回第一个非空key中分数最大的成 员和对应的分数。参数 timeout 可以理解为客户端被阻塞的最大秒数值,0 表示永久阻塞。
  • bzpopmin:在参数中的所有集合均为空的情况下,阻塞连接。参数中包含多 个有序集合时,按照参数中key的顺序,返回第一个非空key中分数最小的成 员和对应的分数。参数 timeout 可以理解为客户端被阻塞的最大秒数值,0 表示永久阻塞。
  • zremrangebyrank:删除索引区间内的元素,格式是 zremrangebyrank zset的key 起始索引 终止索引
  • zremrangebyscore:删除分数区间内的元素,格式是命令 zset 的key 起始score 终止score
  • zinterstore:交集,格式是ZINTERSTORE dest-key key-count key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE SUM|MIN|MAX]
  • zunionstore:并集,格式是ZUNIONSTORE dest-key key-count key [key ...] [WEIGHTS weight [weight ...]] [AGGREGATE SUM|MIN|MAX]

10 Sort排序

可以使用sort命令对List、Set、ZSet里面的值进行排序

SORT source-key [BY pattern] [LIMIT offset count] [GET pattern [GET pattern ...]] [ASC|DESC] [ALPHA] [STORE dest-key]

by:设置排序的参考键,可以是字符串类型或者是Hash类型里面的 某个Item键,格式是 Hash键名:->Item键。设置了by参考键, sort将不再依据元素的值来排序,而是对每个元素,使用元素的值 替换参考键中的第一个””,然后获取相应的值,再对获得的值 进行排序。如果参考键不存在,默认为0。如果参考键值一样,再以元素本身的值进行排序。
get:指定sort命令返回结果包含的键的值,形如: Hash键名:*- >Item键,可以指定多个get,返回的时候,一行一个。如果要返回 元素的值,用get #。

  • 对较大数据量进行排序会严重影响性能,所以:1 尽量减少排序集合中数据;2 使用limit限制获取数据量;3 可以考虑使用Store来缓存结果

附录二:对于多快常识

附录三:配置文件

可以在redis-cli里面使用config命令来获取或者设置Redis 配置,这样可以做到不用重新启动Redis来改变配置(仅支持部分配置)。

命令: config get/set 配置名

不支持动态配置的属性有(不是全部):

注意:只推荐在开发和测试环境使用!重启后失效!

配置分类

Redis定义了一些基本的度量单位,只支持bytes,不支持bit,配置对带小写不敏感

Redis配置文件(基于6.0版本)中可以配置的内容根据其作用分,大致包含24种:

这里简单罗列下项目开发中可能经常用到的部分(橙色),红色部分以及相关配置会放在后续专门的主题中来进行介绍,基本可以通过阅读配置文件注释来得到使用方法

网络部分

  • bind:Redis服务监听地址,用于客户端连接,默认本机地址
  • protected-mode :安全保护模式,默认开启,配置bind ip或者设置访问密码访问,关闭后,外部网络可以直接访问
  • port:监听的端口号,默认服务端口是6379(强烈建议更换)
  • tcp-backlog:设置tcp的backlog,backlog其实是一个已经完成三次握手的连接队列,默认是511。在高并发环境下,需要一个高 backlog值来避免慢客户端连接问题。注意Linux内核会将这个值减小到/proc/sys/net/core/somaxconn的值,所以需要确认增大 somaxconn和tcp_max_syn_backlog两个值来达到想要的效果
  • unixsocket:指定 unix socket 的路径
  • unixsocketperm:指定 unix socket file 的权限
  • timeout:连接空闲超时时间,0表示永不关闭,默认值
  • tcp-keepalive:单位为秒,如果设置为0,则不会进行Keepalive 检测,建议设置成60,默认是300

通用部分

  • daemonize:是否以后台daemon方式运行,默认是no
  • supervised:可以通过upstart和systemd管理Redis守护进程,这个参数是和具体的操作系统相关的
  • pidfile:pid进程文件位置,默认会生成在/var/run/redis.pid,启动多个时候配置到不同目录
  • loglevel:log信息级别,共分四级,即debug、verbose、notice 、warning
  • logfile:log文件位置,如果设置为空字符串,则redis会将日志 输出到标准输出。假如你在daemon情况下将日志设置为输出到标准 输出,则日志会被写到/dev/null中
  • syslog-enabled:是否把日志输出到syslog中
  • syslog-ident:指定syslog里的日志标志
  • syslog-facility:指定syslog设备,值可以是USER或LOCAL0- LOCAL7
  • databases:开启数据库的数量,编号从0开始,默认的数据库是编号为0的数据库,可以使用select来选择相应的数据库
  • always-show-logo:是否显示Redis的ASCII艺术logo

安全部分

  • acllog-max-len:ACL日志存储在内存中并消耗内存,设置此项可以设置最大值来回收内存

  • requirepass:设置Redis连接密码

  • rename-command:将命令重命名。为了安全考虑,可以将某些重要的、危险的命令重命名。当你把某个命令重命名成空字符串的时候就等于取消了这个命令

客户端部分

  • maxclients:设置redis同时可以与多少个客户端进行连接。

默认情况下为10000个客户端。当你无法设置进程文件句柄限制时, redis会设置为当前的文件句柄限制值减去32,因为redis会为自身内部处理逻辑留一些句柄出来。

如果达到了此限制,redis则会拒 绝新的连接请求,并且向这些连接请求方发出“max number of clients reached”以作回应。

内存管理部分

  • maxmemory:设置redis可以使用的内存量,最多是物理机的一半。

一旦到达内存使用上限 ,redis将会试图移除内部数据,移除规则可以通过maxmemory-policy来指定。

如果redis无法根据移除规则来移除内存中的数据 ,或者设置了“不允许移除”,那么redis则会针对那些需要申请内存的指令返回错误信息,比如SET、LPUSH等。

但是对于无内存申请的指令,仍然会正常响应,比如GET等。

如果你的redis是主redis(说明你的还有从redis),那么在设置内存使用上限时,需要在系统中留出一些内存空间给同步队列缓存,只有在你设置的是“不移除”的情况下,才不用考虑这个因素。

  • maxmemory-samples:设置样本数量,LRU算法和最小TTL算法都并非是精确的算法,而是估算值,所以你可以设置样本的大小,redis默认会检查这么多个key 并选择其中LRU的那个。

  • maxmemory-policy:设置内存移除规则

注意:无论使用上述哪一种移除规则,如果没有合适的key可以移除的话,redis都 会针对写请求返回错误信息

  • replica-ignore-maxmemory:从 Redis 5 开始,默认情况下,replica 节点会忽略 maxmemory 设置(除非在发生 failover 后,此节点被提升为 master 节点)。

这意味着只有 master 才会执行过期删除策略,并且 master 在删除键之后会对 replica 发送 DEL 命令。

惰性删除部分

  • lazyfree-lazy-eviction:对redis内存使用达到maxmeory,并设置有淘汰策略时,在被动淘汰键时,是否采用lazy free机制。

因为此场景开启lazy free, 可能使用淘汰键的内存释放不及时,导致redis内存超用,超过 maxmemory的限制,一般不启用

  • lazyfree-lazy-expire:对设置有TTL的键,达到过期后,被redis清理删除 时是否采用lazy free机制。此场景建议开启,因TTL本身是自适应调整的速度

  • lazyfree-lazy-server-del:对有些指令在处理已存在的键时,会带有一个 隐式的DEL键的操作。

如rename命令,当目标键已存在,redis会先删除目标键,如果这些目标键是一个big key,那就会引入阻塞删除的性能问题。 此参数设置就是解决这类问题,建议可开启

  • replica-lazy-flush:对slave进行全量数据同步,slave在加载 master的RDB文件前,会运行flushall来清理自己的数据场景,参数设置决定是否采用异常flush机制。

如果内存变动不大,建议可开启。可减少全量同步耗时,从而减少主库因输出缓冲区爆涨引起 的内存使用增长

  • lazyfree-lazy-user-del:修改DEL的默认行为,使得命令的行为 完全像UNLINK

脚本与日志部分

  • lua-time-limit:设置lua脚本的最大运行时间,单位是毫秒,如果此值设置为0或负数,则既不会有报错也不会有时间限制,默认是5秒

  • slowlog-log-slower-than:判断是否慢日志的执行时长,单位是微秒,负数则会禁用慢日志功能,而0则表示强制记录每一个命令

  • slowlog-max-len:慢日志的长度。当一个新的命令被写入日志时 ,最老的一条会从命令日志队列中被移除

监控与事件部分

  • latency-monitor-threshold:能够采样不同的执行路径,来知道 redis阻塞在哪里,这使得调试各种延时问题变得简单,设置一个 毫秒单位的延时阈值来开启延时监控

  • notify-keyspace-events:设置是否开启Pub/Sub 客户端关于键空间发生的事件,有很多通知的事件类型,默认被禁用,因为用户通常不需要该特性,并且该特性会有性能损耗,设置成空字符串就是禁用

工程师工具箱系列(3)

hzqiuxm阅读(40)评论(0)

Arthas插件入门

Java诊断利器

安装与准备

  • windows下推荐安装arthas,直接下载jar包
curl -O https://arthas.aliyun.com/arthas-boot.jar  //下载
java -jar arthas-boot.jar  //启动
  • linnx/mac下的推荐安装arthas,使用脚本as.sh脚本
curl -L https://arthas.aliyun.com/install.sh | sh

启动后arthas自动检测可以应用的程序:

要选择应用程序,输入对应的序号就行

dashboard命令示意:

  • 在idea中安装arthas插件:直接在应用市场中搜索arthas即可

使用的基本流程如下:

Arthas插件使用场景

启动arthas并已应用到应用程序

查看某个变量值

  • 静态变量的例子
    假设要获取下面这个static field变量值

首先选中这个变量,右键弹出菜单,然后选择Arthas Command一级菜单,再选择Ognl To Get Static Method Field二级菜单命令

成功后弹出以下窗口,点击copy sc command

获取到一个在命令行执行的命令:sc -d com.wangji92.arthas.plugin.demo.controller.StaticTest
复制到命令行下执行如下:1处是复制来要执行的命令,2处是得到的hashcode

复制hashcode到插件弹窗,填入输入框中,执行前最好先clean cache下,然后点击copy command,得到最终过的查询脚本语句:

ognl -x 3 '@com.wangji92.arthas.plugin.demo.controller.StaticTest@INVOKE_STATIC_NAME' -c 2dd83cd2

复制到命令窗口,输入后执行,得到变量当前的值

ognl方式调用Bean方法

SpringContext也是静态的,所以我们也能通过ognl命令来获取,获得了上下文容器后,可以调用Bean,基本可以为所欲为了。

获取的方式是通过:ApplicationObjectSupport#getApplicationContext

我们来尝试调动下CommonController这个Bean的getRandomInteger方法,拿到classload的hashcode后,采用下面的命令(这是ognl语法糖,不清楚也没关系,后面会介绍如何通过插件来达到同样效果):

ognl -x 3 '#springContext=@com.wangji92.arthas.plugin.demo.common.ApplicationContextProvider@context,#springContext.getBean("commonController").getRandomInteger()' -c 2dd83cd2

看到结果输出了一个随机数453

接下来我们尝试通过插件来完成同样的效果,在这之前,我们要先要把项目的SpringContext配置下
首先代码中要有能获取SpringContext的方法

然后把这个类的路径配置到插件的 Spring static Context Ognl setting
配置内容如下:

@com.wangji92.arthas.plugin.demo.common.ApplicationContextProvider@context

完成后我们选择commonController#getRandomInteger方法,按照下面顺序操作

得到下面弹窗,然后根据步骤1-4分别与命令行交替执行下,如果classloader没变,那直接执行第4步即可

观察第4步获得的命令,就是之前介绍的ognl语法糖

ognl -x 3 '#springContext=@com.wangji92.arthas.plugin.demo.common.ApplicationContextProvider@context,#springContext.getBean("commonController").getRandomInteger()' -c 2dd83cd2

再次执行,同样得到一个随机数,不过这次是240

PS:顺便说一嘴,插件提供了3种方式来调用Bean方法,先有个认知,其它方式具体的操作后续的章节再详细展开,包括带参数的如何调用

1 是通过静态的容器获取Bean进行调用(需要额外获取springContext的实现类比如:
ApplicationContextProvider)

2 是通过Watch方式进行调用(不需要额外实现类,单需要额外触发)

3 是通过tt的方式进行调用(不需要额外实现类,需要额外触发)

tt(TimeTunel)方式调用Bean的方法

同样是选中你要执行的方法,然后在菜单中选择tt方式,弹窗中非常明显的给出了执行的步骤

1 先copy command在后台执行

tt -t org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter invokeHandlerMethod -n 5

2 通过浏览器或Postman工具随便调用个接口额外触发下,后台会捕获请求引用,可以看到命令行后台的刷新了,看到了调用类以及方法名,这是属于第二步的工作

3 执行第三步,copy command得到:

tt -w 'target.getApplicationContext().getBean("commonController").getRandomInteger()' -x 3 -i 1000

粘贴到后台执行,可以看到调用正常,进行多次调用也没问题(watch的方式和tt类似,更加简单点就不演示了)

ognl调用带参数方法

等待被调用的方法,包含两个参数,一个是Integer,一个是List,前者是一个简单对象,后者属于复杂对象

按照示意图使用插件调用

通过插件,我们发现插件也只能提供简单参数的自动填充,而复杂对象需要我们自己去构造(参考对应文档)

完成后的命令如下:

ognl  -x  3  '#user=new com.wangji92.arthas.plugin.demo.controller.User(),#user.setName("wangji"),#user.setAge(27L),@com.wangji92.arthas.plugin.demo.controller.StaticTest@invokeStaticMethodParamObjListUser(0,{#user,#user})' -c 2dd83cd2

拷贝到命令行中执行,可以得到一个调用结果

Arthas本身是一个非常强大的运维类工具,Arthas插件给我们提供了许多的方便,替代了我们去记忆很长的脚本语法。

要想灵活的使用它,你还需要完整的阅读官方文档,去了解下,本文就不做详述了,本文目的是入门,相信你看完后至少应该入门了。

插件支持的命令全景图:

文末是一些参考资料,值得你更加全面和深入的进行学习。

资源总览

  • Arthas文档地址:https://arthas.aliyun.com/doc/
  • B站视频:https://www.bilibili.com/video/BV1K7411Q7mW/
  • 语雀文档:https://www.yuque.com/docs/share/01217521-2fdb-4261-8904-ef6e20d4f5ea?#
  • 示例工程地址:https://github.com/WangJi92/arthas-plugin-demo
  • 扩展阅读博客:http://hengyunabc.github.io/
  • idea插件下载地址:https://plugins.jetbrains.com/plugin/13581-arthas-idea
  • 插件源码地址:https://github.com/WangJi92/arthas-idea-plugin

SpringDataJPA系列(7)

hzqiuxm阅读(47)评论(0)

7 Jackson注解在实体中应用

常用的Jackson注解

Springboot中默认集成的是Jackson,我们可以在jackson依赖包下看到Jackson有多个注解

一般常用的有下面这些:

一个实体的示例

测试方法如下:

按照上述图片中的序号做个简单的说明:

  • 1处:指定序列化时候的顺序,先createDate然后是email

  • 2处:将name进行序列化时改成my_name

  • 3处:更新时间序列化时按照给出的格式要求

  • 4处:不序列化sex,报文中并没有出现

  • 5处:不序列化的字段

Jackson在Spring中应用场景

  • SpringMVC的请求参数转换

  • 微服务之间调用报文的转换

  • 将一些数据缓存到redis或其它缓存中时

  • 将调用解耦,使用JMS消息序列化时

工程师工具箱系列(2)

hzqiuxm阅读(116)评论(0)

hasor一瞥入门

简介

Hasor有着自己的独立的生命周期与Spring的不同,是一套完整的体系,提供了注入DataQL、Dataway、hasor-web等等,让你的代码无需在写Controller、Service、Dao、BO、VO、mapper等等东西,这是一个数据聚合项目。

Hasor 本身是由多个不同系列框架组合而成的一个框架体系。这些子框架的能力涵盖了 IoC、Aop、WebMVC、数据库以及其它方方面面。这一切的基础要归功于 Hasor 的插件化能力。

Hasor 帮助您设计更好的 API,它独有的框架扩展能力可以使新的能力完全无缝的集成到统一的 API 体系中。我们构建了通用功能,使您能够扩展 Hasor,而不是向核心框架添加每个特性。

Hasor 的扩展能力更像是一个乐高玩具的接口,任何人都可以通过非常简单的方式提供乐高积木,然后轻松的将它们融合到一起。在使用的过程中完全感受不到背后是多个不同的框架在协作。

特点

Hasor 的设计思想是 “微内核+插件”。微内核是指提供少量必要的功能支持,其余功能全部通过插件化方式实现。这样一来扩展 Hasor 就只需要无限制的添加插件而不是修改核心框架。

Hasor 独有的 API 融合机制会,让框架新的能力完全无缝的集成到统一的 API 体系中。下面这张图是 Hasor 的现有框架体系

环境准备

引入依赖

以主流Maven和Springboot工程为例

<!-- dataway -->
<dependency>
    <groupId>net.hasor</groupId>
    <artifactId>hasor-spring</artifactId>
    <version>${hasor.version}</version>
</dependency>
<dependency>
    <groupId>net.hasor</groupId>
    <artifactId>hasor-dataway</artifactId>
    <version>${hasor.version}</version>
</dependency>
<!-- druid 连接池 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.1.21</version>
</dependency>
<!--mysql 驱动-->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.19</version>

数据库脚本

考虑到不同版本字段不同,数据脚本最好从依赖包中获取,目前支持好几种数据库,大家根据数据库类型自选

以下是4.2.5版本脚本

CREATE TABLE interface_info (
  api_id          varchar(64)  NOT NULL COMMENT 'ID',
  api_method      varchar(12)  NOT NULL COMMENT 'HttpMethod:GET、PUT、POST',
  api_path        varchar(512) NOT NULL COMMENT '拦截路径',
  api_status      varchar(4)   NOT NULL COMMENT '状态:-1-删除, 0-草稿,1-发布,2-有变更,3-禁用',
  api_comment     varchar(255) NOT NULL COMMENT '注释',
  api_type        varchar(24)  NOT NULL COMMENT '脚本类型:SQL、DataQL',
  api_script      mediumtext   NOT NULL COMMENT '查询脚本:xxxxxxx',
  api_schema      mediumtext   NOT NULL COMMENT '接口的请求/响应数据结构',
  api_sample      mediumtext   NOT NULL COMMENT '请求/响应/请求头样本数据',
  api_option      mediumtext   NOT NULL COMMENT '扩展配置信息',
  api_create_time varchar(32)  NOT NULL COMMENT '创建时间',
  api_gmt_time    varchar(32)  NOT NULL COMMENT '修改时间',
  PRIMARY KEY (api_id),
  UNIQUE KEY uk_interface_info (api_path)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Dataway 中的API';
CREATE TABLE interface_release (
  pub_id           varchar(64)  NOT NULL COMMENT 'Publish ID',
  pub_api_id       varchar(64)  NOT NULL COMMENT '所属API ID',
  pub_method       varchar(12)  NOT NULL COMMENT 'HttpMethod:GET、PUT、POST',
  pub_path         varchar(512) NOT NULL COMMENT '拦截路径',
  pub_status       varchar(4)   NOT NULL COMMENT '状态:-1-删除, 0-草稿,1-发布,2-有变更,3-禁用',
  pub_comment      varchar(255) NOT NULL COMMENT '注释',
  pub_type         varchar(24)  NOT NULL COMMENT '脚本类型:SQL、DataQL',
  pub_script       mediumtext   NOT NULL COMMENT '查询脚本:xxxxxxx',
  pub_script_ori   mediumtext   NOT NULL COMMENT '原始查询脚本,仅当类型为SQL时不同',
  pub_schema       mediumtext   NOT NULL COMMENT '接口的请求/响应数据结构',
  pub_sample       mediumtext   NOT NULL COMMENT '请求/响应/请求头样本数据',
  pub_option       mediumtext   NOT NULL COMMENT '扩展配置信息',
  pub_release_time varchar(32)  NOT NULL COMMENT '发布时间(下线不更新)',
  PRIMARY KEY (pub_id),
  KEY idx_interface_release_api  (pub_api_id),
  KEY idx_interface_release_path (pub_path)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Dataway API 发布历史。'

文件配置

配置项目工程中properties.yml文件

  # 数据源
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.jdbc.Driver
    druid:
      driver-class-name: com.mysql.cj.jdbc.Driver
      username: root
      password: 123321qq
      url: jdbc:mysql://localhost:3306/mp?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=GMT%2B8&allowMultiQueries=true

# 是否启用 Dataway 功能(必选:默认false)
HASOR_DATAQL_DATAWAY: true
# 是否开启 Dataway 后台管理界面(必选:默认false, 生产环境不要打开!)
HASOR_DATAQL_DATAWAY_ADMIN: true
# SQL执行器方言设置(可选,建议设置)
HASOR_DATAQL_FX_PAGE_DIALECT: mysql
# dataway  API工作路径(可选,默认:/api/)
HASOR_DATAQL_DATAWAY_API_URL: /interface/
# dataway-ui 的工作路径(可选,默认:/interface-ui/)
HASOR_DATAQL_DATAWAY_UI_URL: /config/

Hasor配置

  • 配置启动类,需要加上二个注解:@EnableHasor,@EnableHasorWeb

  • 配置Module接管Spring容器和数据源

这里要注意,V4.2.2开始,Dataway前后端不再处理任何编码,需要自己指定请求和响应的编码,否则可能出现中文乱码

运行测试

运行项目Application启动类,启动过程会出现Hasor boot的样式,说明集成成功了

启动成功后,打开你的本地地址:http://127.0.0.1:10086/config

一开始整个页面是空的,你可以点击new操作按钮来添加你的接口
整个页面的布局分为三大块空间:

  • 1号区域:API列表区
  • 2号区域:调用接口参数配置区,以及相关操作
  • 3号区域:响应结果区

【示例】新增一个查询接口,查询user表里name包含某个字的人
1号区域中写下SQL语句:select * from user where `name` like concat('%',#{userName},'%')

2号区域中写下查询条件示例:{"userName":"567"},点击执行后

3号区域中出现响应结果:

如果接口没问题,你进行保存后再执行冒烟测试(从数据库取出示例数据进行测试),通过后你就可以发布了,发布后会在interface_release表新增一条数据

注意:同一个接口可以被发布多次,只有发布过的接口才能被访问

这里为了演示简单,采用的是常用的sql语句方式,你可以使用功能更加强大的DataQL,编写语法稍微会复杂写。不过可以支持函数式方式编写任何你想要的逻辑,格式化你需要的返回数据。

使用Postman进行调用演示:

小结

hasor的一些设计思想还是值得我们借鉴的,特别是对轻业务逻辑的系统非常适用。还有与Spring如何集成的一些思想和方法也具有很高的参考的价值。

我们可以适当加以改造实现接口配置化,同时把加入权限功能,可以解放企业中非业务层重复劳动。特别是一些报表和查询类的系统,真的是非常灵活,可以解放好多的生产力。

推荐阅读:https://www.hasor.net/doc/

工程师工具箱系列(1)

hzqiuxm阅读(109)评论(0)

MapStruct简明教程

芸芸众生

在Java项目开发中,不管你是采用传统的MVC分层模式,还是DDD驱动的微服务模式,都免不了在各层级之间传递对象,在这个过程中会出现许多的对象概念性名词:VO,DTO,DO,Entity,ValueObj等等。我们先不管这些对象在你们各自项目里的作用,有一个共同的工作就是完成他们之间赋值转换。

靠手动赋值来完成对象转换的人毕竟已经很稀缺了,我们一般都知道借助一些工具去简化这部分重复劳动。

目前市面上用的比较常见的可能有下面这几种:

它们之间的性能对比大致如下:

结合性能和吞吐量来看,手动写性能肯定是最高的,省去中间商赚差价嘛,但是社会有分工才能进步,整体效能才能增加,所以我们应该借助工具。

综合分析下来,MapStruct的性能和吞吐量都是最好的,毕竟实现原理上决定了一切,接下来我们就上手下MapStruct。

初窥门径

mapstruct的使用和如何把大象放进冰箱的步骤是一样的:1 引入mapstruct;2 创建转换器与转换方法;3 获取转换实例进行使用

引入POM依赖

Maven

<dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>${org.mapstruct.version}</version>
</dependency>
...
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version>
            <configuration>
                <source>1.8</source>
                <target>1.8</target>
                <annotationProcessorPaths>
                    <path>
                        <groupId>org.mapstruct</groupId>
                        <artifactId>mapstruct-processor</artifactId>
                        <version>${org.mapstruct.version}</version>
                    </path>
                </annotationProcessorPaths>
            </configuration>
        </plugin>
    </plugins>
</build>

Gradle

plugins {
    ...
    id "com.diffplug.eclipse.apt" version "3.26.0" // Only for Eclipse
}
dependencies {
    ...
    compile 'org.mapstruct:mapstruct:1.4.2.Final'
    annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
    testAnnotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final' // if you are using mapstruct in test code
}

创建转换器与方法

创建之前你肯定已经明确了需要转换的两个类,比如下面的代码示例,是将Car对象转换成一个CarDto对象

@Mapper //指定该类为mapstruct的映射器
public interface CarMapper {
     // 通过ClassLoader加载
    CarMapper INSTANCE = Mappers.getMapper( CarMapper.class );
     //转换方法,自动匹配名称与类型相同的字段,不同的字段需要通过Mapping注解进行指定
     // 这里就指定了将Cat对象的numberOfSeats属性转换赋值到CatDto的seatCount属性
    @Mapping(target = "seatCount", source = "numberOfSeats")
    CarDto carToCarDto(Car car);
}

进行使用

使用的时候就特别简单了,直接获取转换器的实例,调用转换方法,传入对应的参数即可

CarDto carDto = UserMapper.INSTANCE.carToCarDto(car);

好像还蛮简单的,但是实际开发的时候可没怎么简单,实际的业务和不用的开发人员有不同的习惯,有时候面临的场景就会复杂起来:

  • 字段名称相同,但是类型不同怎么处理?mapstruct会帮我们自动转换吗,它怎么知道怎么转换?
  • 对象和字符串之间转换怎么处理?实际开发
  • 列表和列表之间转换怎么处理?难道我也循环遍历吗?
  • 灵活的自定义转换怎么处理?

另外喜欢偷懒的小伙伴可能还会有一个疑问:虽然说用起来只有三步,但是每次都要为两个转换对象创建一个转换器的话,那岂不是会有很多的转换器了?

这些问题都会游刃有余小节中得到解答,该小结中利用了面向对象设计方法,省去了编写大量转换器与方法工作,并利用java8新特性方便实现灵活的自定义转换。

IDEA好基友

为了更好使用mapstruct,如果你使用的是Intellij IDEA编辑器,那么建议你安装个插件,它可以为我们提供一些遍历操作。

安装时候直接在IDEA的插件市场上搜索mapstruct,安装重启即可,插件为我们提供了一下几个便捷操作:

  • 自动填充属性与枚举常量
    自动填充属性与枚举常量

  • 点击可以直达注解使用的声明字段

  • 可以查找使用过的地方

PS:插件地址:https://plugins.jetbrains.com/plugin/10036-mapstruct-support

游刃有余

示例说明

为了更好说明示例,我们定义两个需要转换的对象类,我把它们之间字段的区别也列了出来

  • 相同字段:指的是名词和类型都相同,工具会自动转换
  • 原始类特有:指的是原始类UserE所特有的,可能有3种情况:类型一致但是名称不一致,类型不一致名称也不一致,类型不一致名称一致
  • 目标类特有:指的是目标类UserVO所特有的,它同时也对应上面原始类的三种情况

避免编写重复转换器

要避免编写重复的转换器接口,类似我们要避免编写不同类型的字段进行某种相同计算一样。很自然的就想到使用泛型来解决。

我们可以定义一个基础接口,包含了通用的映射方法,只要是字段类型相同的对象需要转换,这个基础接口就满足了,通过继承基础接口,传入具体的转换类型,无需任何实现与配置。

这里我提供了三种通用转换方法:1 单对象的转换;2 列表对象的转换;3 Stream对象转换,因为每种类型存在互相转换,所以基础接口包含了6个方法

同时,你可以把项目中约定好的一些字段约束加到其中,比如创建日期的格式等等

实现复杂灵活转换

接下来就是解决上面表格中的3种情况,它们的解决方案分别如下:

首先定义个UserMapping接口,继承BaseMapping,传入转换的类型,注意你自己规定的SOURCE和TARGET参数,不要搞混就行

@Mapper(componentModel = "spring")//spring注入方式
public interface UserMapping extends BaseMapping<UserE,UserVO>{

重载接口的方法,比如现在我们把UserE转换成为UserVO,解决类型一致,名称不一致的Mapping示例

@Mappings({
            @Mapping(source = "etest", target = "vtest"),
            @Mapping(source = "sex", target = "gender"),
    })
    @Override
    UserVO sourceToTarget(UserE var1);

去掉@Mappings,直接把多个@Mapping加在方法上面作用是相同的

那怎么解决cteateTime名称一致,类型不一致呢?

从UserE到UserVO是把时间类型转换为String类型,这是一种很常见的转换常见,注意看我们在基础接口中定义了目标字段的cteateTime时间格式,这就给工具提供了自动转换的可能性,主要给出的格式符合这个要求,那么工具会自动帮助我们完成转换

/**
     * 映射同名属性
     */
    @Mapping(target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
    TARGET sourceToTarget(SOURCE var1);

最后看名称不一致,类型也不一致的字段,UserE中的字符串如何变成UserVO中的一个对象,首先容易想到的一点是我们可以通过@Mapping配置建立二者之间的转换关系,但是工具肯定不知道怎么转换了,所以还我们需要提供如何转换方法。

@Mappings({
            @Mapping(source = "etest", target = "vtest"),
            @Mapping(source = "sex", target = "gender"),
            @Mapping(source = "configE",target = "configs")
    })
    @Override
    UserVO sourceToTarget(UserE var1);

那么如何提供呢?假设我们已经写好一个转换方法,应该如何告知工具去选择使用?我相信你已经想到了,只要指定入参和出参类型,再结合mapping指定映射关系工具应该就能完成转换了。

于是我们再利用java8种接口可以使用默认方法的特性,我们直接在接口里增加

/**
     * 映射string config 到 List<UserVO.UserConfig> list的转换
     * 会被自动调用
     */
    default List<UserVO.UserConfig> strConfigToListUserConfig(String config) {
        return JSONUtil.toList(config,UserVO.UserConfig.class);
    }

这两步加起来就构成完成了类型不一致,名称不一致属性之间的转换

但是其实还是存在一个问题,如果存在多个指定转换关系,入参和出参也一致的情况,那工具就不知道具体采用哪个默认方法了。所以我们还需要知道如何完全自定义转换。
自定义一个转换类

public class CustmMapping {

    public static String convertFiled1(UserVO.UserConfig userConfig){

        return "自定义" + userConfig.getField1();
    }
}

在接口类中导入转换类(1处),在@Mapping中指定目标字段的转换类函数(2处)

@Mapper(componentModel = "spring",imports = CustmMapping.class)//1处
public interface UserMapping extends BaseMapping<UserE,UserVO>{
...
@Mapping(target = "sex", source = "gender")
@Mapping(target = "password", ignore = true)
@Mapping(target = "etest", source = "vtest") 
@Mapping(target="configE",expression="java(CustmMapping.convertFiled1(var1.getConfigs().get(0)))")//2处
@Mapping(target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Override
UserE targetToSource(UserVO var1);
...

对应的测试代码:

@Log4j2
@DisplayName("使用MapStruct进行对象赋值转换")
public class MapStructTest {
    private static UserE userE;
    private static UserVO newUserVO;
    private static UserMapping userMapping;

    @BeforeAll
    public static void init() {
        userE = new UserE()
                .setId(100L)
                .setBirthday(LocalDate.of(1988,02,25))
                .setUsername("临江仙")
                .setCreateTime(LocalDateTime.now())
                .setSex(1)
                .setEtest(Arrays.asList("a","b","c"))
                .setConfigE("[{\"field1\":\"Test Field1\",\"field2\":500}]");

        userMapping = Mappers.getMapper(UserMapping.class);
        List<UserVO.UserConfig> userConfigs = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            UserVO.UserConfig userConfig = new UserVO.UserConfig("字段"+i, i+10);
            userConfigs.add(userConfig);
        }
        newUserVO = new UserVO()
                .setId(200L)
                .setUsername("鹊桥仙")
                .setPassword("123321")
                .setBirthday(LocalDate.of(1988,07,06))
                .setCreateTime("1988-02-25 12:00:00")
                .setGender(2)
                .setVtest(Arrays.asList("备注1","备注2","备注3"))
                .setConfigs(userConfigs);
        log.info("@BeforeAll: init()");
    }
    @DisplayName("准备好UserE和UserVO")
    @Test
    public void testHasUserEandUserVO(){
        System.out.println("准备好的userE:" + userE);
        System.out.println("准备好的newUserVO:" + newUserVO);;
    }
    @DisplayName("将UserE转换成UserVO")
    @Test
    public void testEtoVO() {
        UserVO userVO = userMapping.sourceToTarget(userE);
        System.out.println("转化后得到的userVO: " + userVO);
    }
    @DisplayName("将UserVO转换成UserE")
    @Test
    public void testVOtoE(){
        UserE userE = userMapping.targetToSource(newUserVO);
        System.out.println("转化后得到的userE:" + userE);
    }
}

测试结果:

转化后得到的userVO: UserVO(id=100, username=临江仙, password=null, gender=1, birthday=1988-02-25, createTime=2021年6月1号, vtest=[a, b, c], configs=[UserVO.UserConfig(field1=Test Field1, field2=500)])


转化后得到的userE:UserE(id=200, username=鹊桥仙, password=null, sex=2, birthday=1988-07-06, createTime=1988-02-25T12:00, etest=[备注1, 备注2, 备注3], configE=自定义字段0)

准备好的userE:UserE(id=100, username=临江仙, password=null, sex=1, birthday=1988-02-25, createTime=2021-06-03T13:22:42.203, etest=[a, b, c], configE=[{"field1":"Test Field1","field2":500}])
准备好的newUserVO:UserVO(id=200, username=鹊桥仙, password=123321, gender=2, birthday=1988-07-06, createTime=1988-02-25 12:00:00, vtest=[备注1, 备注2, 备注3], configs=[UserVO.UserConfig(field1=字段0, field2=10), UserVO.UserConfig(field1=字段1, field2=11), UserVO.UserConfig(field1=字段2, field2=12), UserVO.UserConfig(field1=字段3, field2=13), UserVO.UserConfig(field1=字段4, field2=14)])

温故知新

最后我们对mapstruct工具做个小结:

  • 核心特点 :基于 JSR 269 的 Java 注解处理器实现,用纯java方法而不是反射进行属性赋值,做到了编译时类型安全,相当于编译时的代码生成器。

  • 性能更高:使用简单的Java方法调用代替反射,无需手动 set/get 或 implements Serializable 以达到深拷贝
  • 编译时类型安全:只能映射相同名称或带映射标记的属性,编译时如果映射不完整(存在未被映射的目标属性)或映射不正确(找不到合适的映射方法或类型转换)则会在编译时抛出异常

使用技巧

  • 技巧一:定义一个公共的转换器接口,使用泛型定义好常用的方法,如果字段完全一样公共接口就满足要求了
  • 技巧二:同类型不同名称的转换直接使用Mapping在转换方法上指定
  • 技巧三:不同类型同名称的,可以使用Mapping也可以使用default方法的方式
  • 技巧四:不同类型不同名称,可以使用Mapping+default方式或自定义转换类方式

运用这些技巧你还可以实现多个bean之间映射,复杂数据结构之间映射等,充分满足多种业务场景下使用。

PS:文中源码是示例地址:https://gitee.com/hzqiuxm/middleware-projects.git [java-base模块]-[mapstruct包]

SpringDataJPA系列(6)

hzqiuxm阅读(294)评论(0)

6 Entiry注解使用

JPA协议规定

  • 实体是直接进行数据库持久化操作的领域对象,必须通过 @Entity 注解进行标示
  • 实体必须有一个 public 或者 protected 的无参数构造方法
  • 实体里面必须要有一个主键,主键标示的字段可以是单个字段,也可以是复合主键字段
  • 持久化映射的注解可以标示在 Entity 的字段 field 上,也可以将持久化注解运用在 Entity 里面的 get/set 方法上
//字段上
@Column(length = 20, nullable = false)
private String userName;
//get/set上
@Column(length = 20, nullable = false)
public String getUserName(){
    return userName;
}

详细的协议地址:https://download.oracle.com/otn-pub/jcp/persistence-2_2-mrel-spec/JavaPersistence.pdf

Entiry注解

有哪些Entity注解,可以打开@Entity注解所在的包一窥究竟:

差不多有100多个注解......
这里只提及一些最常见的,包括 @Entity、@Table、@Access、@Id、@GeneratedValue、@Enumerated、@Basic、@Column、@Transient、@Lob、@Temporal 等。

  • @Entity:定义对象将会成为被 JPA 管理的实体,必填,将字段映射到指定的数据库表中,使用起来很简单,直接用在实体类上面即可
  • @Table:指定数据库的表名,表示此实体对应的数据库里面的表名,非必填,默认表名和 entity 名字一样
  • @Access:指定 entity 里面的注解是写在字段上面,还是 get/set 方法上面生效,非必填。当实体里面的第一个注解出现在字段上或者 get/set 方法上面,就以第一次出现的方式为准
  • @Id:定义属性为数据库的主键,一个实体里面必须有一个主键,但不一定是这个注解,可以和 @GeneratedValue 配合使用或成对出现
  • @GeneratedValue:主键生成策略,共有四个值

  • @Enumerated:这个注解很好用,因为它对 enum 提供了下标和 name 两种方式,用法直接映射在 enum 枚举类型的字段上

//有一个枚举类,用户的性别
public enum Gender {
    MAIL("男性"), FMAIL("女性");
    private String value;
    private Gender(String value) {
        this.value = value;
    }
}
//实体类@Enumerated的写法如下
@Entity
@Table(name = "tb_user")
public class User implements Serializable {
    @Enumerated(EnumType.STRING)
    @Column(name = "user_gender")
    private Gender gender;
    .......................
}

这时候插入两条数据,数据库里面的值会变成 MAIL/FMAIL,而不是“男性” / 女性。

  • @Basic:表示属性是到数据库表的字段的映射。如果实体的字段上没有任何注解,默认即为 @Basic。也就是说默认所有的字段肯定是和数据库进行映射的,并且默认为 Eager 类型
  • @Transient:该属性并非一个到数据库表的字段的映射,表示非持久化属性
  • @Column:定义该属性对应数据库中的列名
  • @Temporal:设置 Date 类型的属性映射到对应精度的字段(日期、时间、日期时间)

注解生成技巧

生成的结果示例如下:

联合主键

@IdClass 做联合主键

可以通过 javax.persistence.EmbeddedId 和 javax.persistence.IdClass 两个注解实现联合主键的效果。
第一步:新建一个 UserInfoID 类里面是联合主键。

public class UserInfoID implements Serializable {
   private String name,telephone;
}

第二步:再新建一个 UserInfo 的实体,采用 @IdClass 引用联合主键类。

@IdClass(UserInfoID.class)
public class UserInfo {
   private Integer ages;
   @Id
   private String name;
   @Id
   private String telephone;
}

使用示例:

 userInfoRepository.save(UserInfo.builder().ages(1).name("jack").telephone("123456789").build());
Optional<UserInfo> userInfo = userInfoRepository.findById(UserInfoID.builder().name("jack").telephone("123456789").build());

资源库仍然按照标准DQM方式进行名称查询,实际上表的主键是 primary key (name, telephone),而 Entity 里面不再是一个 @Id 字段了。

@Embeddable 和@EmbedId

第一步:在我们上面例子中的 UserInfoID 里面添加 @Embeddable 注解。

@Embeddable
public class UserInfoID implements Serializable {
   private String name,telephone;
}

第二步:改一下我们刚才的 User 对象,删除 @IdClass,添加 @EmbeddedId 注解:

public class UserInfo {
   private Integer ages;
   @EmbeddedId
   private UserInfoID userInfoID;
   @Column(unique = true)
   private String uniqueNumber;
}

使用情况和上面@IdClass 的类似,那么 @IdClass 和 @EmbeddedId 的区别是什么?在使用的时候,Embedded 用的是对象,而 IdClass 用的是具体的某一个字段,二者的JPQL 也会不一样。

继承关系的实现

在 Java 面向对象的语言环境中,@Entity 之间的关系多种多样,而根据 JPA 的规范,我们大致可以将其分为以下几种:

  • 纯粹的继承,和表没关系,对象之间的字段共享。利用注解 @MappedSuperclass,协议规定父类不能是 @Entity

  • 单表多态问题,同一张 Table,表示了不同的对象,通过一个字段来进行区分。利用@Inheritance(strategy = InheritanceType.SINGLE_TABLE)注解完成,只有父类有 @Table

  • 多表多态,每一个子类一张表,父类的表拥有所有公用字段。通过@Inheritance(strategy = InheritanceType.JOINED)注解完成,父类和子类都是表,有公用的字段在父表里面

  • Object 的继承,数据库里面每一张表是分开的,相互独立不受影响。通过@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)注解完成,父类(可以是一张表,也可以不是)和子类都是表,相互之间没有关系。

@Inheritance 的这种使用方式会逐渐被淘汰,因为这样的表的设计很复杂,本应该在业务层面做的事情(多态),而在 datasoure 的表级别做了。所以在 JPA 中使用这个的时候你就会想:“这么复杂的东西,我直接用 Mybatis 算了。”其实它们是一样的,只是我们使用的思路不对。

我个人建议第一种情况,项目中会经常碰到,其它三种除非是老项目中维护需要,不建议如此使用了

SpringDataJPA系列(5)

hzqiuxm阅读(488)评论(0)

@Query应该怎么用?

之前说到过,DMQ查询策略有两种:方法命令和@Query注解的方式。为什么需要两种呢?它们分别适用的场景是怎么样的?

@Query使用

定义一个通过名字查询用户的方法

以下是测试方法:


QueryLookupStrategy 实现原理

我们可以通过QueryExecutorMethodInterceptor类来进行跟踪和分析,它是查询方法的拦截器,我们在lookupQuery()方法中打个断点。
可以看到显示默认的策略是CreateIfNotFound,也就是如果有@Query注解,那么以@Query的注解内容为准,可以忽略方法名方式。

我们可以看到strategy.resolveQuery采用了策略模式,它有三种实现策略:

我们可以看到在解析查询的时候,还有个容错机制,出错后还会采用一次方法名个识别方式进行sql语句的拼接

那么接着进入到 llookupStratrgy.resolveQuery 方法里面,我们可以看到图中 ①处,如果 Query 注解找到了,就不会走到 ② 处了。

这时我们点开 Query 里面的 Query 属性的值看一下,你会发现这里同时生成了两个 SQL:一个是查询总数的 Query 定义,另一个是查询结果 Query 定义。

到这里我们已经基本明白了,如果想看看 Query 具体是怎么生成的、上面的 @Param 注解是怎么生效的,可以在上面的图 ① 处 debug 继续往里面看

PS:这里要注意,在Spring启动过的时候,JPA会对资源库的每个方法都进行扫描,然后进行具体查询器RepositoryQuery的选择。

下图是关于RepositoryQuery接口相关类图:

@Query用法和语法

基本语法

@Query 用法是使用 JPQL 为实体创建声明式查询方法。我们一般只需要关心 @Query 里面的 value 和 nativeQuery、countQuery 的值即可,因为其他的不常用。

  • value:JPQL表达式
  • nativeQuery:JPQL是否是原生的Sql语句
  • countQuery :指定count的JPQL语句,如果不指定将根据query自动生成

使用声明式 JPQL 查询有个好处,就是启动的时候就知道你的语法正确不正确。它的语法结构有点类似我们 SQL:

//查询
SELECT ... FROM ...
[WHERE ...]
[GROUP BY ... [HAVING ...]]
[ORDER BY ...]
//删除
DELETE FROM ... [WHERE ...]
//更新
UPDATE ... SET ... [WHERE ...]

你会发现它的语法结构有点类似我们 SQL,唯一的区别就是 JPQL FROM 后面跟的是对象,而 SQL 里面的字段对应的是对象里面的属性字段

其中“...”省略的部分是实体对象名字和实体对象里面的字段名字,而其中类似 SQL 一样包含的语法关键字有:

SELECT FROM WHERE UPDATE DELETE JOIN OUTER INNER LEFT GROUP BY HAVING FETCH DISTINCT OBJECT NULL TRUE FALSE NOT AND OR BETWEEN LIKE IN AS UNKNOWN EMPTY MEMBER OF IS AVG MAX MIN SUM COUNT ORDER BY ASC DESC MOD UPPER LOWER TRIM POSITION CHARACTER_LENGTH CHAR_LENGTH BIT_LENGTH CURRENT_TIME CURRENT_DATE CURRENT_TIMESTAMP NEW EXISTS ALL ANY SOME

用法案例

  • 单条件查询
  @Query("select u from User u where u.emailAddress = ?1")
  User findByEmailAddress(String emailAddress);
  • LIKE查询
  @Query("select u from User u where u.firstname like %?1")
  List<User> findByFirstnameEndsWith(String firstname);
  • 原始sql查询,nativeQuery = true 即可,注意nativeQuery 不支持直接 Sort 的参数查询
  @Query(value = "SELECT * FROM USERS WHERE EMAIL_ADDRESS = ?1", nativeQuery = true)
  User findByEmailAddress(String emailAddress);
  • nativeQuery 排序的正确写法
@Query(value = "select * from user_info where first_name=?1 order by ?2",nativeQuery = true)
List<UserInfoEntity> findByFirstName(String firstName,String sort);
//调用的地方写法last_name是数据里面的字段名,不是对象的字段名
repository.findByFirstName("jackzhang","last_name");
  • JPQL排序
  @Query("select u from User u where u.lastname like ?1%")
  List<User> findByAndSort(String lastname, Sort sort);
  @Query("select u.id, LENGTH(u.firstname) as fn_len from User u where u.lastname like ?1%")
  List<Object[]> findByAsArrayAndSort(String lastname, Sort sort);

//调用方的写法,如下:
repo.findByAndSort("lannister", new Sort("firstname"));
repo.findByAndSort("stark", new Sort("LENGTH(firstname)"));
repo.findByAndSort("targaryen", JpaSort.unsafe("LENGTH(firstname)"));
repo.findByAsArrayAndSort("bolton", new Sort("fn_len"));
  • JQPl 的排序
  @Query(value = "select u from User u where u.lastname = ?1")
  Page<User> findByLastname(String lastname, Pageable pageable);
//调用者的写法
repository.findByFirstName("jackzhang",new PageRequest(1,10));
  • nativeQuery 的排序
   @Query(value = "select * from user_info where first_name=?1 /* #pageable# */",
         countQuery = "select count(*) from user_info where first_name=?1",
         nativeQuery = true)
   Page<UserInfoEntity> findByFirstName(String firstName, Pageable pageable);
}
//调用者的写法
return userRepository.findByFirstName("jackzhang",new PageRequest(1,10, Sort.Direction.DESC,"last_name"));
//打印出来的sql
select  *   from  user_info  where  first_name=? /* #pageable# */  order by  last_name desc limit ?, ?

这里需要注意:这个注释 / #pageable# / 必须有。

  • 根据 firstname 和 lastname 参数查询 user 对象
  @Query("select u from User u where u.firstname = :firstname or u.lastname = :lastname")
  User findByLastnameOrFirstname(@Param("lastname") String lastname,  @Param("firstname") String firstname);
  • 根据 firstname 和 lastname 参数查询 user 对象,并带上限制返回
  @Query("select u from User u where u.firstname = :firstname or u.lastname = :lastname")
  User findTop10ByLastnameOrFirstname(@Param("lastname") String lastname, @Param("firstname") String firstname);

@Param 注解指定方法参数的具体名称,通过绑定的参数名字指定查询条件,这样不需要关心参数的顺序。比较推荐这种做法,因为它比较利于代码重构

@Query最佳实践

使用场景:映射返回指定过的DTO
新增一个实体表

原来的用户表

当我们需要查询用户的名称、部队、主帅技能时应该如何操作?

  • 小白写法,查询获得的对象后再塞到DTO中
   @Query("select u.name,u.email,e.idCard from User u,UserExtend e where u.id= e.userId and u.id=:id")
   List<Object[]> findByUserId(@Param("id") Long id);
  • 进阶写法:定义个返回dto,@Query中构建返回dto直接返回

查询方法的实现,注意红色标注部分是实现关键

下面是测试代码:

注意:我们在构建返回的时候还可以使用CONCAT 的关键字做了一个字符串拼接,这对一些统一的返回处理还是有好处的,但不建议太复杂的计算。

我们可以在ParameterizedFunctionExpression 类中看到支持的关键字

  • 高阶写法:定义一个返回接口,@Query中构建返回dto直接返回

@Query的查询写法如下:

测试方法如下:

比起 DTO 我们不需要 new 了,并且接口只能读,那么我们返回的结果 DTO 的职责就更单一了,只用来查询。接口的方式是比较推荐的做法,因为它是只读的,对构造方法没有要求,返回的实际是 HashMap。

@Query动态查询

通过上面的实例可以看得出来,我们采用了 :email isnullor s.email = :email 这种方式来实现动态查询的效果,实际工作中也可以演变得很复杂。

总结

  • 能用方法名表示的,尽量用方法名表示,因为这样语义清晰、简单快速,基本上只要编译通过,一定不会有问题
  • 能用 @Query 里面的 JPQL 表示的,就用 JPQL,这样与 SQL 无关,万一哪天换数据库了,基本上代码不用改变
  • 最后实在没有办法了,可以选择 nativeQuery 写原始 SQL,特别是一开始从 MyBatis 转过来的同学,选择写 SQL 会更容易一些

欢迎加入极客江湖

进入江湖关于作者