Loading

[10] FastDFS

1. 分布式文件系统

分布式文件系统(Distributed File System,DFS)又叫做网络文件系统(Network File System)。一种允许文件通过网络在多台主机上分享的文件系统,可让多机器上的多用户分享文件和存储空间。

【特点】在一个分享的磁盘文件系统中,所有节点对数据存储区块都有相同的访问权,在这样的系统中,访问权限就必须由客户端程序来控制。分布式文件系统可能包含的功能有:透通的数据复制与容错。

【区别】分布式文件系统是被设计用在局域网。而分布式数据存储,则是泛指应用分布式运算技术的文件和数据库等提供数据存储服务的系统。

FastDFS 是一个开源的轻量级分布式文件系统,功能包括:文件存储、文件同步、文件访问(文件上传、文件下载)等,解决了大容量存储和负载均衡的问题。特别适合中小文件(建议范围:4KB < file_size < 500MB),对以文件为载体的在线服务,如相册网站、视频网站等。

2. 系统架构&设计理念

2.1 架构详解

FastDFS 架构包括「跟踪服务器 Tracker Server」、「存储服务器 Storage Server」和「客户端 Client」构成。Client 请求 Tracker Server 进行文件上传、下载,经 Tracker Server 调度,最终由 Storage Server 完成文件上传和下载。

  • 客户端 Client
    • 作为业务请求的发起方,通过 TCP/IP 协议,使用专有接口与「跟踪服务器 Tracker Server」或「存储服务器 Storage Server」进行数据交互;
    • FastDFS 向使用者提供基本文件访问接口,比如 upload、download、append、delete 等,以客户端库的方式提供给用户使用。
  • 存储服务器 Storage Server
    • Storage Server 没有实现自己的文件系统而是利用操作系统的文件系统来管理文件,也可将 Storage 称为「存储服务器」,主要提供容量和备份服务。以 Group 为单位,每个 Group 内可以有多台 Storage Server,数据互为备份,不分主从
    • 以 Group 为单位组织存储能方便地进行应用隔离、负载均衡、副本数定制(Group 内 Storage Server 数量即为该 Group 的副本数),比如将不同应用数据存到不同的 Group 就能隔离应用数据,同时还可根据应用的访问特性来将应用分配到不同的 Group 来做负载均衡;缺点是 Group 的容量受单机存储容量的限制,同时当 Group 内有机器坏掉时,数据恢复只能依赖 Group 内的其他机器,使得恢复时间会很长;
    • Group 内每个 Storage 的存储依赖于本地文件系统,Storage 可配置多个数据存储目录,比如有 10 块磁盘,分别挂载在 /data/disk1 ~ /data/disk10,则可将这 10 个目录都配置为 Storage 的数据存储目录。Storage 接收到写文件请求时,会根据配置好的规则选择其中一个存储目录来存储文件。为了避免单个目录下的文件数太多,在 Storage 第一次启动时,会在每个数据存储目录里创建 2 级子目录,每级 256 个,总共 65536 个文件目录,新写的文件会以 hash 的方式被路由到其中某个子目录下,然后将文件数据作为本地文件存储到该目录中
  • 跟踪服务器 Tracker Server
    • 主要做调度工作,起到均衡的作用。负责管理所有的 Storage 和 Group,每个 Storage 在启动后会连接 Tracker,告知自己所属 Group 等信息并保持周期性心跳。Tracker 根据 Storage 的心跳信息,建立 <Group, StorageServerList> 的映射表
    • Tracker 需要管理的元信息很少,会全部存储在内存中;另外 Tracker 上的元信息都是由 Storage 汇报的信息生成的,本身不需要持久化任何数据,这样使得 Tracker 非常容易扩展,直接增加 Tracker 机器即可扩展为 Tracker Cluster 来服务,Cluster 里每个 Tracker 之间是完全对等的,所有的 Tracker 都接收 Stroage 的心跳信息,生成元数据信息来提供读写服务

2.2 设计理念

a. 轻量级

FastDFS 服务端只有两个角色: Tracker Server 和 Storage Server。

Tracker Server 在内存中记录〈分组 Group〉和 Storage Server 的状态等信息,不记录文件索引信息,占用的内存量很少。另外,客户端(应用)和 Storage Server 访问 Tracker Server 时, Tracker Server 扫描内存中的分组和 Storage Server 状态信息,然后给出应答。由此可以看出 Tracker Server 非常轻量化,不会成为系统瓶颈。

FastDFS 中的 Storage Server 直接利用 OS 的文件系统存储文件。 FastDFS 不会对文件进行分块存储,客户端上传的文件和 Storage Server 上的文件一一对应。对于互联网应用,文件分块存储没有多大的必要。它既没有带来多大的好处,又增加了系统的复杂性。 FastDFS 不对文件进行分块存储,与支持文件分块存储的 DFS 相比,更加简洁高效,并且完全能满足绝大多数互联网应用的实际需要。

在 FastDFS 中,客户端上传文件时,文件 ID 不是由客户端指定,而是由 Storage Server 生成后返回给客户端的。文件 ID 中包含了组名、 文件相对路径和文件名, Storage Server 可以根据文件 ID 直接定位到文件。因此 FastDFS 集群中根本不需要存储文件索引信息,这是 FastDFS 比较轻量级的一个例证。而其他文件系统则需要存储文件索引信息,这样的角色通常称作 NameServer 。其中 mogileFS 采用 MySQL 数据库来存储文件索引以及系统相关的信息,其局限性显而易见, MySQL 将成为整个系统的瓶颈。

FastDFS 轻量级的另外一个体现是代码量较小。最新的 V2.0 包括了 C 客户端 API 、 FastDHT 客户端 API 和 PHP extension 等,代码行数不到 5.2 万行。

b. 分组存储

FastDFS 采用了「分组存储」方式。集群由一个或多个组(Group)构成,集群存储总容量为集群中所有组的存储容量之和。一个组(Group)由一台或多台存储服务器组成,同组内的多台 Storage Server 之间是对等的互备关系。文件上传、下载、删除等操作可以在组内任意一台 Storage Server 上进行。类似「木桶短板效应」,一个组的存储容量为该组内存储服务器容量最小的那个,由此可见组内存储服务器的软硬件配置最好是一致的

用分组存储方式的好处是灵活、可控性较强。比如上传文件时,可以由客户端直接指定上传到的组。一个分组的存储服务器访问压力较大时,可以在该组增加存储服务器来扩充服务能力(纵向扩容);当系统容量不足时,可以增加组来扩充存储容量(横向扩容)。采用这样的分组存储方式,可以使用 FastDFS 对文件进行管理,使用 Nginx 等进行文件下载。

c. 对等设计

FastDFS 集群中的 Tracker server 也可以有多台, Tracker Server 和 Storage Server 均不存在单点问题。Tracker Server 之间是对等关系,组内的 Storage Server 之间也是对等关系。传统的 Master-Slave 结构中的 Master 是单点,写操作仅针对 Master 。如果 Master 失效,需要将 Slave 提升为 Master ,实现逻辑会比较复杂。和 Master-Slave 结构相比,对等结构中所有结点的地位是相同,每个结点都是 Master,不存在单点问题。

  • 「跟踪器」由对等节点构成,提供调度和负载能力;
  • 「存储器」按组划分,组之间独立,组内由对等节点构成,相互之间备份数据;
  • 「存储器」需要向「跟踪器」上报状态信息;
  • 「存储器」和「跟踪器」均支持扩容。

2.3 存储策略

为了支持大容量,存储节点(服务器)采用了分卷(或分组)的组织方式。存储系统由一个或多个卷组成,卷与卷之间的文件是相互独立的,所有卷的文件容量累加就是整个存储系统中的文件容量。一个卷可以由一台或多台存储服务器组成,一个卷下的存储服务器中的文件都是相同的,卷中的多台存储服务器起到了冗余备份和负载均衡的作用。

在卷中增加服务器时,同步已有的文件由系统自动完成,同步完成后,系统自动将新增服务器切换到线上提供服务。当存储空间不足或即将耗尽时,可以动态添加卷。只需要增加一台或多台服务器,并将它们配置为一个新的卷,这样就扩大了存储系统的容量。

3. FastDFS 常见流程

3.1 上传

(1)选择 Tracker Server 和 Group

当集群中不止一个 Tracker Server 时,由于 Tracker 之间是完全对等的关系,客户端在 upload 文件时可以任意选择一个 Trakcer。 当 Tracker 接收到 upload_file 的请求时,会为该文件分配一个可以存储该文件的 group,使用 store_lookup 选择 group 的规则:

  • Round Robin,所有的 group 间轮询;
  • Specified Group,指定某一个确定的 group;
  • Load Balance,剩余存储空间多的 group 优先。

(2)选择 Storage Server

当选定 group 后,Tracker Server 会在 group 内选择一个 Storage Server 给客户端,使用 store_server 选择 Storage Server 的规则:

  • Round Robin,在 group 内的所有 Storage 间轮询;
  • First Server Ordered by IP,按 IP 排序;
  • First Server Ordered by Priority,按优先级排序(优先级在 Storage 上配置)。

(3)选择 Storage Path

当分配好 Storage Server 后,客户端将向 Storage Server 发送写文件请求,Storage Server 将会为文件分配一个数据存储目录(可以有多个存放文件的存储路径,可以理解为多个磁盘),store_path 支持如下:

  • Round Robin,多个存储目录间轮询;
  • 剩余存储空间最多的优先。

(4)生成文件名

选定存储目录之后,Storage Server 会为文件生一个文件名,由 Storage Server IP、文件创建时间、文件大小、文件 CRC32 和一个随机数拼接而成,然后将这个二进制串进行 base64 编码,转换为可打印的字符串。当选定存储目录之后,Storage Server 会按文件进行两次 hash(每个存储目录下有两级 256*256 的子目录),路由到其中一个子目录,然后将文件以这个文件标识为文件名存储到该子目录下。

(5)返回文件 ID

当文件存储到某个子目录后,即认为该文件存储成功,接下来 Storage Server 会将文件 ID 返回给客户端,此文件 ID 用于以后访问该文件的索引信息。文件索引信息包括: group、存储目录、两级子目录、内部文件名、文件后缀名(由客户端指定,主要用于区分文件类型)拼接而成。

  • 【组名】文件上传后所在的 Storage 组名称,在文件上传成功后由 Storage 服务器返回,需要客户端自行保存;
  • 【虚拟磁盘路径】Storage 配置的虚拟路径,与磁盘选项 store_path* 对应。如果配置了 store_path0 则是 M00,如果配置了 store_path1 则是 M01,以此类推;
  • 【数据两级目录】Storage 服务器在每个虚拟磁盘路径下创建的两级目录,用于存储数据文件;
  • 【文件名】与文件上传时不同。是由存储服务器根据特定信息生成,文件名包含:源存储服务器 IP 地址、文件创建时间戳、文件大小、随机数和文件拓展名等信息。

3.2 下载

客户端 uploadFile 成功后,会拿到一个 Storage 生成的文件 ID,接下来客户端根据这个文件 ID 即可访问到该文件。

客户端带上文件名信息请求任意 Tracker,Tracker 从文件名中解析出文件的 Group、大小、创建时间等信息,然后为该请求选择一个 Storage 用来服务读请求。

  • 轮询方式,可以下载当前文件的任意一个 Storage Server 进行轮询;
  • 哪个为源 Storage Server 就用哪个。

客户端获取到 Storage 的 IP 地址和端口,然后客户端根据返回的 IP 地址和端口号请求下载文件,Storage 接收到请求后返回文件给客户端。

由于 group 内的文件同步时在后台异步进行的,所以有可能出现在读到时候,文件还没有同步到某些 Storage Server,为了尽量避免访问到这样的 Storage Server,会有相应的文件同步规则。

3.3 同步

写文件时,客户端将文件写至 Group 内一个 Storage Server 即认为写文件成功,Storage Server 写完文件后,会由后台线程将文件同步至同 Group 内其他的 Storage Server。

每个 Storage 写文件后,同时会写一份 binlog,binlog 里不包含文件数据,只包含文件名等元信息,这份 binlog 用于后台同步,Storage 会记录向 Group 内其他 Storage 同步的进度,以便重启后能接上次的进度继续同步;进度以“时间戳”的方式进行记录,所以最好能保证集群内所有 Storage 的时钟保持同步。

Storage 的同步进度会作为元数据的一部分汇报到 Tracker 上,Tracker 在选择读 Storage 的时候会以“同步进度”作为参考。比如一个 group 内有 A、B、C 三个 Storage Server,A 向 C 同步的进度为 T1 ,B 向 C 同步的时间戳为 T2(T2 > T1),Tracker 接收到这些同步进度信息时,就会进行整理,将最小的那个做为 C 的同步时间戳,本例中 T1 即为 C 的同步时间戳(即所有 T1 以前写的数据都已经同步到 C 了)。同理,根据上述规则,Tracker 会为 A、B 生成一个同步时间戳。

Tracker 选择 group 内可用的 Storage 的规则:

  1. 该文件上传到的源头 Storage:源 Storage 只要存活着,肯定包含这个文件,源头的地址被编码在文件名中;
  2. 文件创建时间戳 == Storage 被同步到的时间戳 && (当前时间 - 文件创建时间戳) > 文件同步最大时间:文件创建后,认为经过最大同步时间后,肯定已经同步到其他 Storage 了;
  3. 文件创建时间戳 < Storage 被同步到的时间戳:同步时间戳之前的文件确定已经同步了;
  4. (当前时间 - 文件创建时间戳) > 同步延迟阀值:经过同步延迟阈值时间,认为文件肯定已经同步了。

3.4 删除

删除处理流程与文件下载类似:

  1. Client 询问 Tracker Server 可以删除指定文件的 Storage Server,参数为文件 ID(包含组名和文件名);
  2. Tracker Server 返回一台可用的 Storage Server;
  3. Client 直接和该 Storage Server 建立连接,完成文件删除;

3.5 断点续传

提供 appender file 的支持,通过 upload_appender_file 接口完成,appender file 允许在创建后,对该文件进行 append 操作。实际上,appender file 与普通文件的存储方式是相同的,不同的是,appender file 不能被合并存储到 trunk file。续传涉及到的文件大小 MD5 不会改变。续传流程与文件上传类似,先定位到源 Storage,完成完整或部分上传,再通过 binlog 进行同 group 内 Server 文件同步。

3.6 http 访问支持

FastDFS 的 Tracker 和 Storage 都内置了 http 协议的支持,客户端可以通过 http 协议来下载文件,Tracker 在接收到请求时,通过 http 的 redirect 机制将请求重定向至文件所在的 Storage 上。除了内置的 http 协议外,FastDFS 还提供了通过 Nginx 扩展模块下载文件的支持。

4. 搭建环境

4.1 FastDFS

a. 安装依赖库

(1)安装 libevent 事件通知函数库:yum -y install libevent

(2)安装 libfastcommon,它是从 FastDFS 和 FastDHT 中提取出来的公共 C 函数库,基础环境;

a. 解压 libfastcommon:tar -xzvf libfastcommon-1.0.43.tar.gz

b. 进入目录,编译 ./make.sh,然后安装 ./make.sh install

安装的时候控制台的打印会提到:默认装到 /usr/lib64 下面。我忘截图了,下图是网上找的:

cd 到 /usr/lib64 下,查看相关文件:

libfastcommon.so 安装到了 /usr/lib64/libfastcommon.so,但是 FastDFS 主程序设置的 lib 目录是 /usr/lib,所以需要创建软链接(这里不用了,我用的这个版本已经把这事给做了)。

b. 安装 FastDFS

(1)解压 fastdfs-5.12.zip:unzip fastdfs-5.12.zip

(2)仍然是进入加压目录,编译 ./make.sh 然后安装 ./make.sh install,默认会安装在 /usr/bin 目录下:

(3)除了 fastdfs-5.12/conf 之外,还有一部分配置文件在 /etc/fdfs 下,现都给它并到 /etc/fdfs 下:

c. 配置 FastDFS

注意,下面出现的目录配置,都要你自己先建好。

(1)tracker.conf:Tracker 数据和日志目录地址(根目录必须存在,子目录会自动创建)

(2)storage.conf

  • base_path:Storage 工作主目录
  • group_name:看当前 Storage 该分配到哪个组就配哪个组
  • store_path0:文件存储路径(store_path0 对应的虚拟路径为 M00)
  • tracker_server:Tracker 的地址,有多个 Tracker 则配多条

(3)client.conf

d. 运行 FastDFS

服务的启动都是“服务名+配置文件名”的形式:首先启动 fdfs_trackerd 服务 fdfs_trackerd /etc/fdfs/tracker.conf,然后启动 fdfs_storage 服务 fdfs_storaged /etc/fdfs/storage.conf

通过 fdfs_monitor 查看 Storage 和 Tracker 是否在通信:

再通过 fdfs_test 简单测试一下文件上传:

根据返回的 url 去 store_path0 下找一下这张图片(Storage 启动成功后,会创建好用于存储数据的两级目录):

但目前通过这个 url 并不能实现在浏览器中访问,因为没有配置 Nginx ~

4.2 Storage 上安装 Nginx

上面将文件上传成功了,但我们无法下载。因此安装 Nginx 作为服务器以支持 HTTP 方式访问文件。同时,后面安装 FastDFS 的 Nginx 模块也需要 Nginx 环境。

Nginx 只需要安装到 Storage 所在的服务器即可,用于访问文件。我这里由于是单机,TrackerServer 和 StorageServer 在一台服务器上。

a. 安装 Nginx

(0)安装依赖库

  • GCC:yum install gcc-c++
  • PCRE pcre-devel(正则解析):yum install -y pcre pcre-devel
  • zlib(数据压缩):yum install -y zlib zlib-devel
  • OpenSSL(安全|加密):yum install -y openssl openssl-devel

(1)解压 tar -xzvf nginx-1.12.0.tar.gz

(2)使用默认配置 ./configure

(3)编译 make,安装 make install

(4)启动 Nginx

(5)简单的测试访问文件:首先修改 nginx.conf,然后重启 Nginx,最后用浏览器访问~

b. fastdfs-nginx-module

FastDFS 通过 Tracker 服务器,将文件放在 Storage 服务器存储,但是同组存储服务器之间需要进行文件复制,有同步延迟的问题。

现假设 Tracker 服务器将文件上传到了 192.168.206.129,上传成功后文件 ID 已经返回给客户端。此时 FastDFS 存储集群机制会将这个文件同步到同组存储 192.168.206.128,在文件还没有复制完成的情况下,客户端如果用这个文件 ID 在 192.168.206.128 上取文件,就会出现文件无法访问的错误。

fastdfs-nginx-module 可以重定向文件链接到源服务器取文件,避免客户端由于复制延迟导致的文件无法访问错误。

(1)先停掉 Nginx:/usr/local/nginx/sbin/nginx -s stop

(2)解压 fastdfs-nginx-module.tar.gz

(3)进入 Nginx 目录,给 Nginx 添加模块:./configure --add-module=../fastdfs-nginx-module-1.20/src

(4)重新编译 make、安装 make install Nginx 后,查看 Nginx 版本和配置:

(5)复制 fastdfs-nginx-module/src 中的配置文件到 /etc/fdfs 目录

(6)修改 mod_fastdfs.conf

(7)修改 nginx.conf,然后启动 Nginx

(8)再去访问那张图,也是 OK 的 ~

4.3 Tracker 上安装 Nginx

现在能通过 HTTP 访问,但是目的地都是 Storage Server,实际情况是 Storage Server Cluster 是一个动态的,不利于客户端直接访问。客户端直连 Tracker Server 访问即可。

当然也可以在 Tracker Server Cluster 前面再放一个 Nginx,实现对 Tracker 的负载均衡。

5. 配置优化

(1)最大连接数设置

配置文件:tracker.conf 和 storage.conf

  • 参数名:max_connections
  • 缺省值:256
  • 说明:FastDFS 为一个连接分配一个 task buffer,为了提升分配效率,FastDFS 采用内存池的做法。FastDFS 老版本直接事先分配 max_connections 个 buffer,这个做法显然不是太合理,在 max_connections 设置过大的情况下太浪费内存。v5.04 对预分配采用增量方式,tracker 一次预分配 1024 个,storage 一次预分配 256 个。
    #define ALLOC_CONNECTIONS_ONCE 1024
    

总的 task buffer 初始内存占用情况测算如下:

  • 改进前:max_connections * buffer_size
  • 改进后:预分配的 max_connections 中较小的那个 * buffer_size

使用 v5.04 及后续版本的可以根据实际需要将 max_connections 设置为一个较大的数值,比如 10240 甚至更大。

注意此时需要将一个进程允许打开的最大文件数调大到超过 max_connections,否则 FastDFS Server 启动会报错。

vi /etc/security/limits.conf
* soft nofile 65535
* hard nofile 65535

重启系统生效。另外,对于 32 位系统,请注意使用到的内存不要超过 3GB。

(2)工作线程数设置

配置文件:tracker.conf 和 storage.conf

  • 参数名: work_threads
  • 缺省值:4
  • 说明:为了避免 CPU 上下文切换的开销,以及不必要的资源消耗,不建议将本参数设置得过大。为了发挥出多个 CPU 的效能,系统中的线程数总和,应等于 CPU 总数。
    • [Tracker] work_threads + 1 = CPU 数
    • [Storage] work_threads + 1 + (disk_reader_threads + disk_writer_threads) * store_path_count = CPU 数

(3)Storage 目录数设置

配置文件: storage.conf

  • 参数名:subdir_count_per_path
  • 缺省值:256
  • 说明:FastDFS 采用二级目录的做法,目录会在 FastDFS 初始化时自动创建。存储海量小文件,打开了 trunk 存储方式的情况下,建议将本参数适当改小,比如设置为 32,此时存放文件的目录数为 32 * 32 = 1024。假如 trunk 文件大小采用缺省值 64MB,磁盘空间为 2TB,那么每个目录下存放的 trunk 文件数均值为:2TB / (1024 * 64MB) = 32 个。

(4)Storage 磁盘读写线程设置

配置文件:storage.conf

  • disk_rw_separated:磁盘读写是否分离;
  • disk_reader_threads:单个磁盘读线程数;
  • disk_writer_threads:单个磁盘写线程数。

如果磁盘读写混合,单个磁盘读写线程数为读线程数和写线程数之和;对于单盘挂载方式,磁盘读写线程分别设置为 1 即可。如果磁盘做了 RAID,那么需要酌情加大读写线程数,这样才能最大程度地发挥磁盘性能。

(6)Storage 同步延迟相关设置

配置文件:storage.conf

  • sync_binlog_buff_interval:将 binlog buffer 写入磁盘的时间间隔,取值大于 0,缺省值为 60s;
  • sync_wait_msec:如果没有需要同步的文件,对 binlog 进行轮询的时间间隔,取值大于 0,缺省值为 200ms;
  • sync_interval:同步完一个文件后,休眠的毫秒数,缺省值为 0。

为了缩短文件同步时间,可以将上述 3 个参数适当调小即可。

6. Java 客户端

业务系统中是前端先跟本地系统对接,由后端服务对接 FastDFS。

(1)Maven 依赖

<dependency>
    <groupId>org.csource</groupId>
    <artifactId>fastdfs-client-java</artifactId>
    <version>1.27</version>
</dependency>

(2)application-dev.properties

fastdfs.connect_timeout_in_seconds = 5
fastdfs.network_timeout_in_seconds = 30
fastdfs.charset = UTF-8
fastdfs.http_anti_steal_token = false
fastdfs.http_tracker_http_port = ${scms.fdfs.nginx.port}


fastdfs.httpIp = ${scms.fdfs.nginx.ip}
fastdfs.httpPort = ${scms.fdfs.nginx.port}
fastdfs.pathHeader = /ZNVFSIMG/
fastdfs.tracker_servers = ${scms.fastdfs.tracker.servers}

fastdfs.reservedStorageSpace = 10%
fastdfs.fileEncrypted= 0
fastdfs.fileExtName = jpg
fastdfs.oldFileIntervalDays = 365

# 0:按时间段  1:按文件个数  其他数值:两者结合
fastdfs.spaceFullDeleteFileMethod = 1
fastdfs.spaceFullDeleteFileDays = 90
fastdfs.spaceFullDeleteFileCnt = 3000

# 每次批量插入文件记录的最多条数
batch.insert.files.count=2000

#################### ↓ Nacos ↓ ####################

scms.fdfs.nginx.ip=10.45.154.171
scms.fdfs.nginx.port=8888
scms.fastdfs.tracker.servers=10.45.154.171:22122

(3)文件上传流程

  1. 创建一个访问 Tracker 的客户端对象 TrackerClient;
  2. 通过 TrackerClient 访问 TrackerServer 服务,获取连接信息;
  3. 通过 TrackerServer 返回的 Storage 连接信息,创建 StorageClient 对象;
  4. 通过 StorageClient 访问 StorageServer,实现文件上传,并获取上传后的存储信息。
posted @ 2021-09-21 11:40  tree6x7  阅读(143)  评论(0编辑  收藏  举报