做 Web 开发这些年,有一个需求隔三差五就会冒出来:拿到一张 MySQL 表的字段名和字段值,用 PHP 在页面上展示出来。说它简单,其实也就两三个函数的事;但真动手的时候,不少人会卡在“怎么把列名取出来”这一步——SELECT *能查到数据,但页面表头往往只能手写死,表结构一改就得跟着改代码。这个需求的典型场景包括:内部管理后台的数据浏览页、给非技术人员快速核对数据的临时工具、还有把一张表导出成 CSV 之前的预览环节。
这篇文章就把这件事彻底讲透。从最基础的mysqli写法,到 PDO 版本,再到空表、大数据量、动态表名这些进阶场景,我都会给出可复现的代码和踩坑记录。适合刚学 PHP 数据库操作的初学者,也适合想把手头“能跑但不太好用”的展示工具打磨得更健壮的从业者。
1. 先在需求层面把问题拆清楚
1.1 什么样的场景需要“列名 + 值”一起展示
这个需求最常见的出现方式,不是某个产品经理提的正式需求,而是开发过程中的“顺手工具”。比如某天你需要给运营同事看一眼订单表的内容,他们不懂 SQL,你也不想给他们一个命令行入口,最方便的做法就是写一个简单的页面:输入表名,页面上把这个表的全部数据以表格形式渲染出来。
真正动手时你会发现,这个页面要解决的不是“查出数据”,而是“把数据变成表格”的两件事:
- 列名来自哪里?通常不能写死,因为表结构会变,换张表也要能复用。
- 数据如何按列对齐?每行的值必须落到正确的列下面。
还有一个容易忽略的点:当表里暂时没有数据时,页面仍然应该显示表头,而不是一片空白。这时候取列名的方案和直接查结果集就有本质区别了,后面第 5 节会专门讲。
另外,在开发调试阶段,这个功能也很有用。比如怀疑某张表的字段命名不统一,写个小工具把列名一次性列出来,比在命令行里反复敲DESCRIBE要直观得多。还有一些人会把列名动态拼到导出功能里,生成 CSV 时把字段名当表头——本质也是这个需求。
1.2 三种拿列名的思路,别选错
“拿列名”这件事,PHP 里大概有三条路,不同场景适合不同方案。先把它们放在一起对比,后面写代码时你就知道自己在用什么。
| 方案 | 数据来源 | 空表时能否拿到列名 | 额外查询成本 | 灵活度 |
|---|---|---|---|---|
| 结果集元数据 | mysqli_result::fetch_fields()或 PDO 的getColumnMeta() | 不能 | 无 | 一般,只能反映当前 SQL 的列 |
SHOW COLUMNS FROM 表名 | MySQL 内部命令 | 能 | 一条轻量查询 | 较高,能拿到类型、默认值、键信息 |
information_schema.COLUMNS系统表 | 系统字典表 | 能 | 一条稍微复杂的查询 | 最高,可按库、按表过滤排序 |
方案 A 最直接,结果集本身就带着列的描述信息,不需要额外查询,缺点是如果结果集一行数据都没有,列信息也可能为空。方案 B 适合“先展示表结构,再展示数据”的页面。方案 C 适合要做字段筛选、字段排序,或者要跨多个表批量取列名的场景,但 SQL 写起来长,而且对账号的权限要求也更高。
从我自己的使用频率看,方案 A 是主力,方案 B 是兜底,方案 C 通常用在一个比较少见的需求——把一张表的字段说明书打印出来。下面正文就围绕方案 A 和 B 展开。
2. 连接 MySQL:现在推荐哪一套 API
2.1 mysql_xxx 老函数为什么不能再用
早些年网上的 PHP 教程里常见这么写:
$conn = mysql_connect('localhost', 'root', 'password'); mysql_select_db('test', $conn); $result = mysql_query('SELECT * FROM users');这套mysql_系列函数从 PHP 5.5 开始被标注废弃,PHP 7.0 正式移除。现在哪怕你只是在本地老项目里跑一下,也会直接遇到“Call to undefined function mysql_connect()”。这不是某个配置的问题,而是这套扩展已经从 PHP 里消失了。
我记得早年用mysql_函数踩过的坑不止是移除的问题。它没有参数化查询,拼 SQL 时稍微不注意就容易带上用户输入,酿成注入风险。而且它把连接、结果集、资源类型的处理方式设计得比较粗糙,PHP 官方后续也明确建议用mysqli或 PDO 来替代。所以现在写新代码,直接在mysqli和 PDO 里二选一就行。
2.2 mysqli 连接参数与字符集,一次配到位
mysqli有两种用法,过程风格和面向对象风格。我推荐面向对象,参数清晰,代码也好维护。最基本的连接是这样:
<?php $host = '127.0.0.1'; $port = 3306; $user = 'your_user'; $pass = 'your_password'; $dbname = 'your_database'; $conn = new mysqli($host, $user, $pass, $dbname, $port); if ($conn->connect_errno) { error_log('数据库连接失败: ' . $conn->connect_error); exit('数据库连接失败,请稍后重试'); } $conn->set_charset('utf8mb4');这里有几个细节值得说。
第一,new mysqli()的构造函数参数顺序是host, username, password, dbname, port, socket,很多人会把 port 漏掉。localhost和127.0.0.1在部分环境下走不同的连接方式(前者可能走 Unix socket),如果你发现能连上但偶尔不稳定,可以尝试显式指定127.0.0.1。
第二,set_charset('utf8mb4')一定要在查询之前执行。不设置的话,连接默认字符集可能不是 utf8mb4,中文数据容易出现乱码。这个问题我后面还会提到。
第三,正式项目里不要把明文密码写在页面里。最简单的做法是放在单独的配置文件里,require进来,并且代码库忽略掉这个配置文件。敏感信息不进版本库,这不是洁癖,是基本习惯。
PDO 的连接方式稍微不同,它的 DSN 里包含了数据库类型、主机、端口、库名和字符集:
<?php $dsn = 'mysql:host=' . $host . ';port=' . $port . ';dbname=' . $dbname . ';charset=utf8mb4'; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; $pdo = new PDO($dsn, $user, $pass, $options);PDO 的charset写在 DSN 里,这也是一个容易踩的坑——一旦 DSN 里漏了charset=utf8mb4,后续即使再执行SET NAMES utf8mb4,行为也可能不一致。所以干脆在 DSN 里写清楚。
3. 核心实现:用 mysqli 把列名和值按表格输出
3.1 用 fetch_fields 从结果集里取列名
当我们执行了SELECT * FROM users LIMIT 50之后,$result是一个mysqli_result对象。这个对象里不光有数据行,还保存着列的信息。fetch_fields()会把每一列的描述信息作为一个对象返回给你,对象上有name、orgname、table、orgtable、type、length等属性。
直接看代码:
<?php $sql = 'SELECT * FROM `users` LIMIT 50'; $result = $conn->query($sql); if ($result === false) { exit('查询失败: ' . $conn->error); } $fields = $result->fetch_fields(); foreach ($fields as $field) { echo $field->name . "\n"; }$field->name是查询结果里这一列的显示名字。如果你写的是SELECT id AS user_id FROM users,那么$field->name就是user_id,而$field->orgname才是原始列名id。这个细节在做字段映射、导出文件表头时非常有用。
fetch_fields()有个特点值得注意:它返回的是当前结果集的列,而不是表的全部列。如果查询里有JOIN,结果集里会有多张表的列,字段对象的table属性可以帮助你区分这一列来自哪张表。这在展示关联查询结果时很好用。
3.2 用 fetch_assoc 让数据和列名一一对应
拿到了列名之后,再用fetch_assoc()逐行读取数据。fetch_assoc()返回的是一个关联数组,键名就是列的名字。
核心技巧就在这里:我们可以先遍历$fields输出表头,然后遍历每一行数据时,再次遍历$fields,用$field->name去关联数组里取值。这样列名和数据天然对齐,不需要手工维护“第几列是什么”的映射表。
<?php echo '<table border="1" cellpadding="6">'; echo '<thead><tr>'; foreach ($fields as $field) { $colName = htmlspecialchars($field->name, ENT_QUOTES, 'UTF-8'); echo '<th>' . $colName . '</th>'; } echo '</tr></thead>'; echo '<tbody>'; while ($row = $result->fetch_assoc()) { echo '<tr>'; foreach ($fields as $field) { $value = $row[$field->name] ?? 'NULL'; $value = htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); echo '<td>' . $value . '</td>'; } echo '</tr>'; } echo '</tbody></table>';这里有一个判断值得说明:$row[$field->name] ?? 'NULL'。如果这一行的某个字段在数据库里就是NULL,PHP 里取出来的值也是null,直接输出到页面上会变成空字符串,看起来就像“缺了一个单元格”。我习惯把它显示成NULL字样,至少在数据核对时能一眼看出空值和空字符串的区别。
还有一种情况:如果查询里用了别名,而你在取值时用了原名,$row里就会找不到这个键,导致报错。取值的键必须和输出表头时用的键保持一致。最稳妥的做法就是像上面这样,永远只通过$field->name去取值。
3.3 输出转义:简单一步,避开 XSS
很多从“能跑就行”阶段过来的朋友,会忽略这一行:htmlspecialchars()。
数据库里的内容不该默认信任。比如用户备注字段里存了一段<script>标签,直接输出到 HTML 页面里,浏览器就会把它当成页面脚本执行。在后台展示工具里这似乎“只是给自己看”,但万一页面被分享出去,或者数据来自不可信的外部导入,这就是一个真实的 XSS 漏洞。
htmlspecialchars的作用是把<、>、&、引号等字符转成 HTML 实体。加上ENT_QUOTES表示单引号和双引号都转义;'UTF-8'是字符集声明,避免某些环境转义后中文变成乱码。
列名也要转义。虽然列名来自表结构而不是用户输入,但如果你做一个“允许输入表名”的工具,表的列名同样可能包含特殊字符。统一转义是最省心的方案。
我自己在这个问题上栽过跟头。有次写一个内部数据预览页面,为了“看起来简单”,直接echo $row['remark'],结果某行数据里带着一段 HTML,页面结构当场被破坏。从那以后,凡是从数据库里取出来要输出到 HTML 的字符串,我一律过一遍htmlspecialchars。
4. 用 PDO 实现同一功能,以及两种写法怎么选
4.1 PDO 版:columnCount 配合 getColumnMeta
PDO 取列名的接口和 mysqli 不太一样。PDOStatement提供了columnCount()获取列数,以及getColumnMeta($index)获取指定列的元信息。
<?php $stmt = $pdo->query('SELECT * FROM `users` LIMIT 50'); if ($stmt === false) { exit('查询失败'); } $cols = []; for ($i = 0; $i < $stmt->columnCount(); $i++) { $meta = $stmt->getColumnMeta($i); if ($meta === false || !isset($meta['name'])) { $cols[] = 'column_' . $i; } else { $cols[] = $meta['name']; } } $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);然后输出表格:
<?php echo '<table border="1" cellpadding="6">'; echo '<thead><tr>'; foreach ($cols as $col) { echo '<th>' . htmlspecialchars($col, ENT_QUOTES, 'UTF-8') . '</th>'; } echo '</tr></thead>'; echo '<tbody>'; foreach ($rows as $row) { echo '<tr>'; foreach ($cols as $col) { $value = $row[$col] ?? 'NULL'; echo '<td>' . htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8') . '</td>'; } echo '</tr>'; } echo '</tbody></table>';注意getColumnMeta()这个 API 官方文档里的定位是“仅供调试,不建议生产环境依赖”。原因是它在没有mysqlnd驱动的环境里可能返回false,导致列名完全拿不到。所以我上面代码里做了兜底判断:如果返回false或者没有name,就给一个column_序号这样的临时名称。这样页面不会崩,但列名显示就不准确了。生产环境遇到确切返回false的情况,建议改用SHOW COLUMNS方案。
另外,fetchAll(PDO::FETCH_ASSOC)会把所有数据一次加载进内存,数据量不大时没问题。数据量大时应该改成循环fetch(),和 mysqli 的while ($row = ...)一个道理。
4.2 mysqli 和 PDO 该怎么选
很多刚接触 PHP 数据库操作的人会纠结选哪个。我的建议很简单:新项目直接用 PDO,老项目已经在用 mysqli 对象写法、改造成本高就继续用 mysqli。两者都能完成这件事,区别主要看未来的需求方向。
| 维度 | mysqli | PDO |
|---|---|---|
| 预处理语句 | 支持 | 支持 |
| 多数据库支持 | 仅 MySQL | MySQL、PostgreSQL、SQLite 等 |
| 取列名的接口 | fetch_fields()稳定 | getColumnMeta()依赖 mysqlnd |
| 异常处理 | 可以开mysqli_report或手动判断 | 设置ERRMODE_EXCEPTION后比较顺手 |
| 代码风格 | 过程式/面向对象都有 | 主要是面向对象 |
如果你的项目未来可能要从 MySQL 迁到 PostgreSQL,用 PDO 迁移成本更低。如果只是在 MySQL 生态里折腾,mysqli 对象的全部门槛并不高,很多老项目里也大量存在。
我还遇到过一种情况:服务器上 PHP 同时装了pdo_mysql和mysqli,但mysqlnd没装,导致getColumnMeta()拿不到信息。这种环境用 mysqli 反而更稳,因为fetch_fields()是mysqli扩展自己实现的,不依赖mysqlnd提供列元信息。
5. 进阶打磨:让这个展示工具真正能用
5.1 空表也要显示表头:SHOW COLUMNS 方案
前面提到过,SELECT * FROM users LIMIT 50如果一行数据都没有,$result->fetch_fields()返回的是空数组。很多人第一次遇到这个情况都会愣住:明明表里有字段,怎么页面连表头都没有?
原因其实不难理解:结果集的列信息来自这次查询的元数据,而 MySQL 返回一个空结果集时,不一定携带完整的列描述信息,至少在 PHP 层拿不到稳定的列名。解决办法是先单独查一次表结构。
<?php $table = 'users'; $result = $conn->query('SHOW COLUMNS FROM `' . $table . '`'); if ($result === false) { exit('查询表结构失败: ' . $conn->error); } $columns = []; while ($row = $result->fetch_assoc()) { $columns[] = [ 'name' => $row['Field'], 'type' => $row['Type'], 'null' => $row['Null'], 'key' => $row['Key'], 'default' => $row['Default'], 'extra' => $row['Extra'], ]; }拿到$columns之后,即使表里没数据,我们也可以先渲染一个“只有表头”的表格,然后在表体区显示一行提示:“当前表暂无数据”。
SHOW COLUMNS返回的字段不仅仅是列名,还带有类型、是否为空、默认值、键信息。这些信息可以做成一个“字段说明”区域。比如在表格上方展示:
| 字段名 | 类型 | 是否为空 | 键 | 默认值 | 额外 |
|---|---|---|---|---|---|
| id | int(11) | NO | PRI | NULL | auto_increment |
| user_name | varchar(64) | YES | NULL |
对非技术人员来说,这种展示比裸数据表更友好。我通常会把 “SHOW COLUMNS 拿结构” 和 “SELECT 拿数据” 两个步骤组合使用,展示效果会比较完整。
5.2 数据量大时:分页与字段裁剪
“把整张表拉出来”在开发阶段没问题,表一旦上了十万行,页面会变得非常卡,浏览器甚至会崩掉。所以数据预览工具必须考虑分页。
<?php $page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1; $pageSize = 100; $table = 'users'; // 算总条数 $countSql = "SELECT COUNT(*) AS c FROM `$table`"; $countRow = $conn->query($countSql)->fetch_assoc(); $total = (int)$countRow['c']; // 算偏移量 $offset = ($page - 1) * $pageSize; // 查当前页 $sql = "SELECT * FROM `$table` LIMIT $offset, $pageSize"; $result = $conn->query($sql); $fields = $result->fetch_fields();分页参数$_GET['page']必须经过去脏和类型转换,直接用(int)转一次并把最小值限制为 1。有人会把LIMIT $offset, $pageSize写成LIMIT $_GET['page'], 100,这是很典型的注入位和类型错误位。
还有一个容易被忽视的问题:一张表可能有二三十个字段,其中还有TEXT、JSON、BLOB类型的大字段。预览工具里没必要每次都全查出来。更好的做法是提供一个“可见列”的选择框,默认只展示前十个常用列,用户勾选后才把大字段带上。这样页面渲染压力会小很多。
做一个横向滚动容器也是必要的小优化。字段多的时候表格会超过页面宽度,直接给表格外面包一层<div style="overflow-x:auto">,比 CSS 压缩列宽要舒服得多。
5.3 动态表名的安全处理:白名单比反引号更可靠
当你的工具允许用户输入表名时,安全处理就不能只靠反引号了。反引号只能保证表名被当作标识符处理,但如果表名本身是从 URL 参数直接拿来的,攻击者构造一个奇怪的值传进来,拼接后的 SQL 仍然可能被注入。
推荐的方案是表名白名单:
<?php $allowedTables = ['users', 'orders', 'products']; $table = $_GET['table'] ?? ''; if (!in_array($table, $allowedTables, true)) { exit('Invalid table name'); }in_array的第三个参数true表示严格比较,确保传入的值和数组里的字符串完全一致。通过白名单之后,表名就只可能是预设值,SQL 拼接没有注入面。这个方法比任何转义都可靠。
列名也一样。如果你允许前端传列名用于排序,不要把ORDER BY的字段直接拼进 SQL,应该先和表的真实列名做比对。我通常在查询之前用SHOW COLUMNS FROM $table把列名全部拉出来,再和用户传入的排序字段比对,不在列表里就拒绝。
有人可能觉得“这只是内部工具,没必要这么紧张”。但内部工具面对的往往是同事和临时脚本,数据安全事件很多时候就是从这类“不起眼的小工具”里漏出去的。白名单就几行代码,加上不亏。
6. 常见问题与排查实录
6.1 函数不存在:旧代码迁移到 PHP 7+
报错信息长这样:
Call to undefined function mysql_connect()
原因很明确:PHP 7.0 起mysql_*系列函数已被移除。现在网上偶尔还能搜到使用mysql_query的老教程,如果照着写,跑起来第一行就会报错。
处理方案有三个:
- 使用
mysqli替换mysql_connect、mysql_query、mysql_fetch_assoc,改动量相对小。 - 直接换到 PDO,重写连接和查询部分。
- 如果项目里大面积使用旧函数,用一个简单的兼容封装也可以过渡,但不建议长期依赖。
排查时还要注意一点:PHP 里同时有mysqli和mysqlnd两个概念。mysqli扩展需要启用,mysqlnd是底层驱动。可以通过php -m查看已加载的扩展,确认有没有mysqli和pdo_mysql。有的精简安装只启用了 PDO,没启用 mysqli,用 mysqli 写法就会报“Class not found”之类的错误。
6.2 中文乱码:先查连接字符集
页面输出正常,但中文变成“?”或者乱码,是这类展示工具里出现频率最高的问题。乱码原因通常有三个。
连接层:mysqli没有执行set_charset('utf8mb4'),或者 PDO 的 DSN 里没写charset=utf8mb4。这会造成 PHP 按照默认字符集(可能是 latin1)解读 MySQL 返回的字节流。
表字符集:表或字段的collation不是 utf8 系列。如果表是latin1_swedish_ci,即使连接用 utf8mb4,数据本身也是乱码。
文件编码:PHP 文件本身存成了 GBK 编码,或者 HTML 页面没有声明Content-Type: text/html; charset=utf-8。浏览器按错误编码解析导致显示异常。
排查顺序建议先查表结构,再查连接层,最后看浏览器端。因为连接层是代码里最容易漏掉的一环,检查SHOW CREATE TABLE 表名,能看到表的默认字符集;在 PHP 里执行一次SELECT @@character_set_connection,也能确认当前连接使用的字符集。两头对了之后,绝大多数乱码都能定位。
6.3 结果集为空时拿不到列名
这个问题在前文已经提过,这里单独列成一条,因为它太容易让人困惑了。现象就是表里有字段,数据没有,“表头”也不见了。
关键认知:$result->fetch_fields()不是“读表结构”,而是“读结果集的列元数据”。MySQL 返回空结果集时,PHP 拿到的列元数据是空的,所以什么名字都取不到。
解决办法其实是设计思路的调整:先SHOW COLUMNS FROM 表名取列名,再执行SELECT ... LIMIT ...查数据。把“结构展示”和“数据展示”解耦,空表时也能正常显示表头,后续做分页、列筛选也顺理成章。
6.4 getColumnMeta 返回 false 的兼容处理
PDO 的getColumnMeta()在部分环境里不能正常工作,返回false或者报“PDO::getColumnMeta() is not supported by this driver”。这个问题在 Windows 开发环境或者某些通过其他方式安装 PDO 的服务器上更常见。
我的处理思路是做一个降级:如果能拿到 meta 就用 meta,拿不到就回退到SHOW COLUMNS。
<?php private function getColumnNames(PDO $pdo, string $table): array { $stmt = $pdo->query("SELECT * FROM `$table` LIMIT 1"); $columns = []; for ($i = 0; $i < $stmt->columnCount(); $i++) { $meta = $stmt->getColumnMeta($i); if ($meta === false || !isset($meta['name'])) { // 降级:从 SHOW COLUMNS 拿列名 $cols = $pdo->query("SHOW COLUMNS FROM `$table`")->fetchAll(PDO::FETCH_COLUMN); return $cols; } $columns[] = $meta['name']; } return $columns; }把兼容逻辑封装成函数后,页面主流程不用变。以后即使换 PHP 版本、换驱动,也不会因为这个 API 触雷。
结尾
我个人在刚开始做这类功能时,最简单粗暴的写法就是SELECT *再fetch_assoc直接输出。直到有一次遇到空表,页面连表头都没了,才意识到“列名”这件事必须单独处理。后来我把这个工具页迭代成了一个小工具:用白名单限制表名,用SHOW COLUMNS先拿结构,再分页查询数据,输出统一走htmlspecialchars,一块不到两百行的代码,内部用了一两年都没出问题。
最后再分享一个小技巧:如果你只是临时想看一眼表结构,命令行里敲一行DESCRIBE 表名;确实比写 PHP 快得多;但如果你需要在 Web 后台、内部系统里展示给不写 SQL 的人看,或者要基于列名做导出、做字段映射,那本文里这套“取列名 + 对齐数据 + 输出转义”的组合,就是真正绕不开的基础功。把这个基础打牢,后面做起数据管理类功能会顺手很多。