PHP 文件系统完全指南
Posted PHP研究院
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了PHP 文件系统完全指南相关的知识,希望对你有一定的参考价值。
本文首发于个人博客 【php 文件系统完全指南】(http://blog.phpzendo.com/?p=421),转载请注明出处。
今天我们将开启一个新的探索旅程,深入到 PHP 文件系统中,系统的学习和掌握 PHP 文件系统的基本使用。
相信大家在日常研发过程中,难免需要和各种文件纠缠不清。比如,打开 .env 文件并从中读取配置信息、把项目中的错误信息写入到日志文件中或者获取图片的创建时间等等。在处理这些功能时,我们都需要使用到 PHP 文件系统接口。
下面是本文所涉主题的提纲:
一 什么是文件系统
二 深入 PHP 文件系统
三 面向对象的目录遍历
四 PHP 文件系统思维导图
本文较长,耗时约 20 分钟,请做好战斗准备!
一 什么是文件系统
开始之前,我们首先需要厘清我们所研究的问题领域,理解什么是文件系统,还有我们所研究的对象。
在计算机中,文件系统(file system or filesystem)用于管理数据如何存储和如何被获取的。 - 维基百科
简单来说,就是我们应该如何管理我们的目录(文件夹)和文件。通常,我们将具有相似属性的文件,存储到同一个目录中以便后续查找,这个常见的操作就会涉及到目录和文件。
对于软件工程师来讲,一个非常典型的使用场景,就是在开发 MVC 项目时,将控制器、视图和模型等模块的文件,存储到不同的目录结构中方便管理。
无论如何,我们依据不同特性划分文件和目录都是为了解决文件存储和查找的问题。
有了这些认知后,应该自然而然的想到我们当前研究的 PHP 文件系统(或者说文件系统)其所研究的对象,简单概括起来就是:
目录(文件夹)
文件
也就是说,本文我们所讲解的 PHP 文件系统函数处理,基本都是围绕目录和文件展开的。
二 深入 PHP 文件系统
在 PHP 文件系统中内置提供了超过 80 个可用的 文件系统函数。由于数量繁多功能强大,自然本文无法将对所有的系统函数逐一讲解。一来,时间过于仓促;再者,短时间内我们也没有那么多的精力将它们全部掌握。
尽管如此,大家也不必气馁,本文会将有限的时间和精力,来研究以下几个在文件处理时的常见话题:
文件的元数据应该如何获取
文件的 MIME 类型如何获取
文件和目录的操作处理
文件和目录的权限管理
另外,补充说明一点,PHP 标准函数库不仅为我们提供了面向过程的文件系统处理函数。同时,还封装了常用目录及文件操作的面向对象接口和迭代器接口方便大家使用:
SplFileInfo
finfo
DirectoryIterator
RecursiveDirectoryIterator
文件系统的元数据
什么是元数据
元数据(meta data):通俗一点讲就是「数据的数据」。拿一个 php 文件来说它的元数据可以是 创建时间、文件名、文件大小 或 文件所有权限 等,这类能够表明该文件基本特征的数据就是「元数据(meta data)」了。
常用元数据获取
在这一节,我们将学习一些经常需要获取的文件元数据函数,包括:
获取文件的最后修改时间
获取文件的上次访问时间
获取文件的路径信息
获取文件的绝对路径
获取文件类型
获取文件大小
获取文件权限
获取文件所属用户及用户组
话不多说,开干吧!
1、获取文件的最后修改时间
要获取文件的上次被修改时间戳,我们可以使用函数 filemtime($filename) 或 SplFileInfo::getMTime() 方法。
注意:SplFileInfo 类实例化时接收 $filename 文件路径作为参数,后续没有特别说明默认我们已经获取到了 SplFileInfo 实例才能进行 getMTime() 等类似处理。
// 文件路径请求改成你自己的文件路径
$filename = "f://filesystem/test.txt";
// 面向过程: 获取文件时间
$modifyTimestamp = filemtime($filename);
// 面向对象
$file = new SplFileInfo($filename);
$modifyTimestamp = $file->getMTime();
2、获取文件的上次访问时间
可以使用函数 fileatile($filename) 或 SplFileInfo::getATime() 方法,来获取文件的最后被访问时间戳。
// 文件路径请求改成你自己的文件路径
$filename = "f://filesystem/test.txt";
// 面向过程: 获取文件时间
$accessTimestamp = fileatime($filename);
// 面向对象
$file = new SplFileInfo($filename);
$accessTimestamp = $file->getATime();
除了 filemtile 和 fileatime 之外,还有 filectime 来获取文件的 inode 修改时间(可认为是创建时间)。
有关时间的函数常用的就这些,为了方便记住,我们来看看它们是如何命名的:
2.1 面向过程 file 前缀,面向对象 get 前缀
2.2 a: access(访问);m:modify(修改);c:create(创建)
2.3 time 后缀
2.4 fileatime,SplFileInfo::getATime;filemtime,SplFileInfo::getMTime;filectime,SplFileInfo::getCTime。
是不是很简单呢!
注意,使用 filectime 时,对于 Windows 系统会获取创建时间,但对于类 Unix 系统是修改时间,因为在类 Unix 系统中多数文件系统并没有创建时间的概念。具体说明可以看 PHP: how can I get file creation date?。
3、获取文件的路径信息
除了时间这些元数据,另一个经常遇到的情况是获取文件的路径信息,包括:
3.1 目录信息
获取目录信息我们可以使用 pathinfo(\$filename, PATHINFO_DIRNAME)、dirname(\$filename) 和 SplFileInfo::getPath()
比如下面给出的文件:
$filename = 'F:\Program Files\SSH Communications Security\SSH Secure Shell\Output.txt';
将会获取到 F:\Program Files\SSH Communications Security\SSH Secure Shell 这部分目录信息。
3.2 文件名信息
这里我们所有的文件名指的是不带扩展名后缀的文件名称,比如需要获取your_path/filename.txt 中的 filename 部分。
需要取得文件名信息,我们可以使用 pathinfo(\$filename, PATHINFO_FILENAME)、basename(\$filename, \$suffix) 和SplFileInfo::getBasename(\$suffix) 获取。
这里给出的 $suffix 指不获取 $suffix 扩展名部分(比如不获取 $suffix = '.txt')。
请看下面的示例:
$filename = 'F:\Program Files\SSH Communications Security\SSH Secure Shell\Output.txt';
将会获取到 Output 这部分文件名信息。
3.3 扩展名信息
扩展名我们可以使用 pathinfo(\$filename, PATHINFO_EXTENSION) 和SplFileInfo::getExtension() 方法拿到。
基于前面的了解,我们可以获取到 txt 这部分扩展信息,这里不再赘述。
3.4 basename(文件名 + 扩展名)信息
basename 指的是 文件名 + 扩展名 内容信息,可以使用 pathinfo(\$filename, PATHINFO_BASENAME)、 basename(\$filename)、SplFileInfo::getBasename()和 SplFileInfo::getFilename() 方法拿到。
虽然这里我们列出了很多的函数,但是基本上还是比较容易理解的,需要注意的是:
pathinfo 可以获取所有文件相关的路径信息,如果指定第二个参数选项将仅获取该部分的信息
文件名和 basename 不是特别容易理解,你可以使用完全相同的方法或函数 basename 和SplFileInfo::getBasename() 获取他们,区别在于是否摘除指定的 $suffix 后缀。
3.5 示例
<?php
$filename = 'F:\Program Files\SSH Communications Security\SSH Secure Shell\Output.txt';
$file = new SplFileInfo($filename);
// 目录路径
$directory1 = pathinfo($filename, PATHINFO_DIRNAME);
$directory2 = dirname($filename);
$directory3 = $file->getPath();
echo '--- directory begin: ---' . PHP_EOL;
echo $directory1 . PHP_EOL, $directory2 . PHP_EOL, $directory3 . PHP_EOL;
// 文件名
$suffix = '.txt';
$filename1 = pathinfo($filename, PATHINFO_FILENAME);
$filename2 = basename($filename, $suffix);
$filename3 = $file->getBasename($suffix);
echo '--- filename begin: ---' . PHP_EOL;
echo $filename1 . PHP_EOL, $filename2 . PHP_EOL, $filename3 . PHP_EOL;
// 扩展名
$extension1 = pathinfo($filename, PATHINFO_EXTENSION);
$extension2 = $file->getExtension();
echo '--- extension begin: ---' . PHP_EOL;
echo $extension1 . PHP_EOL, $extension2 . PHP_EOL;
// basename = 文件名 + 扩展名
$basename1 = pathinfo($filename, PATHINFO_BASENAME);
$basename2 = basename($filename);
$basename3 = $file->getBasename();
$basename4 = $file->getFilename();
echo '--- basename begin: ---' . PHP_EOL;
echo $basename1 . PHP_EOL, $basename2 . PHP_EOL, $basename3 . PHP_EOL, $basename4 . PHP_EOL;
它们的运行结果如下:
--- directory begin: ---
F:\Program Files\SSH Communications Security\SSH Secure Shell
F:\Program Files\SSH Communications Security\SSH Secure Shell
F:\Program Files\SSH Communications Security\SSH Secure Shell
--- filename begin: ---
Output
Output
Output
--- extension begin: ---
txt
txt
--- basename begin: ---
Output.txt
Output.txt
Output.txt
Output.txt
3.6 文件路径信息关系图
另外需要注意的一点是在使用 SplFileInfo 获取 basename 时,getBasename() 和 getFilename() 返回基本一致,但是在处理根目录下的文件名获取时表现稍有不同。 这里可以到官方文档中用户 提交的反馈 去详细了解一下。
4、获取文件的绝对路径
绝对路径由 realpath($path) 和 SplFileInfo::getRealpath() 获取。
5、获取文件类型
可以使用 filetype($filename) 和 SplFileInfo::getType() 来获取文件的类型。
返回值范围:
dir
file
char
fifo
block
link
unknown
可以查看 Linux 文件类型与扩展名 相关文件类型,这里我们重点关注下 dir 目录和 file 普通文件类型即可。
6、获取文件大小
可以使用 filesize(\$filename) 和 SplFileInfo::getSize() 来获取文件的大小,不再赘述。
7、 获取文件权限
可以使用 fileperms(\$filename) 和 SplFileInfo::getPerms() 来获取到文件的所属权限。
值得注意的是它们的返回值是十进制表示的权限,如果需要获取类似 0655 八进制权限表示法,我们需要对返回值进行处处理才行:
// @see http://php.net/manual/zh/function.fileperms.php#refsect1-function.fileperms-examples
$permissions = substr(sprintf("%o", fileperms($filename)), -4);
你可以通过 PHP: fileperms() values and convert these 了解更多关于 PHP 获取文件权限转换的更多细节。
基本上学习完这些文件元数据信息获取方法,差不多可以应对日常开发过程中的多数应用场景,尽管如此,还是建议仔细去阅读官方 文件系统函数,那里才是知识的源泉。
掌握文件的元数据,对我们了解文件的特性大有裨益,就好比两个人谈恋爱,懂得彼此才是最好的状态。
文件系统操作
可以说我们日常在处理文件的过程中,更多的是在操作文件或者目录(文件夹),本节我们将学习文件系统操作相关知识。
依据文件类型的不同我们可以简单的将操作分为:
对目录(dir)的操作
和对普通文件(file)的操作
目录操作使用场景
在处理目录时我们一般涉及如下处理:
创建目录
删除目录
打开目录
读取目录
关闭目录句柄
场景一:
我们有一套 CMS 管理系统支持文件上传处理,当目录不存在时依据文件上传时间,动态的创建文件存储目录,比如,我们依据 年/月/日(2018/01/01) 格式创建目录。这里就涉及到目录创建 的处理。
场景二:
当然,文件上传完成了还不够,我们还需要读取各个目录下的所有文件。这里涉及 打开目录、读取目录 以及读取完成后 关闭目录句柄。
有了相关概念和思路后,我们具体看看究竟 PHP 文件系统给我们提供了哪些方便处理目录的函数呢?
创建目录
在 PHP 文件系统扩展中同样给我们提供了处理 目录结构的系统函数。
其中创建一个新目录需要使用 mkdir($pathname [, $mode = 0777, $recursive = false]) 函数。
$pathname 参数为待创建目录的路径
$mode 为创建目录时的访问权限,0777 意味着获取最大访问权限
$recursive 用于标识是否递归创建目录,默认 false 不会递归创建
请看一个示例:
$pathname = "/path/to/your/upload/file/2018/01/01";
$created = mkdir($pathname);
创建目录是不是特别的简单呢?
但是等等,我们在类 Unix 系统中满心欢喜的使用 mkdir 并采用 $mode=0777 权限来创建一个全新的目录,但为什么当我们进入到目录中看到的目录的权限却是 0755 呢?
umask 掩码
这里涉及到 umask 掩码的问题!
重点:原来我们在类 Unix 系统中创建新目录是给出的权限会默认减去当前系统的 umask值,才是实际创建目录时的所属权限。
什么意思呢?
比如:
// 我们期望创建的文件权限
$mode = 0777;
// 当前系统中 umask 值
$umask = 0022;// 可以由 umask 命令查看当前系统 umask 值,默认是 0022
// 实际创建的文件权限
0777
- 0022
------
= 0755
现在我们来对之前的实例稍作修改,看看 PHP 如何创建目录时得到希望的系统权限吧:
$pathname = "/path/to/your/upload/file/2018/01/01";
// 将系统 umask 设置为 0,并取得当前 umask 值(比如默认 0022)
$umask = umask(0);
$created = mkdir($pathname, $mode = 0777);
// 将系统 umask 设置回原值
umask($umask);
有关 umask 函数说明可以查看官方手册。另外可以查看 Why can't PHP create a directory with 777 permissions? 这个问答了解更多细节。
目录遍历
面向过程的目录遍历提供两种解决方案:
通过 opendir、readdir 和 closedir 来遍历目录;
另一种是直接使用 scandir 遍历指定路径中的文件和目录。
目录遍历示例一,出自 官方文档:
<?php
$dir = "/etc/php5/";
// Open a known directory, and proceed to read its contents
if (is_dir($dir)) {
if ($dh = opendir($dir)) {
while (($file = readdir($dh)) !== false) {
echo "filename: $file : filetype: " . filetype($dir . $file) . "\n";
}
closedir($dh);
}
}
// 输出结构类似于:
// filename: . : filetype: dir
// filename: .. : filetype: dir
// filename: apache : filetype: dir
// filename: cgi : filetype: dir
// filename: cli : filetype: dir
?>
目录遍历示例二,出自 官方文档:
<?php
$dir = '/tmp';
$files1 = scandir($dir);
print_r($files1);
// 输出结构类似于:
// Array
// (
// [0] => .
// [1] => ..
// [2] => bar.php
// [3] => foo.txt
// [4] => somedir
// )
目录的操作处理大致就是在处理这两类问题,相比于普通文件的处理来讲简单很多,下一节我们会学习有关普通文件的处理,请大家做好战斗准备。
文件操作使用场景
可以说我们在处理文件系统时,绝大多数都是在处理一个普通文件,那么我们在操作文件时,我们究竟在做什么呢?
你可能已经想到了,没错我们多数时候就是在处理如下文件问题:
创建一个新的空文件
打开一个文件句柄,以供后续读取或写入
将文件中的内容覆盖掉(覆盖写入),或者在文件末尾写入新的内容(追加写入)
读取文件的内容
删除文件
复制文件
关闭文件句柄
文件的读取和写入相对会复杂一些,所以这两部分的内容会在稍后详细讲解。先让我们看看其它几个常见文件处理。
创建空文件
创建空文件有两种方式:
一是以写入(w)模式使用 fopen($filename, $mode = 'wb') 打开一个文件,当文件不存在时则会创建一个新文件;
二是使用 touch 函数创建一个新文件。
这两个函数同其它文件系统函数使用大致相同,感兴趣的朋友可以阅读手册,这里不作展开。
删除文件
删除文件由 unlink($filename) 函数完成。
复制文件
复制文件由 copy($source, $dest) 函数完成,会将 $source 文件拷贝到 $dest 文件中。
如果需要移动文件(重命名)可以使用 rename($oldname, $newname) 完成这个处理。
以上都是相对简单的文件处理函数就不一一举例说明了。
接下来学习如何读取文件中的内容。依据二八原则,可以说我们百分之八十的时间都在处理文件写入和读取的处理,所以我们有必要理清如何对文件进行读取和写入。
读取文件
读取文件的标准流程是:
打开一个文件句柄;
使用文件读取函数读取文件;
判断是否到文件结尾,到结尾则结束读取,否则回到操作 2;
读取完成关闭句柄;
开始之前我们需要准备一个有数据的文件,比如 F:\php_workspace\php-code-kata\read.txt,在看一个简单的文件读取示例:
<?php
// 这里为了贴合读取文件的标准流程,使用 do{} while 语句,你也可以修改成 while 语句。
$filename = "F:\\php_workspace\\php-code-kata\\read.txt";
// 1. 打开一个文件句柄;
$handle = fopen($filename, $mode = 'rb');
do {
// 2. 使用文件读取函数读取文件;
$content = fgetc($handle);
echo $content;
// 3. 判断是否到文件结尾,到结尾则结束读取,否则回到操作 2;
} while (!feof($handle));
// 4. 读取完成关闭句柄;
fclose($handle);
// 读取显示大致类似:
// hello world!
现在,我们来详细讲解一下上述代码做了什么处理吧:
使用 fopen($filename, $mode) 打开一个文件或 URL 句柄,供后续文件系统函数使用;
使用 fgetc($handle) 函数从文件句柄中读取一个字符;
使用 feof($handle) 判断文件句柄是否到文件的结尾处,否则继续读取文件;
当读取完成后使用 fclose($handle) 关闭打开的文件句柄,完成文件读取的所有操作。
总体来说,在读取文件时按照以上处理流程,基本上太容易出错的。不过即便如此,还是有些重点需要我们小心处理:
我们以什么模式打开一个文件句柄,示例中使用 $mode='rb' r(read) 只读模式开个一个文件句柄(只读模式下不能对文件尽心写入)。另外还有几个常用模式可供使用:
r+ 读写模式
w(write) 覆盖写入
w+ 覆盖读写
a(append) 追加写入
a+ 追加读写
b 重点关注此模式,为增强项目可移植和健壮性,推荐所有模式添加「b」模式强制使用二进制模式
有关所有可用模式的说明可以从 模式 手册中查找。
在执行文件内容读取时除了逐字符读取(fgetc),要支持一下集中读取形式:
fgets($handle) 每次读取一行数据
fgetss($handle) 每次读取一行数据,并过来 html 标记
fgetcsv($handle) 读取 CSV 文件,每次读取一样并解析字段
fread($handle, $length) 每次从句柄中最多读取 $length 个字节。
处理可以从句柄中读取文件数据,PHP 还提供将整个文件读取的方法:
file($filename) 把整个文件读入一个数组中
filegetcontents($filename) 将整个文件读入一个字符串
注意:读取文件操作时我们推荐使用 filegetcontents。
到这里我们基本上就涵盖了文件读取的所有知识点,相信大家对文件读取已经有了一个比较系统的认知。
下面我们进入到文件写入处理中,看看文件写入的正确姿势。
读取写入
典型的文件写入流程基本上和文件读取流程一致:
打开一个文件句柄;
使用文件读取函数向文件中写入内容;
写入完成关闭句柄。
依据惯例我们来看一个简单的示例:
<?php
$filename = "F:\\php_workspace\\php-code-kata\\read.txt";
// 1. 打开一个文件句柄;
$handle = fopen($filename, $mode = 'ab');
// 2. 使用文件读取函数向文件中写入内容
fwrite($handle, "hello filesystem to write!\n");
// 3. 写入完成关闭句柄;
fclose($handle);
注意:这里我们以追加写入的模式 * $mode = 'ab'* 写入文件内容。
文件写入就如同文件读取一样的简单,相信大家能够轻松掌握这方面的知识。然而,我们显示世界可能充满了荆棘,稍不留神可能就会深陷泥沼。比如:
我在写入文件时,同时其他人也在对同一个文件进行写入,怎么办?我们可以使用 flock($handle, LOCK_EX) 加锁函数进行独占写入。
每次都需要 打开文件、写入、再关闭 是在麻烦!有没有更简单的方式写文件呢?PHP 同样为你考虑到了这点,所以提供了 fileputcontents($filename, $data [, LOCK_EX]) 将一个字符串写入文件,同样的它也支持独占写入。
到这里,我们基本上就学习完 PHP 文件系统中大多数常用的函数了。然而就如我所说的那样,现实世界总是残酷的。尤其是在读写文件时,经常会遇到各种各样的错误,我们应该如何才能避免呢?
嗯,PHP 一样为我们内置了检测文件有效性的函数,规避各种错误。
如何处理文件权限及检测有效性
1、文件有效性检测
检测文件的有效性能够让我们规避常见的开发错误,比如:
当相文件中写入数据时,是不是需要检测它有可写的权限,并且它是不是一个文件而非文件夹?
读取文件内容时,是不是需要查看下我们能不能对其进行读取?
在安装项目时,我们是不是需要检测已经依据实例配置文件创建了实际的配置文件呢?
这些内容都需要使用到文件有效性检测相关知识。
判断文件是否可写我们有:is_writable($filename) 和 SplFileInfo::isWritable()。
路径目录判断:isdir(\$filename) 和 SplFileInfo::isDir();文件判断:isfile($filename) 和 **SplFileInfo::isFile()。
检测文件或目录是否已经创建过,我们使用 file_exists($filename) 函数完成。
2、如何修改文件权限
当我们能够正确的检测文件是否存在时,我们还需要面对的问题时,如果我们的文件当前用户 不可写入,我们应该如何修改权限使其可写呢?
这里就涉及修改文件权限操作,之前我们在创建目录是已经接触过 umask 掩码相关知识。这里我们将讲解那些已经创建的文件权限变更的方法。
通常,我们会使用 chmod($filename, $mode) 去修改一个文件的权限。
另外,还可以关注以下几个权限相关的处理函数:
chgrp($filename, $group) 改变文件所属的组
chown($filename, $user) 改变文件的所有者
以及,之前提到过的 umask 修改掩码函数。
文章进行到这里,其实基本上 PHP 文件系统的所有知识都已经涉及到了。那么,下回见吧?不不不...
为了应对实战(面试需要),我们可能需要进一步对目录遍历做更进一步的研究。还记得我们之前使用过 scandir 来遍历指定路径中的文件和目录夹么?
现在我们将使用面向对象的接口来重新实现一个权限的目录遍历处理。
3 面向对象的目录遍历
使用面向对象的接口来遍历目录,是一个非常有意义的教程,这里我们所涉及使用的接口包括:
DirectoryIterator 创建非递归的目录迭代器
RecursiveDirectoryIterator 创建递归的目录迭代器
RecursiveIteratorIterator 创建一个递归迭代器的迭代器(用于迭代获取 RecursiveIteratorIterator 示例)
话不多说,我们看下如何创建一个功能强大的支持递归迭代的目录迭代程序:
/**
* 目录扫描
*
* @method listContents($path, $recursive = false) 获取目录中所有文件及文件夹
*/
class DirectoryScanner
{
/**
* 获取目录中所有文件及文件夹
*
* @param $path 目录
* @param $recursive 递归获取
*
* @return array
*/
public static function listContents($path, $recursive = false)
{
$iter = $recursive ? static::getRecursiveDirectoryIterator($path) : static::getDirectoryIterator($path);
$result = [];
foreach ($iter as $file) {
if (in_array($file->getFilename(), ['.', '..'])) {
continue;
}
$result[] = clone $file;
}
return $result;
}
/**
* 获取目录迭代器
*
* @param $path 目录
*
* @return DirectoryIterator::class
*/
public static function getDirectoryIterator($path)
{
return new DirectoryIterator($path);
}
/**
* 获取递归目录迭代器
*
* @param $path 目录
* @param $mode 遍历模式: RecursiveIteratorIterator::SELF_FIRST 从当前目录开始遍历;RecursiveIteratorIterator::CHILD_FIRST 从子目录开始遍历
*
* @return RecursiveIteratorIterator::class
*/
public static function getRecursiveDirectoryIterator($path, $mode = RecursiveIteratorIterator::SELF_FIRST )
{
return new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
$mode
);
}
}
$path = 'F:\php_workspace\php-code-kata\direcotry-iterator\dir';
var_dump(DirectoryScanner::listContents($path));
var_dump(DirectoryScanner::listContents($path, true));
4 PHP 文件系统思维导图
文件系统思维导图:
5 扩展阅读
文件系统函数
DirectoryIterator 迭代器
RecursiveDirectoryIterator 迭代器
RecursiveIteratorIterator 迭代器
以上是关于PHP 文件系统完全指南的主要内容,如果未能解决你的问题,请参考以下文章