ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python Tkinter桌面应用集成SQLite数据库实战:MVC架构与CRUD实现

Python Tkinter桌面应用集成SQLite数据库实战:MVC架构与CRUD实现 1. 项目概述为什么图形界面需要数据库做Python桌面应用开发尤其是用Tkinter这种轻量级工具很多朋友在完成界面布局、按钮事件后会遇到一个核心问题数据放哪儿用户输入的信息、程序的配置、历史记录总不能每次都让用户手动填或者程序一关就全丢了吧。这就是我们今天要聊的“集成数据库”的核心价值——为你的Tkinter应用赋予持久化存储的能力让它从一个“一次性玩具”变成一个“正经工具”。我见过不少初学者写的工具功能逻辑都不错但所有数据都存在变量里关闭即消失。下次想用一切从头再来用户体验大打折扣。而集成数据库就是解决这个痛点的标准答案。它不仅仅是“存数据”更是构建一个完整、可维护应用的基础架构。通过将界面Tkinter与数据层数据库分离你的代码会变得更清晰数据管理更安全功能扩展也更容易。比如你可以轻松实现用户管理、数据查询、报表导出等一系列进阶功能。在众多数据库选项中我首推SQLite。原因很简单它无需安装独立的数据库服务器整个数据库就是一个文件用Python标准库sqlite3就能直接操作完美契合Tkinter桌面应用轻量、便携、开箱即用的特性。你不需要折腾MySQL或PostgreSQL的安装配置对于大多数中小型桌面应用来说SQLite的性能和功能完全够用。这个项目我们就以SQLite为例手把手带你打通Tkinter到数据库的任督二脉。2. 核心思路与架构设计2.1 为什么选择“模型-视图-控制器”MVC模式直接在一堆按钮回调函数里写SQL语句是快速实现功能的方法但也是制造“屎山”代码的捷径。为了项目的可维护性我强烈建议哪怕是一个小工具也应有意识地采用简化的MVCModel-View-Controller模式来组织代码。视图View 这就是Tkinter的活儿。它负责所有你看得见的东西——窗口、标签、输入框、按钮、表格Treeview。它的任务就是展示数据并接收用户的操作指令。模型Model 这是数据库和业务逻辑的核心。它不关心界面长什么样只关心数据怎么存、怎么取、怎么算。我们将创建一个专门的类例如DatabaseManager来封装所有与SQLite交互的操作。控制器Controller 它是视图和模型之间的桥梁。当用户在界面上点击“保存”按钮视图事件时控制器会从输入框收集数据然后调用模型层的“插入数据”方法。当需要显示数据时控制器又从模型层获取数据并交给视图去渲染。这样做的好处是解耦。哪天你想把Tkinter界面换成PyQt或者把SQLite换成其他数据库你只需要替换对应的“视图”或“模型”模块而不用把整个程序重写一遍。代码结构清晰调试也方便。2.2 技术栈选型与工具准备我们的技术栈非常精简Python 3.6 确保你的Python环境已就绪。建议使用虚拟环境venv来管理项目依赖避免污染系统环境。Tkinter Python的标准GUI库无需额外安装。但为了更高效的开发我推荐使用ttk模块下的控件如ttk.Entry,ttk.Button,ttk.Treeview它们外观更现代且支持主题。sqlite3 Python标准库无需安装。它是我们连接和操作SQLite数据库的接口。辅助工具DB Browser for SQLite 这是一个图形化的SQLite数据库管理工具。在开发过程中用它来直观地查看、修改数据库表结构和数据比纯命令行要方便得多。这不是必须的但强烈推荐。注意 虽然Tkinter是内置的但在一些极简的Python发行版或特定系统配置下可能缺失。如果在导入tkinter时出错通常需要通过系统包管理器安装python3-tk或类似名称的包。3. 数据库模型层设计与实现3.1 设计数据表结构在写代码之前先想好你要存什么。我们以一个简单的“个人联系人管理”应用为例。首先设计一张contacts表字段名数据类型约束说明idINTEGERPRIMARY KEY AUTOINCREMENT主键自增长唯一标识每条记录nameTEXTNOT NULL联系人姓名不能为空phoneTEXT电话号码emailTEXT电子邮箱created_atTEXTDEFAULT (datetime(now))创建时间默认为当前时间为什么用TEXT存时间SQLite没有专门的DATETIME类型用TEXT存储ISO格式的时间字符串如2023-10-27 10:30:00既方便又易于阅读。AUTOINCREMENT关键字确保每次插入新记录时id都会自动加1。3.2 封装数据库操作类这是模型层的核心。我们将创建一个DatabaseManager类它负责所有底层数据库操作。import sqlite3 from datetime import datetime class DatabaseManager: def __init__(self, db_namecontacts.db): 初始化连接到数据库文件。如果文件不存在会自动创建。 self.connection sqlite3.connect(db_name) self.cursor self.connection.cursor() self._create_table() def _create_table(self): 创建数据表。如果表已存在则跳过。 create_table_sql CREATE TABLE IF NOT EXISTS contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, phone TEXT, email TEXT, created_at TEXT DEFAULT (datetime(now)) ) self.cursor.execute(create_table_sql) self.connection.commit() print(数据表就绪。) # 开发时用于调试正式版可移除 def add_contact(self, name, phone, email): 向数据库插入一条新的联系人记录。 insert_sql INSERT INTO contacts (name, phone, email) VALUES (?, ?, ?) # 使用参数化查询避免SQL注入攻击 self.cursor.execute(insert_sql, (name, phone, email)) self.connection.commit() return self.cursor.lastrowid # 返回新插入行的ID def get_all_contacts(self): 获取所有联系人记录返回列表。 select_sql SELECT * FROM contacts ORDER BY name self.cursor.execute(select_sql) # fetchall() 返回一个由元组组成的列表每行是一个元组。 return self.cursor.fetchall() def search_contacts(self, keyword): 根据关键词搜索联系人姓名、电话或邮箱。 search_sql SELECT * FROM contacts WHERE name LIKE ? OR phone LIKE ? OR email LIKE ? ORDER BY name # 使用 % 进行模糊匹配 like_keyword f%{keyword}% self.cursor.execute(search_sql, (like_keyword, like_keyword, like_keyword)) return self.cursor.fetchall() def update_contact(self, contact_id, name, phone, email): 更新指定ID的联系人信息。 update_sql UPDATE contacts SET name ?, phone ?, email ? WHERE id ? self.cursor.execute(update_sql, (name, phone, email, contact_id)) self.connection.commit() return self.cursor.rowcount # 返回受影响的行数 def delete_contact(self, contact_id): 删除指定ID的联系人。 delete_sql DELETE FROM contacts WHERE id ? self.cursor.execute(delete_sql, (contact_id,)) self.connection.commit() return self.cursor.rowcount def close(self): 关闭数据库连接。 self.connection.close()关键点解析与避坑指南参数化查询?占位符 这是安全性的生命线绝对不要用字符串拼接的方式将变量直接放入SQL语句如fINSERT ... VALUES ({name})。这会导致严重的SQL注入漏洞。使用?作为占位符然后将变量作为元组传给execute()方法sqlite3模块会帮你安全地处理。IF NOT EXISTS 建表时加上这个子句可以防止程序重复运行时因表已存在而报错让代码更健壮。连接与游标connection代表与数据库文件的连接会话cursor是用于执行SQL命令和获取结果的主要工具。一个连接可以有多个游标。提交commit 对于修改数据的操作INSERT, UPDATE, DELETE必须在执行后调用self.connection.commit()否则更改不会真正保存到磁盘文件。查询操作SELECT则不需要。资源管理 在应用退出时记得调用close()方法关闭连接。虽然Python在对象销毁时会尝试关闭但显式关闭是更好的习惯。更优雅的做法是使用with语句sqlite3支持连接作为上下文管理器确保在任何情况下连接都会被正确关闭。4. Tkinter视图层与控制器层构建4.1 设计主界面布局我们将构建一个具备增删改查CRUD功能的完整界面。主窗口主要分为三个区域顶部功能区 输入框和“添加”、“搜索”按钮。中部数据展示区 用ttk.Treeview表格组件显示联系人列表。底部操作区 “编辑”、“删除”按钮。import tkinter as tk from tkinter import ttk, messagebox # 假设 DatabaseManager 类保存在 database.py 中 from database import DatabaseManager class ContactApp: def __init__(self, root): self.root root self.root.title(个人联系人管理器 - PythonTkinterSQLite) self.root.geometry(800x600) # 初始化数据库模型 self.db DatabaseManager() # 存储当前选中的联系人ID用于编辑和删除 self.selected_contact_id None # 创建界面组件 self._create_widgets() # 加载并显示所有联系人 self._refresh_contact_list() def _create_widgets(self): 创建所有界面控件 # 1. 顶部输入框架 input_frame ttk.LabelFrame(self.root, text联系人信息, padding10) input_frame.pack(fillx, padx10, pady10) ttk.Label(input_frame, text姓名).grid(row0, column0, stickyw, padx5, pady5) self.name_var tk.StringVar() ttk.Entry(input_frame, textvariableself.name_var, width25).grid(row0, column1, padx5, pady5) ttk.Label(input_frame, text电话).grid(row0, column2, stickyw, padx5, pady5) self.phone_var tk.StringVar() ttk.Entry(input_frame, textvariableself.phone_var, width25).grid(row0, column3, padx5, pady5) ttk.Label(input_frame, text邮箱).grid(row1, column0, stickyw, padx5, pady5) self.email_var tk.StringVar() ttk.Entry(input_frame, textvariableself.email_var, width25).grid(row1, column1, padx5, pady5) # 搜索框 ttk.Label(input_frame, text搜索).grid(row1, column2, stickyw, padx5, pady5) self.search_var tk.StringVar() # 绑定搜索变量到搜索方法实现输入时实时搜索可选 self.search_var.trace(w, self._on_search_change) ttk.Entry(input_frame, textvariableself.search_var, width25).grid(row1, column3, padx5, pady5) # 按钮框架 button_frame ttk.Frame(input_frame) button_frame.grid(row2, column0, columnspan4, pady10) ttk.Button(button_frame, text添加联系人, commandself._add_contact).pack(sideleft, padx5) ttk.Button(button_frame, text搜索, commandself._search_contacts).pack(sideleft, padx5) ttk.Button(button_frame, text清空搜索, commandself._clear_search).pack(sideleft, padx5) ttk.Button(button_frame, text清空表单, commandself._clear_form).pack(sideleft, padx5) # 2. 中部表格展示框架 table_frame ttk.LabelFrame(self.root, text联系人列表, padding10) table_frame.pack(fillboth, expandTrue, padx10, pady(0, 10)) # 创建Treeview表格 columns (ID, 姓名, 电话, 邮箱, 创建时间) self.tree ttk.Treeview(table_frame, columnscolumns, showheadings, height15) # 定义列标题和宽度 column_widths [50, 120, 150, 200, 160] for col, width in zip(columns, column_widths): self.tree.heading(col, textcol) self.tree.column(col, widthwidth, anchorcenter) # 添加滚动条 scrollbar ttk.Scrollbar(table_frame, orientvertical, commandself.tree.yview) self.tree.configure(yscrollcommandscrollbar.set) self.tree.pack(sideleft, fillboth, expandTrue) scrollbar.pack(sideright, filly) # 绑定表格行选中事件 self.tree.bind(TreeviewSelect, self._on_tree_select) # 3. 底部操作按钮框架 action_frame ttk.Frame(self.root) action_frame.pack(fillx, padx10, pady(0, 10)) ttk.Button(action_frame, text编辑选中, commandself._edit_contact, statedisabled).pack(sideleft, padx5) # 我们将这个按钮对象保存起来以便后续启用/禁用 self.edit_btn self.root.nametowidget(action_frame.winfo_children()[0].winfo_pathname()) ttk.Button(action_frame, text删除选中, commandself._delete_contact, statedisabled).pack(sideleft, padx5) self.delete_btn self.root.nametowidget(action_frame.winfo_children()[1].winfo_pathname()) def _refresh_contact_list(self, dataNone): 刷新表格中的数据。如果提供了data则显示data否则显示全部。 # 清空现有数据 for item in self.tree.get_children(): self.tree.delete(item) # 获取数据 if data is None: contacts self.db.get_all_contacts() else: contacts data # 插入新数据 for contact in contacts: # contact 是一个元组例如 (1, 张三, 13800138000, zhangsanexample.com, 2023-10-27 10:30:00) self.tree.insert(, end, valuescontact) def _on_tree_select(self, event): 当在表格中选中一行时触发。 selection self.tree.selection() if selection: # 获取选中行的值是一个元组 selected_item self.tree.item(selection[0], values) if selected_item: self.selected_contact_id selected_item[0] # 第一列是ID # 将选中行的数据填充到顶部输入框方便编辑 self.name_var.set(selected_item[1]) self.phone_var.set(selected_item[2]) self.email_var.set(selected_item[3]) # 启用编辑和删除按钮 self.edit_btn.config(statenormal) self.delete_btn.config(statenormal)4.2 实现控制器逻辑连接视图与模型现在我们需要为界面上的按钮绑定事件这些事件处理函数就是“控制器”。# 在 ContactApp 类中继续添加以下方法 def _add_contact(self): 添加联系人按钮的事件处理 name self.name_var.get().strip() phone self.phone_var.get().strip() email self.email_var.get().strip() if not name: messagebox.showwarning(输入错误, 联系人姓名不能为空) return try: # 调用模型层的方法 new_id self.db.add_contact(name, phone, email) messagebox.showinfo(成功, f联系人 {name} 已添加ID: {new_id}) self._clear_form() self._refresh_contact_list() # 刷新列表 except sqlite3.Error as e: messagebox.showerror(数据库错误, f添加失败{e}) def _search_contacts(self): 搜索按钮的事件处理 keyword self.search_var.get().strip() if keyword: results self.db.search_contacts(keyword) self._refresh_contact_list(results) status f找到 {len(results)} 条相关记录。 self.root.title(f个人联系人管理器 - 搜索结果{status}) else: # 如果搜索框为空则显示全部 self._refresh_contact_list() def _on_search_change(self, *args): 搜索框内容改变时实时搜索简单防抖处理 # 这里可以加入防抖逻辑避免频繁查询数据库。 # 简单实现每次变化都搜索对于小数据量可以接受 self._search_contacts() def _clear_search(self): 清空搜索 self.search_var.set() self.root.title(个人联系人管理器 - PythonTkinterSQLite) self._refresh_contact_list() def _clear_form(self): 清空输入表单 self.name_var.set() self.phone_var.set() self.email_var.set() # 清空选中状态 self.tree.selection_remove(self.tree.selection()) self.selected_contact_id None self.edit_btn.config(statedisabled) self.delete_btn.config(statedisabled) def _edit_contact(self): 编辑选中联系人 if not self.selected_contact_id: messagebox.showwarning(未选中, 请先从列表中选择一个要编辑的联系人。) return name self.name_var.get().strip() if not name: messagebox.showwarning(输入错误, 联系人姓名不能为空) return if messagebox.askyesno(确认, f确定要更新联系人 (ID: {self.selected_contact_id}) 的信息吗): try: rows_affected self.db.update_contact(self.selected_contact_id, name, self.phone_var.get().strip(), self.email_var.get().strip()) if rows_affected 0: messagebox.showinfo(成功, 联系人信息已更新) self._refresh_contact_list() self._clear_form() else: messagebox.showwarning(未更新, 未找到对应的联系人记录可能已被删除。) except sqlite3.Error as e: messagebox.showerror(数据库错误, f更新失败{e}) def _delete_contact(self): 删除选中联系人 if not self.selected_contact_id: return selected_name self.name_var.get() if messagebox.askyesno(确认删除, f确定要删除联系人 {selected_name} (ID: {self.selected_contact_id}) 吗\n此操作不可恢复): try: rows_affected self.db.delete_contact(self.selected_contact_id) if rows_affected 0: messagebox.showinfo(成功, 联系人已删除。) self._refresh_contact_list() self._clear_form() else: messagebox.showwarning(未删除, 未找到对应的联系人记录。) except sqlite3.Error as e: messagebox.showerror(数据库错误, f删除失败{e}) def on_closing(self): 窗口关闭时的事件处理 self.db.close() self.root.destroy() # 主程序入口 if __name__ __main__: root tk.Tk() app ContactApp(root) # 绑定窗口关闭事件 root.protocol(WM_DELETE_WINDOW, app.on_closing) root.mainloop()5. 功能增强与实战优化技巧一个基础可用的应用已经完成了。但要让它在实际中更健壮、更好用还需要一些“打磨”。5.1 数据验证与用户体验输入验证 上面的代码只验证了姓名非空。在实际项目中你需要更严格的验证。电话格式 可以使用正则表达式检查是否为有效的手机号或固话格式。邮箱格式 同样可以用正则表达式进行基础校验。实时反馈 可以在输入框失去焦点FocusOut事件时立即验证并给出提示如将输入框背景色变红。Treeview优化双击编辑 绑定Double-Button-1事件实现双击某行直接进入编辑状态比先选中再点“编辑”按钮更符合直觉。右键菜单 为Treeview绑定Button-3事件弹出包含“编辑”、“删除”、“复制信息”等选项的右键菜单提升操作效率。列排序 点击Treeview的列标题时可以按该列进行升序/降序排序。这需要重新执行SQL查询并刷新列表。5.2 异常处理与数据备份更健壮的数据库连接 使用try...except...finally块确保数据库连接在任何情况下都能被妥善关闭或者直接使用with sqlite3.connect(...) as conn:上下文管理器。事务处理 对于一系列必须同时成功的数据库操作例如转账需要从一个账户扣钱向另一个账户加钱应该使用事务。在SQLite中默认每条SQL语句都在一个独立的事务中。你可以手动用BEGIN TRANSACTION和COMMIT/ROLLBACK来控制。try: self.connection.execute(BEGIN TRANSACTION) # 执行多条修改数据的SQL self.connection.execute(INSERT INTO ...) self.connection.execute(UPDATE ...) self.connection.commit() # 提交事务 except: self.connection.rollback() # 回滚事务撤销所有更改 raise数据备份 SQLite数据库是单个文件备份非常简单。你可以在应用内添加一个“备份”功能其实就是将当前的.db文件复制到另一个位置。记得在复制前先调用self.connection.commit()和self.connection.close()或使用with语句结束确保所有数据都已写入磁盘。5.3 使用DB Browser for SQLite进行调试在开发过程中你的应用和DB Browser可以同时打开数据库文件。你可以实时查看数据变化 在应用中添加一条记录然后在DB Browser中点击“刷新”立刻就能看到新数据。手动修改或删除数据 用于测试程序的异常处理逻辑。检查表结构 确保代码中创建的表和你想的完全一致。直接执行SQL 快速验证某个复杂查询语句的结果。实操心得 经常遇到的一个坑是程序运行时数据库文件被锁定了特别是在Windows上导致DB Browser无法打开。这通常是因为你的应用没有正确关闭数据库连接。确保在退出应用或重新打开数据库前调用close()方法。使用with语句是避免这个问题的好习惯。6. 从SQLite到其他数据库的迁移思考虽然SQLite非常适合桌面应用但如果你未来需要考虑多用户访问、更高的并发性能或更复杂的数据关系可能会迁移到如MySQL或PostgreSQL。我们的MVC架构此时就显出了优势。迁移工作主要在于模型层Model更换数据库驱动 将sqlite3换成pymysql或psycopg2。修改连接字符串 SQLite是文件路径而MySQL/PostgreSQL需要主机、端口、用户名、密码和数据库名。调整SQL方言 不同数据库的SQL语法有细微差别。例如自增字段的语法SQLite:INTEGER PRIMARY KEY AUTOINCREMENTMySQL:INT PRIMARY KEY AUTO_INCREMENTPostgreSQL:SERIAL PRIMARY KEY创建连接池 对于网络数据库通常需要使用连接池来管理连接避免频繁创建和销毁连接带来的开销。视图层Tkinter界面和控制器层业务逻辑几乎不需要改动因为它们是通过抽象的DatabaseManager类的方法来交互的。你只需要确保新的数据库管理类实现了同样的接口方法名和参数一致即可。这就是分层架构的魅力所在。7. 常见问题与排查技巧实录在实际开发中你肯定会遇到各种各样的问题。这里记录几个我踩过的坑和解决方法。问题现象可能原因排查与解决程序运行后数据库文件没有创建或数据没保存1. 程序没有执行到CREATE TABLE语句。2. 执行了INSERT/UPDATE/DELETE但忘了commit()。3. 程序当前工作目录不是你以为的目录。1. 检查_create_table方法是否被调用打印日志。2.务必在所有修改数据的操作后调用connection.commit()。3. 使用os.path.abspath(.)打印当前工作目录或用绝对路径指定数据库文件位置如./data/contacts.db。no such table: contacts错误1. 表名拼写错误。2. 数据库文件路径错误程序连接到了一个全新的空数据库文件。1. 仔细检查SQL语句中的表名。2. 用DB Browser打开程序当前目录下的.db文件看看里面是否有表。确认连接的是同一个文件。插入数据时出现sqlite3.IntegrityError违反了数据库约束比如1. 试图插入重复的主键。2. 往声明为NOT NULL的列插入了NULL值。1. 检查你的插入数据。对于自增主键不要手动指定id值。2. 确保所有NOT NULL的字段在插入时都有有效值。在应用代码层面做好输入验证。Treeview显示乱码或中文问题默认编码问题。虽然SQLite和Python 3对UTF-8支持很好但仍有小概率遇到。确保Python源文件本身以UTF-8编码保存。在连接数据库时指定编码sqlite3.connect(file.db, detect_typessqlite3.PARSE_DECLTYPES)。通常不需要特殊处理。界面卡顿特别是数据量大时1. 每次操作都重新查询并刷新整个Treeview数据量大时效率低。2. 在事件循环中执行了耗时数据库操作阻塞了GUI主线程。1. 对于查询确保SQL语句有合适的索引比如在name字段上创建索引。2.对于非常耗时的操作如导入上万条数据务必使用多线程将耗时操作放在子线程中运行完成后再通过线程安全的方式如queue或after方法通知主线程更新界面。否则界面会“冻住”。在Windows上数据库文件被锁定无法用其他工具打开数据库连接没有正确关闭。Tkinter程序可能崩溃或没有调用close()方法。1. 始终在__del__或窗口关闭事件中调用close()。2. 使用with sqlite3.connect(...) as conn:模式这是最推荐的做法。3. 如果已经锁定尝试完全退出你的Python程序包括后台进程。一个关于多线程的简单示例假设我们有一个“导出所有数据到CSV”的耗时功能。import threading import queue import csv class ContactApp: # ... 之前的代码 ... def _export_to_csv(self): 触发导出操作在主线程调用 # 禁用导出按钮防止重复点击 self.export_btn.config(statedisabled, text导出中...) # 创建一个队列用于线程间通信 self.export_queue queue.Queue() # 启动后台线程 thread threading.Thread(targetself._background_export, daemonTrue) thread.start() # 定期检查队列每100毫秒 self.root.after(100, self._check_export_queue) def _background_export(self): 在后台线程中执行耗时的导出操作 try: contacts self.db.get_all_contacts() # 假设这是耗时操作 filename contacts_backup.csv with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([ID, 姓名, 电话, 邮箱, 创建时间]) writer.writerows(contacts) # 将结果或信号放入队列 self.export_queue.put((success, filename, len(contacts))) except Exception as e: self.export_queue.put((error, str(e))) def _check_export_queue(self): 在主线程中检查并处理后台线程的结果 try: result self.export_queue.get_nowait() status, *msg result if status success: filename, count msg messagebox.showinfo(导出成功, f已导出 {count} 条记录到 {filename}) else: error_msg msg[0] messagebox.showerror(导出失败, error_msg) # 恢复按钮状态 self.export_btn.config(statenormal, text导出到CSV) except queue.Empty: # 队列为空说明后台线程还没完成继续等待 self.root.after(100, self._check_export_queue)这个模式是Tkinter处理耗时任务的经典方案主线程负责GUI响应子线程负责“干活”通过队列queue.Queue或Tkinter的after回调机制进行通信。记住绝对不能在子线程中直接操作Tkinter控件这会导致程序不稳定或崩溃。走到这里你已经拥有了一个结构清晰、功能完整、具备一定健壮性的Python Tkinter桌面应用并且它牢牢地建立在本地数据库之上。从理解MVC分层到安全地进行数据库操作再到处理现实世界中的异常和性能问题这套流程和代码框架可以直接套用到你的很多个人项目中去比如本地笔记管理、小型库存系统、实验数据记录器等。关键在于动手去改、去加功能在解决具体问题的过程中你会对GUI和数据库集成有更深的领悟。
返回列表