springboot整合Elasticsearch - 实现全局高亮分页搜索

1 篇文章 0 订阅

更多最新文章欢迎大家访问我的个人博客😄:豆腐别馆

一、前言

  • 常用的全局搜索引擎有很多,有兴趣的童靴戳这里常见的Java全局搜索引擎,目前更常用的除了solr,还有就是我们今天要说的分布式搜索引擎 Elasticsearch(简称ES)

  • spring boot整合ES的方式目前常见的有两种,一种是使用spring data elasticsearch,一种就是使用elasticsearchTemplate进行整合。如对搜索没有高亮需求,用前者即可,如有高亮需求,则必须使用后者。
    (ps:当然了,可能还有其它好用的方式是我不清楚的,若有的话承蒙不弃,请留言告知,thx。)

  • 事实上Elasticsearch只是我们常讲的ELK技术栈中的其中一项,emm,现在最新的叫法为Elastic Stack,即在ELK的基础上再加上用于数据收集的Beats。ELK,即:

1)Elasticsearch(简称ES):用于深度搜索和数据分析,它是基于Apache Lucene的分布式开源搜索引擎,无须预先定义数据结构就能动态地对数据进行索引;

2)Logstash:用于日志集中管理,包括从多台服务器上传输和转发日志,并对日志进行丰富和解析,是一个数据管道,提供了大量插件来支持数据的输入和输出处理;


3)Kibana:提供了强大而美观的数据可视化,Kibana完全使用HTML和Javascript编写,它利用Elasticsearch
的RESTful API来实现其强大的搜索能力,将结果显示位各种震撼的图形提供给最终的用户。

ps:该篇文章只展示如何实现高亮及权重分页搜索,其它前置操作如索引创建/删除、数据保存删除等由于相关文章较多就不再赘述,可参考SpringBoot整合Elasticsearch

Here we go…

二、下载安装

在开始整合之前你需得先把需要用到的东西下载准备好,下面是常用插件的官方下载地址:
ps:如果只是想实现搜索效果的话你只需下载ES。至于分词器,可以使你的搜索效果更加友好。安装基本上都是开箱即用,如有不清楚的百度即可。

三、主要配置

1. maven依赖
<!-- ElasticSearch -->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
2. application.properties
## ElasticSearch - start
# 开启 Elasticsearch 仓库(默认值:true)
spring.data.elasticsearch.repositories.enabled=true
# 默认 9300 是 Java 客户端的端口。9200 是支持 Restful HTTP 的接口
# spring.data.elasticsearch.cluster-nodes=127.0.0.1:9300
spring.data.elasticsearch.cluster-nodes=192.168.5.58:9300
# 集群名(默认值: elasticsearch)
spring.data.elasticsearch.cluster-name=realestate
# 集群节点地址列表,用逗号分隔。如果没有指定,就启动一个客户端节点
#spring.data.elasticsearch.propertie 用来配置客户端的额外属性
# 存储索引的位置
spring.data.elasticsearch.properties.path.home=/data/project/target/elastic
# 连接超时的时间
spring.data.elasticsearch.properties.transport.tcp.connect_timeout=120s
## ElasticSearch - end

三、代码实现

1. 实体注解

注:Spring Data通过注解来声明字段的映射属性,有下面的三个注解:

@Document:作用在类,标记实体类为文档对象,一般有两个属性

  • indexName:对应索引库名称
  • type:对应在索引库中的类型
  • shards:分片数量,默认5
  • replicas:副本数量,默认1

@Id:作用在成员变量,标记一个字段作为id主键

@Field:作用在成员变量,标记为文档的字段,并指定字段映射属性:

  • type:字段类型,是枚举:FieldType,可以是text、long、short、date、integer、object等

    • text:存储数据时候,会自动分词,并生成索引
    • keyword:存储数据时候,不会分词建立索引
    • Numerical:数值类型,分两类
      • 基本数据类型:long、interger、short、byte、double、float、half_float
      • 浮点数的高精度类型:scaled_float。
        需要指定一个精度因子,比如10或100。elasticsearch会把真实值乘以这个因子后存储,取出时再还原。
    • Date:日期类型。elasticsearch可以对日期格式化为字符串存储,但是建议我们存储为毫秒值,存储为long,节省空间

  • index:是否索引,布尔类型,默认是true

  • store:是否存储,布尔类型,默认是false

  • analyzer:分词器名称,这里的ik_max_word即使用ik分词器

2. 创建实体
  • 此处的实体为提供搜索用的实体,应与业务实体区分开,如果有相同实体,为避免混淆应写两份。正常情况下全局搜索接口可单独部署服务器。
  • 实体的配置方式有多种,除了下方每个字段使用注解外,spring boot还为我们提供了@Setting及@Mapping注解方式。如对字段设置要求较高的建议使用后者,后者可在配置文件处写原生DDL语句,虽然麻烦些,但是较为清晰。
package com.yby.es.po;

import java.io.Serializable;

import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

import com.yby.es.po.constant.EsConstant;

/**
 * 游记
 */
@Document(indexName = EsConstant.INDEX_NAME.TRAVEL, type = "doc")
public class Travel extends BaseEntity implements Serializable {

	private static final long serialVersionUID = -1838668690328733289L;

	/**
	 * 关键词
	 */
	@Field(type = FieldType.Keyword)
	private String keyword;

	/**
	 * 途经城市
	 */
	@Field(type = FieldType.Keyword)
	private String passCity;

	public Travel() {
		super();
	}

	public String getKeyword() {
		return keyword;
	}

	public String getPassCity() {
		return passCity;
	}

	public void setKeyword(String keyword) {
		this.keyword = keyword;
	}

	public void setPassCity(String passCity) {
		this.passCity = passCity;
	}

}

package com.yby.es.po;

import java.io.Serializable;
import java.util.Date;

import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

/**
 * 基础实体
 * 
 * @author lwx
 * @date 2019/02/28
 */
public class BaseEntity implements Serializable {

	private static final long serialVersionUID = 5695568297523302402L;

	/**
	 * ID
	 */
	@Id
	private Long id;

	/**
	 * 类型
	 */
	@Field(type = FieldType.Keyword)
	private String type;

	/**
	 * 状态
	 */
	@Field(type = FieldType.Keyword)
	private String status;

	/**
	 * 标题
	 */
	@Field(type = FieldType.Text, analyzer = "ik_max_word")
	private String title;

	/**
	 * 内容
	 */
	@Field(type = FieldType.Text, analyzer = "ik_max_word")
	private String content;

	/**
	 * 忽略检索内容
	 */
	private String ignoreContent;

	/**
	 * 描述
	 */
	@Field(type = FieldType.Text, analyzer = "ik_max_word")
	private String description;

	/**
	 * 创建时间
	 */
	@Field(type = FieldType.Keyword)
	private Date createTime;

	/**
	 * 更新时间
	 */
	@Field(type = FieldType.Keyword)
	private Date updateTime;

	public BaseEntity() {
		super();
	}

	public Long getId() {
		return id;
	}

	public String getType() {
		return type;
	}

	public String getStatus() {
		return status;
	}

	public String getTitle() {
		return title;
	}

	public String getContent() {
		return content;
	}

	public String getIgnoreContent() {
		return ignoreContent;
	}

	public String getDescription() {
		return description;
	}

	public Date getCreateTime() {
		return createTime;
	}

	public Date getUpdateTime() {
		return updateTime;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public void setType(String type) {
		this.type = type;
	}

	public void setStatus(String status) {
		this.status = status;
	}

	public void setTitle(String title) {
		this.title = title;
	}

	public void setContent(String content) {
		this.content = content;
	}

	public void setIgnoreContent(String ignoreContent) {
		this.ignoreContent = ignoreContent;
	}

	public void setDescription(String description) {
		this.description = description;
	}

	public void setCreateTime(Date createTime) {
		this.createTime = createTime;
	}

	public void setUpdateTime(Date updateTime) {
		this.updateTime = updateTime;
	}
}


3. 实现方法
package com.yby.es.service.impl;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.index.query.QueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.SearchHits;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder.Field;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightField;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.core.ElasticsearchTemplate;
import org.springframework.data.elasticsearch.core.SearchResultMapper;
import org.springframework.data.elasticsearch.core.aggregation.AggregatedPage;
import org.springframework.data.elasticsearch.core.aggregation.impl.AggregatedPageImpl;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

@Autowired
private ElasticsearchTemplate elasticsearchTemplate;

/**
 * 游记
 */
@Override
public Page<Travel> searchTravel(String keyword, Integer pageNumber, Integer pageSize) {
	// 页码
	if (pageNumber == null || pageNumber < 0) {
		pageNumber = 0;
	}
	// 页数
	if (pageSize == null || pageSize < 1) {
		pageSize = 10;
	}
	Page<Travel> page = null;
	try {

		// 构建查询
		NativeSearchQueryBuilder searchQuery = new NativeSearchQueryBuilder();

		// 多索引查询
		searchQuery.withIndices(EsConstant.INDEX_NAME.TRAVEL);

		// 组合查询,boost即为权重,数值越大,权重越大
		QueryBuilder queryBuilder = QueryBuilders.boolQuery()
				.should(QueryBuilders.multiMatchQuery(keyword, "title").boost(3))
				.should(QueryBuilders.multiMatchQuery(keyword, "passCity", "description").boost(2))
				.should(QueryBuilders.multiMatchQuery(keyword, "content", "keyword").boost(1));
		searchQuery.withQuery(queryBuilder);

		// 高亮设置
		List<String> highlightFields = new ArrayList<String>();
		highlightFields.add("title");
		highlightFields.add("passCity");
		highlightFields.add("description");
		highlightFields.add("content");
		highlightFields.add("keyword");
		Field[] fields = new Field[highlightFields.size()];
		for (int x = 0; x < highlightFields.size(); x++) {
			fields[x] = new HighlightBuilder.Field(highlightFields.get(x)).preTags(EsConstant.HIGH_LIGHT_START_TAG)
					.postTags(EsConstant.HIGH_LIGHT_END_TAG);
		}
		searchQuery.withHighlightFields(fields);

		// 分页设置
		searchQuery.withPageable(PageRequest.of(pageNumber, pageSize));

		page = elasticsearchTemplate.queryForPage(searchQuery.build(), Travel.class, new SearchResultMapper() {

			@Override
			@SuppressWarnings("unchecked")
			public <T> AggregatedPage<T> mapResults(SearchResponse response, Class<T> clazz, Pageable pageable) {

				// 获取高亮搜索数据
				List<Travel> list = new ArrayList<Travel>();
				SearchHits hits = response.getHits();
				for (SearchHit searchHit : hits) {
					if (hits.getHits().length <= 0) {
						return null;
					}
					Travel data = new Travel();
					// 公共字段
					data.setId(new Double(searchHit.getId()).longValue());
					data.setType(String.valueOf(searchHit.getSourceAsMap().get("type")));
					data.setStatus(String.valueOf(searchHit.getSourceAsMap().get("status")));
					data.setTitle(String.valueOf(searchHit.getSourceAsMap().get("title")));
					data.setContent(String.valueOf(searchHit.getSourceAsMap().get("content")));
					data.setIgnoreContent(String.valueOf(searchHit.getSourceAsMap().get("ignoreContent")));
					data.setDescription(String.valueOf(searchHit.getSourceAsMap().get("description")));
					Object createTime = searchHit.getSourceAsMap().get("createTime");
					Object updateTime = searchHit.getSourceAsMap().get("updateTime");
					if (createTime != null) {
						data.setCreateTime(new Date(Long.valueOf(createTime.toString())));
					}
					if (updateTime != null) {
						data.setUpdateTime(new Date(Long.valueOf(updateTime.toString())));
					}

					// 个性字段
					data.setKeyword(String.valueOf(searchHit.getSourceAsMap().get("keyword")));
					data.setPassCity(String.valueOf(searchHit.getSourceAsMap().get("passCity")));

					// 反射调用set方法将高亮内容设置进去
					try {
						for (String field : highlightFields) {
							HighlightField highlightField = searchHit.getHighlightFields().get(field);
							if (highlightField != null) {
								String setMethodName = parSetName(field);
								Class<? extends Travel> poemClazz = data.getClass();
								Method setMethod = poemClazz.getMethod(setMethodName, String.class);

								String highlightStr = highlightField.fragments()[0].toString();
								// 截取字符串
								if ("content".equals(field) && highlightStr.length() > 50) {
									highlightStr = StringUtil.truncated(highlightStr,
											EsConstant.HIGH_LIGHT_START_TAG, EsConstant.HIGH_LIGHT_END_TAG);
								}

								setMethod.invoke(data, highlightStr);
							}
						}

					} catch (Exception e) {
						e.printStackTrace();
					}
					list.add(data);
				}

				if (list.size() > 0) {
					AggregatedPage<T> result = new AggregatedPageImpl<T>((List<T>) list, pageable,
							response.getHits().getTotalHits());

					return result;
				}
				return null;
			}
		});

	} catch (Exception e) {
		e.printStackTrace();
	}
	return page;
}


parSetName方法:

private static String parSetName(String fieldName) {
	if (StringUtils.isBlank(fieldName)) {
		return null;
	}
	int startIndex = 0;
	if (fieldName.charAt(0) == '_')
		startIndex = 1;
	return "set" + fieldName.substring(startIndex, startIndex + 1).toUpperCase()
			+ fieldName.substring(startIndex + 1);
}

相关常量:

package com.yby.es.po.constant;

public interface EsConstant {

	/**
	 * 高亮显示 - 开始标签
	 */
	String HIGH_LIGHT_START_TAG = "<em>";

	/**
	 * 高亮显示 - 结束标签
	 */
	String HIGH_LIGHT_END_TAG = "</em>";

	/**
	 * 索引名称
	 */
	class INDEX_NAME {
		/**
		 * 游记
		 */
		public static final String TRAVEL = "travel";
	}
}

4. 返回示例

当发现搜索返回结果中出现<em>即表示高亮搜索成功,如下:
接口返回示例

四、数据同步

实际开发中,我们需要将自己的数据库导入以及同步到ES,数据同步的方式多种多样,这里展示其中两种:

方式一:logstash多数据源导入

注:logstash具体部署这里不展开讲,不清楚的搜索下即可。

以mysql为例,jdbc.conf配置如下:

input {
	stdin {}
	jdbc {
		# type => "activity"
		add_field => { "[@metadata][type]" => "activity" } 
		
		jdbc_connection_string => "jdbc:mysql://192.168.5.58:3306/181_realestate?characterEncoding=UTF-8&autoReconnect=true"
		jdbc_user => "root"
		jdbc_password => "root"
		
		# mysql依赖包路径;
		jdbc_driver_library => "mysql/mysql-connector-java-8.0.13.jar"
		# mysql驱动
		jdbc_driver_class => "com.mysql.jdbc.Driver"
		
		# 数据库重连尝试次数
		connection_retry_attempts => "3"
		
		# 判断数据库连接是否可用,默认false不开启
		jdbc_validate_connection => "true"
		
		# 数据库连接可用校验超时时间,默认3600S
		jdbc_validation_timeout => "3600"
		
		# 开启分页查询(默认false不开启);
		# jdbc_paging_enabled => "true"
		# 单次分页查询条数(默认100000,若字段较多且更新频率较高,建议调低此值);
		# jdbc_page_size => "10000"
		
		# statement为查询数据sql,如果sql较复杂,建议配通过statement_filepath配置sql文件的存放路径;
		# sql_last_value为内置的变量,存放上次查询结果中最后一条数据tracking_column的值,此处即为ModifyTime;
		# statement => "SELECT KeyId,TradeTime,OrderUserName,ModifyTime FROM `DetailTab` WHERE ModifyTime>= :sql_last_value order by ModifyTime asc"
		statement_filepath => "mysql/realestate/sql/activity.sql"
		
		# 是否将字段名转换为小写,默认true(如果有数据序列化、反序列化需求,建议改为false);
		lowercase_column_names => false
		
		# Value can be any of: fatal,error,warn,info,debug,默认info;
		sql_log_level => warn
		
		# 是否记录上次执行结果,true表示会将上次执行结果的tracking_column字段的值保存到last_run_metadata_path指定的文件中;
		record_last_run => true
		
		# 需要记录查询结果某字段的值时,此字段为true,否则默认tracking_column为timestamp的值;
		use_column_value => true
		
		# 需要记录的字段,用于增量同步,需是数据库字段
		tracking_column => "id"
		
		# Value can be any of: numeric,timestamp,Default value is "numeric"
		tracking_column_type => numeric
		
		# record_last_run上次数据存放位置;
		last_run_metadata_path => "mysql/realestate/last_id/activity.txt"
		
		# 是否清除last_run_metadata_path的记录,需要增量同步时此字段必须为false;
		clean_run => false
		
		# 同步频率(分 时 天 月 年),默认每分钟同步一次;
		# 每天凌晨3点
		# schedule => "* * * * *"
		schedule => "0 3 * * *"
	}
	
	jdbc {
		# type => "travel"
		add_field => { "[@metadata][type]" => "travel" } 
		
		jdbc_connection_string => "jdbc:mysql://192.168.5.58:3306/181_realestate?characterEncoding=UTF-8&autoReconnect=true"
		jdbc_user => "root"
		jdbc_password => "root"
		
		jdbc_driver_library => "mysql/mysql-connector-java-8.0.13.jar"
		jdbc_driver_class => "com.mysql.jdbc.Driver"
		
		connection_retry_attempts => "3"
		jdbc_validate_connection => "true"
		jdbc_validation_timeout => "3600"
		

		# jdbc_paging_enabled => "true"
		# jdbc_page_size => "10000"
		
		statement_filepath => "mysql/realestate/sql/travel.sql"
		
		lowercase_column_names => false
		sql_log_level => warn
		
		record_last_run => true
		use_column_value => true
		
		# 需要记录的字段,用于增量同步,需是数据库字段
		tracking_column => "id"
		# 跟踪数据类型: numeric,timestamp,Default value is "numeric"
		tracking_column_type => numeric
		
		# record_last_run上次数据存放位置;
		last_run_metadata_path => "mysql/realestate/last_id/travel.txt"
		
		# 是否清除last_run_metadata_path的记录,需要增量同步时此字段必须为false;
		clean_run => false
		
		# 同步频率(分 时 天 月 年),默认每分钟同步一次;
		# 每天凌晨3点
		# schedule => "* * * * *"
		schedule => "0 3 * * *"
	}
}


filter {
	json {
		source => "message"
		remove_field => ["message"]
	}
}

output {
	
	# output模块的type需和jdbc模块的type一致
	# if [type] == "activity" {
	if [@metadata][type] == "activity" {
		elasticsearch {
			# host => "192.168.1.1"
			# port => "9200"
			# 配置ES集群地址
			# hosts => ["192.168.1.1:9200", "192.168.1.2:9200", "192.168.1.3:9200"]
			hosts => ["127.0.0.1:9200"]
			# 索引名字,必须小写
			index => "activity"
			# 数据唯一索引(建议使用数据库KeyID)
			document_id => "%{id}"
		}
	}
	if [@metadata][type] == "travel" {
		elasticsearch {
			hosts => ["127.0.0.1:9200"]
			index => "travel"
			document_id => "%{id}"
		}
	}

	stdout {
		codec => json_lines
	}
}

方式二:定时任务

注:至于需要同步单独的某行数据的可以以提供接口的方式进行同步,此处不展开

package com.yby.es.task;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.elasticsearch.core.ElasticsearchTemplate;

import com.yby.es.po.Travel;

/**
 * <pre>
 * 数据导入定时任务
 * </pre>
 * 
 * @author lwx
 */
@Component
// 开启定时任务
@EnableScheduling
// 开启多线程
@EnableAsync
public class ImportTask {

	protected Log logger = LogFactory.getLog(this.getClass());

	@Autowired
	private ElasticsearchTemplate elasticsearchTemplate;

	@Autowired
	private TravelEsRepository travelEsRepository;

	@Autowired
	private TravelMapper travelMapper;

	/**
	 * 游记
	 */
	@Async
	@Scheduled(cron = "0 16 3 ? * *") // 每天 03:16
	public void importTravel() throws InterruptedException {
		logger.info("【信息】执行定时任务...");
		logger.info("【信息】正在清除游记数据...");
		try {
			boolean delete = elasticsearchTemplate.deleteIndex(Travel.class);
			if (delete) {
				logger.info("【信息】游记数据清除完毕,正在导入游记数据...");
				Iterable<Travel> data = travelEsRepository.saveAll(travelMapper.list());
				if (data != null) {
					logger.info("【success】游记数据导入完毕!");
				}
			}
		} catch (Exception e) {
			logger.debug("【error】游记数据导入失败!");
			e.printStackTrace();
		}
	}
}

五、注意事项

1. 新版不支持多type
  • 为什么?

1、index、type的初衷:
之前es将index、type类比于关系型数据库(例如mysql)中database、table,这么考虑的目的是“方便管理数据之间的关系”。

2、为什么现在要移除type?
      2.1 在关系型数据库中table是独立的(独立存储),但es中同一个index中不同type是存储在同一个索引中的(lucene的索引文件),因此不同type中相同名字的字段的定义(mapping)必须一致。
      2.2 不同类型的“记录”存储在同一个index中,会影响lucene的压缩性能。

  • 替换策略

3.1 一个index只存储一种类型的“记录”
这种方案的优点:
a)lucene索引中数据比较整齐(相对于稀疏),利于lucene进行压缩。
b)文本相关性打分更加精确(tf、idf,考虑idf中命中文档总数)

3.2 用一个字段来存储type
如果有很多规模比较小的数据表需要建立索引,可以考虑放到同一个index中,每条记录添加一个type字段进行区分。
这种方案的优点:
a)es集群对分片数量有限制,这种方案可以减少index的数量。

  • 迁移方案

之前一个index上有多个type,如何迁移到3.1、3.2方案?
4.1 先针对实际情况创建新的index,[3.1方案]有多少个type就需要创建多少个新的index,[3.2方案]只需要创建一个新的index。
4.2 调用_reindex将之前index上的数据同步到新的索引上。

此处参考:https://www.cnblogs.com/huangfox/p/9460361.html


2. Index命名规范

必须小写,如果出现大写的名称,将报如下异常:

Caused by: org.elasticsearch.hadoop.EsHadoopIllegalArgumentException: Cannot determine write shards for [CC-2017.01.24/compliance]; likely its format is incorrect (maybe it contains illegal characters?)
    at org.elasticsearch.hadoop.util.Assert.isTrue(Assert.java:50)
    at org.elasticsearch.hadoop.rest.RestService.initSingleIndex(RestService.java:439)
    at org.elasticsearch.hadoop.rest.RestService.createWriter(RestService.java:400)
    at org.elasticsearch.spark.rdd.EsRDDWriter.write(EsRDDWriter.scala:40)
    at org.elasticsearch.spark.rdd.EsSpark$$anonfun$saveToEs$1.apply(EsSpark.scala:67)
    at org.elasticsearch.spark.rdd.EsSpark$$anonfun$saveToEs$1.apply(EsSpark.scala:67)
    at org.apache.spark.scheduler.ResultTask.runTask(ResultTask.scala:66)
    at org.apache.spark.scheduler.Task.run(Task.scala:89)
    at org.apache.spark.executor.Executor$TaskRunner.run(Executor.scala:213

The end.

  • 11
    点赞
  • 46
    收藏
    觉得还不错? 一键收藏
  • 7
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值