`
twh1224
  • 浏览: 95560 次
  • 性别: Icon_minigender_1
  • 来自: 武汉
社区版块
存档分类
最新评论

Lucene学习(16)

阅读更多
在接触到索引删除的策略IndexDeletionPolicy 的时候,提到一个提交点(IndexCommitPoint)的概念。在合适的时机,根据策略需求,需要对这些提交点(IndexCommitPoint)执行删除操作。

这些个提交点(IndexCommitPoint)究竟具有怎样的特征呢?

IndexCommitPoint是一个索引提交点的接口类,定义非常简单,如下所示:

package org.apache.lucene.index; 
 
public interface IndexCommitPoint { 
 
   /** 
   * 获取与指定的索引提交点相关的索引段文件(这些索引段文件的名称形如segments_N) 
   * 例如,我们在测试实例化一个IndexWriter索引器的时候,在创建索引的过程中就生成了索引段文件 
   * 参考文章 Lucene-2.2.0 源代码阅读学习(11) ,可以看到生成的索引段文件为segments_1,大小为1K 
   */ 
 
  public String getSegmentsFileName(); 
 
// 删除指定的索引提交点相关的索引段文件 
  public void delete(); 
} 



实现IndexCommitPoint接口的类为CommitPoint类。CommitPoint类是一个最终类,而且它是作为一个内部类来定义的,那么它的外部类为IndexFileDeleter类。由此可以看出,一些索引提交点(IndexCommitPoint)的存在,是依赖于 IndexFileDeleter类的,只有选择了某种索引文件删除策略,才能够构造一个IndexFileDeleter类的实例。倘若初始化了一个 IndexFileDeleter类的实例,没有索引删除策略,则这个IndexFileDeleter类的实例根本就没有应用的价值,更不必谈什么索引提交点(IndexCommitPoint)了。

在IndexWriter索引器类中,定义了一个内部成员:

private IndexFileDeleter deleter; 


也就是说,一个索引器的实例化必然要初始化一个IndexFileDeleter类的实例,然后在索引器初始化的时候,初始化索引器主要是调用IndexWriter的init方法,而IndexWriter类只定义了两个重载的init方法,他们的声明如下:

private void init(Directory d, Analyzer a, boolean closeDir, IndexDeletionPolicy deletionPolicy, boolean autoCommit) 
    throws CorruptIndexException, LockObtainFailedException, IOException ; 
 
private void init(Directory d, Analyzer a, final boolean create, boolean closeDir, IndexDeletionPolicy deletionPolicy, boolean autoCommit) 
    throws CorruptIndexException, LockObtainFailedException, IOException; 


这里面,最重要的是第二个init方法,该方法才真正地实现了一些索引器的初始化工作,而第一个init方法只是在通过调用IndexReader类的静态方法:

public static boolean indexExists(Directory directory) throws IOException 


来判断指定的索引目录中是否存在索引文件,从而间接地调用第二个init方法来初始化一个IndexWriter索引器。

然后,IndexWriter索引器类不同的构造方法根据构造需要,调用上面的两个init方法实现初始化工作。

在上面的第二个init方法中,根据指定的索引文件删除策略,实例化一个IndexFileDeleter:

deleter = new IndexFileDeleter(directory, deletionPolicy == null ? new KeepOnlyLastCommitDeletionPolicy() : deletionPolicy,segmentInfos, infoStream); 


其中infoStream是PrintStream类的一个实例,而PrintStream类继承自FilterOutputStream类,即PrintStream是一个文件输出流类。

这里,如果deletionPolicy=null,即构造一个索引器没有指定删除策略,则自动指派其删除策略为KeepOnlyLastCommitDeletionPolicy,否则使用指定的删除策略deletionPolicy。

一个IndexWriter索引器与IndexFileDeleter索引文件删除工具相关,有必要关注一下IndexFileDeleter类的定义,先把它的一个重要的内部类CommitPoint类放在后面学习:

package org.apache.lucene.index;

import org.apache.lucene.index.IndexFileNames;
import org.apache.lucene.index.SegmentInfos;
import org.apache.lucene.index.SegmentInfo;
import org.apache.lucene.store.Directory;

import java.io.IOException;
import java.io.PrintStream;
import java.util.Map;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.ArrayList;
import java.util.Collections;

//    该类对建立索引过程中指定的Directory目录中的索引文件的删除操作进行管理 

// 注意:在IndexFileDeleter实例化之前,必须持有write.lock锁 

final class IndexFileDeleter {

	// 在删除索引文件过程中可能会由于一些I/O等异常删除失败,将删除失败的文件放到deletable列表中,以期待再次尝试删除它们 
	private List deletable;

	// 存储了与一个索引段文件相关的源数据中的文件的个数,即通过这个索引可以检索到的文件的数目,这里refCount的Key是索引文件的名称,Value就是该索引文件被引用的次数 
	private Map refCounts = new HashMap();

	// 当前索引目录下的索引文件列表 
	private List commits = new ArrayList();

	// 在某个检查点(checkpoint)处可能存在修改了引用计数,但是没有生成提交点,要暂时把这些索引文件存放到lastFiles列表中 
	private List lastFiles = new ArrayList();

	// 提交删除指定索引策略下的索引文件列表 
	private List commitsToDelete = new ArrayList();

	private PrintStream infoStream;
	private Directory directory;
	private IndexDeletionPolicy policy;

	void setInfoStream(PrintStream infoStream) {
		this.infoStream = infoStream;
	}

	private void message(String message) {
		infoStream.println(this + " " + Thread.currentThread().getName() + ": "
				+ message);
	}

	//================IndexFileDeleter()方法开始================ 

	// 初始化一个IndexFileDeleter实例,初始化要做大量工作 
	public IndexFileDeleter(Directory directory, IndexDeletionPolicy policy,
			SegmentInfos segmentInfos, PrintStream infoStream)
			throws CorruptIndexException, IOException {

		this.infoStream = infoStream;
		this.policy = policy;
		this.directory = directory;

		// 第一次遍历索引目录下的索引文件,初始化索引文件索引的文件计数为0 
		long currentGen = segmentInfos.getGeneration(); // 获取下一次提交时索引段文件segments_N的版本号 

		// 初始化一个对索引文件进行过滤的IndexFileNameFilter实例 
		IndexFileNameFilter filter = IndexFileNameFilter.getFilter();

		String[] files = directory.list();
		if (files == null)
			throw new IOException("cannot read directory " + directory
					+ ": list() returned null");

		CommitPoint currentCommitPoint = null;

		for (int i = 0; i < files.length; i++) {

			String fileName = files[i];

			if (filter.accept(null, fileName)
					&& !fileName.equals(IndexFileNames.SEGMENTS_GEN)) {

				// IndexFileNames.SEGMENTS_GEN常量的值为segments.gen,可以在Lucene-2.2.0 源代码阅读学习(11) 看到生成的segments.gen文件 

				// 如果生成的索引文件合法,则添加到一个初始化索引计数为0的RefCount中 
				getRefCount(fileName);

				if (fileName.startsWith(IndexFileNames.SEGMENTS)) {

					// This is a commit (segments or segments_N), and 
					// it's valid (<= the max gen). Load it, then 
					// incref all files it refers to: 
					if (SegmentInfos.generationFromSegmentsFileName(fileName) <= currentGen) {
						if (infoStream != null) {
							message("init: load commit \"" + fileName + "\"");
						}
						SegmentInfos sis = new SegmentInfos();
						sis.read(directory, fileName);
						CommitPoint commitPoint = new CommitPoint(sis);
						if (sis.getGeneration() == segmentInfos.getGeneration()) {
							currentCommitPoint = commitPoint;
						}
						commits.add(commitPoint);
						incRef(sis, true);
					}
				}
			}
		}

		if (currentCommitPoint == null) {
			throw new CorruptIndexException(
					"failed to locate current segments_N file");
		}

		// 对索引目录中所有的索引段文件进行排序 
		Collections.sort(commits);

		// 删除引用计数为0的索引文件. 
		Iterator it = refCounts.keySet().iterator();
		while (it.hasNext()) {
			String fileName = (String) it.next();
			RefCount rc = (RefCount) refCounts.get(fileName);
			if (0 == rc.count) {
				if (infoStream != null) {
					message("init: removing unreferenced file \"" + fileName
							+ "\"");
				}
				deleteFile(fileName);
			}
		}

		// 在索引器启动的时刻根据指定删除策略删除索引文件 
		policy.onInit(commits);

		// 索引器启动的时刻成功地删除了索引文件,之后还要盘点当前驻留内存中的SegmentInfos,避免它们仍然使用删除的索引文件 
		if (currentCommitPoint.deleted) {
			checkpoint(segmentInfos, false);
		}

		deleteCommits(); // 提交删除 
	}

	//================IndexFileDeleter()方法结束================ 

	//   根据索引文件删除策略决定删除的提交点,将commitsToDelete列表中的提交点从每个SegmentInfos中删除掉 
	private void deleteCommits() throws IOException {

		int size = commitsToDelete.size();

		if (size > 0) {

			// First decref all files that had been referred to by 
			// the now-deleted commits: 
			for (int i = 0; i < size; i++) {
				CommitPoint commit = (CommitPoint) commitsToDelete.get(i);
				if (infoStream != null) {
					message("deleteCommits: now remove commit \""
							+ commit.getSegmentsFileName() + "\"");
				}
				int size2 = commit.files.size();
				for (int j = 0; j < size2; j++) {
					decRef((List) commit.files.get(j));
				}
				decRef(commit.getSegmentsFileName());
			}
			commitsToDelete.clear();

			// Now compact commits to remove deleted ones (保持有序): 
			size = commits.size();
			int readFrom = 0;
			int writeTo = 0;
			while (readFrom < size) {
				CommitPoint commit = (CommitPoint) commits.get(readFrom);
				if (!commit.deleted) {
					if (writeTo != readFrom) {
						commits.set(writeTo, commits.get(readFrom));
					}
					writeTo++;
				}
				readFrom++;
			}

			while (size > writeTo) {
				commits.remove(size - 1);
				size--;
			}
		}
	}

	/** 
	 * 用于检查优化的方法 
	 * 因为在复杂的操作过程中,可能发生异常,索引目录中可能存在不被引用的索引文件, 
	 * 应该删除这些无用的索引文件,释放磁盘空间 
	 */
	public void refresh() throws IOException {
		String[] files = directory.list();
		if (files == null)
			throw new IOException("cannot read directory " + directory
					+ ": list() returned null");
		IndexFileNameFilter filter = IndexFileNameFilter.getFilter();
		for (int i = 0; i < files.length; i++) {
			String fileName = files[i];
			if (filter.accept(null, fileName)
					&& !refCounts.containsKey(fileName)
					&& !fileName.equals(IndexFileNames.SEGMENTS_GEN)) {
				// 经过过滤、检查,找出残留的无用索引文件,删除他们 
				if (infoStream != null) {
					message("refresh: removing newly created unreferenced file \""
							+ fileName + "\"");
				}
				deleteFile(fileName);
			}
		}
	}

	/** 
	 * For definition of "check point" see IndexWriter comments: 
	 * removed, we decref their files as well. 
	 */
	public void checkpoint(SegmentInfos segmentInfos, boolean isCommit)
			throws IOException {

		if (infoStream != null) {
			message("now checkpoint \""
					+ segmentInfos.getCurrentSegmentFileName()
					+ "\" [isCommit = " + isCommit + "]");
		}

		// Try again now to delete any previously un-deletable 
		// files (because they were in use, on Windows): 
		if (deletable != null) {
			List oldDeletable = deletable;
			deletable = null;
			int size = oldDeletable.size();
			for (int i = 0; i < size; i++) {
				deleteFile((String) oldDeletable.get(i));
			}
		}

		// Incref the files: 
		incRef(segmentInfos, isCommit);

		if (isCommit) {
			// Append to our commits list: 
			commits.add(new CommitPoint(segmentInfos));

			// Tell policy so it can remove commits: 
			policy.onCommit(commits);

			// Decref files for commits that were deleted by the policy: 
			deleteCommits();
		}

		// DecRef old files from the last checkpoint, if any: 
		int size = lastFiles.size();
		if (size > 0) {
			for (int i = 0; i < size; i++) {
				decRef((List) lastFiles.get(i));
			}
			lastFiles.clear();
		}

		if (!isCommit) {
			// Save files so we can decr on next checkpoint/commit: 
			size = segmentInfos.size();
			for (int i = 0; i < size; i++) {
				SegmentInfo segmentInfo = segmentInfos.info(i);
				if (segmentInfo.dir == directory) {
					lastFiles.add(segmentInfo.files());
				}
			}
		}
	}

	void incRef(SegmentInfos segmentInfos, boolean isCommit) throws IOException {
		int size = segmentInfos.size();
		for (int i = 0; i < size; i++) {
			SegmentInfo segmentInfo = segmentInfos.info(i);
			if (segmentInfo.dir == directory) {
				incRef(segmentInfo.files());
			}
		}

		if (isCommit) {
			// Since this is a commit point, also incref its 
			// segments_N file: 
			getRefCount(segmentInfos.getCurrentSegmentFileName()).IncRef();
		}
	}

	// 对列表files中的索引文件,进行批量引用计数加1操作 

	private void incRef(List files) throws IOException {
		int size = files.size();
		for (int i = 0; i < size; i++) {
			String fileName = (String) files.get(i);
			RefCount rc = getRefCount(fileName);
			if (infoStream != null) {
				message(" IncRef \"" + fileName + "\": pre-incr count is "
						+ rc.count);
			}
			rc.IncRef();
		}
	}

	// 对列表files中的索引文件,进行批量引用计数减1操作 

	private void decRef(List files) throws IOException {
		int size = files.size();
		for (int i = 0; i < size; i++) {
			decRef((String) files.get(i));
		}
	}

	// 指定索引文件的引用计数减1 

	private void decRef(String fileName) throws IOException {
		RefCount rc = getRefCount(fileName);
		if (infoStream != null) {
			message(" DecRef \"" + fileName + "\": pre-decr count is "
					+ rc.count);
		}
		if (0 == rc.DecRef()) {
			// 一个索引文件的引用计数为0了,即该索引文件已变成垃圾索引,要删除该索引文件 
			deleteFile(fileName);
			refCounts.remove(fileName);
		}
	}

	void decRef(SegmentInfos segmentInfos) throws IOException {
		final int size = segmentInfos.size();
		for (int i = 0; i < size; i++) {
			SegmentInfo segmentInfo = segmentInfos.info(i);
			if (segmentInfo.dir == directory) {
				decRef(segmentInfo.files());
			}
		}
	}

	// 根据指定的索引文件的名称,获取用于管理该索引文件的引用计数RefCount实例 

	private RefCount getRefCount(String fileName) {
		RefCount rc;
		if (!refCounts.containsKey(fileName)) {
			rc = new RefCount();
			refCounts.put(fileName, rc);
		} else {
			rc = (RefCount) refCounts.get(fileName);
		}
		return rc;
	}

	// 从Directory directory目录中删除指定索引文件fileName 

	private void deleteFile(String fileName) throws IOException {
		try {
			if (infoStream != null) { // 如果输出流保持打开状态 
				message("delete \"" + fileName + "\"");
			}
			directory.deleteFile(fileName);
		} catch (IOException e) { //   如果删除失败 
			if (directory.fileExists(fileName)) {

				// 删除失败索引文件还残留于索引目录中,并且,如果输出流关闭,则提示稍后删除 

				if (infoStream != null) {
					message("IndexFileDeleter: unable to remove file \""
							+ fileName + "\": " + e.toString()
							+ "; Will re-try later.");
				}
				if (deletable == null) { //   将删除失败的索引文件添加到列表deletable中 
					deletable = new ArrayList();
				}
				deletable.add(fileName);
			}
		}
	}

	/** 
	 * Blindly delete the files used by the specific segments, 
	 * with no reference counting and no retry. This is only 
	 * currently used by writer to delete its RAM segments 
	 * from a RAMDirectory. 
	 */
	public void deleteDirect(Directory otherDir, List segments)
			throws IOException {
		int size = segments.size();
		for (int i = 0; i < size; i++) {
			List filestoDelete = ((SegmentInfo) segments.get(i)).files();
			int size2 = filestoDelete.size();
			for (int j = 0; j < size2; j++) {
				otherDir.deleteFile((String) filestoDelete.get(j));
			}
		}
	}

	//  RefCount类是用于管理一个索引文件的引用计数的,当然,一个索引文件可能没有被引用过,这时引用计数this.count=0,应该删除掉这个没有意义的索引文件 
	final private static class RefCount {

		int count;

		final private int IncRef() { // 计数加1 
			return ++count;
		}

		final private int DecRef() { // 计数减1 
			return --count;
		}
	}

}


将静态内部类CommitPoint(是IndexCommitPoint接口的一个具体实现类)单独拿出来看:

/** 
   * 保存每个提交点的详细信息,为了更好地在应用删除策略时进行应用提供方便。 
   * 该类实现了Comparable接口;该类的实例,即提交点,在放到一个List中的时候,不能有重复的 
   */ 
 
final private class CommitPoint implements Comparable, IndexCommitPoint { 
 
    long gen;    // 下次提交索引段segments_N的版本 
    List files;    // 属于当前索引目录的索引段的一个列表 
    String segmentsFileName;    // 一个索引段 
    boolean deleted;    // 删除标志 
 
    public CommitPoint(SegmentInfos segmentInfos) throws IOException { 
      segmentsFileName = segmentInfos.getCurrentSegmentFileName(); 
      int size = segmentInfos.size();    // segmentInfos是一个索引段SegmentInfo的向量 
      files = new ArrayList(size); 
      gen = segmentInfos.getGeneration();     // 获取下次提交索引段segments_N的版本号 
      for(int i=0;i<size;i++) { 
        SegmentInfo segmentInfo = segmentInfos.info(i);    // 从segmentInfos向量列表中取出一个segmentInfo 
        if (segmentInfo.dir == directory) { 
          files.add(segmentInfo.files());    // 如果该索引段segmentInfo属于该索引目录,则加入到列表files中 
        } 
      } 
    } 
 
    /** 
     * 获取与该提交点相关的segments_N索引段 
     */ 
    public String getSegmentsFileName() { 
      return segmentsFileName; 
    } 
 
    /** 
     * 删除一个提交点 
     */ 
    public void delete() { 
      if (!deleted) { 
        deleted = true; 
        commitsToDelete.add(this); 
      } 
    } 
 
    public int compareTo(Object obj) { 
      CommitPoint commit = (CommitPoint) obj; 
      if (gen < commit.gen) { 
        return -1; 
      } else if (gen > commit.gen) { 
        return 1; 
      } else { 
        return 0; 
      } 
    } 
} 
分享到:
评论

相关推荐

    【分享:lucene学习资料】---<下载不扣分,回帖加1分,欢迎下载,童叟无欺>

    1&gt; lucene学习笔记 2&gt; 全文检索的实现机制 【1】lucene学习笔记的目录如下 1. 概述 3 2. lucene 的包结构 3 3. 索引文件格式 3 4. lucene中主要的类 4 4.1. Document文档类 4 4.1.1. 常用方法 4 4.1.2. 示例 4 4.2...

    开发自己的搜索引擎lucene+heritrix(第2版)(ch13-ch15源码)

    综上所述,"开发自己的搜索引擎lucene+heritrix(第2版)"的源码涵盖了从网络数据抓取到全文检索的全过程,适合开发者深入学习和实践搜索引擎技术。通过研究ch13至ch16的源代码,可以更深入地理解这两个工具的交互和...

    Lucene4.X实战类baidu搜索的大型文档海量搜索系统-16.Lucene高级进阶2 共4页.pptx

    在本课程中,我们主要探讨了Lucene 4.x版本的高级进阶应用,特别是针对大规模文档搜索引擎的构建。...通过不断学习和实践,开发者可以更好地掌握Lucene的高级特性,提升搜索引擎的性能和用户体验。

    搜索引擎Lucene+Heritrix(第二版)4

    通过学习《搜索引擎Lucene+Heritrix(第二版)4》这本书,读者不仅可以了解搜索引擎的基本原理,还能掌握如何利用Lucene构建自己的全文搜索引擎,以及如何用Heritrix进行大规模的网络数据抓取。书中的ch14-ch16章节...

    java学习过程资源

    【学习阶段16】就业指导(79天): 这个阶段可能包含简历撰写、面试技巧、职业规划等内容,帮助你更好地准备进入IT行业。 【学习阶段17】项目二:宜立方商城(80-93天): 这是一个更大型的电商项目,涉及更多的...

    java的28个学习目标

    了解并掌握常用的外部API和框架,如Log4J(日志)、Quartz(调度)、JGroups(网络组通信)、JCache(分布式缓存)、Lucene(全文检索)等。 ### 19. 跨平台与本地接口 学习跨平台开发技巧,掌握JNI(Java Native ...

    Java学习的30个目标

    熟悉日志框架(如Log4J)、任务调度(如Quartz)、分布式缓存(如JCache)、全文搜索(如Lucene)等常用框架和API。 #### 21. 本地接口与连接器架构 学习Java Native Interface(JNI)和Java Connector ...

    学习java的30个目标.txt

    #### 目标16:熟悉J2EE标准API - **API介绍**:JNDI、JMS、JTA/JTS、JMX及Java Mail等。 - **应用场景**:命名服务、消息传递、事务处理、管理系统监控等。 #### 目标17:学习企业级Java Beans(EJB) - **技术...

    学习Java语言的30个参考,让你坐拥别人之上的30个擦考

    - **Lucene**:学习全文检索技术的基础知识。 ### 21. 本地接口与连接器 - **JNI、JCA**:掌握Java Native Interface、Java Connector Architecture等技术,实现Java与其他语言或平台的交互。 通过以上知识点的...

    java学习目标

    #### 16. 本机接口与连接器 学习Java Native Interface (JNI) 和 Java Connector Architecture (JCA),可以让你的应用更好地与本地系统和外部系统集成。 #### 17. JINI与CORBA 熟悉JINI和CORBA等分布式计算协议,...

    java的30个学习目标

    #### 16. **远程方法调用(RMI)** - RMI/IIOP用于实现分布式应用中的对象通信。 #### 17. **XML处理** - JAXP、JDOM、DOM4J、JAXR等API用于解析、生成和操作XML文档。 #### 18. **Web服务** - JAX-RPC、SAAJ、...

    搜索链接类网站(多套)

    [搜索链接]深度学习网址导航系统 v0.0.16(jsp)_jspurl [搜索链接]淘特搜索引擎共享版_tot_search_engine [搜索链接]相见欢友情链接系统ASPX版 v1.0_xjlinkaspxv1.0 [搜索链接]要广告分类系统 v2.0_yad20

    搜索引擎开发培训课程提纲PPT学习教案.pptx

    11. **中文分词**:在Lucene和Lietu等工具中,中文分词是文本预处理的关键步骤,正向最大匹配是常见的分词算法。 12. **查找词典算法**:Trie树是构建词典和进行快速查找的高效数据结构,包括数字搜索树和Tire树,...

    80个JavaWeb项目(包含项目源码).rar

    80个JavaWeb项目(包含项目源码) ExtS2.2开源网络硬盘系统...深度学习网址导航系统v0.0.16(jsp)jspurl..rar 海特搜索擎共享版tot_search_engine.rar 相见欢友情链接系统ASPX版v1.0 jlinkaspxv1.0.rai 要告分类系统...

    收集java学习资料和面试题包括git上好的项目

    ##### 16. Solr & Elasticsearch - **简介**:两个流行的全文搜索引擎。 - **链接**:[http://lucene.apache.org/solr/](http://lucene.apache.org/solr/) [https://www.elastic.co/](https://www.elastic.co/) - **...

    全文检索Solr8.0-架构师速成记

    1.采用Solr最新版本视频录制,全网最新课程(Solr8.1于2019年5月16日发布) 2.技能点全网最全,会结合工作经验,项目中用到的技能点都会有所涉及,更新章节比较全面 3.适用范围广,从零基础到高级架构以及分布式集群...

    基于Javaweb的搜索引擎的实现报告模板.docx

    - 10月16日至10月23日,建立索引并编写搜索实现类,设计搜索结果显示页面的初步框架。 - 10月24日至10月31日,加入中文分词器,完成数据库信息的添加,编写数据访问和实体类。 - 11月1日至11月7日,整合Tika实现...

    《ElasticSearch入门到实战》电子书,从入门到进阶实战项目的教程文档,框架SpringBoot框架整合ES.zip

    Elasticsearch 是一个基于 Lucene 的开源全文搜索引擎,以其分布式、可扩展性、实时搜索以及强大的数据分析能力而受到广泛欢迎。它不仅支持文本搜索,还可以处理结构化和非结构化数据,适用于日志分析、监控、信息...

    JEECMS2012系统使用手册2012-07美化版(pdf格式)

    16. **[@CMS_LUCENE_PAGE]**:基于Lucene搜索的文章分页列表。 17. **[@CMS_TAG_LIST]**:列出带有特定标签的文章。 18. **[@CMS_CUT]**:截断字符串。 #### 五、常用的一些标签 此外,手册还提供了常用的标签及其...

Global site tag (gtag.js) - Google Analytics