尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java后端非结构化文本清洗与信息提取实战:从电商标题处理到生产级组件构建

Java后端非结构化文本清洗与信息提取实战:从电商标题处理到生产级组件构建 在实际项目中我们经常需要处理来自外部系统的、非结构化的文本数据例如商品标题、社交媒体帖子或用户生成的内容。这些数据往往包含丰富的语义信息但同时也混杂着大量与核心业务逻辑无关的“噪声”例如表情符号、营销符号、语气词等。以“飞天小女警CALL ME手机挂件毛绒盲盒来啦✨”这样一个典型的电商或内容平台标题为例它包含了IP名称、产品类型、材质、营销状态等多种信息但其中的“来啦✨”对于商品分类、搜索索引或数据分析而言通常是无意义的。本文将以这个标题为切入点探讨如何设计一个健壮的文本信息提取与清洗流程适用于Java后端开发中处理类似非结构化文本的场景。我们将从核心概念入手逐步构建一个可配置、可扩展的文本处理器涵盖规则匹配、正则表达式、关键词提取等常见技术并最终实现一个能够稳定运行、便于排查的生产级组件。1. 理解文本清洗与信息提取的核心挑战在处理类似“飞天小女警CALL ME手机挂件毛绒盲盒来啦✨”的文本时我们的目标不是简单地删除所有非中文字符而是有选择地识别和分离出有价值的结构化信息同时过滤掉干扰项。这背后涉及几个关键的技术判断。1.1 噪声与信号的界定噪声并非绝对。在一个电商标题中“来啦✨”是促销语气和装饰符号属于噪声但在情感分析中“✨”可能代表积极情绪又成了信号。因此清洗策略必须与业务目标强绑定。对于商品信息结构化我们通常关注实体如品牌、IP、品类、属性和状态如新品、盲盒。1.2 规则与模型的取舍纯规则方法正则、关键词表开发快、解释性强但难以覆盖所有语言变体如“Call Me”、“call me”、“CALLME”。机器学习或深度学习模型泛化能力强但需要标注数据、计算资源且存在“黑盒”问题。对于大多数后台数据处理任务尤其是初期或对确定性要求高的场景基于规则的方法往往是更稳妥的起点。1.3 处理流程的阶段性一个完整的处理流程应该是管道式的包含多个阶段例如编码标准化、无用字符过滤、文本归一化、实体识别与提取、剩余文本分析。每个阶段职责单一便于测试、调试和迭代。2. 环境准备与项目结构设计我们将创建一个标准的Maven项目来实现这个文本处理器。确保你的开发环境已安装JDK 8或以上版本以及Maven。2.1 创建Maven项目通过命令行或IDE创建一个新的Maven项目。pom.xml的核心依赖目前只需要单元测试库因为我们先实现核心逻辑。?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdtext-processor/artifactId version1.0-SNAPSHOT/version properties maven.compiler.source8/maven.compiler.source maven.compiler.target8/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties dependencies !-- 单元测试 -- dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version5.9.2/version scopetest/scope /dependency !-- 可选用于更复杂的字符串操作或未来扩展 -- dependency groupIdorg.apache.commons/groupId artifactIdcommons-lang3/artifactId version3.12.0/version /dependency /dependencies /project2.2 设计项目包结构一个清晰的结构有助于分离关注点。我们采用以下包结构src/main/java/com/example/textprocessor/ ├── processor/ │ ├── TextProcessingPipeline.java // 处理管道串联各个阶段 │ ├── stage/ │ │ ├── ProcessingStage.java // 处理阶段接口 │ │ ├── UnicodeNormalizerStage.java // 编码标准化 │ │ ├── NoiseCharFilterStage.java // 噪声字符过滤 │ │ ├── KeywordExtractorStage.java // 关键词提取 │ │ └── TextNormalizerStage.java // 文本归一化如大小写、空格 │ └── model/ │ ├── ProcessedResult.java // 处理结果封装 │ └── ExtractedEntity.java // 提取出的实体 └── config/ └── ProcessingRuleConfig.java // 规则配置可外部化3. 构建可配置的文本处理管道处理管道的核心思想是责任链模式。每个ProcessingStage接收输入进行处理并输出给下一个阶段。3.1 定义处理阶段接口与结果模型首先定义最基础的接口和模型类明确数据流转的格式。// ProcessingStage.java package com.example.textprocessor.processor.stage; import com.example.textprocessor.processor.model.ProcessedResult; /** * 文本处理阶段接口 */ public interface ProcessingStage { /** * 处理文本 * param input 上一阶段的处理结果 * return 本阶段处理后的结果 */ ProcessedResult process(ProcessedResult input); }// ProcessedResult.java package com.example.textprocessor.processor.model; import java.util.ArrayList; import java.util.List; /** * 文本处理结果封装 */ public class ProcessedResult { // 原始输入文本 private String originalText; // 当前阶段的清洗后文本 private String cleanedText; // 从文本中提取出的实体列表如品牌、品类 private ListExtractedEntity entities; // 被移除的噪声内容可用于审计或调试 private ListString removedNoise; public ProcessedResult(String originalText) { this.originalText originalText; this.cleanedText originalText; this.entities new ArrayList(); this.removedNoise new ArrayList(); } // 省略 getter 和 setter 方法 }// ExtractedEntity.java package com.example.textprocessor.processor.model; /** * 提取出的实体 */ public class ExtractedEntity { public enum EntityType { IP, // 知识产权/品牌如“飞天小女警” PRODUCT_TYPE, // 产品类型如“手机挂件” MATERIAL, // 材质如“毛绒” MARKETING_TAG // 营销标签如“盲盒” } private EntityType type; private String value; private int startIndex; // 在原始文本中的起始位置可选用于溯源 private int endIndex; // 在原始文本中的结束位置 // 省略构造方法和 getter/setter }3.2 实现具体的处理阶段我们实现几个最基础的阶段来演示流程。首先是一个统一字符编码的阶段防止因编码问题导致后续正则匹配失败。// UnicodeNormalizerStage.java package com.example.textprocessor.processor.stage; import com.example.textprocessor.processor.model.ProcessedResult; /** * 统一字符编码将全角字符、特殊空格等转换为半角标准形式。 * 这是一个防御性阶段确保后续阶段处理的一致性。 */ public class UnicodeNormalizerStage implements ProcessingStage { Override public ProcessedResult process(ProcessedResult input) { if (input null || input.getCleanedText() null) { return input; } String text input.getCleanedText(); // 示例将全角字母、数字、空格转换为半角 // 实际项目中可能需要处理更多字符可使用第三方库如 commons-lang3 的 StringUtils StringBuilder sb new StringBuilder(); for (char c : text.toCharArray()) { if (c \uff01 c \uff5e) { // 全角字符范围 sb.append((char) (c - 0xfee0)); } else if (c \u3000) { // 全角空格 sb.append( ); } else { sb.append(c); } } input.setCleanedText(sb.toString()); return input; } }接下来是关键阶段基于配置规则过滤噪声字符和提取关键词。// NoiseCharFilterStage.java package com.example.textprocessor.processor.stage; import com.example.textprocessor.processor.model.ProcessedResult; import java.util.regex.Pattern; /** * 根据预定义的正则表达式模式移除常见的语气词、表情符号等噪声。 * 规则应可配置这里硬编码用于演示。 */ public class NoiseCharFilterStage implements ProcessingStage { // 配置化的噪声模式匹配常见的语气词、颜文字、无关符号 // 使用正则表达式注意性能可预编译。 private static final Pattern NOISE_PATTERN Pattern.compile( // 匹配中文语气词啦、咯、哟、~等及相邻的常见符号 “[啦咯哟喔~]*[!]*✨*” “|^[【\\[【]?([来新]品|首发|到货)[】\\]】]?” // 简单匹配营销头尾标签 “|\\s的\\s”, // 移除某些无意义的“的”需谨慎 Pattern.CASE_INSENSITIVE ); Override public ProcessedResult process(ProcessedResult input) { String text input.getCleanedText(); // 使用正则匹配并移除 java.util.regex.Matcher matcher NOISE_PATTERN.matcher(text); StringBuffer cleaned new StringBuffer(); while (matcher.find()) { // 将被匹配到的噪声内容记录下来 input.getRemovedNoise().add(matcher.group()); matcher.appendReplacement(cleaned, “”); } matcher.appendTail(cleaned); input.setCleanedText(cleaned.toString().trim()); return input; } }// KeywordExtractorStage.java package com.example.textprocessor.processor.stage; import com.example.textprocessor.processor.model.ExtractedEntity; import com.example.textprocessor.processor.model.ProcessedResult; import java.util.*; import java.util.regex.Pattern; /** * 基于关键词词典和简单模式匹配从文本中提取结构化实体。 * 生产环境中词典应外部化如数据库、配置文件。 */ public class KeywordExtractorStage implements ProcessingStage { // 模拟一个可配置的实体词典 Map实体类型, List关键词 private final MapExtractedEntity.EntityType, ListString keywordDict; public KeywordExtractorStage() { keywordDict new EnumMap(ExtractedEntity.EntityType.class); keywordDict.put(ExtractedEntity.EntityType.IP, Arrays.asList(“飞天小女警”, “泡泡”, “花花”, “毛毛”)); keywordDict.put(ExtractedEntity.EntityType.PRODUCT_TYPE, Arrays.asList(“手机挂件”, “钥匙扣”, “摆件”)); keywordDict.put(ExtractedEntity.EntityType.MATERIAL, Arrays.asList(“毛绒”, “塑料”, “金属”)); keywordDict.put(ExtractedEntity.EntityType.MARKETING_TAG, Arrays.asList(“盲盒”, “福袋”, “限定”)); } Override public ProcessedResult process(ProcessedResult input) { String text input.getCleanedText(); String originalText input.getOriginalText(); for (Map.EntryExtractedEntity.EntityType, ListString entry : keywordDict.entrySet()) { ExtractedEntity.EntityType type entry.getKey(); for (String keyword : entry.getValue()) { // 简单字符串包含匹配实际中可能需要更复杂的边界处理如“手机挂件” vs “手机” if (text.contains(keyword)) { ExtractedEntity entity new ExtractedEntity(); entity.setType(type); entity.setValue(keyword); // 记录在原始文本中的位置简单实现实际应用需处理多次出现 int index originalText.indexOf(keyword); if (index 0) { entity.setStartIndex(index); entity.setEndIndex(index keyword.length()); } input.getEntities().add(entity); // 可选将已识别的关键词从 cleanedText 中移除避免重复匹配 // text text.replace(keyword, “”); } } } // 如果执行了移除操作需要更新 cleanedText // input.setCleanedText(text); return input; } }3.3 组装处理管道管道类负责按顺序组织各个阶段并执行处理流程。// TextProcessingPipeline.java package com.example.textprocessor.processor; import com.example.textprocessor.processor.model.ProcessedResult; import com.example.textprocessor.processor.stage.*; import java.util.ArrayList; import java.util.List; /** * 文本处理管道 */ public class TextProcessingPipeline { private final ListProcessingStage stages; public TextProcessingPipeline() { this.stages new ArrayList(); // 按顺序组装处理阶段 this.stages.add(new UnicodeNormalizerStage()); // 1. 编码标准化 this.stages.add(new NoiseCharFilterStage()); // 2. 噪声过滤 this.stages.add(new KeywordExtractorStage()); // 3. 关键词提取 // 可以继续添加其他阶段如文本归一化统一大小写、去除多余空格 } /** * 执行完整的文本处理流程 * param rawText 原始文本 * return 最终的处理结果 */ public ProcessedResult execute(String rawText) { ProcessedResult result new ProcessedResult(rawText); for (ProcessingStage stage : stages) { result stage.process(result); // 这里可以加入短路逻辑如果某阶段后文本已为空可提前终止 if (result.getCleanedText() null || result.getCleanedText().isEmpty()) { break; } } return result; } // 提供方法允许运行时动态添加/移除阶段增加灵活性 public void addStage(ProcessingStage stage) { this.stages.add(stage); } }4. 运行验证与结果分析现在我们编写单元测试来验证整个处理流程并使用示例标题“飞天小女警CALL ME手机挂件毛绒盲盒来啦✨”进行测试。4.1 编写单元测试在src/test/java下创建测试类。// TextProcessingPipelineTest.java package com.example.textprocessor.processor; import com.example.textprocessor.processor.model.ExtractedEntity; import com.example.textprocessor.processor.model.ProcessedResult; import org.junit.jupiter.api.Test; import java.util.List; import static org.junit.jupiter.api.Assertions.*; class TextProcessingPipelineTest { Test void testProcessExampleTitle() { // 1. 准备 String rawTitle “飞天小女警CALL ME手机挂件毛绒盲盒来啦✨”; TextProcessingPipeline pipeline new TextProcessingPipeline(); // 2. 执行 ProcessedResult result pipeline.execute(rawTitle); // 3. 验证 assertNotNull(result); System.out.println(“原始文本: “ result.getOriginalText()); System.out.println(“清洗后文本: “ result.getCleanedText()); System.out.println(“移除的噪声: “ result.getRemovedNoise()); System.out.println(“提取的实体: “); ListExtractedEntity entities result.getEntities(); assertFalse(entities.isEmpty()); boolean foundIP false, foundProduct false, foundMaterial false, foundTag false; for (ExtractedEntity entity : entities) { System.out.println(“ - 类型: “ entity.getType() “, 值: “ entity.getValue()); switch (entity.getType()) { case IP: if (“飞天小女警”.equals(entity.getValue())) foundIP true; break; case PRODUCT_TYPE: if (“手机挂件”.equals(entity.getValue())) foundProduct true; break; case MATERIAL: if (“毛绒”.equals(entity.getValue())) foundMaterial true; break; case MARKETING_TAG: if (“盲盒”.equals(entity.getValue())) foundTag true; break; } } // 断言关键实体都被正确提取 assertTrue(foundIP, “应提取到IP‘飞天小女警’”); assertTrue(foundProduct, “应提取到产品类型‘手机挂件’”); assertTrue(foundMaterial, “应提取到材质‘毛绒’”); assertTrue(foundTag, “应提取到营销标签‘盲盒’”); // 断言噪声被移除清洗后文本不应包含“来啦✨” String cleaned result.getCleanedText(); assertFalse(cleaned.contains(“来啦”), “清洗后文本应移除‘来啦’”); assertFalse(cleaned.contains(“✨”), “清洗后文本应移除‘✨’”); // 注意CALL ME 目前未被定义为实体也未在噪声规则中所以会保留在cleanedText中 System.out.println(“最终清洗文本验证通过包含: “ cleaned); } }4.2 运行测试并分析输出运行上述测试使用Maven命令mvn test或IDE的测试功能预期控制台输出如下原始文本: 飞天小女警CALL ME手机挂件毛绒盲盒来啦✨ 清洗后文本: 飞天小女警CALL ME手机挂件毛绒盲盒 移除的噪声: [来啦✨] 提取的实体: - 类型: IP, 值: 飞天小女警 - 类型: PRODUCT_TYPE, 值: 手机挂件 - 类型: MATERIAL, 值: 毛绒 - 类型: MARKETING_TAG, 值: 盲盒结果分析噪声过滤成功语气词“来啦”和表情符号“✨”被识别为噪声并从cleanedText中移除并记录在removedNoise列表中。实体提取成功根据我们预定义的词典“飞天小女警”、“手机挂件”、“毛绒”、“盲盒”被正确识别并分类提取。遗留内容“CALL ME”作为未定义的关键词留在了cleanedText中。这符合设计预期它既不是已知实体也不是噪声可以作为后续其他分析如分词、NER模型的输入。5. 关键配置、参数详解与生产级考量上面的示例将规则硬编码在代码中这在生产环境中是不可接受的。我们需要将规则外部化、配置化。5.1 外部化规则配置创建一个配置类用于加载噪声正则表达式和关键词词典。这里使用YAML格式示例并通过ConfigurationProperties或简单读取文件的方式加载。# application-rules.yml (或 config/rules.yaml) text-processing: noise-patterns: - pattern: “[啦咯哟喔~]*[!]*✨*” description: “过滤中文语气词和星星符号” - pattern: “^[【\\[【]?([来新]品|首发|到货)[】\\]】]?” description: “过滤开头的营销标签” - pattern: “\\s的\\s” description: “过滤孤立的‘的’字需谨慎” entity-keywords: IP: - “飞天小女警” - “泡泡” - “花花” - “毛毛” PRODUCT_TYPE: - “手机挂件” - “钥匙扣” - “摆件” MATERIAL: - “毛绒” - “塑料” - “金属” MARKETING_TAG: - “盲盒” - “福袋” - “限定”对应的Java配置类简化版// ProcessingRuleConfig.java package com.example.textprocessor.config; import lombok.Data; // 使用 Lombok 简化需添加依赖 import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import java.util.List; import java.util.Map; Component ConfigurationProperties(prefix “text-processing”) Data public class ProcessingRuleConfig { private ListNoisePattern noisePatterns; private MapString, ListString entityKeywords; Data public static class NoisePattern { private String pattern; private String description; } }然后修改NoiseCharFilterStage和KeywordExtractorStage从ProcessingRuleConfig中读取规则。5.2 性能优化与正则表达式预编译正则表达式编译开销大。应在阶段初始化时预编译所有模式。// 在 NoiseCharFilterStage 初始化时 private ListPattern compiledNoisePatterns; public NoiseCharFilterStage(ProcessingRuleConfig config) { this.compiledNoisePatterns config.getNoisePatterns().stream() .map(np - Pattern.compile(np.getPattern(), Pattern.CASE_INSENSITIVE)) .collect(Collectors.toList()); }5.3 处理边界与歧义当前的简单字符串包含匹配text.contains(keyword)存在缺陷。例如文本“智能手机挂件”会错误匹配关键词“手机挂件”。更健壮的方法是使用正则表达式配合单词边界\b对中文不友好或利用分词工具先对文本进行分词然后在词序列中匹配。改进的匹配逻辑示例// 使用正则匹配确保关键词是作为一个独立单元或前后有非中文字符/边界 private boolean matchesKeyword(String text, String keyword) { // 构建模式确保keyword前后不是中文字符、数字、字母简易版 String pattern String.format(“(^|[^\\p{L}\\p{N}])%s($|[^\\p{L}\\p{N}])”, Pattern.quote(keyword)); return Pattern.compile(pattern).matcher(text).find(); } // \p{L}匹配任何语言的字母\p{N}匹配数字。这比简单的contains更精确。6. 常见问题排查与调试指南在实际部署和运行中你可能会遇到以下典型问题。6.1 问题现象实体提取不全或错误问题现象可能原因检查方式处理建议某个已知关键词未提取出来1. 关键词词典未加载或配置错误。2. 文本编码问题导致字符串匹配失败。3. 关键词前后有特殊字符或空格。1. 打印加载后的配置确认关键词存在。2. 打印cleanedText在匹配前的实际字符串检查编码和内容。3. 使用调试器或日志查看text.contains(keyword)或匹配函数的实际结果。1. 检查配置文件路径和格式。2. 在UnicodeNormalizerStage后增加日志输出标准化后的文本。3. 采用更健壮的匹配方法如6.3节所述的正则匹配。提取了错误的实体如“手机”匹配了“智能手机”匹配逻辑过于宽松未考虑单词边界。编写单元测试输入“智能手机挂件”检查是否错误匹配了“手机挂件”。升级匹配逻辑使用基于分词或精确边界正则的方法。同一实体被重复提取文本中包含多个相同关键词且提取逻辑未去重。检查entities列表查看是否有重复的type和value。在KeywordExtractorStage的process方法中添加实体去重逻辑基于type和value判断。6.2 问题现象噪声过滤过度或不足问题现象可能原因检查方式处理建议有用的信息被当作噪声过滤掉了如“新品”被移除噪声正则表达式模式过于宽泛。检查removedNoise列表看被移除的内容是否包含有价值信息。收紧正则表达式规则。例如将匹配“新品”的规则修改为只匹配被特定符号包围的“新品”如【新品】。明显的噪声如“”未被过滤噪声正则表达式未覆盖该模式。收集一批未过滤的噪声样本。将新的噪声模式添加到配置文件中。建议建立一个噪声样本库定期更新规则。过滤后文本残留乱码或特殊字符UnicodeNormalizerStage未能处理某些特殊字符。在UnicodeNormalizerStage前后打印文本的十六进制表示。扩展UnicodeNormalizerStage的字符映射表或引入成熟的库如org.apache.commons.text.StringEscapeUtils或java.text.Normalizer。6.3 问题现象管道性能低下问题现象可能原因检查方式处理建议处理大量文本时速度慢1. 正则表达式未预编译每次处理都编译。2. 关键词匹配使用List.contains遍历复杂度O(n)。3. 阶段顺序不合理导致无效处理。1. 使用性能分析工具如JProfiler查看热点。2. 记录每个阶段的处理时间。1. 确保所有Pattern都在初始化时预编译。2. 将关键词列表转换为Set或Trie树进行查找。3. 将最可能过滤掉大量内容的阶段如噪声过滤前置。6.4 增加调试日志在生产环境中为管道和每个阶段添加详细的调试日志至关重要。使用SLF4J和Logback。// 在每个Stage的process方法开始和结束时记录日志 import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class NoiseCharFilterStage implements ProcessingStage { private static final Logger log LoggerFactory.getLogger(NoiseCharFilterStage.class); Override public ProcessedResult process(ProcessedResult input) { log.debug(“开始噪声过滤阶段输入文本: {}”, input.getCleanedText()); // ... 处理逻辑 log.debug(“噪声过滤阶段结束移除噪声: {}, 输出文本: {}”, input.getRemovedNoise(), input.getCleanedText()); return input; } }通过调整日志级别可以在问题发生时快速定位到出错的阶段和具体的输入输出。7. 最佳实践与扩展方向基于以上实现和问题排查经验可以总结出以下适用于生产环境的最佳实践。7.1 配置管理绝对禁止硬编码所有规则正则表达式、关键词、阈值必须外部化到配置文件、数据库或配置中心。版本化与回滚规则变更应有版本记录并支持快速回滚到上一版本。环境隔离为开发、测试、生产环境配置不同的规则集测试充分后再上线。7.2 管道设计阶段无状态化每个ProcessingStage应设计为无状态的只依赖输入和配置便于并发处理和测试。支持短路与旁路管道应支持在特定条件下跳过某些阶段或在某个阶段失败时提供降级策略如记录错误并继续。度量与监控为每个阶段添加性能指标处理时长、处理条目数和业务指标实体提取率、噪声识别率接入监控系统。7.3 匹配算法升级引入分词对于中文文本使用成熟的分词工具如HanLP、Jieba的Java版先将句子切分为词序列然后在词序列上进行实体匹配能极大提高准确率并解决边界问题。使用Aho-Corasick算法当关键词词典非常大时使用AC自动机进行多模式匹配效率远高于遍历列表。集成NER模型对于更复杂的、非固定模式的实体如产品型号、颜色描述可以集成训练好的命名实体识别模型与规则系统互补。7.4 可观测性结构化日志输出结构化的日志如JSON格式包含请求ID、原始文本、各阶段结果、耗时、提取的实体列表等便于后续检索和分析。采样与审计定期对处理结果进行采样由人工审核评估清洗和提取的准确率并据此优化规则。7.5 扩展方向示例情感符号处理新增一个EmotionSymbolStage专门识别并提取“✨”、“❤️”、“”等符号并将其转化为情感极性标签存入ProcessedResult。同义词归一化新增一个SynonymNormalizerStage将“手机链”、“手机绳”等同义词统一归一化为“手机挂件”。流水线编排将管道配置也外部化实现动态编排不同的处理阶段顺序以应对不同的文本类型如商品标题、用户评论、搜索词。通过这样一个从简单到复杂、从本地到可配置、从演示到生产级的逐步构建过程我们不仅解决了“飞天小女警CALL ME手机挂件毛绒盲盒来啦✨”这个具体例子的处理问题更建立了一套可复用于各类非结构化文本清洗和提取任务的工程框架。在实际项目中起点可以就是这个最小可行产品然后根据真实的业务数据和反馈迭代规则、优化算法、增强系统可靠性。
返回列表