• solr6.3.0升级与IK动态词库自动加载


    摘要:对于中文的搜索来说,词库系统是一个很比较重要的模块,本篇以IK分词器为例子,介绍如何让分词器从缓存或文件系统中自动按照一定频次进行加载扩展词库

    Lucene、Solr或ElasticStack如何从外部动态加载词库进入到内存作为分词使用,且这一系列动作不需要重启相应的搜索服务?当前市面上各种博客、论坛都是各种转载或者只是最简单的使用IK,根本无法达到线上使用的条件,而IK分词器默认是一次启动将主词库、停用词以及扩展词库全部加载完毕,后续如果再想要增加额外的扩展词就必须得修改对应的扩展词表并重新打包上传并重启服务方能生效,这种方式不适合应用与线上服务。那么到底如何实现这种无缝扩充词库呢?下面针对IK来做分析,其他的几种分词器,都是大同小异的原理。

    (一)词库介绍

    不论使用什么类型的分词器,一般都少不了使用词库,而词库里面,除了主词库之外,还有扩展词库,同义词库,禁用词库等,其中扩展词库,同义词库,禁用词库是比较基础的词库,一般类型的业务开发,使用这3种词库后,基本能满足需求,特殊情况需要另外考虑。

    (二)词库需求

    每一个网站都需要有一个特定行业的词库,来丰富词库系统,当然你可以不用建立词库,这样的效果可能检索的时候,用户体验可能会比较差,在系统运行过程中,词库是可以动态更新的,所以要求我们的分词器,能够动态更新所有的词库,比如禁用词,同义词,扩展词等,这样做动态性比较好,但已经建好索引的文本,与目前的词库可能会存在一些误差,这种差别会在下一次重建索引时得到改变,所谓词库的动态更新,也就是在后台单独起个线程定时在内存里重新Load词库。

    (三)为什么需要从数据库或文件系统加载词库?

    在实际的开发中,搜索作为一个重要的组件,很少单独部署作为一个应用,除非是那种比较小的数据量,或者对搜索要求不是非常严格,通常在互联网或者电子商务行业,特别是电商行业,因为访问量比较大,对系统并发、负载均衡、响应请求要求比较高,所以搜索作为一个关键的应用通常需要采用集群的方式来构建一个高可用,高扩展的检索系统,在集群中,一般采用主从架构的方式,这样一来1主N从,需要有很多份词库文件,如果词库经常变化那么这种牵一发而动全身的趋势,就会变的很明显,解决办法主要有2种:

    (1)在配置主从同步架构时,把变化的词库放在Master上,然后同步的时候把词库的配置文件也同步过去。

    (2)第二种就是今天主题所说,所有的词库文件都从某一个集中的地方管理,然后各个solr节点,定时从数据库或缓存里读取并更新(在IK源码的Dictionary里进行更新)。

    第一种方式的弊端在于,仅仅在solr的主从架构时,采用这种会比较方便,如果是solrcloud的模式,这种方法就不适用了

    第二种方式相对来说比较方便,整个集群只维持一份词库文件,改动较小,而且更好的办法我们可以结合本地词库+数据库的方式一起工作,这样一来当数据库出现宕机的时候,我们的词库仍能正常工作。

    (四)使用流程简析

    定义一个IKTokenizerFactory类继承TokenizerFactory并实现ResourceLoaderAware接口,并重写inform方法和create方法,在solr里配置使用。

    GitHub源码地址:https://github.com/liang68/ik-analyzer-solr6

    (五) 修改详情

    1. 源码级别(需要JDK1.8)

    代码下载、编译和修改,扩展自己用于动态更新词库的类IKTokenizerFactory以及固定频次扫描的UpdateKeeper类。具体看如下代码示例:

    package:org.wltea.analyzer.lucene

    可以直接从我的github上下载solr-6.3.0版本下的词库动态更新版。额外增加用于配置文件配置IK词库加载的工厂类:IKTokenizerFactory,具体源代码见下或者参看我的github源码。其中集成了TokenizerFactory,实现了ResourceLoaderAware和UpdateKeeper.UpdateJob接口,需要重写inform和create方法:

      1 /**
      2  * @author liangyongxing
      3  * @editTime 2017-02-06
      4  * 增加IK扩展词库动态更新类
      5  */
      6 public class IKTokenizerFactory extends TokenizerFactory
      7         implements ResourceLoaderAware, UpdateKeeper.UpdateJob {
      8   private boolean useSmart = false;
      9   private ResourceLoader loader;
     10   private long lastUpdateTime = -1L;
     11   private String conf = null;
     12 
     13   public IKTokenizerFactory(Map<String, String> args) {
     14     super(args);
     15     this.useSmart = getBoolean(args, "useSmart", false);
     16     this.conf = get(args, "conf");
     17     System.out.println(String.format(":::ik:construction:::::::::::::::::::::::::: %s", this.conf));
     18   }
     19 
     20     @Override
     21     public Tokenizer create(AttributeFactory attributeFactory) {
     22         return new IKTokenizer(attributeFactory, useSmart());
     23     }
     24 
     25   @Override
     26   public void inform(ResourceLoader resourceLoader) throws IOException {
     27      System.out.println(String.format(":::ik:::inform:::::::::::::::::::::::: %s", this.conf));
     28       this.loader = resourceLoader;
     29       update();
     30       if ((this.conf != null) && (!this.conf.trim().isEmpty())) {
     31           UpdateKeeper.getInstance().register(this);
     32       }
     33   }
     34 
     35   @Override
     36   /**
     37    * 执行更新词典操作
     38    * @throws IOException
     39    */
     40   public void update() throws IOException {
     41       Properties p = canUpdate();
     42       if (p != null) {
     43           List<String> dicPaths = SplitFileNames(p.getProperty("files"));
     44           List inputStreamList = new ArrayList();
     45           for (String path : dicPaths) {
     46               if ((path != null) && (!path.isEmpty())) {
     47                   InputStream is = this.loader.openResource(path);
     48 
     49                   if (is != null) {
     50                       inputStreamList.add(is);
     51                   }
     52               }
     53           }
     54           if (!inputStreamList.isEmpty())
     55               Dictionary.reloadDic(inputStreamList);
     56       }
     57   }
     58 
     59     /**
     60      * 检查是否要更新
     61      * @return
     62      */
     63     private Properties canUpdate() {
     64         try {
     65             if (this.conf == null)
     66                 return null;
     67             Properties p = new Properties();
     68             InputStream confStream = this.loader.openResource(this.conf);
     69             p.load(confStream);
     70             confStream.close();
     71             String lastupdate = p.getProperty("lastupdate", "0");
     72             //System.err.println(String.format("read %s file get lastupdate is %s.", this.conf, lastupdate));
     73             Long t = new Long(lastupdate);
     74             if (t.longValue() > this.lastUpdateTime) {
     75                 this.lastUpdateTime = t.longValue();
     76                 String paths = p.getProperty("files");
     77                 if ((paths == null) || (paths.trim().isEmpty()))
     78                     return null;
     79                 System.out.println("loading conf files success.");
     80                 return p;
     81             }
     82             this.lastUpdateTime = t.longValue();
     83             return null;
     84         }
     85         catch (Exception e) {
     86             //e.printStackTrace();
     87             System.err.println("IK parsing conf NullPointerException~~~~~" + e.getStackTrace());
     88         }
     89         return null;
     90     }
     91 
     92     public static List<String> SplitFileNames(String fileNames) {
     93         if (fileNames == null) {
     94             return Collections.emptyList();
     95         }
     96         List result = new ArrayList();
     97         for (String file : fileNames.split("[,\s]+")) {
     98             result.add(file);
     99         }
    100         return result;
    101     }
    102 
    103   private boolean useSmart() {
    104     return this.useSmart;
    105   }
    106 }
    View Code

    UpdateKeeper这个类主要实现了Runnable接口,封装了每1分钟自动读取配置,查看当前配置是否发生改变(根据配置的lastupdate=1 整数与当前内存中的整数做对比从而判断是否需要重新加载扩展词库),若发生改变会自动读取扩展词库将其加入到内存中。

     1 /**
     2  * Created by liangyongxing on 2017/2/6.
     3  * 1分钟自动判断更新
     4  */
     5 public class UpdateKeeper implements Runnable {
     6     static final long INTERVAL = 60000L;
     7     private static UpdateKeeper singleton;
     8     Vector<UpdateJob> filterFactorys;
     9     Thread worker;
    10 
    11     private UpdateKeeper() {
    12         this.filterFactorys = new Vector();
    13 
    14         this.worker = new Thread(this);
    15         this.worker.setDaemon(true);
    16         this.worker.start();
    17     }
    18 
    19     public static UpdateKeeper getInstance() {
    20         if (singleton == null) {
    21             synchronized (UpdateKeeper.class) {
    22                 if (singleton == null) {
    23                     singleton = new UpdateKeeper();
    24                     return singleton;
    25                 }
    26             }
    27         }
    28         return singleton;
    29     }
    30 
    31     public void register(UpdateJob filterFactory) {
    32         this.filterFactorys.add(filterFactory);
    33     }
    34 
    35     public void run() {
    36         while (true) {
    37             try {
    38                 Thread.sleep(INTERVAL);
    39             } catch (InterruptedException e) {
    40                 e.printStackTrace();
    41             }
    42 
    43             if (!this.filterFactorys.isEmpty()) {
    44                 for (UpdateJob factory : this.filterFactorys) {
    45                     try {
    46                         factory.update();
    47                     } catch (IOException e) {
    48                         e.printStackTrace();
    49                     }
    50                 }
    51             }
    52         }
    53     }
    54 
    55     public interface UpdateJob {
    56         void update() throws IOException;
    57     }
    58 }
    View Code

    package:org.wltea.analyzer.dic

    这个包下的Dictionary类是加载词典到内存的主要类,针对自动动态扩展词库功能需要在这个类中增加额外的方法来实现只针对自己配置的扩展词库进行加载,具体增加的代码片段详情如下(考虑到该类代码有点多,只贴出来自己新增加的方法):

    1 /**
     2  * 重新更新词典
     3  * 由于停用词等不经常变也不建议常增加,故这里只修改动态扩展词库
     4  * @param inputStreamList
     5  * @author liangyongxing
     6  * @createTime 2017年2月7日
     7  * @return
     8  */
     9 public static Dictionary reloadDic(List<InputStream> inputStreamList) {
    10     if (singleton == null) {
    11         Configuration cfg = DefaultConfig.getInstance();
    12         initial(cfg);
    13     }
    14     for (InputStream is : inputStreamList) {
    15         try {
    16             BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8"), 512);
    17             String theWord = null;
    18             HashMap<String, String> map = new HashMap();
    19             do {
    20                 theWord = br.readLine();
    21                 if (theWord != null && !"".equals(theWord.trim())) {
    22                     singleton._MainDict.fillSegment(theWord.trim().toLowerCase().toCharArray());
    23                 }
    24             } while (theWord != null);
    25         } catch (IOException ioe) {
    26             System.err.println("Other Dictionary loading exception.");
    27             ioe.printStackTrace();
    28         } finally {
    29             try {
    30                 if (is != null) {
    31                     is.close();
    32                     is = null;
    33                 }
    34             } catch (IOException e) {
    35                 e.printStackTrace();
    36             }
    37         }
    38     }
    39     return singleton;
    40 }

    改好了之后需要修改兼容版本的pom.xml文件,对应的lucene版本如下:

     <groupId>org.wltea.ik-analyzer</groupId>
     2 <artifactId>ik-analyzer-solr</artifactId>
     3 <version>6.3.0</version>
     4 <packaging>jar</packaging>
     6 <name>ik-analyzer-solr6.3</name>
     7 <url>http://code.google.com/p/ik-analyzer/</url>
     9 <properties>
    10     <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    11     <lucene.version>6.3.0</lucene.version>
    12     <javac.src.version>1.8</javac.src.version>
    13     <javac.target.version>1.8</javac.target.version>
    14     <maven.compiler.plugin.version>3.3</maven.compiler.plugin.version>
    15 </properties>

      改完之后重新build之后对应的有个核心类会抛异常,对应的类IKQueryExpressionParser会报错,报错的原因是对应的方法以及下线了,需要修改为当前最新用法,通过官方API文档看到BooleanQuery的使用方法已经改变,将其改正。 具体修改如下所示:

      

    修改为如下所示:

      

    改正完成后重新打包即可,将对应的jar上传到服务器solr安装的对应dist位置下即可。剩下的就是需要配置好相对应集群的配置文件上传,具体不要心急继续往后看哦。

    2. 配置级别

      jar包上传完之后搜索引擎服务启动或者重启之后,需要将配置文件上传到zk进行统一管理,其中我们需要修改schema.xml和solrconfig.xml

    schema.xml中需要将中文分词引入(6.x名称修改为managed-schema,该文件采用和ElasticStack类似的映射方式,但是不论solr还是ES还是建议使用人为指定配置方式,这样不会出一些其他意想不到的问题--具体问题请参看我的博客:http://www.cnblogs.com/liang1101/articles/6379393.html)

    <fieldType name="text_zh" class="solr.TextField" >
        <analyzer type="index" >
            <tokenizer class="org.wltea.analyzer.lucene.IKTokenizerFactory" useSmart="false" conf="ik.conf"/>
            <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt" />
        </analyzer>
        <analyzer type="query">
            <tokenizer class="org.wltea.analyzer.lucene.IKTokenizerFactory" useSmart="false" conf="ik.conf"/>
            <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords.txt" />
        </analyzer>
    </fieldType>

      其中userSmart的意思是是否启用智能分词,默认是最大细粒度的方式,一般建议index为默认的,query为智能分词,但是这也需要看自己的业务再做相应的决定;对应conf="ik.conf"是自己指定的,这块最终是在代码处进行处理,这里定义的conf则在代码中需要通过conf名称去获取到ik.conf配置文件,再通过ik.conf文件配置项进行判断是否需要动态更新词库,具体的可以参看源代码,这里就不做多余的解释了。

    solrconfig.xml配置文件需要将你新打的ik-analyzer-solr-6.3.0.jar引入:

    <lib dir="/home/solr/solr/dist/" regex="ik-analyzer-solr-d.*.jar" />

    至此代码级别和配置级别都已经大功告成,剩下的就是具体的测试流程了。

    特此申明:

       有好多朋友在按照上述配置运行代码的时候发现有问题,报出空指针异常等问题,这里我再作进一步的解释,包括整个要上传的文件目录与内容,以及 ik.conf 有什么用

      1. ik.conf 作用

       ik.conf 主要用于标识 IK 词库是否有进行更新的,有一个实时扫描程序在扫描这个文件里的内容从而判定是否需要重新加载词库。ik.conf 内容为:

      lastupdate=1
      files=dict1.txt
    

       其中:lastupdate 后面的数字来记录当前更新版本,如果有需要修改只需要修改这个值(我一般就是直接 + 1 处理,例如我增加词库后会将这里修改为 2)重新上传即可。而 files 后面的文件指向你自己的扩展词库,这样你想增加新词库的话,后面用逗号跟着新文件就可以接入新词库文件,而不需要修改 solr ik 底层核心配置文件 IKAnalyzer.cfg,如果修改这个文件必须得重启 solr,这个线上肯定是不允许的(你要知道一个文件大小限制是1MB,所以可能会随着词库的增加,文件数也是需要增加的)

      2. 往 zk 上传一个集群所需要的配置信息列表

      以下列表列出来的就是我在 solr-6.4.0 上做测试时的所有内容

      

      如有其它朋友有什么问题,欢迎在博客下面提问,或者在我对应的 github 地址提问题,这两块我有对应的信息提示,会及时回复大家。

    (六) 测试详情

    1. 配置信息上传至ZooKeeper

      通过zk上传命令将对应的配置文件夹上传,具体上传命令如下:

      /data/solr-6.3.0/server8983/scripts/cloud-scripts/zkcli.sh -zkhost zk1 -cmd upconfig -confdir /data/article_common_newest -confname article_common_newest

    【解释一下】
      solr-6.3.0 安装在/data 目录下

      一台服务器上起4个实例,分别为:8983/7574/6362/5251,对应服务也分别是server8983/server7574/server6362/server5251

      zk1 是其中一台zk服务器也是solr服务,对应的服务器hostname为zk1

      article_common_newest 为对应配置文件存放的文件夹名称,是放在了/data 目录下

      -confname article_common_newest 对应创建集群时所依赖的底层配置名称

    上传完之后到对应服务器浏览器界面Tree目录下,效果如下:
      

    2. 搜索引擎集群创建

    curl "http://ip:8983/solr/admin/collections?action=CREATE&name=article_test&router.field=fingerprint&numShards=2&replicationFactor=2&maxShardsPerNode=2&collection.configName=article_common_newest&createNodeSet=ip:8983_solr,ip:7574_solr"

    命令执行完之后,首先看一下对应的logging界面是否有错误提示,没有说明离成功很接近了:
      
    3. IK分词器验证

    到创建好的集群内通过界面的Analysis选项卡进行分析,查看IK是否生效:

      

    如果遇到这种界面的话,恭喜你,说明至此自动动态词库更新已经配置成功了。

    add by 2017/02/22 增加第二种远程读取方案

    申明:当前提交在github上的代码,主版本还是以读取相对本地文件来进行更新词库的(虽然是通过ZooKeeper进行同步的),所以为了区分主逻辑,在需要改动的类上都加上后缀:Remote

    1. 本地主词库和主扩展词库是不需要改动的,即源IKAnalyzer.cfg.xml中的配置是不需要改动的。

    2. 由于新增加了远程读取词库,故需要在IKAnalyzer.cfg.xml中增加两项配置,指定远程路径地址,例如:

        <!--用户可以在这里配置远程扩展字典 -->
        <entry key="remote_ext_dict">location</entry>
        <!--用户可以在这里配置远程扩展停止词字典-->
        <entry key="remote_ext_stopwords">location</entry>

      其中 location 是指一个 url,比如 http://yoursite.com/getCustomDict,该请求只需满足以下两点即可完成分词热更新。该 http 请求需要返回两个头部(header),一个是 Last-Modified,一个是 ETag,这两者都是字符串类型,只要有一个发生变化,该插件就会去抓取新的分词进而更新词库。该 http 请求返回的内容格式是一行一个分词,换行符用 即可。
    满足上面两点要求就可以实现热更新分词了,不需要重启solr
      可以将需自动更新的热词放在一个 UTF-8 编码的 .txt 文件里,放在 nginx 或其他简易 http server 下,当 .txt 文件修改时,http server 会在客户端请求该文件时自动返回相应的 Last-Modified 和 ETag。可以另外做一个工具来从业务系统提取相关词汇,并更新这个 .txt 文件。

    3. 增加了两项配置,故需要在源码级别的配置文件中增加两个属性值,用于读取上面配置的路径,在org.wltea.analyzer.cfg.ConfigurationRemote中,即:

        /**
         * 获取远程扩展词典配置路径
         * @return List<String> 相对类加载器的路径
         */
        public abstract List<String> getRemoteExtDictionarys();
        /**
         * 获取远程停止词配置路径
         * @return List<String> 相对类加载器的路径
         */
        public abstract List<String> getRemoteExtStopWordDictionarys();

      其次在org.wltea.analyzer.cfg.DefaultConfigRemote类中(这个类为对应获取相应地址值),增加对这两个属性值的读取:

    //配置属性--远程扩展词典
    private static final String REMOTE_EXT_DICT = "remote_ext_dict";
    //配置属性--远程停止词典
    private static final String REMOTE_EXT_STOP = "remote_ext_stopwords";
    
    /**
    * 读取远程扩展词典内容到内存
    */
    public List<String> getRemoteExtDictionarys() {
        List remoteExtDictFiles = new ArrayList(2);
        String remoteExtDictCfg = this.props.getProperty("remote_ext_dict");
        if (remoteExtDictCfg != null) {
            String[] filePaths = remoteExtDictCfg.split(";");
            if (filePaths != null) {
                for (String filePath : filePaths) {
                    if ((filePath != null) && (!"".equals(filePath.trim()))) {
                        remoteExtDictFiles.add(filePath);
                    }
                }
            }
        }
        return remoteExtDictFiles;
    }
    
    /**
    * 读取远程停止词典内容到内存
    */
    public List<String> getRemoteExtStopWordDictionarys() {
        List remoteExtStopWordDictFiles = new ArrayList(2);
        String remoteExtStopWordDictCfg = this.props.getProperty("remote_ext_stopwords");
        if (remoteExtStopWordDictCfg != null) {
            String[] filePaths = remoteExtStopWordDictCfg.split(";");
            if (filePaths != null) {
                for (String filePath : filePaths) {
                    if ((filePath != null) && (!"".equals(filePath.trim()))) {
                        remoteExtStopWordDictFiles.add(filePath);
                    }
                }
            }
        }
        return remoteExtStopWordDictFiles;
    }
    View Code

    4. 修改词典加载主类,为了与之前的org.wltea.analyzer.dic.Dictionary类不相互影响,故新扩展了一个名为:org.wltea.analyzer.dic.DictionaryRemote类,内部基本不用变,只需要修改动态加载词库那部分代码,具体请看第5点。

    5. 新增加一个名为:org.wltea.analyzer.dic.Monitor类,用于定时更新词库的事件类,这个类主要的功能就是每1分钟执行一次Monitor类查看指定的指标数据是否发生变化,如果发生变化则自动更新词库,否则不变,具体代码如下所示:

     1 /**
     2  * 远程调用文件并定时检查更新
     3  * add by liangyongxing
     4  * @createTime 2017-02-22
     5  */
     6 public class Monitor implements Runnable {
     7     private static CloseableHttpClient httpclient = HttpClients.createDefault();
     8     private String last_modified;
     9     private String eTags;
    10     private String location;
    11 
    12     public Monitor(String location) {
    13         this.location = location;
    14         this.last_modified = null;
    15         this.eTags = null;
    16     }
    17 
    18     @Override
    19     public void run() {
    20         RequestConfig rc = RequestConfig.custom().setConnectionRequestTimeout(10000).setConnectTimeout(10000).setSocketTimeout(15000).build();
    21 
    22         HttpHead head = new HttpHead(this.location);
    23         head.setConfig(rc);
    24 
    25         if (this.last_modified != null) {
    26             head.setHeader("If-Modified-Since", this.last_modified);
    27         }
    28         if (this.eTags != null) {
    29             head.setHeader("If-None-Match", this.eTags);
    30         }
    31         CloseableHttpResponse response = null;
    32         try {
    33             response = httpclient.execute(head);
    34 
    35             if (response.getStatusLine().getStatusCode() == 200) {
    36                 if ((!response.getLastHeader("Last-Modified").getValue().equalsIgnoreCase(this.last_modified))
    37                         || (!response.getLastHeader("ETag").getValue().equalsIgnoreCase(this.eTags))) {
    38                     DictionaryRemote.getSingleton().reLoadMainDict();
    39                     this.last_modified = (response.getLastHeader("Last-Modified") == null ? null : response.getLastHeader("Last-Modified").getValue());
    40                     this.eTags = (response.getLastHeader("ETag") == null ? null : response.getLastHeader("ETag").getValue());
    41                 }
    42             } else if (response.getStatusLine().getStatusCode() != 304) {
    43                 System.err.println("remote_ext_dict " + this.location + " return bad code " + response.getStatusLine().getStatusCode() + "");
    44             }
    45         } catch (Exception e) {
    46             System.err.println("remote_ext_dict  error!" + e.getStackTrace());
    47         } finally {
    48             try {
    49                 if (response != null)
    50                     response.close();
    51             } catch (IOException e) {
    52                 e.printStackTrace();
    53             }
    54         }
    55     }
    56 }
    View Code

    6. 在DictionaryRemote类即字典主类初始化时,需要将自动监控加载代码注册

      

     7. 新增加IKTokenizerFactoryRemote类,如果想要使用这种方式自动加载词库,那么在solrconfig.xml文件中需要配置这个类即可

      相应最新的代码都已经上传到我的github上,地址为:https://github.com/liang68/ik-analyzer-solr6

    有什么问题欢迎留言,我会尽可能早的回复。

  • 相关阅读:
    matlab中用来批量读取的dir函数
    cat 函数应用
    线性移不变系统
    为什么低频信息描述了图像在光滑部位的整体灰度信息,而高频部分则反映了图像在边缘、噪声等细节方面的表现?
    红灯检测宇视科技专利分析与总结2
    红灯检测宇视科技专利分析与总结1
    matlab中冒号的用法
    第一篇博文,大橙子的博客生涯要开始啦
    Spring Boot和Shiro整合
    Spring Boot + Redis使用短信平台发送验证码(腾讯云短信平台)
  • 原文地址:https://www.cnblogs.com/liang1101/p/6395016.html
Copyright © 2020-2023  润新知