Docker 搭建 ES 集群并整合 Spring Boot
小小 ACMer / Spring Boot 小博主 / 开源点小项目 / 并发网小编辑 …
一、前言
什么是 Elasticsearch ?
Elasticsearch 是一个基于 Apache Lucene(TM) 的开源搜索引擎。无论在开源还是专有领域,Lucene
可以被认为是迄今为止最先进、性能最好的、功能最全的搜索引擎库,并通过简单的 RESTful API 来隐藏 Lucene 的复杂性,从而让全文搜索变得简单。
Elasticsearch 不仅仅是 Lucene 和全文搜索,我们还能这样去描述它:
- 分布式的实时文件存储,每个字段都被索引并可被搜索;
- 分布式的实时分析搜索引擎;
- 可以扩展到上百台服务器,处理 PB 级结构化或非结构化数据。
Elasticsearch 三大要素:
- 文档(Document):在面向对象观念就是一个对象。在 ES 里面,是一个大 JSON 对象,是指定了唯一 ID 的最底层或者根对象。文档的位置由
_index
、_type
和_id
唯一标识。 - 索引(Index):用于区分文档成组,即分到一组的文档集合。索引,用于存储文档和使文档可被搜索。比如项目存索引 project 里面,交易存索引 sales 等。
- 类型(Type):用于区分索引中的文档,即在索引中对数据逻辑分区。比如索引 project 的项目数据,根据项目类型 ui 项目、插画项目等进行区分。
二、ES 集群安装
基于 Dokcer ,单机安装 Docker 版集群。使用版本如下:
- Elasticsearch 5.3.2
- Kibana 5.3.2
- JDK 8
安装步骤:
- 安装 ES 集群实例 elasticsearch001
- 安装 ES 集群实例 elasticsearch002
- 安装 Kibana 监控
1. 安装 ES 集群实例 elasticsearch001
打开命令行执行:
docker run -d -p 9200:9200 -p 9300:9300 --name elasticsearch001 -h elasticsearch001\
-e cluster.name=lookout-es -e ES_JAVA_OPTS="-Xms512m -Xmx512m" -e xpack.security.enabled=false\
docker.elastic.co/elasticsearch/elasticsearch:5.3.2
命令浅析如下:
-d
设置后台运行容器。-p [宿主机端口]:[容器内端口]
。--name
设置容器别名。-h
设置容器的主机名。-e
设置环境变量。这里关闭 x-pack 的安全校验功能,防止访问认证。
第一次运行会比较慢,因为拉取 es docker image,但是可以设置国内 Docker 镜像地址。如果成功,命令行会出现如图所示:

那么验证下是否启动成功,继续执行如下命令:
curl http://localhost:9200/_cat/health\?v
会出现如图所示的结果:

cluster.name ES 集群名为 lookout-es,这个后面需要指定关联。node 表示只有一个实例。默认 shards
分片为主备两个。status 状态是我们要关心的,状态可能是下列三个值之一:
- green:所有的主分片和副本分片都已分配。你的集群是 100% 可用的。
- yellow:所有的主分片已经分片了,但至少还有一个副本是缺失的。不会有数据丢失,所以搜索结果依然是完整的。高可用会弱化把 yellow 想象成一个需要及时调查的警告。
- red:至少一个主分片(以及它的全部副本)都在缺失中。这意味着你在缺少数据:搜索只能返回部分数据,而分配到这个分片上的写入请求会返回一个异常。
也可以访问 http://localhost:9200/,可以看到成功运行的案例,返回的 JSON 页面。如图:

2. 安装 ES 集群实例 elasticsearch002
继续执行如下命令:
docker run -d -p 9211:9200 -p 9311:9300 --link elasticsearch001\
--name elasticsearch002 -e cluster.name=lookout-es -e ES_JAVA_OPTS="-Xms512m -Xmx512m" -e xpack.security.enabled=false\
-e discovery.zen.ping.unicast.hosts=elasticsearch001 docker.elastic.co/elasticsearch/elasticsearch:5.3.2
命令浅析如下:
-d
设置后台运行容器。-p [宿主机端口]:[容器内端口]
,这边指定新的端口,和实例 elasticsearch001 区别开。--link [其他容器名]:[在该容器中的别名]
添加链接到另一个容器, 在本容器 hosts 文件中加入关联容器的记录。--name
设置容器别名。-h
设置容器的主机名。-e
设置环境变量。这里额外指定了 ES 集群的 cluster.name、ES 集群节点淡泊配置 discovery.zen.ping.unicast.hosts 设置为实例 elasticsearch001。
这次运行会很快,成功后命令行会出现如图所示:

Docker 启动会有一点延时,稍后再使用 health 命令检查下 ES 的集群状态:
curl http://localhost:9200/_cat/health\?v
会出现如图所示的结果:

对比上面检查数值可以看出,首先集群状态为 green , 所有的主分片和副本分片都已分配。你的集群是 100% 可用的。相应的 node 、shards
都增加。
如果你还想装个 elasticsearch003、elasticsearch004 更多 ES 实例,可以试试。
3. 安装 Kibana 监控
为了更好的监控集群,我们装个 Kibana ,命令行如下:
docker run -d --name kibana001 --link elasticsearch001 -e ELASTICSEARCH_URL=http://elasticsearch001:9200 -p 5601:5601\
docker.elastic.co/kibana/kibana:5.3.2
命令浅析如下:
-d
设置后台运行容器。--link [其他容器名]:[在该容器中的别名]
添加链接到另一个容器,在本容器 hosts 文件中加入关联容器的记录。--name
设置容器别名。-e
设置环境变量。这里额外指定了ELASTICSEARCH_URL
为搜索实例地址。
打开网页访问 127.0.0.1:5601,默认账号为 elasti,密码为 changeme。会出现如下的截图:

三、Spring Boot 整合 Elasticsearch
首先是代码的 GitHub 地址:
1. 工程结构
工程结构相对简单。如图所示:

主要关注的是如下:
- application.yml 配置文件
- entity 包和 repository 包实现了 ES DAO 操作层
- 最重要的还是 ContentServiceImpl 搜索服务实现层
2. 依赖配置 spring-boot-starter-data-elasticsearch
在 pom.xml 配置如下:
<!-- ES -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
要了解 Spring Data Elasticsearch 是什么,首先了解什么是 Spring Data。
Spring Data 基于 Spring 为数据访问提供一种相似且一致性的编程模型,并保存底层数据存储。Spring Data
Elasticsearch 是 Spring Data 的 Community Modules 之一,是 Spring Data 对
Elasticsearch 引擎的实现。
Elasticsearch 默认提供轻量级的 HTTP RESTful 接口形式的访问。相对来说,使用 HTTP Client 调用也很简单。但
Spring Data Elasticsearch 可以更快地支持构建在 Spring 应用上,比如在 application.properties 配置
ES 节点信息和 spring-boot-starter-data-elasticsearch 依赖,直接在 Spring Boot 应用上使用。
这里依赖的 spring-boot-starter-data-elasticsearch 版本是 2.0,对应的 Spring Data
Elasticsearch 版本是 5.5.3 Release。对应 ES 尽量安装成版本一致的 5.5.3。
这里会有个坑,ES 的版本升级不是很平滑,比如用这个连接低版本 ES 是连不上的。对应版本如下:
Spring Boot Version (x) | Spring Data Elasticsearch Version (y) |
Elasticsearch Version (z)
—|—|—
x <= 1.3.5 | y <= 1.3.4 | z <= 1.7.2*
x >= 1.4.x | 2.0.0 <=y < 5.0.0** | 2.0.0 <= z < 5.0.0**
- 只需要你修改下对应的 pom 文件版本号
- 下一个 ES 的版本会有重大的更新
具体 Spring Data Elasticsearch 和 Elasticsearch 的版本对应:
Spring Data Elasticsearch | Elasticsearch |
---|---|
3.2.x | 6.5.0 |
3.1.x | 6.2.2 |
3.0.x | 5.5.0 |
2.1.x | 2.4.0 |
2.0.x | 2.2.0 |
1.3.x | 1.5.2 |
然后配置下 application.yml 配置文件:
# ES
spring:
data:
elasticsearch:
repositories:
enabled: true
cluster-nodes: 127.0.0.1:9300
cluster-name: lookout-es
指定 ES 集群名称和节点地址,默认 9300 是 Java 客户端的端口。有了 starter 组件,配置就相对简单很多了。
更多配置:
- spring.data.elasticsearch.cluster-name Elasticsearch 集群名。
- spring.data.elasticsearch.cluster-nodes 集群节点地址列表,用逗号分隔。如果没有指定,就启动一个客户端节点。
- spring.data.elasticsearch.propertie 用来配置客户端的额外属性。
- spring.data.elasticsearch.repositories.enabled 开启 Elasticsearch 仓库(默认值 true)。
3. 搜索 DAO 层
搜索案例实体类代码如下:
package com.bysocket.entity;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import java.io.Serializable;
/**
* 文章
*/
@Document(indexName = "content", type = "content")
@Data
public class ContentEntity implements Serializable {
// 内容 ID
@Id
private Long id;
// 内容标题
private String title;
// 内容
private String content;
// 内容类型 1:文章 2:问题
private Integer type;
// 内容类别
private String category;
// 文章阅读数
private Integer read;
// 问题支持数
private Integer support;
}
注意下面几点:
- ContentEntity 类中字段属性名不支持驼峰式。比如 type 不能写成 contentType。
- @Document 注解的 indexName 配置必须是全部小写,不然会出异常。索引用于区分文档成组,即分到一组的文档集合;用于存储文档和使文档可被搜索。比如项目存在索引 project 里面,交易存在索引 sales 等。
- @Document 注解的 type 类型,用于区分索引中的文档,即在索引中对数据逻辑分区。比如索引 project 的项目数据,根据项目类型 ui 项目、插画项目等进行区分。
ES 数据操作层实现代码如下:
package com.bysocket.repository;
import com.bysocket.entity.ContentEntity;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ContentRepository extends ElasticsearchRepository<ContentEntity, Long> {
}
接口只要继承 ElasticsearchRepository
接口类即可,具体使用的是该接口的方法。有兴趣的读者,可以看看里面的实现逻辑,增删改查、搜索都有了,不需要我们具体实现,只需我们传入对应的参数即可。
ElasticsearchRepository 对应官方文档:
<http://docs.spring.io/spring-
data/elasticsearch/docs/2.1.0.RELEASE/reference/html/>
3. 搜索 Service 层
首先,定义一个接口,作为服务层搜索接口 ContentService,代码如下:
package com.bysocket.service;
import com.bysocket.bean.ContentBean;
import com.bysocket.bean.ContentSearchBean;
import com.bysocket.common.PageBean;
import java.util.List;
public interface ContentService {
/**
* 批量向 ES 保存内容
*/
boolean saveContents(List<ContentBean> contentBeanList);
/**
* 搜索
*/
PageBean searchContent(ContentSearchBean contentSearchBean);
}
ContentSearchBean 定义了一些需要搜索的参数:分页参数 pageNumber、pageSize、搜索内容 searchContent
、内容类型 type 和内容类别 category。
定义好接口,最重要的是实现类了。实现类代码太长,我们先看看 saveContents(List<ContentBean>
contentBeanList)
的实现。具体在 ContentServiceImpl 类中,代码如下:
package com.bysocket.service.impl;
import com.bysocket.bean.ContentBean;
import com.bysocket.bean.ContentSearchBean;
import com.bysocket.common.PageBean;
import com.bysocket.constant.SearchConstant;
import com.bysocket.entity.ContentEntity;
import com.bysocket.repository.ContentRepository;
import com.bysocket.service.ContentService;
import lombok.AllArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Primary;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.springframework.data.elasticsearch.core.query.SearchQuery;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
@Service
@Primary
@AllArgsConstructor
@Log4j2
public class ContentServiceImpl implements ContentService {
private static final Logger LOGGER = LoggerFactory.getLogger(ContentServiceImpl.class);
private final ContentRepository contentRepository;
@Override
public boolean saveContents(List<ContentBean> contentBeanList) {
List<ContentEntity> contentEntityList = transferToContentEntityList(contentBeanList);
contentRepository.saveAll(contentEntityList);
return true;
}
}
代码中:
- 注意上面讲的,DAO 层 Bean:ContentRepository。
- 批量保存接口也很简单,直接传入对应的 ContentEntity 列表。
- transferToContentEntityList 顾名思义,只是一个 DO/BO 转换。
然后具体看看全部的代码:
package com.bysocket.service.impl;
import com.bysocket.bean.ContentBean;
import com.bysocket.bean.ContentSearchBean;
import com.bysocket.common.PageBean;
import com.bysocket.constant.SearchConstant;
import com.bysocket.entity.ContentEntity;
import com.bysocket.repository.ContentRepository;
import com.bysocket.service.ContentService;
import lombok.AllArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Primary;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.springframework.data.elasticsearch.core.query.SearchQuery;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
@Service
@Primary
@AllArgsConstructor
@Log4j2
public class ContentServiceImpl implements ContentService {
private static final Logger LOGGER = LoggerFactory.getLogger(ContentServiceImpl.class);
private final ContentRepository contentRepository;
@Override
public boolean saveContents(List<ContentBean> contentBeanList) {
List<ContentEntity> contentEntityList = transferToContentEntityList(contentBeanList);
contentRepository.saveAll(contentEntityList);
return true;
}
@Override
public PageBean searchContent(ContentSearchBean contentSearchBean) {
Integer pageNumber = contentSearchBean.getPageNumber();
Integer pageSize = contentSearchBean.getPageSize();
PageBean<ContentEntity> resultPageBean = new PageBean<>();
resultPageBean.setPageNumber(pageNumber);
resultPageBean.setPageSize(pageSize);
// 构建查询条件
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
// 搜索内容
String searchContent = contentSearchBean.getSearchContent();
if (!StringUtils.isEmpty(searchContent)) {
boolQueryBuilder.should(QueryBuilders.matchPhraseQuery(SearchConstant.CONTENT_ES_NAME, searchContent));
boolQueryBuilder.minimumShouldMatch(SearchConstant.MINIMUM_SHOULD_MATCH);
}
// 内容类型筛选
Integer type = contentSearchBean.getType();
if (type != null) {
boolQueryBuilder.must(QueryBuilders.matchQuery(SearchConstant.TYPE_ES_NAME, type).lenient(true));
}
// 内容类别筛选
String category = contentSearchBean.getCategory();
if (!StringUtils.isEmpty(category)) {
boolQueryBuilder.must(QueryBuilders.matchQuery(SearchConstant.CATEGORY_ES_NAME, category).lenient(true));
}
// 构建分页、排序条件
Pageable pageable = PageRequest.of(pageNumber, pageSize);
if (!StringUtils.isEmpty(contentSearchBean.getOrderName())) {
pageable = PageRequest.of(pageNumber, pageSize, Sort.Direction.DESC, contentSearchBean.getOrderName());
}
SearchQuery searchQuery = new NativeSearchQueryBuilder().withPageable(pageable)
.withQuery(boolQueryBuilder).build();
// 搜索
LOGGER.info("\n ContentServiceImpl.searchContent() DSL = \n " + searchQuery.getQuery().toString());
Page<ContentEntity> contentPage = contentRepository.search(searchQuery);
resultPageBean.setResult(contentPage.getContent());
resultPageBean.setTotalCount((int) contentPage.getTotalElements());
resultPageBean.setTotalPage((int) contentPage.getTotalElements() / resultPageBean.getPageSize() + 1);
return resultPageBean;
}
/**
* 转换
*/
private List<ContentEntity> transferToContentEntityList(final List<ContentBean> contentBeanList) {
List<ContentEntity> contentEntityList = new ArrayList<>();
contentBeanList.forEach(contentBean -> {
ContentEntity contentEntity = transferToContentEntity(contentBean);
contentEntityList.add(contentEntity);
});
return contentEntityList;
}
/**
* 转换
*/
private ContentEntity transferToContentEntity(final ContentBean contentBean) {
if (contentBean == null)
return null;
ContentEntity contentEntity = new ContentEntity();
contentEntity.setId(contentBean.getId());
contentEntity.setTitle(contentBean.getTitle());
contentEntity.setContent(contentBean.getContent());
contentEntity.setType(contentBean.getType());
contentEntity.setCategory(contentBean.getCategory());
contentEntity.setRead(contentBean.getRead());
contentEntity.setSupport(contentBean.getSupport());
return contentEntity;
}
}
重点看看 searchContent(ContentSearchBean contentSearchBean) 搜索的实现吧。
- 构造查询条件,利用的是 QueryBuilders 构建查询工程类。可以支持丰富的查询构建,包括:
matchAllQuery()
方法用来匹配全部文档、matchQuery("filedname","value")
匹配单个字段,匹配字段名为 filedname、值为 value 的文档等。 - 这里利用 BoolQueryBuilder 进行复合查询,调用
QueryBuilders.boolQuery()
构建了符合查询的 Builder。 - 对于内容搜索,利用的是
QueryBuilders.matchPhraseQuery(SearchConstant.CONTENT_ES_NAME, searchContent)
短语匹配查询。 - 最后利用 PageRequest 构建了分页、排序条件。
具体接口使用的姿势:
- BoolQueryBuilder:用于拼装连接查询条件。
- QueryBuilders:简单的静态工厂,用于查询条件,如区间、精确、多值等条件。
- NativeSearchQueryBuilder:将连接条件和聚合函数等组合。
- SearchQuery:生成查询对象。
4. 新增数据接口 & 搜索数据接口
新增 ES 保存接口和搜索接口,代码如下:
package com.bysocket.api;
import com.bysocket.bean.ContentBean;
import com.bysocket.bean.ContentSearchBean;
import com.bysocket.common.PageBean;
import com.bysocket.common.ResponseBean;
import com.bysocket.service.ContentService;
import lombok.AllArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@AllArgsConstructor
@RequestMapping("/api")
@Log4j2
public class ContentCommonApi {
private final ContentService contentService;
@PostMapping("/contents")
public ResponseBean saveContents(@RequestBody List<ContentBean> contentBeanList) {
boolean result = contentService.saveContents(contentBeanList);
if (result) {
return ResponseBean.success(result);
}
return ResponseBean.fail(result);
}
@PostMapping(value = "/content/search")
public ResponseBean searchContent(@RequestBody ContentSearchBean contentSearchBean) {
PageBean pageBean = contentService.searchContent(contentSearchBean);
return ResponseBean.successPage(pageBean);
}
}
在 IDEA 中执行 Application 类启动,任意正常模式或者 Debug 模式。可以在控制台看到成功运行的输出:
... 省略
2017-10-15 10:05:19.994 INFO 17963 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http)
2017-10-15 10:05:20.000 INFO 17963 --- [ main] demo.springboot.QuickStartApplication : Started Application in 5.544 seconds (JVM running for 6.802)
然后打开 Postman 工具,构造保存数据请求:
POST /api/contents HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/json
Cache-Control: no-cache
Postman-Token: 5025663f-edc0-9b99-20d8-e30480cd14f0
[
{
"id":1,
"title":"《见识》",
"content":"摩根说:任意让小钱从身边溜走的人,一定留不住大钱",
"type":1,
"category":"文学",
"read":999,
"support":100
},
{
"id":2,
"title":"《态度》",
"content":"人类的幸福不是来自偶然的幸运,而是来自每天的小恩惠",
"type":2,
"category":"文学",
"read":888,
"support":88
},
{
"id":3,
"title":"《Java 编程思想》",
"content":"Java 是世界上最diao的语言",
"type":2,
"category":"计算",
"read":999,
"support":100
}
]
如图所示:

点击 Send ,请求成功会返回如下所示:
{
"code": 0,
"message": "success",
"data": true
}
那接下来去验证下数据是否在 ES。打开网页访问 127.0.0.1:5601,在 Kibana 监控中输入需要监控的 index name 为
content。如下图,取消打钩,然后进入:

进入后,会得到如图所示的界面,里面罗列了该索引 content 下面所有字段:

打开左侧 Discover 栏目,即可看到可视化的搜索界面及数据:

针对这个 JSON ,如下:
{
"_index": "content",
"_type": "content",
"_id": "3",
"_score": 1,
"_source": {
"id": 3,
"title": "《Java 编程思想》",
"content": "Java 是世界上最diao的语言",
"type": 2,
"category": "计算",
"read": 999,
"support": 100
}
}
- _index 就是索引,用于区分文档成组,即分到一组的文档集合。索引,用于存储文档和使文档可被搜索。比如项目存索引 project 里面,交易存索引 sales 等。
- _type 就是类型,用于区分索引中的文档,即在索引中对数据逻辑分区。比如索引 project 的项目数据,根据项目类型 ui 项目、插画项目等进行区分。
- _id 是该文档的唯一标示,代码中我们一 ID 作为他的唯一标示。
更具体的 Kibana 操作,具体操作操作即可。
最后继续打开 Postman 工具,调用下面搜索请求:
POST /api/content/search HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/json
Cache-Control: no-cache
Postman-Token: 19e37f50-cb52-8527-c074-f91903eda6ae
{
"searchContent":"Java",
"type":2,
"pageSize":3,
"pageNumber":0
}
对应结果如下:
{
"code": 0,
"message": "success",
"data": {
"pageNumber": 0,
"pageSize": 3,
"totalPage": 1,
"totalCount": 1,
"result": [
{
"id": 3,
"title": "《Java 编程思想》",
"content": "Java 是世界上最diao的语言",
"type": 2,
"category": "计算",
"read": 999,
"support": 100
}
]
}
}
这里根据 searchContent 匹配短语 +type 匹配单个字段,一起构建了搜索语句。用于搜索出我们期待的结果,就是《Java 编程思想》。
最后,还是多多实践。代码 GitHub 地址:
拓展阅读: 《案例上手 Spring
全家桶》
本文首发于 GitChat,未经授权不得转载,转载需与 GitChat 联系。
130
互动评论
评论
李微尘7 个月前
按作者的流程来做 好多问题呀,按照第一个命令拉取容器启动,执行完成后就退出了
评论
马鹿野郎7 个月前
ContentBean找了半天,还以为是某个库自带的方法呢,结果发现源码里面是ContentBean就是文章里面ContentEntity,真是坑,严谨点行吗
评论
XiuWood8 个月前
es可用于持久化方式的存储吗
评论
落雨7 个月前
持久化存储需要考虑数据的一致性,ES作为弱一致性NOSQL,允许部分数据非实时的情况下,是可以做为持久化存储来用的,不过一般来说,都会有一些补偿的程序去纠正ES的数据,背后还是一套Mysql
- binLog同步的方式来做。由于ES的实时更新性能较差,很多人开始考虑转到MongoDB来做实时索引。不过ES内核可以优化,达到高性能实时更新
评论
绿乄茶8 个月前
es 本身就是一个数据库,应该可以的
评论
查看更多