开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

如何优雅地在 Spring Boot 中使用自定义注解,AO

发表于 2019-04-29

欢迎关注个人微信公众号: 小哈学Java, 文末分享《Java 核心知识整理&面试.pdf》资源链接!!

个人网站: www.exception.site/springboot/…

其实,小哈在之前就出过一篇关于如何使用 AOP 切面统一打印请求日志的文章,那为什么还要再出一篇呢?没东西写了?

哈哈,当然不是!原因是当时的实现方案还是存在缺陷的,原因如下:

  1. 不够灵活,由于是以所有 Controller 方法中的方法为切面,也就是说切死了,如果说我们不想让某个接口打印出入参日志,就办不到了;
  2. Controller 包层级过深时,导致很多包下的接口切不到;

今天主要说说如何通过自定义注解的方式,在 Spring Boot 中来实现 AOP 切面统一打印出入参日志。小伙伴们可以收藏一波。

废话不多说,进入正题 !

目录

一、先看看切面日志输出效果

二、添加 AOP Maven 依赖

三、自定义日志注解

四、配置 AOP 切面

五、怎么使用呢?

六、对于文件上传好使不?

七、只想在开发环境和测试环境中使用?

八、多切面如何指定优先级?

一、先看看切面日志输出效果

在看看实现方法之前,我们先看下切面日志输出效果咋样:

Spring boot 自定义注解,aop切面统一打印请求日志效果图

Spring boot 自定义注解,aop切面统一打印请求日志效果图

从上图中可以看到,每个对于每个请求,开始与结束一目了然,并且打印了以下参数:

  • URL: 请求接口地址;
  • Description: 接口的中文说明信息;
  • HTTP Method: 请求的方法,是 POST, GET, 还是 DELETE 等;
  • Class Method: 被请求的方法路径 : 包名 + 方法名;
  • IP: 请求方的 IP 地址;
  • Request Args: 请求入参,以 JSON 格式输出;
  • Response Args: 响应出参,以 JSON 格式输出;
  • Time-Consuming: 请求耗时,以此估算每个接口的性能指数;

怎么样?看上去效果还不错呢?接下来看看,我们要如何一步一步实现它呢?

二、添加 AOP Maven 依赖

在项目 pom.xml 文件中添加依赖:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码<!-- aop 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<!-- 用于日志切面中,以 json 格式打印出入参 -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.5</version>
</dependency>

三、自定义日志注解

让我们来自定义一个日志注解,如下所示:

自定义注解

自定义注解

  • ①:什么时候使用该注解,我们定义为运行时;
  • ②:注解用于什么地方,我们定义为作用于方法上;
  • ③:注解是否将包含在 JavaDoc 中;
  • ④:注解名为 WebLog;
  • ⑤:定义一个属性,默认为空字符串;

源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码package site.exception.springbootaopwebrequest.aspect;

import java.lang.annotation.*;

/**
* @author 犬小哈 (微信号:小哈学Java)
* @site www.exception.site
* @date 2019/2/12
* @time 下午9:19
* @discription
**/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface WebLog {
/**
* 日志描述信息
*
* @return
*/
String description() default "";

}

到这里,一个完整的自定义注解就定义完成了。

四、配置 AOP 切面

在配置 AOP 切面之前,我们需要了解下 aspectj 相关注解的作用:

  • @Aspect:声明该类为一个注解类;
  • @Pointcut:定义一个切点,后面跟随一个表达式,表达式可以定义为切某个注解,也可以切某个 package 下的方法;

切点定义好后,就是围绕这个切点做文章了:

  • @Before: 在切点之前,织入相关代码;
  • @After: 在切点之后,织入相关代码;
  • @AfterReturning: 在切点返回内容后,织入相关代码,一般用于对返回值做些加工处理的场景;
  • @AfterThrowing: 用来处理当织入的代码抛出异常后的逻辑处理;
  • @Around: 环绕,可以在切入点前后织入代码,并且可以自由的控制何时执行切点;

注解执行顺序

注解执行顺序

接下来,定义一个 WebLogAspect.java 切面类,声明一个切点:

定义一个切点

定义一个切点

然后,定义 @Around 环绕,用于何时执行切点:

环绕

环绕

  • ①:记录一下调用接口的开始时间;
  • ②:执行切点,执行切点后,会去依次调用 @Before -> 接口逻辑代码 -> @After -> @AfterReturning;
  • ③:打印出参;
  • ④:打印接口处理耗时;
  • ⑤:返回接口返参结果;

再来看看 @Before 方法:

@Before

@Before

看注释功能说明,因为注释说得还是比较清楚的!

最后,用 @After 来做个收尾:

换行

换行

@After

@After

我们在每个接口的最后,打印日志结束标志。最后再看下项目包结构:

项目包结构

项目包结构

到这里,切面相关的代码就完成了!

上完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
复制代码package site.exception.springbootaopwebrequest.aspect;

import com.google.gson.Gson;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;

/**
* @author 犬小哈 (微信号:小哈学Java)
* @site www.exception.site
* @date 2019/2/12
* @time 下午9:19
* @discription
**/
@Aspect
@Component
@Profile({"dev", "test"})
public class WebLogAspect {

private final static Logger logger = LoggerFactory.getLogger(WebLogAspect.class);
/** 换行符 */
private static final String LINE_SEPARATOR = System.lineSeparator();

/** 以自定义 @WebLog 注解为切点 */
@Pointcut("@annotation(site.exception.springbootaopwebrequest.aspect.WebLog)")
public void webLog() {}

/**
* 在切点之前织入
* @param joinPoint
* @throws Throwable
*/
@Before("webLog()")
public void doBefore(JoinPoint joinPoint) throws Throwable {
// 开始打印请求日志
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();

// 获取 @WebLog 注解的描述信息
String methodDescription = getAspectLogDescription(joinPoint);

// 打印请求相关参数
logger.info("========================================== Start ==========================================");
// 打印请求 url
logger.info("URL : {}", request.getRequestURL().toString());
// 打印描述信息
logger.info("Description : {}", methodDescription);
// 打印 Http method
logger.info("HTTP Method : {}", request.getMethod());
// 打印调用 controller 的全路径以及执行方法
logger.info("Class Method : {}.{}", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName());
// 打印请求的 IP
logger.info("IP : {}", request.getRemoteAddr());
// 打印请求入参
logger.info("Request Args : {}", new Gson().toJson(joinPoint.getArgs()));
}

/**
* 在切点之后织入
* @throws Throwable
*/
@After("webLog()")
public void doAfter() throws Throwable {
// 接口结束后换行,方便分割查看
logger.info("=========================================== End ===========================================" + LINE_SEPARATOR);
}

/**
* 环绕
* @param proceedingJoinPoint
* @return
* @throws Throwable
*/
@Around("webLog()")
public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
Object result = proceedingJoinPoint.proceed();
// 打印出参
logger.info("Response Args : {}", new Gson().toJson(result));
// 执行耗时
logger.info("Time-Consuming : {} ms", System.currentTimeMillis() - startTime);
return result;
}


/**
* 获取切面注解的描述
*
* @param joinPoint 切点
* @return 描述信息
* @throws Exception
*/
public String getAspectLogDescription(JoinPoint joinPoint)
throws Exception {
String targetName = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
Object[] arguments = joinPoint.getArgs();
Class targetClass = Class.forName(targetName);
Method[] methods = targetClass.getMethods();
StringBuilder description = new StringBuilder("");
for (Method method : methods) {
if (method.getName().equals(methodName)) {
Class[] clazzs = method.getParameterTypes();
if (clazzs.length == arguments.length) {
description.append(method.getAnnotation(WebLog.class).description());
break;
}
}
}
return description.toString();
}

}

五、怎么使用呢?

因为我们的切点是自定义注解 @WebLog, 所以我们仅仅需要在 Controller 控制器的每个接口方法添加 @WebLog 注解即可,如果我们不想某个接口打印出入参日志,不加注解就可以了:

用户登录接口

用户登录接口

六、对于文件上传好使不?

是好使的!不论是单文件上传,抑或是多文件上传,切面日志均运行良好,这里测试的东西,小哈就不贴出来了。有兴趣的小伙伴可以试试!

七、只想在开发环境和测试环境中使用?

对于那些性能要求较高的应用,不想在生产环境中打印日志,只想在开发环境或者测试环境中使用,要怎么做呢?我们只需为切面添加 @Profile 就可以了,如下图所示:

指定profile

指定profile

这样就指定了只能作用于 dev 开发环境和 test 测试环境,生产环境 prod 是不生效的!

八、多切面如何指定优先级?

假设说我们的服务中不止定义了一个切面,比如说我们针对 Web 层的接口,不止要打印日志,还要校验 token 等。要如何指定切面的优先级呢?也就是如何指定切面的执行顺序?

我们可以通过 @Order(i)注解来指定优先级,注意:i 值越小,优先级则越高。

假设说我们定义上面这个日志切面的优先级为 @Order(10), 然后我们还有个校验 token 的切面 CheckTokenAspect.java,我们定义为了 @Order(11), 那么它们之间的执行顺序如下:

多切点优先级

多切点优先级

我们可以总结一下:

  • 在切点之前,@Order 从小到大被执行,也就是说越小的优先级越高;
  • 在切点之后,@Order 从大到小被执行,也就是说越大的优先级越高;

九、Ref

blog.didispace.com/springboota…

十、GitHub 源码地址

github.com/weiwosuoai/…

赠送 | 面试&学习福利资源

最近在网上发现一个不错的 PDF 资源《Java 核心知识&面试.pdf》分享给大家,不光是面试,学习,你都值得拥有!!!

获取方式: 关注公众号: 小哈学Java, 后台回复 资源,既可免费无套路获取资源链接,下面是目录以及部分截图:

关注微信公众号【小哈学Java】,回复【资源】,即可免费无套路领取资源链接哦

关注微信公众号【小哈学Java】,回复【资源】,即可免费无套路领取资源链接哦

关注微信公众号【小哈学Java】,回复【资源】,即可免费无套路领取资源链接哦

关注微信公众号【小哈学Java】,回复【资源】,即可免费无套路领取资源链接哦

关注微信公众号【小哈学Java】,回复【资源】,即可免费无套路领取资源链接哦

关注微信公众号【小哈学Java】,回复【资源】,即可免费无套路领取资源链接哦

关注微信公众号【小哈学Java】,回复【资源】,即可免费无套路领取资源链接哦

关注微信公众号【小哈学Java】,回复【资源】,即可免费无套路领取资源链接哦

关注微信公众号【小哈学Java】,回复【资源】,即可免费无套路领取资源链接哦

关注微信公众号【小哈学Java】,回复【资源】,即可免费无套路领取资源链接哦

关注微信公众号【小哈学Java】,回复【资源】,即可免费无套路领取资源链接哦

关注微信公众号【小哈学Java】,回复【资源】,即可免费无套路领取资源链接哦

重要的事情说两遍,关注公众号: 小哈学Java, 后台回复 资源,既可免费无套路获取资源链接 !!!

欢迎关注微信公众号: 小哈学Java

小哈学Java,关注领取10G面试学习资料哦

小哈学Java,关注领取10G面试学习资料哦

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

json web token 实践登录以及校验码验证

发表于 2019-04-27

去年我写了一篇介绍 jwt 的文章。

文章指出如果没有特别的用户注销及单用户多设备登录的需求,可以使用 jwt,而 jwt 的最大的特征就是无状态,且不加密。

除了用户登录方面外,还可以使用 jwt 验证邮箱验证码,其实也可以验证手机验证码,但是鉴于我囊中羞涩,只能验证邮箱了。

另外,我已在我的试验田进行了实践,不过目前前端代码写的比较简陋,甚至没有失败的回馈提示。至于为什么前端写的简陋,完全是因为前端的代码量相比后端来讲实在过于庞大…

另外,如果你熟悉 graphql,也可以在本项目的 graphql-playground 中查看效果。

本文地址 shanyue.tech/post/jwt-an…

发送验证码

校验之前,需要配合一个随机数供邮箱和短信发送。使用以下代码片段生成一个六位数字的随机码,你也可以把它包装为一个函数

1
复制代码const verifyCode = Array.from(Array(6), () => parseInt((Math.random() * 10))).join('')

如果使用传统有状态的解决方案,此时需要在服务端维护一个用户邮箱及随机码的键值对,而使用 jwt 也需要给前端返回一个 token,随后用来校验验证码。

我们知道 jwt 只会校验数据的完整性,而不对数据加密。此时当拿用户邮箱及校验码配对时,但是如果都放到 payload 中,而 jwt 使用明文传输数据,校验码会被泄露

1
2
复制代码// 放到明文中,校验码泄露
jwt.sign({ email, verifyCode }, config.jwtSecret, { expiresIn: '30m' })

那如何保证校验码不被泄露,而且能够正确校验数据呢

我们知道 secret 是不会被泄露的,此时把校验码放到 secret 中,完成配对

1
2
复制代码// 再给个半小时的过期时间
const token = jwt.sign({ email }, config.jwtSecret + verifyCode, { expiresIn: '30m' })

在服务端发送邮件的同时,把 token 再传递给前端,随注册时再发送到后端进行验证,这是我项目中关于校验的 graphql 的代码。如果你不懂 graphql 也可以把它当做伪代码,大致应该都可以看的懂

1
2
3
4
5
6
7
复制代码type Mutation {
# 发送邮件
# 返回一个 token,注册时需要携带 token,用以校验验证码
sendEmailVerifyCode (
email: String! @constraint(format: "email")
): String!
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码const Mutation = {
async sendEmailVerifyCode (root, { email }, { email: emailService }) {
// 生成六个随机数
const verifyCode = Array.from(Array(6), () => parseInt((Math.random() * 10))).join('')
// TODO 可以放到消息队列里,但是没有多少量,而且本 Mutation 还有限流,其实目前没啥必要...
// 与打点一样,不关注结果
emailService.send({
to: email,
subject: '【诗词弦歌】账号安全——邮箱验证',
html: `您正在进行邮箱验证,本次请求的验证码为:<span style="color:#337ab7">${verifyCode}</span>(为了保证您帐号的安全性,请在30分钟内完成验证)\n\n诗词弦歌团队`
})
return jwt.sign({ email }, config.jwtSecret + verifyCode, { expiresIn: '30m' })
}
}

题外话,发送邮件也有几个问题需要思考一下,不过这里先不管它了,以后实现了再写篇文章总结一下

  1. 如果邮件由服务提供,如何考虑异步服务和同步服务
  2. 消息队列处理,发邮件不要求可靠性,更像是 UDP
  3. 为了避免用户短时间内大量邮件发送,如何实现限流 (RateLimit)

题外题外话,一般发送邮件或者手机短信之前需要一个图片校验码来进行用户真实性校验和限流。而图片校验码也可以通过 jwt 进行实现

注册

注册就简单很多了,对客户端传入的数据进行邮箱检验,校验成功后直接入库就可以了,以下是 graphql 的代码

1
2
3
4
5
6
7
8
9
10
11
复制代码type Mutation {
# 注册
createUser (
name: String!
password: String!
email: String! @constraint(format: "email")
verifyCode: String!
# 发送邮件传给客户端的 token
token: String!
): User!
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码const Mutation = {
async createUser (root, { name, password, email, verifyCode, token }, { models }) {
const { email: verifyEmail } = jwt.verify(token, config.jwtSecret + verifyCode)
if (email !== verifyEmail) {
throw new Error('请输入正确的邮箱')
}
const user = await models.users.create({
name,
email,
// 入库时密码做了加盐处理
password: hash(password)
})
return user
}
}

这里有一个细节,对入库的密码使用 MD5 与一个参数 salt 做了不可逆处理

1
2
3
复制代码function hash (str) {
return crypto.createHash('md5').update(`${str}-${config.salt}`, 'utf8').digest('hex')
}

题外话,salt 是否可以与 JWT 的 secret 设置为同一字符串?

再题外话,这里的输入正确邮箱的 Error 明显不应该发送至 Sentry (报警系统),而有的 Error 的信息可以直接显示在前端,如何对 Error 进行规范与分类

校验码由传统方法实现与 jwt 比较

如果使用传统方法,只需要一个 key/value 数据库,维护手机号/邮箱与检验码的对应关系即可实现,相比 jwt 而言要简单很多。

登录

一个用 jwt 实现登录的 graphql 代码,把 user_id 与 user_role 置于 payload 中

1
2
3
4
5
6
7
复制代码type Mutation {
# 登录,如果返回 null,则登录失败
createUserToken (
email: String! @constraint(format: "email")
password: String!
): String
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码const Mutation = {
async createUserToken (root, { email, password }, { models }) {
const user = await models.users.findOne({
where: {
email,
password: hash(password)
},
attributes: ['id', 'role'],
raw: true
})
if (!user) {
// 返回空代表用户登录失败
return
}
return jwt.sign(user, config.jwtSecret, { expiresIn: '1d' })
}
}

关注公众号山月行,记录我的技术成长,欢迎交流

欢迎关注公众号山月行,记录我的技术成长,欢迎交流

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试官:为什么Mysql innoDB是两段式提交?

发表于 2019-04-27

Mysql的日志模块尤为重要,平日的crash-safe和主从都依赖我们的日志模块。

Mysql innoDB日志

Mysql innoDB有两个日志模块:redolog 和 binlog

咱们先看一下redolog。

redolog中文来讲就是重做日志,它有什么用呢?如果每次你的更新或者插入都写入磁盘的话那这个IO成本就比较大了,所以InnoDB就把记录先记录在redolog中,并同时更新到内存中,这样就完成了一次更新或插入了!

而且redolog是循环写的,也就是有固定大小的,当快写满的时候mysql就会把把一些记录更新到磁盘中,然后清除更新的那些redolog,给之后的记录腾出空间。

binlog也就是归档日志,它又是什么用呢?顾名思义它的主要作用就是归档(还有主从)!有三种模式:

statement:记录每一条除了查询之外语句。

row:记录每一行记录修改的形式,也就是记录了哪一行改了,改了啥!就比如你update了100条记录,那它就会记录这100条记录改了啥(5.1.5版本才有)

mixed:就是statement和row的混合了,由mysql来判断这条语句用哪种形式记录!(5.1.8版本才有)

binlog没有固定大小,每次都是追加记录不会覆盖之前的。

还有一点,redolog只有InnoDB才有,它是存在引擎层的,而binlog是存在Server层的。所以如果你用的存储引擎的MyISAM,那么你就没有redolog了!

两段式提交

接下来我们再说说两段式提交。

两段式提交,就是我们先把这次更新写入到redolog中,并设redolog为prepare状态,然后再写入binlog,写完binlog之后再提交事务,并设redolog为commit状态。也就是把relolog拆成了prepare和commit两段!

为啥要这样做?

其实redolog是后来才加上的,binlog是之前就有的。一开始存储引擎只有MyISAM,后来才有的InnoDB,然后MyISAM没有事务,没有crash-safe的能力。所以InnoDB搞了个redolog。然后为了保证两份日志同步,所以才有了两段式提交。

你假设一下如果先保存好redolog,然后再记录binlog。如果redolog写好了之后挂了。ok你看起来好像是没问题了,但是你的binlog还没记录,所以这条记录就少了!如果你备份这份binlog之后,你这条记录就永远的少了!

那如果先写binlog再写redolog呢?那binlog写完了,你数据库挂了,那redolog是不是没有,没有的意思就是你以前你没更新成功。但是binlog已经记录好了,在它那边反正是成功了,所以那备份的binlog也不对!

综上所述:两段式提交!

如果有错误欢迎指正!

个人公众号:yes的练级攻略

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

dom节点和vue中template浅谈

发表于 2019-04-26

前言:在开发前段页面使用vue时,我们能经常看到template标签。这里粗略讲下自己对vue中template理解和使用。

  1. 先了解vue

vue.js是一个轻巧、高性能、壳组件画的MVVM库。

Vue的两大特征:响应式编程、组件化

vue的优势:轻量级框架、简单易学、双向数据绑定、组件化、视图、数据和结构分离、虚拟DOM、运行速度快
2. dom相关知识


2.1 html中的dom

我们知道HTML中所有的内容都是节点组成的。

当网页被加载时,浏览器会创建页面的文档对象模型(Document Object Model)。

通过DOM,可以访问所有的HTML元素,连同它们的文本和属性,可以进行修改、删除以及创建新的元素。

HTML文档中的所有元素(节点)组成了一个文档树(节点树、DOM树)

2.2 vdom

相比频繁的手动去操作dom而带来的性能问题,vdom(virtual-dom)很好的将dom做了一层映射关系,将我们本需要直接进行dom的一系列操作映射到了vdom中。

在vdom上定义了关于真实dom的一些关键信息,而vdom完全使用js去实现的,和宿主浏览器没有任何联系。

此外得益于js的执行速度,将原本需要在真实dom进行的创建节点,删除节点,添加节点等一系列复杂的dom操作全部放到vdom中进行,这样就通过操作vdom来提高直接操作的dom的效率和性能。

2.3 vue和vdom的关系

在Vue的整个应用生命周期当中,每次需要更新视图的时候便会使用vdom。

  1. template

3.1 HTML5中的template

在HTML5中,templae用来声明”模板元素”。

1
2
3
复制代码<script type="text/template">
//相对这样的标准写法而言,<template>元素的出现旨在让HTML模板HTML变得更加标准与规范。
<template>

template性质:

  1. 标签内容隐藏性,template自带标签内容隐藏的性质。
  2. 标签位置任意性,可以在head标签中,也可以在body标签中或者frameset标签中。
  3. childNodes无效性,可以使用template.innerHTML获取完整的HTML片段;template.content会返回一个文档片段,可理解为另外一个docuent,获取“伪子元素”。

3.2 vue中的template

3.2.1 生命周期

根据vue生命周期中所表示的,找到el中有template配置项,则会用template配置项的自定义组件去替换html中的el。

但是这个template不是

3.2.2 作为组件或者是字符串

1
复制代码template:"<four_component/>"

作为组件时需要先注册;不是组件,则设置成字符串

1
复制代码template:"<div><div/>"

3.2.3 作为插槽使用

当我们直接应用组件时,因为vue无法直接进行渲染而导致组件失效

1
复制代码<child-component>想要输出的内容</child-component>

如果要使用组件标签,我们就可以利用template标签,加上slot插槽属性,组成

1
2
3
4
5
复制代码<child-component>
<template slot="插槽名">
想要输出的内容
</template>
</child-component

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

腾讯面试:一条SQL语句执行得很慢的原因有哪些?---不看后

发表于 2019-04-25

说实话,这个问题可以涉及到 MySQL 的很多核心知识,可以扯出一大堆,就像要考你计算机网络的知识时,问你“输入URL回车之后,究竟发生了什么”一样,看看你能说出多少了。

之前腾讯面试的实话,也问到这个问题了,不过答的很不好,之前没去想过相关原因,导致一时之间扯不出来。所以今天,我带大家来详细扯一下有哪些原因,相信你看完之后一定会有所收获,不然你打我。

开始装逼:分类讨论

一条 SQL 语句执行的很慢,那是每次执行都很慢呢?还是大多数情况下是正常的,偶尔出现很慢呢?所以我觉得,我们还得分以下两种情况来讨论。

1、大多数情况是正常的,只是偶尔会出现很慢的情况。

2、在数据量不变的情况下,这条SQL语句一直以来都执行的很慢。

针对这两种情况,我们来分析下可能是哪些原因导致的。

针对偶尔很慢的情况

一条 SQL 大多数情况正常,偶尔才能出现很慢的情况,针对这种情况,我觉得这条SQL语句的书写本身是没什么问题的,而是其他原因导致的,那会是什么原因呢?

数据库在刷新脏页我也无奈啊

当我们要往数据库插入一条数据、或者要更新一条数据的时候,我们知道数据库会在内存中把对应字段的数据更新了,但是更新之后,这些更新的字段并不会马上同步持久化到磁盘中去,而是把这些更新的记录写入到 redo log 日记中去,等到空闲的时候,在通过 redo log 里的日记把最新的数据同步到磁盘中去。

不过,redo log 里的容量是有限的,如果数据库一直很忙,更新又很频繁,这个时候 redo log 很快就会被写满了,这个时候就没办法等到空闲的时候再把数据同步到磁盘的,只能暂停其他操作,全身心来把数据同步到磁盘中去的,而这个时候,就会导致我们平时正常的SQL语句突然执行的很慢,所以说,数据库在在同步数据到磁盘的时候,就有可能导致我们的SQL语句执行的很慢了。

拿不到锁我能怎么办

这个就比较容易想到了,我们要执行的这条语句,刚好这条语句涉及到的表,别人在用,并且加锁了,我们拿不到锁,只能慢慢等待别人释放锁了。或者,表没有加锁,但要使用到的某个一行被加锁了,这个时候,我也没办法啊。

如果要判断是否真的在等待锁,我们可以用 show processlist这个命令来查看当前的状态哦,这里我要提醒一下,有些命令最好记录一下,反正,我被问了好几个命令,都不知道怎么写,呵呵。

下来我们来访分析下第二种情况,我觉得第二种情况的分析才是最重要的

针对一直都这么慢的情况

如果在数据量一样大的情况下,这条 SQL 语句每次都执行的这么慢,那就就要好好考虑下你的 SQL 书写了,下面我们来分析下哪些原因会导致我们的 SQL 语句执行的很不理想。

我们先来假设我们有一个表,表里有下面两个字段,分别是主键 id,和两个普通字段 c 和 d。

1
2
3
4
5
6
复制代码mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;

扎心了,没用到索引

没有用上索引,我觉得这个原因是很多人都能想到的,例如你要查询这条语句

1
复制代码select * from t where 100 <c and c < 100000;

字段没有索引

刚好你的 c 字段上没有索引,那么抱歉,只能走全表扫描了,你就体验不会索引带来的乐趣了,所以,这回导致这条查询语句很慢。

字段有索引,但却没有用索引

好吧,这个时候你给 c 这个字段加上了索引,然后又查询了一条语句

1
复制代码select * from t where c - 1 = 1000;

我想问大家一个问题,这样子在查询的时候会用索引查询吗?

答是不会,如果我们在字段的左边做了运算,那么很抱歉,在查询的时候,就不会用上索引了,所以呢,大家要注意这种字段上有索引,但由于自己的疏忽,导致系统没有使用索引的情况了。

正确的查询应该如下

1
复制代码select * from t where c = 1000 + 1;

有人可能会说,右边有运算就能用上索引?难道数据库就不会自动帮我们优化一下,自动把 c - 1=1000 自动转换为 c = 1000+1。

不好意思,确实不会帮你,所以,你要注意了。

函数操作导致没有用上索引

如果我们在查询的时候,对字段进行了函数操作,也是会导致没有用上索引的,例如

1
复制代码select * from t where pow(c,2) = 1000;

这里我只是做一个例子,假设函数 pow 是求 c 的 n 次方,实际上可能并没有 pow(c,2)这个函数。其实这个和上面在左边做运算也是很类似的。

所以呢,一条语句执行都很慢的时候,可能是该语句没有用上索引了,不过具体是啥原因导致没有用上索引的呢,你就要会分析了,我上面列举的三个原因,应该是出现的比较多的吧。

呵呵,数据库自己选错索引了

我们在进行查询操作的时候,例如

1
复制代码select * from t where 100 < c and c < 100000;

我们知道,主键索引和非主键索引是有区别的,主键索引存放的值是整行字段的数据,而非主键索引上存放的值不是整行字段的数据,而且存放主键字段的值。不大懂的可以看我这篇文章:面试小知识:MySQL索引相关 里面有说到主键索引和非主键索引的区别

也就是说,我们如果走 c 这个字段的索引的话,最后会查询到对应主键的值,然后,再根据主键的值走主键索引,查询到整行数据返回。

好吧扯了这么多,其实我就是想告诉你,就算你在 c 字段上有索引,系统也并不一定会走 c 这个字段上的索引,而是有可能会直接扫描扫描全表,找出所有符合 100 < c and c < 100000 的数据。

为什么会这样呢?

其实是这样的,系统在执行这条语句的时候,会进行预测:究竟是走 c 索引扫描的行数少,还是直接扫描全表扫描的行数少呢?显然,扫描行数越少当然越好了,因为扫描行数越少,意味着I/O操作的次数越少。

如果是扫描全表的话,那么扫描的次数就是这个表的总行数了,假设为 n;而如果走索引 c 的话,我们通过索引 c 找到主键之后,还得再通过主键索引来找我们整行的数据,也就是说,需要走两次索引。而且,我们也不知道符合 100 c < and c < 10000 这个条件的数据有多少行,万一这个表是全部数据都符合呢?这个时候意味着,走 c 索引不仅扫描的行数是 n,同时还得每行数据走两次索引。

所以呢,系统是有可能走全表扫描而不走索引的。那系统是怎么判断呢?

判断来源于系统的预测,也就是说,如果要走 c 字段索引的话,系统会预测走 c 字段索引大概需要扫描多少行。如果预测到要扫描的行数很多,它可能就不走索引而直接扫描全表了。

那么问题来了,**系统是怎么预测判断的呢?**这里我给你讲下系统是怎么判断的吧,虽然这个时候我已经写到脖子有点酸了。

系统是通过索引的区分度来判断的,一个索引上不同的值越多,意味着出现相同数值的索引越少,意味着索引的区分度越高。我们也把区分度称之为基数,即区分度越高,基数越大。所以呢,基数越大,意味着符合 100 < c and c < 10000 这个条件的行数越少。

所以呢,一个索引的基数越大,意味着走索引查询越有优势。

那么问题来了,怎么知道这个索引的基数呢?

系统当然是不会遍历全部来获得一个索引的基数的,代价太大了,索引系统是通过遍历部分数据,也就是通过采样的方式,来预测索引的基数的。

扯了这么多,重点的来了,居然是采样,那就有可能出现失误的情况,也就是说,c 这个索引的基数实际上是很大的,但是采样的时候,却很不幸,把这个索引的基数预测成很小。例如你采样的那一部分数据刚好基数很小,然后就误以为索引的基数很小。然后就呵呵,系统就不走 c 索引了,直接走全部扫描了。

所以呢,说了这么多,得出结论:由于统计的失误,导致系统没有走索引,而是走了全表扫描,而这,也是导致我们 SQL 语句执行的很慢的原因。

这里我声明一下,系统判断是否走索引,扫描行数的预测其实只是原因之一,这条查询语句是否需要使用使用临时表、是否需要排序等也是会影响系统的选择的。

不过呢,我们有时候也可以通过强制走索引的方式来查询,例如

1
复制代码select * from t force index(a) where c < 100 and c < 100000;

我们也可以通过

1
复制代码show index from t;

来查询索引的基数和实际是否符合,如果和实际很不符合的话,我们可以重新来统计索引的基数,可以用这条命令

1
复制代码analyze table t;

来重新统计分析。

既然会预测错索引的基数,这也意味着,当我们的查询语句有多个索引的时候,系统有可能也会选错索引哦,这也可能是 SQL 执行的很慢的一个原因。

好吧,就先扯这么多了,你到时候能扯出这么多,我觉得已经很棒了,下面做一个总结。

总结

以上是我的总结与理解,最后一个部分,我怕很多人不大懂数据库居然会选错索引,所以我详细解释了一下,下面我对以上做一个总结。

一个 SQL 执行的很慢,我们要分两种情况讨论:

1、大多数情况下很正常,偶尔很慢,则有如下原因

(1)、数据库在刷新脏页,例如 redo log 写满了需要同步到磁盘。

(2)、执行的时候,遇到锁,如表锁、行锁。

2、这条 SQL 语句一直执行的很慢,则有如下原因。

(1)、没有用上索引:例如该字段没有索引;由于对字段进行运算、函数操作导致无法用索引。

(2)、数据库选错了索引。

大家如果有补充的,也是可以留言区补充一波哦。

最后推广下我的公众号:苦逼的码农:戳我即可关注,文章都会首发于我的公众号,期待各路英雄的关注交流。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

深入理解Transform

发表于 2019-04-24

前言

其实Transform API在一个android工程的打包流程中作用非常大, 像是我们熟知的混淆处理, 类文件转dex文件的处理, 都是通过Transform API去完成的.
本篇内容主要围绕Transform做展开:

  1. Transform API的使用及原理
  2. 字节码处理框架ASM使用技巧
  3. Transform API在应用工程上的使用摸索

Transform的使用及原理

什么是Transform

自从1.5.0-beta1版本开始, android gradle插件就包含了一个Transform API, 它允许第三方插件在编译后的类文件转换为dex文件之前做处理操作.
而使用Transform API, 我们完全可以不用去关注相关task的生成与执行流程, 它让我们可以只聚焦在如何对输入的类文件进行处理

Transform的使用

Transform的注册和使用非常易懂, 在我们自定义的plugin内, 我们可以通过android.registerTransform(theTransform)或者android.registerTransform(theTransform, dependencies).就可以进行注册.

1
2
3
4
5
6
复制代码class DemoPlugin: Plugin<Project> {
override fun apply(target: Project) {
val android = target.extensions.findByType(BaseExtension::class.java)
android?.registerTransform(DemoTransform())
}
}

而我们自定义的Transform继承于com.android.build.api.transform.Transform, 具体我们可以看javaDoc, 以下代码是比较常见的transform处理模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
复制代码class DemoTransform: Transform() {
/**
* transform 名字
*/
override fun getName(): String = "DemoTransform"

/**
* 输入文件的类型
* 可供我们去处理的有两种类型, 分别是编译后的java代码, 以及资源文件(非res下文件, 而是assests内的资源)
*/
override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> = TransformManager.CONTENT_CLASS

/**
* 是否支持增量
* 如果支持增量执行, 则变化输入内容可能包含 修改/删除/添加 文件的列表
*/
override fun isIncremental(): Boolean = false

/**
* 指定作用范围
*/
override fun getScopes(): MutableSet<in QualifiedContent.Scope> = TransformManager.SCOPE_FULL_PROJECT

/**
* transform的执行主函数
*/
override fun transform(transformInvocation: TransformInvocation?) {
transformInvocation?.inputs?.forEach {
// 输入源为文件夹类型
it.directoryInputs.forEach {directoryInput->
with(directoryInput){
// TODO 针对文件夹进行字节码操作
val dest = transformInvocation.outputProvider.getContentLocation(
name,
contentTypes,
scopes,
Format.DIRECTORY
)
file.copyTo(dest)
}
}

// 输入源为jar包类型
it.jarInputs.forEach { jarInput->
with(jarInput){
// TODO 针对Jar文件进行相关处理
val dest = transformInvocation.outputProvider.getContentLocation(
name,
contentTypes,
scopes,
Format.JAR
)
file.copyTo(dest)
}
}
}
}
}

每一个Transform都声明它的作用域, 作用对象以及具体的操作以及操作后输出的内容.

作用域

通过Transform#getScopes方法我们可以声明自定义的transform的作用域, 指定作用域包括如下几种

QualifiedContent.Scope
EXTERNAL_LIBRARIES 只包含外部库
PROJECT 只作用于project本身内容
PROVIDED_ONLY 支持compileOnly的远程依赖
SUB_PROJECTS 子模块内容
TESTED_CODE 当前变体测试的代码以及包括测试的依赖项

作用对象

通过Transform#getInputTypes我们可以声明其的作用对象, 我们可以指定的作用对象只包括两种

QualifiedContent.ContentType
CLASSES Java代码编译后的内容, 包括文件夹以及Jar包内的编译后的类文件
RESOURCES 基于资源获取到的内容

TransformManager整合了部分常用的Scope以及Content集合,
如果是application注册的transform, 通常情况下, 我们一般指定TransformManager.SCOPE_FULL_PROJECT;如果是library注册的transform, 我们只能指定TransformManager.PROJECT_ONLY , 我们可以在LibraryTaskManager#createTasksForVariantScope中看到相关的限制报错代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码            Sets.SetView<? super Scope> difference =
Sets.difference(transform.getScopes(), TransformManager.PROJECT_ONLY);
if (!difference.isEmpty()) {
String scopes = difference.toString();
globalScope
.getAndroidBuilder()
.getIssueReporter()
.reportError(
Type.GENERIC,
new EvalIssueException(
String.format(
"Transforms with scopes '%s' cannot be applied to library projects.",
scopes)));
}

而作用对象我们主要常用到的是TransformManager.CONTENT_CLASS

TransformInvocation

我们通过实现Transform#transform方法来处理我们的中间转换过程, 而中间相关信息都是通过TransformInvocation对象来传递

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
复制代码public interface TransformInvocation {

/**
* transform的上下文
*/
@NonNull
Context getContext();

/**
* 返回transform的输入源
*/
@NonNull
Collection<TransformInput> getInputs();

/**
* 返回引用型输入源
*/
@NonNull Collection<TransformInput> getReferencedInputs();
/**
* 额外输入源
*/
@NonNull Collection<SecondaryInput> getSecondaryInputs();

/**
* 输出源
*/
@Nullable
TransformOutputProvider getOutputProvider();


/**
* 是否增量
*/
boolean isIncremental();
}

关于输入源, 我们可以大致分为消费型和引用型和额外的输入源

  1. 消费型就是我们需要进行transform操作的, 这类对象在处理后我们必须指定输出传给下一级,
    我们主要通过getInputs()获取进行消费的输入源, 而在进行变换后, 我们也必须通过设置getInputTypes()和getScopes()来指定输出源传输给下个transform.
  2. 引用型输入源是指我们不进行transform操作, 但可能存在查看时候使用, 所以这类我们也不需要输出给下一级, 在通过覆写getReferencedScopes()指定我们的引用型输入源的作用域后, 我们可以通过TransformInvocation#getReferencedInputs()获取引用型输入源
  3. 另外我们还可以额外定义另外的输入源供下一级使用, 正常开发中我们很少用到, 不过像是ProGuardTransform中, 就会指定创建mapping.txt传给下一级; 同样像是DexMergerTransform, 如果打开了multiDex功能, 则会将maindexlist.txt文件传给下一级

Transform的原理

Transform的执行链

我们已经大致了解它是如何使用的, 现在看下他的原理(本篇源码基于gradle插件3.3.2版本)在去年AppPlugin源码解析中, 我们粗略了解了android的com.android.application以及com.android.library两个插件都继承于BasePlugin, 而他们的主要执行顺序可以分为三个步骤

  1. project的配置
  2. extension的配置
  3. task的创建

在BaseExtension内部维护了一个transforms集合对象,
android.registerTransform(theTransform)实际上就是将我们自定义的transform实例新增到这个列表对象中.
在3.3.2的源码中, 也可以这样理解. 在BasePlugin#createAndroidTasks中, 我们通过VariantManager#createAndroidTasks创建各个变体的相关编译任务, 最终通过TaskManager#createTasksForVariantScope(application插件最终实现方法在TaskManager#createPostCompilationTasks中, 而library插件最终实现方法在LibraryTaskManager#createTasksForVariantScope中)方法中获取BaseExtension中维护的transforms对象, 通过TransformManager#addTransform将对应的transform对象转换为task, 注册在TaskFactory中.这里关于一系列Transform Task的执行流程, 我们可以选择看下application内的相关transform流程, 由于篇幅原因, 可以自行去看相关源码, 这里的transform task流程分别是从Desugar->MergeJavaRes->自定义的transform->MergeClasses->Shrinker(包括ResourcesShrinker和DexSplitter和Proguard)->MultiDex->BundleMultiDex->Dex->ResourcesShrinker->DexSplitter, 由此调用链, 我们也可以看出在处理类文件的时候, 是不需要去考虑混淆的处理的.

TransformManager

TransformManager管理了项目对应变体的所有Transform对象, 它的内部维护了一个TransformStream集合对象streams, 每当新增一个transform, 对应的transform会消费掉对应的流, 而后将处理后的流添加会streams内

1
2
3
复制代码public class TransformManager extends FilterableStreamCollection{
private final List<TransformStream> streams = Lists.newArrayList();
}

我们可以看下它的核心方法addTransform

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
复制代码@NonNull
public <T extends Transform> Optional<TaskProvider<TransformTask>> addTransform(
@NonNull TaskFactory taskFactory,
@NonNull TransformVariantScope scope,
@NonNull T transform,
@Nullable PreConfigAction preConfigAction,
@Nullable TaskConfigAction<TransformTask> configAction,
@Nullable TaskProviderCallback<TransformTask> providerCallback) {

...

List<TransformStream> inputStreams = Lists.newArrayList();
// transform task的命名规则定义
String taskName = scope.getTaskName(getTaskNamePrefix(transform));

// 获取引用型流
List<TransformStream> referencedStreams = grabReferencedStreams(transform);

// 找到输入流, 并计算通过transform的输出流
IntermediateStream outputStream = findTransformStreams(
transform,
scope,
inputStreams,
taskName,
scope.getGlobalScope().getBuildDir());

// 省略代码是用来校验输入流和引用流是否为空, 理论上不可能为空, 如果为空, 则说明中间有个transform的转换处理有问题
...

transforms.add(transform);

// transform task的创建
return Optional.of(
taskFactory.register(
new TransformTask.CreationAction<>(
scope.getFullVariantName(),
taskName,
transform,
inputStreams,
referencedStreams,
outputStream,
recorder),
preConfigAction,
configAction,
providerCallback));
}

在TransformManager中添加一个Transform管理, 流程可分为以下几步

  1. 定义transform task名
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码static String getTaskNamePrefix(@NonNull Transform transform) {
StringBuilder sb = new StringBuilder(100);
sb.append("transform");

sb.append(
transform
.getInputTypes()
.stream()
.map(
inputType ->
CaseFormat.UPPER_UNDERSCORE.to(
CaseFormat.UPPER_CAMEL, inputType.name()))
.sorted() // Keep the order stable.
.collect(Collectors.joining("And")));
sb.append("With");
StringHelper.appendCapitalized(sb, transform.getName());
sb.append("For");

return sb.toString();
}

从上面代码, 我们可以看到新建的transform task的命名规则可以理解为transform${inputType1.name}And${inputType2.name}With${transform.name}For${variantName}, 对应的我们也可以通过已生成的transform task来验证

  1. 通过transform内部定义的引用型输入的作用域(SCOPE)和作用类型(InputTypes), 通过求取与streams作用域和作用类型的交集来获取对应的流, 将其定义为我们需要的引用型流
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码private List<TransformStream> grabReferencedStreams(@NonNull Transform transform) {
Set<? super Scope> requestedScopes = transform.getReferencedScopes();
...

List<TransformStream> streamMatches = Lists.newArrayListWithExpectedSize(streams.size());

Set<ContentType> requestedTypes = transform.getInputTypes();
for (TransformStream stream : streams) {
Set<ContentType> availableTypes = stream.getContentTypes();
Set<? super Scope> availableScopes = stream.getScopes();

Set<ContentType> commonTypes = Sets.intersection(requestedTypes,
availableTypes);
Set<? super Scope> commonScopes = Sets.intersection(requestedScopes, availableScopes);

if (!commonTypes.isEmpty() && !commonScopes.isEmpty()) {
streamMatches.add(stream);
}
}

return streamMatches;
}
  1. 根据transform内定义的SCOPE和INPUT_TYPE, 获取对应的消费型输入流, 在streams内移除掉这一部分消费性的输入流, 保留无法匹配SCOPE和INPUT_TYPE的流; 构建新的输出流, 并加到streams中做管理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
复制代码private IntermediateStream findTransformStreams(
@NonNull Transform transform,
@NonNull TransformVariantScope scope,
@NonNull List<TransformStream> inputStreams,
@NonNull String taskName,
@NonNull File buildDir) {

Set<? super Scope> requestedScopes = transform.getScopes();
...

Set<ContentType> requestedTypes = transform.getInputTypes();
// 获取消费型输入流
// 并将streams中移除对应的消费型输入流
consumeStreams(requestedScopes, requestedTypes, inputStreams);

// 创建输出流
Set<ContentType> outputTypes = transform.getOutputTypes();
// 创建输出流转换的文件相关路径
File outRootFolder =
FileUtils.join(
buildDir,
StringHelper.toStrings(
AndroidProject.FD_INTERMEDIATES,
FD_TRANSFORMS,
transform.getName(),
scope.getDirectorySegments()));

// 输出流的创建
IntermediateStream outputStream =
IntermediateStream.builder(
project,
transform.getName() + "-" + scope.getFullVariantName(),
taskName)
.addContentTypes(outputTypes)
.addScopes(requestedScopes)
.setRootLocation(outRootFolder)
.build();
streams.add(outputStream);

return outputStream;
}
  1. 最后, 创建TransformTask, 注册到TaskManager中

TransformTask

如何触发到我们实现的Transform#transform方法, 就在TransformTask对应的TaskAction中执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
复制代码void transform(final IncrementalTaskInputs incrementalTaskInputs)
throws IOException, TransformException, InterruptedException {

final ReferenceHolder<List<TransformInput>> consumedInputs = ReferenceHolder.empty();
final ReferenceHolder<List<TransformInput>> referencedInputs = ReferenceHolder.empty();
final ReferenceHolder<Boolean> isIncremental = ReferenceHolder.empty();
final ReferenceHolder<Collection<SecondaryInput>> changedSecondaryInputs =
ReferenceHolder.empty();

isIncremental.setValue(transform.isIncremental() && incrementalTaskInputs.isIncremental());

GradleTransformExecution preExecutionInfo =
GradleTransformExecution.newBuilder()
.setType(AnalyticsUtil.getTransformType(transform.getClass()).getNumber())
.setIsIncremental(isIncremental.getValue())
.build();

// 一些增量模式下的处理, 包括在增量模式下, 判断输入流(引用型和消费型)的变化
...

GradleTransformExecution executionInfo =
preExecutionInfo.toBuilder().setIsIncremental(isIncremental.getValue()).build();

...
transform.transform(
new TransformInvocationBuilder(TransformTask.this)
.addInputs(consumedInputs.getValue())
.addReferencedInputs(referencedInputs.getValue())
.addSecondaryInputs(changedSecondaryInputs.getValue())
.addOutputProvider(
outputStream != null
? outputStream.asOutput(
isIncremental.getValue())
: null)
.setIncrementalMode(isIncremental.getValue())
.build());

if (outputStream != null) {
outputStream.save();
}
}

通过上文的介绍, 我们现在应该知道了自定义的Transform执行的时序, 位置, 以及相关原理. 那么, 我们现在已经拿到了编译后的所有字节码, 我们要怎么去处理呢? 我们可以了解下ASM

ASM的使用

想要处理字节码, 常见的框架有AspectJ, Javasist, ASM. 关于框架的选型网上相关的文章还是比较多的, 从处理速度以及内存占用率上, ASM明显优于其他两个框架.本篇主要着眼于ASM的使用.

什么是ASM

ASM是一个通用的Java字节码操作和分析框架。它可以用于修改现有类或直接以二进制形式动态生成类. ASM提供了一些常见的字节码转换和分析算法,可以从中构建自定义复杂转换和代码分析工具.
ASM库提供了两个用于生成和转换编译类的API:Core API提供基于事件的类表示,而Tree API提供基于对象的表示。由于基于事件的API(Core API)不需要在内存中存储一个表示该类的对象数, 所以从执行速度和内存占用上来说, 它比基于对象的API(Tree API)更优.然后从使用场景上来说, 基于事件的API使用会比基于对象的API使用更为困难, 譬如当我们需要针对某个对象进行调整的时候.由于一个类只能被一种API管理, 所以我们应该要区分场景选取使用对应的API

ASM插件

ASM的使用需要一定的学习成本, 我们可以通过使用ASM Bytecode Outline插件辅助了解, 对应插件在AS中的插件浏览器就可以找到

唯一的遗憾在于它无法转换kotlin文件为通过ASM创建的类文件
然后我们就可以通过打开一份java未编译文件, 通过右键选择Show Bytecode Outline转为对应的字节码, 并可以看到对应的通过ASM创建的类格式

譬如我们新建了一个类, 可以通过asm插件得到通过core api生成的对应方法.

1
2
3
4
复制代码@RouteModule
public class ASMTest {

}

Transform API在应用工程方面的摸索使用

组件通信中的作用

Transform API在组件化工程中有很多应用方向, 目前我们项目中在自开发的路由框架中, 通过其去做了模块的自动化静态注册, 同时考虑到路由通过协议文档维护的不确定性(页面路由地址的维护不及时导致对应开发无法及时更新对应代码), 我们做了路由的常量管理, 首先通过扫描整个工程项目代码收集路由信息, 建立符合一定规则的路由原始基础信息文件, 通过variant#registerJavaGeneratingTask注册 通过对应原始信息文件生成对应常量Java文件下沉在基础通用组件中的task, 这样上层依赖于这个基础组件的项目都可以通过直接调用常量来使用路由.在各组件代码隔离的情况下, 可以通过由组件aar传递原始信息文件, 仍然走上面的步骤生成对应的常量表, 而存在的类重复的问题, 通过自定义Transform处理合并

业务监控中的作用

在应用工程中, 我们通常有关于网络监控,应用性能检测(包括页面加载时间, 甚至包括各个方法调用所耗时间, 可能存在超过阈值需要警告)的需求, 这些需求我们都不可能嵌入在业务代码中, 都是可以基于Transform API进行处理. 而针对于埋点, 我们也可以通过Transform实现自动化埋点的功能, 通过ASM Core和ASM Tree将尽可能多的字段信息形成记录传递, 这里有些我们项目中已经实现了, 有一些则是我们需要去优化或者去实现的.

其他

关于结合Transform+ASM的使用, 我写了个一个小Demo, 包括了如何处理支持增量功能时的转换, 如何使用ASM Core Api和ASM Tree Api, 做了一定的封装, 可以参阅

相关参考

  • ASM用户指南
  • 一起玩转Android项目中的字节码
  • AOP 的利器:ASM 3.0 介绍
  • ASM官网

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

设计模式(八)装饰器模式

发表于 2019-04-23

定义:装饰模式是在不必改变原类文件和使用继承的情况下,动态的扩展一个对象的功能。它是通过创建一个包装对象,也就是装饰来包裹真实的对象。

这一个解释,引自百度百科,我们注意其中的几点。

1,不改变原类文件。

2,不使用继承。

3,动态扩展。

从图中可以看到,我们装饰的是一个接口的任何实现类,而这些实现类也包括了装饰器本身,装饰器本身也可以再被装饰。
另外,这个类图只是装饰器模式的完整结构,但其实里面有很多可以变化的地方

1,Component接口可以是接口也可以是抽象类,甚至是一个普通的父类(这个强烈不推荐,普通的类作为继承体系的超级父类不易于维护)。

2,装饰器的抽象父类Decorator并不是必须的。

那么我们将上述标准的装饰器模式,用我们熟悉的JAVA代码给诠释一下。首先是待装饰的接口Component。

1
2
3
4
5
6
7
复制代码package com.decorator;

public interface Component {

void method();

}

接下来便是我们的一个具体的接口实现类,也就是俗称的原始对象,或者说待装饰对象。

1
2
3
4
5
6
7
8
9
复制代码package com.decorator;

public class ConcreteComponent implements Component{

public void method() {
System.out.println("原来的方法");
}

}

下面便是我们的抽象装饰器父类,它主要是为装饰器定义了我们需要装饰的目标是什么,并对Component进行了基础的装饰。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码 package com.decorator;

public abstract class Decorator implements Component{

protected Component component;

public Decorator(Component component) {
super();
this.component = component;
}

public void method() {
component.method();
}

}

再来便是我们具体的装饰器A和装饰器B

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码package com.decorator;

public class ConcreteDecoratorA extends Decorator{

public ConcreteDecoratorA(Component component) {
super(component);
}

public void methodA(){
System.out.println("被装饰器A扩展的功能");
}

public void method(){
System.out.println("针对该方法加一层A包装");
super.method();
System.out.println("A包装结束");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码package com.decorator;

public class ConcreteDecoratorB extends Decorator{

public ConcreteDecoratorB(Component component) {
super(component);
}

public void methodB(){
System.out.println("被装饰器B扩展的功能");
}

public void method(){
System.out.println("针对该方法加一层B包装");
super.method();
System.out.println("B包装结束");
}
}

下面给出我们的测试类。我们针对多种情况进行包装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码package com.decorator;

public class Main {

public static void main(String[] args) {
Component component =new ConcreteComponent();//原来的对象
System.out.println("------------------------------");
component.method();//原来的方法
ConcreteDecoratorA concreteDecoratorA = new ConcreteDecoratorA(component);//装饰成A
System.out.println("------------------------------");
concreteDecoratorA.method();//原来的方法
concreteDecoratorA.methodA();//装饰成A以后新增的方法
ConcreteDecoratorB concreteDecoratorB = new ConcreteDecoratorB(component);//装饰成B
System.out.println("------------------------------");
concreteDecoratorB.method();//原来的方法
concreteDecoratorB.methodB();//装饰成B以后新增的方法
concreteDecoratorB = new ConcreteDecoratorB(concreteDecoratorA);//装饰成A以后再装饰成B
System.out.println("------------------------------");
concreteDecoratorB.method();//原来的方法
concreteDecoratorB.methodB();//装饰成B以后新增的方法
}
}

下面看下我们运行的结果,到底是产生了什么效果。

从此可以看到,我们首先是使用的原始的类的方法,然后分别让A和B装饰完以后再调用,最后我们将两个装饰器一起使用,再调用该接口定义的方法。

上述当中,我们分别对待装饰类进行了原方法的装饰和新功能的增加,methodA和methodB就是新增加的功能,这些都是装饰器可以做的,当然两者并不一定兼有,但一般至少会有一种,否则也就失去了装饰的意义。

另外,相信各位就算不太清楚,也都大致听说过JAVA的IO是装饰器模式实现的,所以不再废话,在给出一个标准的模板示例以后,直接拿出IO的示例,我们真枪实弹的来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
复制代码 package com.decorator;

import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.DataInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.io.PushbackInputStream;
import java.io.PushbackReader;

public class IOTest {

/* test.txt内容:
* hello world!
*/
public static void main(String[] args) throws IOException, ClassNotFoundException {
//文件路径可自行更换
final String filePath = "E:/myeclipse project/POITest/src/com/decorator/test.txt";

//InputStream相当于被装饰的接口或者抽象类,FileInputStream相当于原始的待装饰的对象,FileInputStream无法装饰InputStream
//另外FileInputStream是以只读方式打开了一个文件,并打开了一个文件的句柄存放在FileDescriptor对象的handle属性
//所以下面有关回退和重新标记等操作,都是在堆中建立缓冲区所造成的假象,并不是真正的文件流在回退或者重新标记
InputStream inputStream = new FileInputStream(filePath);
final int len = inputStream.available();//记录一下流的长度
System.out.println("FileInputStream不支持mark和reset:" + inputStream.markSupported());

System.out.println("---------------------------------------------------------------------------------");

/* 下面分别展示三种装饰器的作用BufferedInputStream,DataInputStream,PushbackInputStream,下面做了三个装饰器的功能演示 */

//首先装饰成BufferedInputStream,它提供我们mark,reset的功能
BufferedInputStream bufferedInputStream = new BufferedInputStream(inputStream);//装饰成 BufferedInputStream
System.out.println("BufferedInputStream支持mark和reset:" + bufferedInputStream.markSupported());
bufferedInputStream.mark(0);//标记一下
char c = (char) bufferedInputStream.read();
System.out.println("LZ文件的第一个字符:" + c);
bufferedInputStream.reset();//重置
c = (char) bufferedInputStream.read();//再读
System.out.println("重置以后再读一个字符,依然会是第一个字符:" + c);
bufferedInputStream.reset();

System.out.println("---------------------------------------------------------------------------------");

//装饰成 DataInputStream,我们为了又使用DataInputStream,又使用BufferedInputStream的mark reset功能,所以我们再进行一层包装
//注意,这里如果不使用BufferedInputStream,而使用原始的InputStream,read方法返回的结果会是-1,即已经读取结束
//因为BufferedInputStream已经将文本的内容读取完毕,并缓冲到堆上,默认的初始缓冲区大小是8192B
DataInputStream dataInputStream = new DataInputStream(bufferedInputStream);
dataInputStream.reset();//这是BufferedInputStream提供的功能,如果不在这个基础上包装会出错
System.out.println("DataInputStream现在具有readInt,readChar,readUTF等功能");
int value = dataInputStream.readInt();//读出来一个int,包含四个字节
//我们转换成字符依次显示出来,可以看到LZ文件的前四个字符
String binary = Integer.toBinaryString(value);
int first = binary.length() % 8;
System.out.print("使用readInt读取的前四个字符:");
for (int i = 0; i < 4; i++) {
if (i == 0) {
System.out.print(((char)Integer.valueOf(binary.substring(0, first), 2).intValue()));
}else {
System.out.print(((char)Integer.valueOf(binary.substring(( i - 1 ) * 8 + first, i * 8 + first), 2).intValue()));
}
}
System.out.println();

System.out.println("---------------------------------------------------------------------------------");

//PushbackInputStream无法包装BufferedInputStream支持mark reset,因为它覆盖了reset和mark方法
//因为流已经被读取到末尾,所以我们必须重新打开一个文件的句柄,即FileInputStream
inputStream = new FileInputStream(filePath);
PushbackInputStream pushbackInputStream = new PushbackInputStream(inputStream,len);//装饰成 PushbackInputStream
System.out.println("PushbackInputStream装饰以后支持退回操作unread");
byte[] bytes = new byte[len];
pushbackInputStream.read(bytes);//读完了整个流
System.out.println("unread回退前的内容:" + new String(bytes));
pushbackInputStream.unread(bytes);//再退回去
bytes = new byte[len];//清空byte数组
pushbackInputStream.read(bytes);//再读
System.out.println("unread回退后的内容:" + new String(bytes));

System.out.println("---------------------------------------------------------------------------------");

/* 以上有两个一层装饰和一个两层装饰,下面我们先装饰成Reader,再进行其它装饰 */

//由于之前被PushbackInputStream将流读取到末尾,我们需要再次重新打开文件句柄
inputStream = new FileInputStream(filePath);
InputStreamReader inputStreamReader = new InputStreamReader(inputStream,"utf-8");//先装饰成InputStreamReader
System.out.println("InputStreamReader有reader的功能,比如转码:" + inputStreamReader.getEncoding());

System.out.println("---------------------------------------------------------------------------------");

BufferedReader bufferedReader = new BufferedReader(inputStreamReader);//我们进一步在reader的基础上装饰成BufferedReader
System.out.println("BufferedReader有readLine等功能:" + bufferedReader.readLine());

System.out.println("---------------------------------------------------------------------------------");

LineNumberReader lineNumberReader = new LineNumberReader(inputStreamReader);//我们进一步在reader的基础上装饰成LineNumberReader
System.out.println("LineNumberReader有设置行号,获取行号等功能(行号从0开始),当前行号:" + lineNumberReader.getLineNumber());

System.out.println("---------------------------------------------------------------------------------");

//此处由于刚才被readLine方法将流读取到末尾,所以我们再次重新打开文件句柄,并需要将inputstream再次包装成reader
inputStreamReader = new InputStreamReader(new FileInputStream(filePath));
PushbackReader pushbackReader = new PushbackReader(inputStreamReader,len);//我们进一步在reader的基础上装饰成PushbackReader
System.out.println("PushbackReader是拥有退回操作的reader对象");
char[] chars = new char[len];
pushbackReader.read(chars);
System.out.println("unread回退前的内容:" + new String(chars));
pushbackReader.unread(chars);//再退回去
chars = new char[len];//清空char数组
pushbackReader.read(chars);//再读
System.out.println("unread回退后的内容:" + new String(chars));
}
}

上述便是IO的装饰器使用,其中InputStream就相当于上述的Component接口,只不过这里是一个抽象类,这是我们装饰的目标抽象类。FileInputstream就是一个ConcreteComponent,即待装饰的具体对象,它并不是JAVA的IO结构中的一个装饰器,因为它无法装饰InputStream。剩下BufferedInputStream,DataInputstream等等就是各种装饰器了,对比上述的标准装饰器样板,JAVA的IO中也有抽象的装饰器基类的存在,只是上述没有体现出来,就是FilterInputStream,它是很多装饰器最基础的装饰基类。

在上述过程中,其中dataInputStream是经过两次装饰后得到的,它具有了dataInputStream和bufferedInputStream的双重功能,另外,InputStreamReader是一个特殊的装饰器,它提供了字节流到字符流的桥梁,其实它除了具有装饰器的特点以外,也有点像一个适配器,但还是觉得它应当算是一个装饰器。

其它的IO装饰器各位可以自行尝试或者和上述的标准的装饰器模式代码比对一下,下面另附LZ的IO装饰器程序运行后结果。

从上面的展示中,已经可以充分体会到装饰器模式的灵活了,我们创建的一个FileInputstream对象,我们可以使用各种装饰器让它具有不同的特别的功能,这正是动态扩展一个类的功能的最佳体现,而装饰器模式的灵活性正是JAVA中IO所需要的,不得不赞一下JAVA类库的建造者实在是强悍。

上述的XXXXInputStream的各个类都继承了InputStream,这样做不仅是为了复用InputStream的父类功能(InputStream也是一种模板方法模式,它定义了read(byte[])方法的简单算法,并将read()方法交给具体的InputStream去实现),也是为了可以重叠装饰,即装饰器也可以再次被装饰,而过渡到Reader以后,Reader的装饰器体系则是类似的。

总之呢,装饰器模式就是一个可以非常灵活的动态扩展类功能的设计模式,它采用组合的方式取代继承,使得各个功能的扩展更加独立和灵活。

note:有些事,很多人都在做,你不做不代表你错啦

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Elasticsearch入门及掌握其JavaAPI 环境

发表于 2019-04-23

个人技术博客:www.zhenganwen.top

环境

  • 64位Win10、8G内存、JDK8
  • ES安装包:elasticsearch-6.2.1
  • ES中文分词插件:ik-6.4.0
  • 官方文档

安装ES

ES项目结构

解压elasticsearch-6.2.1.zip,解压后得到的目录为==ES根目录==,其中各目录作用如下:

  • bin,存放启动ES等命令脚本
  • config,存放ES的配置文件,ES启动时会读取其中的内容
    • elasticsearch.yml,ES的集群信息、对外端口、内存锁定、数据目录、跨域访问等属性的配置
    • jvm.options,ES使用Java写的,此文件用于设置JVM相关参数,如最大堆、最小堆
    • log4j2.properties,ES使用log4j作为其日志框架
  • data,数据存放目录(索引数据)
  • lib,ES依赖的库
  • logs,日志存放目录
  • modules,ES的各功能模块
  • plugins,ES的可扩展插件存放目录,如可以将ik中文分词插件放入此目录,ES启动时会自动加载

属性配置

默认的elasticsearch.yml中的所有属性都被注释了,我们需要设置一些必要的属性值,在文尾添加如下内容(集群相关的配将在后文详细说明):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码cluster.name: xuecheng #集群名称,默认为elasticsearch
node.name: xc_node_1 #节点名称,一个ES实例就是一个节点(通常一台机器上只部署一个ES实例)
network.host: 0.0.0.0 #IP绑定,0.0.0.0表示所有IP都可访问到此ES实例
http.port: 9200 #通过此端口以RESTful的形式访问ES
transport.tcp.port: 9300 #ES集群通信使用的端口
node.master: true #此节点是否能够作为主节点
node.data: true #此节点是否存放数据
discovery.zen.ping.unicast.hosts: ["0.0.0.0:9300", "0.0.0.0:9301"] #集群其他节点的通信端口,ES启动时会发现这些节点
discovery.zen.minimum_master_nodes: 1 #主节点数量的最少值,此值的计算公式为(master_eligible_nodes/2)+1,即可作为主节点的节点数/2+1
node.ingest: true #此节点是否作为协调节点,当索引库具有多个分片并且各分片位于不同节点上时,如果收到查询请求的节点发现要查询的数据在另一个节点的分片上,那么作为协调节点的该节点将会转发此请求并最终响应结果数据
bootstrap.memory_lock: false #是否锁住ES占用的内存,此项涉及到OS的swap概念,当ES空闲时操作系统可能会把ES占用的内存数据暂时保存到磁盘上,当ES活动起来时再调入内存,如果要求ES时刻保持迅速响应状态则可设置为true,那么ES的运行内存永远不会被交换到磁盘以避免交换过程带来的延时
node.max_local_storage_nodes: 2 #本机上的最大存储节点数,多个ES实例可以共享一个数据目录,这一特性有利于我们在开发环境的一台机器上测试集群机制,但在生产环境下建议设置为1,并且官方也建议一台集群仅部署一个ES实例

path.data: D:\software\es\elasticsearch-6.2.1\data #ES的数据目录
path.logs: D:\software\es\elasticsearch-6.2.1\logs #ES的日志目录

http.cors.enabled: true #是否允许跨域访问,后面通过一个可视化ES管理插件时需要通过js跨域访问此ES
http.cors.allow-origin: /.*/ #设置所有域均可跨域访问此ES

JVM参数设置

默认ES启动需要分配的堆内存为1G,如果你的机器内存较小则可在jvm.options中调整为512M:

1
2
复制代码-Xms512m
-Xmx512

启动ES

双击/bin/elasticsearch.bat启动脚本即可启动ES,关闭该命令行窗口即可关闭ES。

启动后访问:http://localhost:9200,如果得到如下响应则ES启动成功:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码{
name: "xc_node_1",
cluster_name: "xuecheng",
cluster_uuid: "93K4AFOVSD-kPF2DdHlcow",
version: {
number: "6.2.1",
build_hash: "7299dc3",
build_date: "2018-02-07T19:34:26.990113Z",
build_snapshot: false,
lucene_version: "7.2.1",
minimum_wire_compatibility_version: "5.6.0",
minimum_index_compatibility_version: "5.0.0"
},
tagline: "You Know, for Search"
}

elasticsearch-head可视化插件

ES是基于Lucene开发的产品级搜索引擎,封装了很多内部细节,通过此插件我们可以通过Web的方式可视化查看其内部状态。

此插件无需放到ES的/plugins目录下,因为它是通过JS与ES进行交互的。

1
2
3
4
复制代码git clone git://github.com/mobz/elasticsearch-head.git
cd elasticsearch-head
npm install
npm run start

浏览器打开:http://localhost:9100,并连接通过ES提供的http端口连接ES:

image

ES快速入门

首先我们要理解几个概念:索引库(index)、文档(document)、字段(field),我们可以类比关系型数据库来理解:

ES MySQL
索引库index 数据库database
type 表table
文档document 行row
字段field 列column

但是自ES6.x开始,type的概念就慢慢被弱化了,官方将在ES9正式剔除它。因此我们可以将索引库类比为一张表。一个索引库用来存储一系列结构类似的数据。虽然可以通过多个type制造出一个索引库“多张表”的效果,但官方不建议这么做,因为这会降低索引和搜索性能,要么你就新建另外一个索引库。类比MySQL来看就是,一个库就只放一张表。

名词索引 & 动词索引

名词索引指的是索引库,一个磁盘上的文件。

一个索引库就是一张倒排索引表,将数据存入ES的过程就是先将数据分词然后添加到倒排索引表的过程。

image

以添加“中华人民共和国”、“中华上下五千年”到索引库为例,倒排索引表的逻辑结构如下:

term doc_id
中华 1、2
人民 1
共和国 1
上下 2
五 2
千年 2
doc_id doc
1 中华人民共和国
2 中华上下五千年

这种将数据分词并建立各分词到文档之间的关联关系的过程称为==索引==(动词)

Postman

Postman是一款HTTP客户端工具,能否方便地发送各种形式的RESTful请求。

下文将以Postman来测试ES的RESTful API,请求的根URL为:http://localhost:9200

索引库管理

创建索引库

创建一个名为“xc_course”的用于存放学成在线(教育平台)的课程数据的索引库:

  • PUT /xc_course
1
2
3
4
5
6
复制代码{
"settings":{
"number_of_shards":1, //索引库分片数量
"number_of_replicas":0 //每个分片的副本数,关于分片、集群等在后文详细介绍
}
}

image

创建成功了吗?我们可以通过elasticsearch-head来查看,刷新localhost:9100:

image

删除索引库

DELET /xc_course

查看索引信息

GET /xc_course

映射管理

创建映射

映射可以类比MySQL的表结构定义,如有哪些字段,字段是什么类型。

创建映射的请求格式为:POST /index_name/type_name/_mapping。

不是说type已经弱化了吗?为什么这里还要指定type的名称?因为在ES9才正式剔除type的概念,在此之前需要一个过渡期,因此我们可以指定一个无意义的type名,如“doc”:

POST /xc_course/doc/_mapping

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码{
"properties":{
"name":{
"type":"text"
},
"description":{
"type":"text"
},
"price":{
"type":"double"
}
}
}

查看映射(类比查看表结构)

GET /xc_course/doc/_mapping

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码{
"xc_course": {
"mappings": {
"doc": {
"properties": {
"description": {
"type": "text"
},
"name": {
"type": "text"
},
"price": {
"type": "double"
}
}
}
}
}
}

也可以通过head插件查看:

image

文档管理

添加文档

PUT /index/type/id

如果不指定id,那么ES会为我们自动生成:

PUT /xc_course/doc

1
2
3
4
5
复制代码{
"name" : "Bootstrap开发框架",
"description" : "Bootstrap是由Twitter推出的一个前台页面开发框架,在行业之中使用较为广泛。此开发框架包含了大量的CSS、JS程序代码,可以帮助开发者(尤其是不擅长页面开发的程序人员)轻松的实现一个不受浏览器限制的精美界面效果。",
"price" : 99.9
}

响应如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码{
"_index": "xc_course",
"_type": "doc",
"_id": "Hib0QmoB7xBOMrejqjF3",
"_version": 1,
"result": "created",
"_shards": {
"total": 1,
"successful": 1,
"failed": 0
},
"_seq_no": 0,
"_primary_term": 1
}

根据id查询文档

GET /index/type/id

于是我们拿到我们刚添加数据生成的id来查询:

GET /xc_course/doc/Hib0QmoB7xBOMrejqjF3

1
2
3
4
5
6
7
8
9
10
11
12
复制代码{
"_index": "xc_course",
"_type": "doc",
"_id": "Hib0QmoB7xBOMrejqjF3",
"_version": 1,
"found": true,
"_source": {
"name": "Bootstrap开发框架",
"description": "Bootstrap是由Twitter推出的一个前台页面开发框架,在行业之中使用较为广泛。此开发框架包含了大量的CSS、JS程序代码,可以帮助开发者(尤其是不擅长页面开发的程序人员)轻松的实现一个不受浏览器限制的精美界面效果。",
"price": 99.9
}
}

查询全部文档

GET /index/type/_search

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
复制代码{
"took": 64, //此次查询花费时间
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 1,
"max_score": 1,
"hits": [ //查询匹配的文档集合
{
"_index": "xc_course",
"_type": "doc",
"_id": "Hib0QmoB7xBOMrejqjF3",
"_score": 1,
"_source": {
"name": "Bootstrap开发框架",
"description": "Bootstrap是由Twitter推出的一个前台页面开发框架,在行业之中使用较为广泛。此开发框架包含了大量的CSS、JS程序代码,可以帮助开发者(尤其是不擅长页面开发的程序人员)轻松的实现一个不受浏览器限制的精美界面效果。",
"price": 99.9
}
}
]
}
}

IK中文分词器

ES默认情况下是不支持中文分词的,也就是说对于添加的中文数据,ES将会把每个字当做一个term(词项),这不利于中文检索。

测试ES默认情况下对中文分词的结果:

POST /_analyze

你会发现ES的固定API都会带上_前缀,如_mapping、_search、_analyze

1
2
3
复制代码{
"text":"中华人民共和国"
}

分词结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
复制代码{
"tokens": [
{
"token": "中",
"start_offset": 0,
"end_offset": 1,
"type": "<IDEOGRAPHIC>",
"position": 0
},
{
"token": "华",
"start_offset": 1,
"end_offset": 2,
"type": "<IDEOGRAPHIC>",
"position": 1
},
{
"token": "人",
"start_offset": 2,
"end_offset": 3,
"type": "<IDEOGRAPHIC>",
"position": 2
},
{
"token": "民",
"start_offset": 3,
"end_offset": 4,
"type": "<IDEOGRAPHIC>",
"position": 3
},
{
"token": "共",
"start_offset": 4,
"end_offset": 5,
"type": "<IDEOGRAPHIC>",
"position": 4
},
{
"token": "和",
"start_offset": 5,
"end_offset": 6,
"type": "<IDEOGRAPHIC>",
"position": 5
},
{
"token": "国",
"start_offset": 6,
"end_offset": 7,
"type": "<IDEOGRAPHIC>",
"position": 6
}
]
}

下载ik-6.4.0并解压到ES/plugins/目录下,并将解压后的目录改名为==ik==,==重启ES==,该插件即会被自动加载。

重启ES后再测试分词效果:

POST http://localhost:9200/_analyze

1
2
3
4
复制代码{
"text":"中华人民共和国",
"analyzer":"ik_max_word" //设置分词器为ik分词器,否则还是会采用默认分词器,可选ik_max_word和ik_smart
}

ik_max_word分词策略是尽可能的分出多的term,即细粒度分词:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
复制代码{
"tokens": [
{
"token": "中华人民共和国",
"start_offset": 0,
"end_offset": 7,
"type": "CN_WORD",
"position": 0
},
{
"token": "中华人民",
"start_offset": 0,
"end_offset": 4,
"type": "CN_WORD",
"position": 1
},
{
"token": "中华",
"start_offset": 0,
"end_offset": 2,
"type": "CN_WORD",
"position": 2
},
{
"token": "华人",
"start_offset": 1,
"end_offset": 3,
"type": "CN_WORD",
"position": 3
},
{
"token": "人民共和国",
"start_offset": 2,
"end_offset": 7,
"type": "CN_WORD",
"position": 4
},
{
"token": "人民",
"start_offset": 2,
"end_offset": 4,
"type": "CN_WORD",
"position": 5
},
{
"token": "共和国",
"start_offset": 4,
"end_offset": 7,
"type": "CN_WORD",
"position": 6
},
{
"token": "共和",
"start_offset": 4,
"end_offset": 6,
"type": "CN_WORD",
"position": 7
},
{
"token": "国",
"start_offset": 6,
"end_offset": 7,
"type": "CN_CHAR",
"position": 8
}
]
}

而ik_smart则是出粒度分词(设置"analyzer" : "ik_smart"):

1
2
3
4
5
6
7
8
9
10
11
复制代码{
"tokens": [
{
"token": "中华人民共和国",
"start_offset": 0,
"end_offset": 7,
"type": "CN_WORD",
"position": 0
}
]
}

自定义词库

ik分词器仅提供了常用中文短语的词库,而对于实时性的热门网络短语则无法识别,因此有时为了增加分词准确性,我们需要自己扩展词库。

首先我们测试ik对网络词汇“蓝瘦香菇”的分词效果:

PUT /_analyze

1
2
3
4
复制代码{
"text":"蓝瘦香菇",
"analyzer":"ik_smart"
}

分词如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
复制代码{
"tokens": [
{
"token": "蓝",
"start_offset": 0,
"end_offset": 1,
"type": "CN_CHAR",
"position": 0
},
{
"token": "瘦",
"start_offset": 1,
"end_offset": 2,
"type": "CN_CHAR",
"position": 1
},
{
"token": "香菇",
"start_offset": 2,
"end_offset": 4,
"type": "CN_WORD",
"position": 2
}
]
}

我们在ES的/plugins/ik/config目录下增加自定义的词库文件my.dic并添加一行“蓝瘦香菇”(词典文件的格式是每一个词项占一行),并在ik的配置文件/plugins/ik/config/IKAnalyzer.cfg.xml中引入该自定义词典:

1
2
复制代码<!--用户可以在这里配置自己的扩展字典 -->
<entry key="ext_dict">my.dic</entry>

==重启ES==,ik分词器将会把我们新增的词项作为分词标准:

1
2
3
4
5
6
7
8
9
10
11
复制代码{
"tokens": [
{
"token": "蓝瘦香菇",
"start_offset": 0,
"end_offset": 4,
"type": "CN_WORD",
"position": 0
}
]
}

映射

新增字段

PUT /xc_course/doc/_mapping

1
2
3
4
5
6
7
复制代码{
"properties":{
"create_time":{
"type":"date"
}
}
}

GET /xc_course/doc/_mapping

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码{
"xc_course": {
"mappings": {
"doc": {
"properties": {
"create_time": {
"type": "date"
},
"description": {
"type": "text"
},
"name": {
"type": "text"
},
"price": {
"type": "double"
}
}
}
}
}
}

已有的映射可以新增字段但不可以更改已有字段的定义!

PUT /xc_course/doc/_mapping

1
2
3
4
5
6
7
复制代码{
"properties":{
"price":{
"type":"integer"
}
}
}

报错:已定义的price不能从double类型更改为integer类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码{
"error": {
"root_cause": [
{
"type": "illegal_argument_exception",
"reason": "mapper [price] cannot be changed from type [double] to [integer]"
}
],
"type": "illegal_argument_exception",
"reason": "mapper [price] cannot be changed from type [double] to [integer]"
},
"status": 400
}

如果一定要更改某字段的定义(包括类型、分词器、是否索引等),那么只有删除此索引库重新建立索引并定义好各字段,再迁入数据。因此在索引库创建时要考虑好映射的定义,因为仅可扩展字段但不可重新定义字段。

常用的映射类型——type

ES6.2的核心数据类型如下:

image

keyword

此类型的字段不会被分词,该字段内容被表示为就是一个短语不可分割。如各大商标和品牌名可使用此类型。并且在该字段查询内容时是精确匹配,如在type为keyword的brand字段搜索“华为”不会搜出字段值为“华为荣耀”的文档。

date

type为date的字段还可以额外指定一个format,如

1
2
3
4
5
6
7
8
复制代码{
"properties":{
"create_time":{
"type":"date",
"format":"yyyy-MM-dd HH:mm:ss||yyyy-MM-dd"
}
}
}

新增文档的create_time字段值可以是日期+时间或仅日期

数值类型

image

1、尽量选择范围小的类型,提高搜索效率
2、对于浮点数尽量用比例因子,比如一个价格字段,单位为元,我们将比例因子设置为100这在ES中会按==分==存
储,映射如下:

1
2
3
4
复制代码"price": {
"type": "scaled_float",       
"scaling_factor": 100
}

由于比例因子为100,如果我们输入的价格是23.45则ES中会将23.45乘以100存储在ES中。如果输入的价格是23.456,ES会将23.456乘以100再取一个接近原始值的数,得出2346。

使用比例因子的好处是整型比浮点型更易压缩,节省磁盘空间。

是否建立索引——index

index默认为true,即需要分词并根据分词所得词项建立倒排索引(词项到文档的关联关系)。有些字段的数据是无实际意义的,如课程图片的url仅作展示图片之用,不需要分词建立索引,那么可以设置为false:

PUT /xc_course/doc/_mapping

1
2
3
4
5
6
7
8
复制代码{
"properties":{
"pic":{
"type":"text"
"index":"false"
}
}
}

索引分词器 & 搜索分词器

索引分词器——analyzer

将数据添加到索引库时使用的分词器,建议使用ik_max_word,比如“中华人民共和国”,如果使用ik_smart,那么整个“中华人民共和国”将被作为一个term(此项)存入倒排索引表,那么在搜索“共和国”时就搜不到此数据(词项与词项之间是精确匹配的)。

搜索分词器——search_analyzer

搜索分词器则是用于将用户的检索输入分词的分词器。

建议使用ik_smart,比如搜索“中华人民共和国”,不应该出现“喜马拉雅共和国”的内容。

是否额外存储——store

是否在source之外存储,每个文档索引后会在 ES中保存一份原始文档,存放在_source中,一般情况下不需要设置
store为true,因为在_source中已经有一份原始文档了。

综合实战

创建一个课程集合的映射:

  1. 首先删除已建立映射的索引

DELET /xc_course
2. 新增索引

PUT /xc_course
3. 创建映射

PUT /xc_course/doc/_mapping

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
复制代码{
"properties": {
"name": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
},
"description": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
},
"price": {
"type": "scaled_float",
"scaling_factor": 100
},
"studypattern": {
"type": "keyword"
},
"pic": {
"type": "text",
"index": false
},
"timestamp": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd"
}
}
}
  1. 添加文档

POST /xc_course/doc

1
2
3
4
5
6
7
8
复制代码{
"name": "Java核心技术",
"description": "深入浅出讲解Java核心技术以及相关原理",
"price": 99.9,
"studypattern": "20101",
"pic": "http://xxx.xxx.xxx/skllsdfsdflsdfk.img",
"timestamp": "2019-4-1 13:16:00"
}
  1. 检索Java

GET http://localhost:9200/xc_course/doc/_search?q=name:java
6. 检索学习模式

GET http://localhost:9200/xc_course/doc/_search?q=studypattern:20101

索引管理和Java客户端

从此章节开始我们将对ES的每个RESTful API实现配套的Java代码。毕竟虽然前端可以通过HTTP访问ES,但是ES的管理和定制化业务还是需要一个后端作为枢纽。

ES提供的Java客户端——RestClient

RestClient是官方推荐使用的,它包括两种:Java Low Level REST Client和 Java High Level REST Client。ES在6.0之后提供 Java High Level REST Client, 两种客户端官方更推荐使用 Java High Level REST Client,不过当前它还处于完善中,有些功能还没有(如果它有不支持的功能,则使用Java Low Level REST Client。)。

依赖如下:

1
2
3
4
5
6
7
8
9
10
复制代码<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>6.2.1</version>
</dependency>
<dependency>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch</artifactId>
<version>6.2.1</version>
</dependency>

Spring整合ES

依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
复制代码<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>6.2.1</version>
</dependency>
<dependency>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch</artifactId>
<version>6.2.1</version>
</dependency>

<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>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-io</artifactId>
</dependency>

配置文件

application.yml:

1
2
3
4
5
6
7
8
复制代码server:
port: ${port:40100}
spring:
application:
name: xc-service-search
xuecheng: #自定义属性项
elasticsearch:
host-list: ${eshostlist:127.0.0.1:9200} #多个节点中间用逗号分隔

启动类

1
2
3
4
5
6
复制代码@SpringBootApplication
public class SearchApplication {
public static void main(String[] args){
SpringApplication.run(SearchApplication.class, args);
}
}

ES配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
复制代码package com.xuecheng.search.config;

import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* ElasticsearchConfig class
*
* @author : zaw
* @date : 2019/4/22
*/
@Configuration
public class ElasticsearchConfig {

@Value("${xuecheng.elasticsearch.host-list}")
private String hostList;

@Bean
public RestHighLevelClient restHighLevelClient() {
return new RestHighLevelClient(RestClient.builder(getHttpHostList(hostList)));
}

private HttpHost[] getHttpHostList(String hostList) {
String[] hosts = hostList.split(",");
HttpHost[] httpHostArr = new HttpHost[hosts.length];
for (int i = 0; i < hosts.length; i++) {
String[] items = hosts[i].split(":");
httpHostArr[i] = new HttpHost(items[0], Integer.parseInt(items[1]), "http");
}
return httpHostArr;
}

// rest low level client
@Bean
public RestClient restClient() {
return RestClient.builder(getHttpHostList(hostList)).build();
}
}

测试类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
复制代码package com.xuecheng.search;

import org.elasticsearch.action.admin.indices.create.CreateIndexRequest;
import org.elasticsearch.action.admin.indices.create.CreateIndexResponse;
import org.elasticsearch.client.IndicesClient;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.xcontent.XContentType;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.io.IOException;

/**
* TestES class
*
* @author : zaw
* @date : 2019/4/22
*/
@SpringBootTest
@RunWith(SpringRunner.class)
public class TestESRestClient {

@Autowired
RestHighLevelClient restHighLevelClient; //ES连接对象

@Autowired
RestClient restClient;
}

ES客户端API

首先我们将之前创建的索引库删除:

DELETE /xc_course

然后回顾一下创建索引库的RESTful形式:

PUT /xc_course

1
2
3
4
5
6
7
8
复制代码{
"settings":{
"index":{
"number_of_shards":1,
"number_of_replicas":0
}
}
}

创建索引库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码@Test
public void testCreateIndex() throws IOException {
CreateIndexRequest request = new CreateIndexRequest("xc_course");
/**
* {
* "settings":{
* "index":{
* "number_of_shards":1,
* "number_of_replicas":0
* }
* }
* }
*/
request.settings(Settings.builder().put("number_of_shards", 1).put("number_of_replicas", 0));
IndicesClient indicesClient = restHighLevelClient.indices(); //通过ES连接对象获取索引库管理对象
CreateIndexResponse response = indicesClient.create(request);
System.out.println(response.isAcknowledged()); //操作是否成功
}

对比RESTful形式,通过CreateIndexRequest方式发起此次请求,第3行通过构造函数指明了要创建的索引库名(对应URI /xc_course),第14行构造了请求体(你会发现settings方法和JSON请求格式很相似)。

操作索引库需要使用IndicesClient对象。

删除索引库

1
2
3
4
5
6
7
复制代码@Test
public void testDeleteIndex() throws IOException {
DeleteIndexRequest request = new DeleteIndexRequest("xc_course");
IndicesClient indicesClient = restHighLevelClient.indices();
DeleteIndexResponse response = indicesClient.delete(request);
System.out.println(response.isAcknowledged());
}

创建索引库时指定映射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
复制代码@Test
public void testCreateIndexWithMapping() throws IOException {
CreateIndexRequest request = new CreateIndexRequest("xc_course");
request.settings(Settings.builder().put("number_of_shards", 1).put("number_of_replicas", 0));
request.mapping("doc", "{\n" +
" \"properties\": {\n" +
" \"name\": {\n" +
" \"type\": \"text\",\n" +
" \"analyzer\": \"ik_max_word\",\n" +
" \"search_analyzer\": \"ik_smart\"\n" +
" },\n" +
" \"price\": {\n" +
" \"type\": \"scaled_float\",\n" +
" \"scaling_factor\": 100\n" +
" },\n" +
" \"timestamp\": {\n" +
" \"type\": \"date\",\n" +
" \"format\": \"yyyy-MM-dd HH:mm:ss||yyyy-MM-dd\"\n" +
" }\n" +
" }\n" +
"}", XContentType.JSON);
IndicesClient indicesClient = restHighLevelClient.indices();
CreateIndexResponse response = indicesClient.create(request);
System.out.println(response.isAcknowledged());
}

添加文档

添加文档的过程就是“索引”(动词)。需要使用IndexRequest对象进行索引操作。

1
2
3
4
5
6
7
8
9
10
11
12
复制代码public static final SimpleDateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
@Test
public void testAddDocument() throws IOException {
Map<String, Object> jsonMap = new HashMap<>();
jsonMap.put("name", "Java核心技术");
jsonMap.put("price", 66.6);
jsonMap.put("timestamp", FORMAT.format(new Date(System.currentTimeMillis())));
IndexRequest request = new IndexRequest("xc_course", "doc");
request.source(jsonMap);
IndexResponse response = restHighLevelClient.index(request);
System.out.println(response);
}

响应结果包含了ES为我们生成的文档id,这里我测试得到的id为fHh6RWoBduPBueXKl_tz

根据id查询文档

1
2
3
4
5
6
复制代码@Test
public void testFindById() throws IOException {
GetRequest request = new GetRequest("xc_course", "doc", "fHh6RWoBduPBueXKl_tz");
GetResponse response = restHighLevelClient.get(request);
System.out.println(response);
}

根据id更新文档

ES更新文档有两种方式:全量替换和局部更新

全量替换:ES首先会根据id查询文档并删除然后将该id作为新文档的id插入。

局部更新:只会更新相应字段

全量替换:

POST /index/type/id

局部更新:

POST /index/type/_update

Java客户端提供的是局部更新,即仅对提交的字段进行更新而其他字段值不变

1
2
3
4
5
6
7
8
9
10
11
12
复制代码@Test
public void testUpdateDoc() throws IOException {
UpdateRequest request = new UpdateRequest("xc_course", "doc", "fHh6RWoBduPBueXKl_tz");
Map<String, Object> docMap = new HashMap<>();
docMap.put("name", "Spring核心技术");
docMap.put("price", 99.8);
docMap.put("timestamp", FORMAT.format(new Date(System.currentTimeMillis())));
request.doc(docMap);
UpdateResponse response = restHighLevelClient.update(request);
System.out.println(response);
testFindById();
}

根据id删除文档

1
2
3
4
5
6
复制代码@Test
public void testDeleteDoc() throws IOException {
DeleteRequest request = new DeleteRequest("xc_course", "doc", "fHh6RWoBduPBueXKl_tz");
DeleteResponse response = restHighLevelClient.delete(request);
System.out.println(response);
}

搜索管理

准备环境

为了有数据可搜,我们重新创建映射并添加一些测试数据

创建映射

DELETE /xc_course

PUT /xc_course

1
2
3
4
5
6
复制代码{
"settings":{
"number_of_shards":1,
"number_of_replicas":0
}
}

PUT /xc_course/doc/_mapping

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
复制代码{
"properties": {
"name": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
},
"description": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
},
"studymodel":{
"type":"keyword" //授课模式,值为数据字典代号
},
"pic": {
"type": "text",
"index": false
},
"price": {
"type": "float"
},
"timestamp": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd"
}
}
}

添加测试数据

PUT /xc_course/doc/1

1
2
3
4
5
6
7
8
复制代码{
"name": "Bootstrap开发",
"description": "Bootstrap是由Twitter推出的一个前台页面开发框架,是一个非常流行的开发框架,此框架集成了多种页面效果。此开发框架包含了大量的CSS、JS程序代码,可以帮助开发者(尤其是不擅长页面开发的程序人员)轻松的实现一个不受浏览器限制的精美界面效果。",
"studymodel": "201002",
"price": 38.6,
"pic": "group1/M00/00/00/wKhlQFs6RCeAY0pHAAJx5ZjNDEM428.jpg",
"timestamp": "2018-04-25 19:11:35"
}

PUT /xc_course/doc/2

1
2
3
4
5
6
7
8
复制代码{
"name": "java编程基础",
"description": "java语言是世界第一编程语言,在软件开发领域使用人数最多。",
"studymodel": "201001",
"price": 68.6,
"pic": "group1/M00/00/00/wKhlQFs6RCeAY0pHAAJx5ZjNDEM428.jpg",
"timestamp": "2018-03-25 19:11:35"
}

PUT /xc_course/doc/3

1
2
3
4
5
6
7
8
复制代码{
"name": "spring开发基础",
"description": "spring 在java领域非常流行,java程序员都在用。",
"studymodel": "201001",
"price": 88.6,
"pic": "group1/M00/00/00/wKhlQFs6RCeAY0pHAAJx5ZjNDEM428.jpg",
"timestamp": "2018-02-24 19:11:35"
}

简单搜索

  • 搜索指定索引库中的所有文档

GET /xc_course/_search

  • 搜索指定type中的所有文档

GET /xc_course/doc/_search

DSL搜索

DSL(Domain Specific Language)是ES提出的基于json的搜索方式,在搜索时传入特定的json格式的数据来完成不同的搜索需求。

DSL比URI搜索方式功能强大,在项目中建议使用DSL方式来完成搜索。

DSL搜索方式是使用POST提交,URI为以_search结尾(在某index或某type范围内搜索),而在JSON请求体中定义搜索条件。

查询所有文档——matchAllQuery

POST /xc_course/doc/_search

1
2
3
4
5
6
复制代码{
"query":{
"match_all":{}
},
"_source":["name","studymodel"]
}

query用来定义搜索条件,_source用来指定返回的结果集中需要包含哪些字段。这在文档本身数据量较大但我们只想获取其中特定几个字段数据时有用(既可过滤掉不必要字段,又可提高传输效率)。

结果说明:

  • took,本次操作花费的时间,单位毫秒
  • time_out,请求是否超时(ES不可用或网络故障时会超时)
  • _shard,本次操作共搜索了哪些分片
  • hits,命中的结果
  • hits.total,符合条件的文档数
  • hits.hits,命中的文档集
  • hits.max_score,hits.hits中各文档得分的最高分,文档得分即查询相关度
  • _source,文档源数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
复制代码{
"took": 57,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 3,
"max_score": 1,
"hits": [
{
"_index": "xc_course",
"_type": "doc",
"_id": "1",
"_score": 1,
"_source": {
"studymodel": "201002",
"name": "Bootstrap开发"
}
},
{
"_index": "xc_course",
"_type": "doc",
"_id": "2",
"_score": 1,
"_source": {
"studymodel": "201001",
"name": "java编程基础"
}
},
{
"_index": "xc_course",
"_type": "doc",
"_id": "3",
"_score": 1,
"_source": {
"studymodel": "201001",
"name": "spring开发基础"
}
}
]
}
}

Java代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
复制代码@Test
public void testMatchAll() throws IOException {
// POST /xc_course/doc
SearchRequest request = new SearchRequest("xc_course"); //DSL搜索请求对象
request.types("doc");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); //DSL请求体构造对象
/**
* {
* "from":2,"size":1,
* "query":{
* "match_all":{
*
* }* },
* "_source":["name","studymodel"]
* }
*/
searchSourceBuilder.query(QueryBuilders.matchAllQuery());
//参数1:要返回哪些字段 参数2:不要返回哪些字段 两者通常指定其一
searchSourceBuilder.fetchSource(new String[]{"name", "studymodel"}, null);
//将请求体设置到请求对象中
request.source(searchSourceBuilder);
//发起DSL请求
SearchResponse response = restHighLevelClient.search(request);
System.out.println(response);
}

DSL核心API

  • new SearchRequest(index),指定要搜索的索引库
  • searchRequest.type(type),指定要搜索的type
  • SearchSourceBuilder,构建DSL请求体
  • searchSourceBuilder.query(queryBuilder),构造请求体中“query”:{}部分的内容
  • QueryBuilders,静态工厂类,方便构造queryBuilder,如searchSourceBuilder.query(QueryBuilders.matchAllQuery())就相当于构造了“query”:{ "match_all":{} }
  • searchRequest.source(),将构造好的请求体设置到请求对象中

分页查询

PUT http://localhost:9200/xc_course/doc/_search

1
2
3
4
5
6
7
8
9
复制代码{
"from":0,"size":1,
"query":{
"match_all":{

}
},
"_source":["name","studymodel"]
}

其中from的含义是结果集偏移,而size则是从偏移位置开始之后的size条结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
复制代码{
"took": 80,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": 3,
"max_score": 1,
"hits": [
{
"_index": "xc_course",
"_type": "doc",
"_id": "1",
"_score": 1,
"_source": {
"studymodel": "201002",
"name": "Bootstrap开发"
}
}
]
}
}

这里虽然hits.total为3,但是只返回了第一条记录。因此我们在做分页功能时需要用到一个公式:from = (page-1)*size

Java代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码@Test
public void testPaginating() throws IOException {
SearchRequest request = new SearchRequest("xc_course");
request.types("doc");

int page = 1, size = 1;
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.from((page - 1) * size);
searchSourceBuilder.size(size);
searchSourceBuilder.query(QueryBuilders.matchAllQuery());
searchSourceBuilder.fetchSource(new String[]{"name", "studymodel"}, null);

request.source(searchSourceBuilder);
SearchResponse response = restHighLevelClient.search(request);
System.out.println(response);
}

提取结果集中的文档

1
2
3
4
5
6
7
8
复制代码SearchResponse response = restHighLevelClient.search(request);
SearchHits hits = response.getHits(); //hits
if (hits != null) {
SearchHit[] results = hits.getHits(); //hits.hits
for (SearchHit result : results) {
System.out.println(result.getSourceAsMap()); //hits.hits._source
}
}

词项匹配——termQuery

词项匹配是==精确匹配==,只有当倒排索引表中存在我们指定的词项时才会返回该词项关联的文档集。

如搜索课程名包含java词项的文档

1
2
3
4
5
6
7
复制代码{
"from":0,"size":1,
"query":{
"term":{ "name":"java" }
},
"_source":["name","studymodel"]
}

结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码"hits": {
"total": 1,
"max_score": 0.9331132,
"hits": [
{
"_index": "xc_course",
"_type": "doc",
"_id": "2",
"_score": 0.9331132,
"_source": {
"studymodel": "201001",
"name": "java编程基础"
}
}
]
}

但如果你指定"term"为{ "name":"java编程" }就搜索不到了:

1
2
3
4
5
复制代码"hits": {
"total": 0,
"max_score": null,
"hits": []
}

因为“java编程基础”在索引时会被分为“java”、“编程”、“基础”三个词项添加到倒排索引表中,因此没有一个叫“java编程”的词项和此次查询匹配。

term查询是精确匹配,term.name不会被search_analyzer分词,而是会作为一个整体和倒排索引表中的词项进行匹配。

根据id精确匹配——termsQuery

查询id为1和3的文档

POST http://localhost:9200/xc_course/doc/_search

1
2
3
4
5
6
7
复制代码{
"query":{
"ids":{
"values":["1","3"]
}
}
}

Java实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
复制代码@Test
public void testQueryByIds(){
SearchRequest request = new SearchRequest("xc_course");
request.types("doc");

SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
List<String> ids = Arrays.asList(new String[]{"1", "3"});
sourceBuilder.query(QueryBuilders.termsQuery("_id", ids));

printResult(request, sourceBuilder);
}

private void printResult(SearchRequest request,SearchSourceBuilder sourceBuilder) {
request.source(sourceBuilder);
SearchResponse response = null;
try {
response = restHighLevelClient.search(request);
} catch (IOException e) {
e.printStackTrace();
}
SearchHits hits = response.getHits();
if (hits != null) {
SearchHit[] results = hits.getHits();
for (SearchHit result : results) {
System.out.println(result.getSourceAsMap());
}
}
}

==大坑==

根据id精确匹配也是term查询的一种,但是调用的API是termsQuery("_id", ids),注意是termsQuery而不是termQuery。

全文检索—— matchQuery

输入的关键词会被search_analyzer指定的分词器分词,然后根据所得词项到倒排索引表中查找文档集合,每个词项关联的文档集合都会被查出来,如查“bootstrap基础”会查出“java编程基础”:

POST

1
2
3
4
5
6
7
复制代码{
"query":{
"match":{
"name":"bootstrap基础"
}
}
}

因为“bootstrap基础”会被分为“bootstrap”和“基础”两个词项,而词项“基础”关联文档“java编程基础”。

1
2
3
4
5
6
7
8
9
10
11
复制代码@Test
public void testMatchQuery() {

SearchRequest request = new SearchRequest("xc_course");
request.types("doc");

SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.matchQuery("name", "bootstrap基础"));

printResult(request, sourceBuilder);
}

operator

上述查询等同于:

1
2
3
4
5
6
7
8
9
10
复制代码{
"query": {
"match": {
"name": {
"query": "bootstrap基础",
"operator": "or"
}
}
}
}

即对检索关键词分词后每个词项的查询结果取并集。

operator可取值or、and,分别对应取并集和取交集。

如下查询就只有一结果(课程名既包含“java”又包含“基础”的只有“java编程基础”):

1
2
3
4
5
6
7
8
9
10
复制代码{
"query": {
"match": {
"name": {
"query": "java基础",
"operator": "and"
}
}
}
}

Java代码

1
2
3
4
5
6
7
8
9
10
复制代码@Test
public void testMatchQuery2() {
SearchRequest request = new SearchRequest("xc_course");
request.types("doc");

SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.matchQuery("name", "java基础").operator(Operator.AND));

printResult(request, sourceBuilder);
}

minimum_should_match

上边使用的operator = or表示只要有一个词匹配上就得分,如果实现三个词至少有两个词匹配如何实现?

使用minimum_should_match可以指定文档匹配词的占比,比如搜索语句如下:

1
2
3
4
5
6
7
8
9
10
复制代码{
"query": {
"match": {
"name": {
"query": "spring开发框架",
"minimum_should_match":"80%"
}
}
}
}

“spring开发框架”会被分为三个词:spring、开发、框架。

设置"minimum_should_match":"80%"表示,三个词在文档的匹配占比为80%,即3*0.8=2.4,向上取整得2,表
示至少有两个词在文档中才算匹配成功。

1
2
3
4
5
6
7
8
9
10
复制代码@Test
public void testMatchQuery3() {
SearchRequest request = new SearchRequest("xc_course");
request.types("doc");

SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.matchQuery("name", "spring开发指南").minimumShouldMatch("70%")); //3*0.7 -> 2

printResult(request, sourceBuilder);
}

多域检索——multiMatchQuery

上边学习的termQuery和matchQuery一次只能匹配一个Field,本节学习multiQuery,一次可以匹配多个字段(即扩大了检索范围,之前一直都是在name字段中检索)。

如检索课程名或课程描述中包含“spring”或“css”的文档:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码{
"query": {
"multi_match": {
"query": "spring css",
"minimum_should_match": "50%",
"fields": [
"name",
"description"
]
}
},
"_source":["name","description"]
}

Java:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码@Test
public void testMultiMatchQuery() {
SearchRequest request = new SearchRequest("xc_course");
request.types("doc");

SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.
multiMatchQuery("spring css","name","description").
minimumShouldMatch("50%"));

printResult(request, sourceBuilder);
}

boost权重

观察上述查出的文档得分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码"hits": [
{
"_index": "xc_course",
"_type": "doc",
"_id": "3",
"_score": 1.3339276,
"_source": {
"name": "spring开发基础",
"description": "spring 在java领域非常流行,java程序员都在用。"
}
},
{
"_index": "xc_course",
"_type": "doc",
"_id": "1",
"_score": 0.69607234,
"_source": {
"name": "Bootstrap开发",
"description": "Bootstrap是由Twitter推出的一个前台页面开发框架,是一个非常流行的开发框架,此框架集成了多种页面效果。此开发框架包含了大量的CSS、JS程序代码,可以帮助开发者(尤其是不擅长页面开发的程序人员)轻松的实现一个不受浏览器限制的精美界面效果。"
}
}
]

你会发现文档3中spring词项在文档出现的次数占文档词项总数的比例较高因此得分(_score)较高。那我们猜想,是不是我们在文档1的课程描述中多添加几个css能否提升其_score呢?

于是我们更新一下文档1:

1
2
3
4
5
6
7
8
9
10
复制代码@Test
public void testUpdateDoc() throws IOException {
UpdateRequest request = new UpdateRequest("xc_course", "doc", "1");
Map<String, Object> docMap = new HashMap<>();
docMap.put("description", "Bootstrap是由Twitter推出的一个css前台页面开发框架,是一个非常流行的css开发框架,此框架集成了多种css页面效果。此开发框架包含了大量的CSS、JS程序代码,可以帮助css开发者(尤其是不擅长css页面开发的程序人员)轻松的实现一个不受浏览器限制的精美界面效果。");
request.doc(docMap);
UpdateResponse response = restHighLevelClient.update(request);
System.out.println(response);
testFindById();
}

再次查询发现文档1的得分果然变高了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码"hits": [
{
"_index": "xc_course",
"_type": "doc",
"_id": "1",
"_score": 1.575484,
"_source": {
"name": "Bootstrap开发",
"description": "Bootstrap是由Twitter推出的一个css前台页面开发框架,是一个非常流行的css开发框架,此框架集成了多种css页面效果。此开发框架包含了大量的CSS、JS程序代码,可以帮助css开发者(尤其是不擅长css页面开发的程序人员)轻松的实现一个不受浏览器限制的精美界面效果。"
}
},
{
"_index": "xc_course",
"_type": "doc",
"_id": "3",
"_score": 1.346281,
"_source": {
"name": "spring开发基础",
"description": "spring 在java领域非常流行,java程序员都在用。"
}
}
]

那我们有这样一个业务需求:课程出现spring或css肯定是与spring或css相关度更大的课程,而课程描述出现则不一定。因此我们想提高课程出现关键词项的得分权重,我们可以这么办(在name字段后追加一个^符号并指定权重,默认为1):

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码{
"query": {
"multi_match": {
"query": "spring css",
"minimum_should_match": "50%",
"fields": [
"name^10",
"description"
]
}
},
"_source":["name","description"]
}

Java:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码@Test
public void testMultiMatchQuery2() {
SearchRequest request = new SearchRequest("xc_course");
request.types("doc");

SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
MultiMatchQueryBuilder multiMatchQueryBuilder = QueryBuilders.multiMatchQuery("spring css", "name", "description").minimumShouldMatch("50%");
multiMatchQueryBuilder.field("name", 10);
sourceBuilder.query(multiMatchQueryBuilder);

printResult(request, sourceBuilder);
}

布尔查询——boolQuery

布尔查询对应于Lucene的BooleanQuery查询,实现==将多个查询组合起来==。

三个参数

  • must:文档必须匹配must所包括的查询条件,相当于 “AND”
  • should:文档应该匹配should所包括的查询条件其中的一个或多个,相当于 “OR”
  • must_not:文档不能匹配must_not所包括的该查询条件,相当于“NOT”

如查询课程名包含“spring”==且==课程名或课程描述跟“开发框架”有关的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码{
"query": {
"bool":{
"must":[
{
"term":{
"name":"spring"
}
},
{
"multi_match":{
"query":"开发框架",
"fields":["name","description"]
}
}
]
}
},
"_source":["name"]
}

Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码@Test
public void testBoolQuery() {
SearchRequest request = new SearchRequest("xc_course");
request.types("doc");

SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); //query
BoolQueryBuilder boolQueryBuilder = new BoolQueryBuilder(); //query.bool

TermQueryBuilder termQueryBuilder = QueryBuilders.termQuery("name", "spring");
MultiMatchQueryBuilder multiMatchQueryBuilder = QueryBuilders.multiMatchQuery("开发框架", "name", "description");

boolQueryBuilder.must(termQueryBuilder); //query.bool.must
boolQueryBuilder.must(multiMatchQueryBuilder);
sourceBuilder.query(boolQueryBuilder);

printResult(request, sourceBuilder);
}

必须满足的条件放到must中(boolQueryBuilder.must(条件)),必须排斥的条件放到must_not中,只需满足其一的条件放到should中。

查询课程名必须包含“开发”但不包含“java”的,且包含“spring”或“boostrap”的课程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
复制代码{
"query": {
"bool":{
"must":[
{
"term":{
"name":"开发"
}
}
],
"must_not":[
{
"term":{
"name":"java"
}
}
],
"should":[
{
"term":{
"name":"bootstrap"
}
},
{
"term":{
"name":"spring"
}
}
]
}
},
"_source":["name"]
}

当然实际项目不会这么设置条件,这里只是为了演示效果,这里为了演示方便用的都是termQuery,事实可用前面任意一种Query。

Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码@Test
public void testBoolQuery2() {
SearchRequest request = new SearchRequest("xc_course");
request.types("doc");

SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
BoolQueryBuilder boolQueryBuilder = new BoolQueryBuilder();

boolQueryBuilder.must(QueryBuilders.termQuery("name","开发"));
boolQueryBuilder.mustNot(QueryBuilders.termQuery("name", "java"));
boolQueryBuilder.should(QueryBuilders.termQuery("name","spring"));
boolQueryBuilder.should(QueryBuilders.termQuery("name","bootstrap"));

sourceBuilder.query(boolQueryBuilder);

printResult(request, sourceBuilder);
}

过滤器——filter

过滤是针对搜索的结果进行过滤,==过滤器主要判断的是文档是否匹配,不去计算和判断文档的匹配度得分==,所以==过滤器性能比查询要高,且方便缓存==,推荐尽量使用过滤器去实现查询或者过滤器和查询共同使用。

过滤器仅能在布尔查询中使用。

全文检索“spring框架”,并过滤掉学习模式代号不是“201001”和课程价格不在10~100之间的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
复制代码{
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "spring框架",
"fields": [
"name",
"description"
]
}
}
],
"filter": [
{
"term": {
"studymodel": "201001"
}
},
{
"range": {
"price": {
"gte": "10",
"lte": "100"
}
}
}
]
}
}
}

Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码@Test
public void testBoolQuery3() {
SearchRequest request = new SearchRequest("xc_course");
request.types("doc");

SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
BoolQueryBuilder boolQueryBuilder = new BoolQueryBuilder();

boolQueryBuilder.must(QueryBuilders.multiMatchQuery("spring框架", "name", "description"));
boolQueryBuilder.filter(QueryBuilders.termQuery("studymodel", "201001"));
boolQueryBuilder.filter(QueryBuilders.rangeQuery("price").gte(10).lte(100));

sourceBuilder.query(boolQueryBuilder);

printResult(request, sourceBuilder);
}

排序

查询课程价格在10~100之间的,并按照价格升序排列,当价格相同时再按照时间戳降序排列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
复制代码{
"query": {
"bool": {
"filter": [
{
"range": {
"price": {
"gte": "10",
"lte": "100"
}
}
}
]
}
},
"sort": [
{
"price": "asc"
},
{
"timestamp": "desc"
}
],
"_source": [
"name",
"price",
"timestamp"
]
}

Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码@Test
public void testSort() {
SearchRequest request = new SearchRequest("xc_course");
request.types("doc");

SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();

BoolQueryBuilder boolQueryBuilder = new BoolQueryBuilder();
boolQueryBuilder.filter(QueryBuilders.rangeQuery("price").gte(10).lte(100));

sourceBuilder.sort("price", SortOrder.ASC);
sourceBuilder.sort("timestamp", SortOrder.DESC);

sourceBuilder.query(boolQueryBuilder);
printResult(request, sourceBuilder);
}

高亮

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
复制代码{
"query": {
"bool": {
"filter": [
{
"multi_match": {
"query": "bootstrap",
"fields": [
"name",
"description"
]
}
}
]
}
},
"highlight":{
"pre_tags":["<tag>"],
"post_tags":["</tag>"],
"fields":{
"name":{},
"description":{}
}
}
}

结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码"hits": [
{
"_index": "xc_course",
"_type": "doc",
"_id": "1",
"_score": 0,
"_source": {
"name": "Bootstrap开发",
"description": "Bootstrap是由Twitter推出的一个css前台页面开发框架,是一个非常流行的css开发框架,此框架集成了多种css页面效果。此开发框架包含了大量的CSS、JS程序代码,可以帮助css开发者(尤其是不擅长css页面开发的程序人员)轻松的实现一个不受浏览器限制的精美界面效果。",
"studymodel": "201002",
"price": 38.6,
"pic": "group1/M00/00/00/wKhlQFs6RCeAY0pHAAJx5ZjNDEM428.jpg",
"timestamp": "2018-04-25 19:11:35"
},
"highlight": {
"name": [
"<tag>Bootstrap</tag>开发"
],
"description": [
"<tag>Bootstrap</tag>是由Twitter推出的一个css前台页面开发框架,是一个非常流行的css开发框架,此框架集成了多种css页面效果。"
]
}
}
]

hits结果集中的每个结果出了给出源文档_source之外,还给出了相应的高亮结果highlight

Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
复制代码@Test
public void testHighlight() throws IOException {
SearchRequest request = new SearchRequest("xc_course");
request.types("doc");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();

BoolQueryBuilder boolQueryBuilder = new BoolQueryBuilder();
boolQueryBuilder.filter(QueryBuilders.multiMatchQuery("bootstrap","name","description"));

HighlightBuilder highlightBuilder = new HighlightBuilder();
highlightBuilder.preTags("<tag>");
highlightBuilder.postTags("</tag>");
highlightBuilder.field("name").field("description");

sourceBuilder.query(boolQueryBuilder);
sourceBuilder.highlighter(highlightBuilder);
request.source(sourceBuilder);

SearchResponse response = restHighLevelClient.search(request);
SearchHits hits = response.getHits(); //hits
if (hits != null) {
SearchHit[] results = hits.getHits(); //hits.hits
if (results != null) {
for (SearchHit result : results) {
Map<String, Object> source = result.getSourceAsMap(); //_source
String name = (String) source.get("name");
Map<String, HighlightField> highlightFields = result.getHighlightFields(); //highlight
HighlightField highlightField = highlightFields.get("name");
if (highlightField != null) {
Text[] fragments = highlightField.getFragments();
StringBuilder stringBuilder = new StringBuilder();
for (Text text : fragments) {
stringBuilder.append(text.string());
}
name = stringBuilder.toString();
}
System.out.println(name);

String description = (String) source.get("description");
HighlightField highlightField2 = highlightFields.get("description");
if (highlightField2 != null) {
Text[] fragments = highlightField2.getFragments();
StringBuilder stringBuilder = new StringBuilder();
for (Text text : fragments) {
stringBuilder.append(text.string());
}
description = stringBuilder.toString();
}
System.out.println(description);
}
}
}
}

比较难理解的API是HighlightFields和highlightField.getFragments(),我们需要对比响应JSO的结构来类比理解。

image

我们可以通过highlightFields.get()来获取highlight.name和highlight.description对应的highlightField,但是为什么hightField.getFragment返回的是一个Text[]而不是Text呢。我们猜测ES将文档按照句子分成了多个段,仅对出现关键词项的段进行高亮并返回,于是我们检索css测试一下果然如此:

image

因此你需要注意返回的highlight可能并不包含所有原字段内容

image

集群管理

ES通常以集群方式工作,这样做不仅能够提高 ES的搜索能力还可以处理大数据搜索的能力,同时也增加了系统的容错能力及高可用,ES可以实现PB级数据的搜索。

下图是ES集群结构的示意图:

image

集群相关概念

节点

ES集群由多个服务器组成,每个服务器即为一个Node节点(该服务只部署了一个ES进程)。

分片

当我们的文档量很大时,由于内存和硬盘的限制,同时也为了提高ES的处理能力、容错能力及高可用能力,我们将索引分成若干分片(可以类比MySQL中的分区来看,一个表分成多个文件),每个分片可以放在不同的服务器,这样就实现了多个服务器共同对外提供索引及搜索服务。

一个搜索请求过来,会分别从各各分片去查询,最后将查询到的数据合并返回给用户。

副本

为了提高ES的高可用同时也为了提高搜索的吞吐量,我们将分片复制一份或多份存储在其它的服务器,这样即使当前的服务器挂掉了,拥有副本的服务器照常可以提供服务。

主节点

一个集群中会有一个或多个主节点,主节点的作用是集群管理,比如增加节点,移除节点等,主节点挂掉后ES会重新选一个主节点。

节点转发

每个节点都知道其它节点的信息,我们可以对任意一个v发起请求,接收请求的节点会转发给其它节点查询数据。

节点的三个角色

主节点

master节点主要用于集群的管理及索引 比如新增节点、分片分配、索引的新增和删除等。

数据节点

data 节点上保存了数据分片,它负责索引和搜索操作。

客户端节点

client 节点仅作为请求客户端存在,client的作用也作为负载均衡器,client 节点不存数据,只是将请求均衡转发到其它节点。

配置

可在/config/elasticsearch.yml中配置节点的功能:

  • node.master: #是否允许为主节点
  • node.data: #允许存储数据作为数据节点
  • node.ingest: #是否允许成为协调节点(数据不在当前ES实例上时转发请求)

四种组合方式:

  • master=true,data=true:即是主节点又是数据节点
  • master=false,data=true:仅是数据节点
  • master=true,data=false:仅是主节点,不存储数据
  • master=false,data=false:即不是主节点也不是数据节点,此时可设置ingest为true表示它是一个客户端。

搭建集群

下我们来实现创建一个2节点的集群,并且索引的分片我们设置2片,每片一个副本。

解压elasticsearch-6.2.1.zip两份为es-1、es-2

配置文件elasticsearch.yml

节点1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码cluster.name: xuecheng
node.name: xc_node_1
network.host: 0.0.0.0
http.port: 9200
transport.tcp.port: 9300
node.master: true
node.data: true
discovery.zen.ping.unicast.hosts: ["0.0.0.0:9300", "0.0.0.0:9301"]
discovery.zen.minimum_master_nodes: 1
node.ingest: true
bootstrap.memory_lock: false
node.max_local_storage_nodes: 2

path.data: D:\software\es\cluster\es-1\data
path.logs: D:\software\es\cluster\es-1\logs

http.cors.enabled: true
http.cors.allow-origin: /.*/

节点2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码cluster.name: xuecheng
node.name: xc_node_2
network.host: 0.0.0.0
http.port: 9201
transport.tcp.port: 9301
node.master: true
node.data: true
discovery.zen.ping.unicast.hosts: ["0.0.0.0:9300", "0.0.0.0:9301"]
discovery.zen.minimum_master_nodes: 1
node.ingest: true
bootstrap.memory_lock: false
node.max_local_storage_nodes: 2

path.data: D:\software\es\cluster\es-2\data
path.logs: D:\software\es\cluster\es-2\logs

http.cors.enabled: true
http.cors.allow-origin: /.*/

测试分片

创建索引,索引分两片,每片有一个副本:

PUT http://localhost:9200/xc_course

1
2
3
4
5
6
复制代码{
"settings": {
"number_of_shards": 2,
"number_of_replicas": 1
}
}

通过head插件查看索引状态:

image

测试主从复制

写入数据

POST http://localhost:9200/xc_course/doc

1
2
3
复制代码{
"name":"java编程基础"
}

两个结点均有数据:

image

image

集群的健康

通过访问 GET /_cluster/health 来查看Elasticsearch 的集群健康情况。

用三种颜色来展示健康状态: green 、 yellow 或者 red 。

  • green:所有的主分片和副本分片都正常运行。
  • yellow:所有的主分片都正常运行,但有些副本分片运行不正常。
  • red:存在主分片运行不正常。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试官:谈谈你对Mysql数据库读写分离的了解,并且有哪些注

发表于 2019-04-22

这篇文章讲述的不是Mysql具体的如何实现读写分离,而是指什么时候需要上读写分离,及其相关的注意事项。

因为用户的增多,数据的增多,单机的数据库往往支撑不住快速发展的业务,所以数据库集群就产生了!今天来说说读写分离的数据库集群方式!
读写分离顾名思义就是读和写分离了,对应到数据库集群一般都是一主一从(一个主库,一个从库)或者一主多从(一个主库,多个从库),业务服务器把需要写的操作都写到主数据库中,读的操作都去从库查询。主库会同步数据到从库保证数据的一致性。

主从集群

这种集群方式的本质就是把访问的压力从主库转移到从库,也就是在单机数据库无法支撑并发读写的时候,并且读的请求很多的情况下适合这种读写分离的数据库集群。如果写的操作很多的话不适合这种集群方式,因为你的数据库压力还是在写操作上,即使主从了之后压力还是在主库上和单机区别就不大了。
在单机的情况下,一般我们做数据库优化都会加索引,但是加了索引对查询有优化,但是会影响写入,因为写入数据会更新索引。所以做了主从之后,我们可以单独的针对从库(读库)做索引上的优化,而主库(写库)可以减少索引而提高写的效率。

看起来还是很简单的,但是有两点要注意:主从同步延迟、分配机制的考虑;

主从同步延迟

主库有数据写入之后,同时也写入在binlog(二进制日志文件)中,从库是通过binlog文件来同步数据的,这期间会有一定时间的延迟,可能是1秒,如果同时有大量数据写入的话,时间可能更长。

这会导致什么问题呢?比如有一个付款操作,你付款了,主库是已经写入数据,但是查询是到从库查,从库里还没有你的付款记录,所以页面上查询的时候你还没付款。那可不急眼了啊,吞钱了这还了得!打电话给客服投诉!

所以为了解决主从同步延迟的问题有以下几个方法:

1、二次读取

二次读取的意思就是读从库没读到之后再去主库读一下,只要通过对数据库访问的API进行封装就能实现这个功能。很简单,并且和业务之间没有耦合。但是有个问题,如果有很多二次读取相当于压力还是回到了主库身上,等于读写分离白分了。而且如有人恶意攻击,就一直访问没有的数据,那主库就可能爆了。

2、写之后的马上的读操作访问主库

也就是写操作之后,立马的读操作指定访问主库,之后的读操作采取访问从库。这就等于写死了,和业务强耦合了。

3、关键业务读写都由主库承担,非关键业务读写分离

类似付钱的这种业务,读写都到主库,避免延迟的问题,但是例如改个头像啊,个人签名这种比较不重要的就读写分离,查询都去从库查,毕竟延迟一下影响也不大,不会立马打客服电话哈哈。

分配机制的考虑

分配机制的考虑也就是怎么制定写操作是去主库写,读操作是去从库读。

一般有两种方式:代码封装、数据库中间件。

1、代码封装
代码封装的实现很简单,就是抽出一个中间层,让这个中间层来实现读写分离和数据库连接。讲白点就是搞个provider封装了save,select等通常数据库操作,内部save操作的dataSource是主库的,select操作的dataSource是从库的。

优点:就是实现简单,并且可以根据业务定制化变化,随心所欲。

缺点:就是是如果哪个数据库宕机了,发生主从切换了之后,就得修改配置重启。并且如果你的系统很大,一个业务可能包含多个子系统,一个子系统是java写的一个子系统用go写的,这样的话得分别为不同语言实现一套中间层,重复开发。

代码封装数据访问层

2、数据库中间件
就是有一个独立的系统,专门来实现读写分离和数据库连接管理,业务服务器和数据库中间件之间是通过标准的SQL协议交流的,所以在业务服务器看来数据库中间件其实就是个数据库。

优点:因为是通过sql协议的所以可以兼容不同的语言不需要单独写一套,并且有中间件来实现主从切换,业务服务器不需要关心这点。

缺点:多了一个系统其实就等于多了一个关心。。如果数据库中间件挂了的话对吧,而且多了一个系统就等于多了一个瓶颈,所以对中间件的性能要求也高,并且所有的数据库操作都要经过它。并且中间件实现很复杂,难度比代码封装高多了。

但是有开源的数据库中间件例如Mysql Proxy,Mysql Route,Atlas。

数据库中间件

总结

读写分离相对而言是比较简单的,比分表分库简单,但是它只能分担访问的压力,分担不了存储的压力,也就是你的数据库表的数据逐渐增多,但是面对一张表海量的数据,查询还是很慢的,所以如果业务发展的快数据暴增,到一定时间还是得分库分表。

但是正常情况下,只要当单机真的顶不住压力了才会集群,不要一上来就集群,没这个必要。有关于软件的东西都是越简单越好,复杂都是形势所迫。

一般我们是先优化,优化一些慢查询,优化业务逻辑的调用或者加入缓存等,如果真的优化到没东西优化了然后才上集群,先读写分离,读写分离之后顶不住就再分库分表。


如有错误欢迎指正!
个人公众号:yes的练级攻略

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

SpringBoot+webservice

发表于 2019-04-20

今天看到一个项目要和工厂的ERP进行对接,用到了webservice。虽然使用用springboot较为方便,还是了解一下:

webservice是什么

网上的解释很多,其实就是跨语言和操作系统的的远程调用技术。比如亚马逊,可以将自己的服务以webservice的服务形式暴露出来,我们就可以通过web调用这些,无论我们使用的语言是java还是c,这也是SOA应用一种表现形式。

WSDL(Web Services Description Language)将无论用何种语言书写的web service描述出来,比如其参数或返回值。WSDL是服务端和客户端都能解读的标准格式。客户端通过URL地址访问到WSDL文件,在调用服务端之前先访问WSDL文件。 读取到WSDL后通过客户端的API类可以生成代理类,调用这些代理类就可以访问webservice服务。代理类将客户端的方法变为soap(Simple Object Access Protocol,可以理解为http+xml)格式通过http发送,同时接受soap格式的返回值并解析。


webservice使用

  • 创建工程

  • 添加依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
复制代码        <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>

<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.4</version>
</dependency>

<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.1.41</version>
</dependency>

<dependency>
<groupId>org.apache.cxf</groupId>
<artifactId>cxf-spring-boot-starter-jaxws</artifactId>
<version>3.1.11</version>
</dependency>

<dependency>
<groupId>org.scala-lang</groupId>
<artifactId>scala-library</artifactId>
<version>2.11.0</version>
</dependency>
</dependencies>
  • 测试对象TestBean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码    public class TestBean {
private String name;
private String id;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public String getId() {
return id;
}

public void setId(String id) {
this.id = id;
}
}
  • 服务端接口

1
2
3
4
5
6
7
8
9
10
复制代码    @WebService(name = "testService",targetNamespace = "http://service.webservicedemo.sxt.com")
public interface Wbservice {

@WebMethod
@WebResult()
public String helloService(@WebParam(name = "name") String name);

@WebMethod
public ArrayList<TestBean> getAllBean()
}
  • 服务端接口实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码    @WebService(name = "testService",
targetNamespace = "http://service.webservicedemo.sxt.com",
endpointInterface="com.sxt.webservicedemo.Service.Wbservice")

@Component
public class WebserviceImpl implements Wbservice {
@Override
public String helloService(String name) {
return "hello"+name;
}

@Override
public ArrayList<TestBean> getAllBean() {
ArrayList<TestBean> list = new ArrayList<>();
TestBean bean1 = new TestBean("zhangsan", "1");
TestBean bean2 = new TestBean("lisi", "2");
list.add(bean1);
list.add(bean2);
return list;
}
}
  • 发布配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
复制代码@Configuration
public class Webserviceconfig {

@Bean
public ServletRegistrationBean dispatcherServlet(){
return new ServletRegistrationBean(new CXFServlet(),"/service/*");//发布服务名称
}

@Bean(name = Bus.DEFAULT_BUS_ID)
public SpringBus springBus()
{
return new SpringBus();
}

@Bean
public Wbservice beanService()
{
return new WebserviceImpl();
}

@Bean
public Endpoint endpoint() {
EndpointImpl endpoint=new EndpointImpl(springBus(), beanService());//绑定要发布的服务
endpoint.publish("/test"); //显示要发布的名称
return (Endpoint) endpoint;
}
  • 客户端调用

1
2
3
4
5
6
7
8
复制代码public static void main(String[] args) throws Exception {
JaxWsDynamicClientFactory dcflient=JaxWsDynamicClientFactory.newInstance();

Client client=dcflient.createClient("http://localhost:8080/service/test?wsdl");

Object[] objects=client.invoke("getBean","411001");
System.out.println("*******"+objects[0].toString());
}

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…874875876…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%