拓冰建站拓冰建站
首页 / 资讯中心 / 正文

PyTorch 读取 MNIST 数据管道与 DataLoader 参数解析

第一次把 PyTorch 装好兴致勃勃敲下两行代码准备跑通 MNIST 数据集结果终端里那个进度条要么停在 0% 一动不动要么直接甩出一个 404这种场面我见得太多。很多人对读取 MNIST这件事的预期是十分钟最后却花了一整个下午在排查网络、路径和参数。问题不在于 MNIST 有多难而在于大多数人只记住了datasets.MNIST(root, trainTrue, downloadTrue)这一行却完全不知道这行代码背后发生了什么。这篇内容想做的事情很简单把这十分钟真正该花的时间花在刀刃上——先搞清楚数据从哪来、长什么样、经过哪些处理再去写模型。适合读这篇的人有三类刚配完 Anaconda 和 PyTorch 环境、准备跑第一个训练脚本的新手能跑通代码但说不清transform和DataLoader每个参数含义的中间层以及准备从 MNIST 迁移到自己的图像数据集、想先摸清标准套路的开发者。下面所有的代码都在 CPU 环境下验证过GPU 环境除了设备号之外没有差别。1. MNIST 在 PyTorch 入门路径里的真实位置1.1 为什么第一课永远是它MNIST 是 6 万张 28×28 的手写数字灰度图加 1 万张测试图共 10 个类别。它的价值不在于能训练出多好的模型而在于它把深度学习的完整链路压缩到了最小的规模数据读取、张量变换、批处理、前向传播、损失计算、反向传播、验证每一步都有但每一步的代价都很低。你在一台没有独立显卡的笔记本上几十秒就能跑完一个 epoch这意味着你可以用极低的试错成本去理解流程。从工程角度看这个数据集还有一个被低估的优点格式极其规整。所有图像尺寸统一、通道数统一、标签是 0 到 9 的整数没有任何脏数据需要清洗。你在真实项目里遇到的那些麻烦——图像大小不一、标注缺失、类别极度不平衡、文件夹结构混乱——它一个都没有。所以它是一个纯粹的管道练习器你在这里练的是数据流通的路径而不是数据清洗的技巧。我自己的经验是把 MNIST 当作读取管道来练比当作分类任务来练收益大得多。因为模型结构网上到处都有现成的但数据管道出问题时的排查思路很少有人系统讲过。1.2 四个压缩文件里到底装了什么当你执行下载时实际拿到的是四个.gz压缩包分别对应训练图像、训练标签、测试图像、测试标签。它们是经典的 IDX 格式一种非常古老的二进制格式头部有一段魔数用来标识维度信息接着是数据本体。图像数据是连续的uint8标签数据是连续的uint8。格式细节对使用者来说不重要但有一个数字必须记住训练集 60000 张测试集 10000 张每张 784 个像素点。因为后面你写DataLoader的时候len(train_dataset)应该等于 60000len(test_dataset)应该等于 10000。这两个数字如果不匹配说明文件下载不完整或者被截断了这是第一时间就能发现问题的自检点。torchvision在首次读取时会把这些压缩包解压成idx格式的裸文件之后每次运行都直接读裸文件不再重复解压。所以你的root目录下通常会看到raw子目录里面同时存在.gz和已解压的文件。这个细节很重要如果你想迁移或者备份数据集把整个raw目录打包带走放到新机器的相同相对位置就能实现零下载运行。1.3 十分钟的时间预算应该怎么分配很多人以为十分钟搞懂的意思是十分钟能从零跑到训练完一个 epoch。实际上如果环境已经配好下载速度正常这两件事确实能在十分钟内完成。但如果把环境搭建算进来那十分钟是远远不够的——装 PyTorch 本身就要考虑版本匹配、源的速度、CPU 还是 GPU 版本这些事。所以我建议把这十分钟拆成三块第一块大约是两分钟用来把数据落到本地并且验证文件完整性第二块是四分钟用来理解transform和DataLoader的每个参数在干什么第三块是四分钟用来做数据体检和一次前向的冒烟测试。这三块做完读取这件事就算彻底通了剩下的是模型的事和读取无关。需要提前说一句如果你的环境还没搭好先别急着上 GPU 版本。CPU 版本安装体积小、依赖少、出错概率低跑 MNIST 完全够用。等读取链路跑通、模型验证过一遍之后再换 GPU 环境排查成本会低很多。2. torchvision.datasets.MNIST 的加载链路拆解2.1 三个必填参数和两个最容易漏的参数这个类的构造参数看起来不多但每一个都有讲究。root是数据根目录它不是文件路径而是目录路径传错成文件名会直接报错。train是布尔值True取 6 万张训练集False取 1 万张测试集注意它控制的是两个完全不同的文件不是同一份数据的切片。download控制是否在本地找不到文件时发起网络请求。真正容易被忽略的是transform和target_transform。前者处理图像后者处理标签。绝大多数教程只写transform因为标签通常不需要变换但如果你想做标签平滑、one-hot 编码或者标签偏移校正就需要动target_transform。这两个参数的默认值都是None此时返回的是原始 PIL 图像对象和整数标签。还有一个隐藏参数是transform到底应该在哪一层生效。有一种说法是把它放在DataLoader里性能更好这在早期的某些版本里确实成立但现在的常规做法是放在Dataset构造时传入。理由很简单transform是数据源的一部分定义和数据集绑定放在DataLoader里会让DataLoader承担它不该承担的职责代码可读性变差。import torch from torchvision import datasets, transforms train_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_set datasets.MNIST( root./data, trainTrue, transformtrain_transform, downloadFalse ) test_set datasets.MNIST( root./data, trainFalse, transformtrain_transform, downloadFalse )注意root./data是相对当前工作目录的路径。如果你在 PyCharm 里跑和在命令行里跑工作目录可能不一样导致数据被下载到两个地方。要么统一用绝对路径要么确认清楚工作目录。2.2 downloadTrue 背后的下载逻辑与 404 的成因当downloadTrue时torchvision会先检查root/MNIST/raw下是否存在对应的文件。如果不存在就发起请求下载。下载完成后会做一次校验校验不通过会重新下载或者直接抛异常。常见的 404 有几类成因我按出现频率排一下。第一类是数据源地址本身发生了变化。早期的一些下载地址在特定网络环境下返回 404 是真实存在的情况后来官方把默认地址切到了更稳定的对象存储镜像上。如果你用的是很老的torchvision版本可能仍然指向旧的地址。解决办法是升级torchvision或者手动把文件下载好放进raw目录。第二类是路径拼接导致的问题。比如root指向了一个不存在的深层目录或者路径里带了奇怪的字符某些系统下会出现资源定位失败。第三类是代理或企业网络环境对请求做了拦截返回了一个 HTML 错误页而不是二进制文件torchvision拿到这个 HTML 后校验失败报错信息看起来像是格式错误实际是网络问题。这类问题最迷惑人因为错误信息不会直接告诉你你拿到的是网页。判断方法很简单去raw目录看文件大小。正常的图像压缩包在 9MB 上下标签包在几十 KB。如果某个文件只有几 KB 或者干脆是 0 字节基本可以确定是下载环节出了问题。2.3 本地已有数据时如何把网络这一环彻底绕开最省事的做法是准备一份别人的raw目录压缩包解压到对应位置然后永远设downloadFalse。这样脚本在任何网络环境下都能跑也省掉了每次运行的检查开销。另一种做法是提前把四个文件准备好通过downloadFalse让它直接读。这里有个坑如果你只放了.gz而没放解压后的文件某些版本下也能正常工作因为它会自己解压但如果你放的是解压后的文件而缺少.gz在downloadTrue的情况下它可能会认为文件不全试图重新下载。所以最稳的状态是raw目录里既有.gz也有解压后的文件且downloadFalse。再补充一个经验团队协作或者做实验记录的时候把数据集路径写成配置项而不是硬编码在代码里。因为不同机器上的盘符、挂载点都可能不一样硬编码路径会让你的脚本只能在你自己的电脑上跑。3. transform 不是可有可无的装饰3.1 ToTensor 到底做了哪三件事很多人把ToTensor()当成一个格式转换器其实它一次性做了三件事每一件都直接影响后续计算。第一件是维度置换。PIL 图像在内存里的布局是高度、宽度、通道也就是 HWC。而 PyTorch 的卷积操作期望的布局是通道、高度、宽度即 CHW。ToTensor()会把这个顺序换过来所以 MNIST 的一张图从(28, 28)变成(1, 28, 28)。这就是为什么你在调试时打印图像形状会看到那个 1它不是多余的维度。第二件是数值类型转换。原始像素是 0 到 255 的整数ToTensor()会把它变成浮点数并且除以 255缩放到 0 到 1 之间。这一步如果漏掉输入就是 0 到 255 的大数值配合默认学习率训练会非常不稳定损失曲线要么爆炸要么纹丝不动。第三件是封装成张量对象。转换之后你拿到的是torch.Tensor可以参与自动求导、可以.to(device)、可以和模型输出直接做运算。如果跳过这一步你拿到的是 PIL 对象根本没法和模型交互。提示ToTensor()内部做的是除以 255如果你的数据本身已经是张量并且已经归一化过就不要再套一次否则数值会被压缩到几乎为零。3.2 Normalize 的 0.1307 和 0.3081 是怎么算出来的这两个数字几乎在所有 MNIST 教程里都能看到但很少有人解释它们的来源。它们是在整个训练集上统计出来的先把所有像素缩放到 0 到 1然后计算全体像素的均值和标准差得到大约 0.1307 和 0.3081。Normalize做的事情是对每个通道执行减去均值、除以标准差。也就是(x - mean) / std。这样处理后数据分布被拉到均值接近 0、标准差接近 1 的状态。为什么这么做因为神经网络的参数初始化通常假设输入是近似标准正态分布的如果输入整体偏大或者偏小梯度传播的效率会明显下降收敛会变慢。你可以自己验证这两个数字代码只有三行import torch from torchvision import datasets raw datasets.MNIST(root./data, trainTrue, downloadFalse) imgs raw.data.float() / 255.0 # 形状 [60000, 28, 28] print(imgs.mean().item()) # 约 0.1307 print(imgs.std().item()) # 约 0.3081这里要注意一个细节严格来说应该只统计训练集而不是把测试集也算进去否则会引入轻微的数据泄漏。上面演示用的是训练集这是正确做法。另外这两个值只对 MNIST 成立。换成 FashionMNIST均值约 0.2860、标准差约 0.3530两者完全不一样不能混用。同理换成彩色数据集就是三个通道各自一组值。3.3 训练集和测试集的处理必须区别对待有一个非常常见但很隐蔽的错误训练集和测试集用了同一个带数据增强的transform。比如训练时加了随机旋转、随机裁剪测试时也套了同一个管道。这会导致测试结果不稳定——同一张图每次评估都可能得到不同的预测结果你根本无法判断模型到底是好还是坏。正确的处理方式是分成两条管道。训练管道可以包含数据增强测试管道只保留确定性的操作也就是ToTensor加Normalize一个随机的都不要有。train_tf transforms.Compose([ transforms.RandomRotation(10), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) eval_tf transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])对于 MNIST 这种字迹本来就很规整的数据集我个人的建议是增强可以非常轻甚至不加。加太多旋转和裁剪反而会让数字变形到无法辨认比如把 6 转成 9 的边缘状态模型学到的东西就被污染了。如果你想验证增强是否有效就做对照实验别凭感觉加。4. DataLoader 的几个参数决定了你的训练效率4.1 batch_size、shuffle 和 drop_last 的实际影响batch_size决定每次送入模型的样本数。MNIST 这种小图上32 到 128 都是常用区间。batch 太小会让梯度噪声大、训练震荡batch 太大会让内存占用上升而且每个 epoch 的更新次数变少收敛可能需要更多轮。选一个中间值先跑通再根据显存和收敛曲线微调这是比较务实的顺序。shuffle只在训练集上设为True测试集一律False。原因有两层。第一层是训练时打乱能避免模型学到样本顺序这种无关规律特别是在数据按类别排序的情况下。第二层是评估时的顺序固定能让你复现出完全一致的结果方便对比不同模型。drop_last处理的是最后一个不完整批次。60000 除以 128 是 468 余 96最后一批只有 96 个样本。如果模型里用了批归一化层一个 96 样本的批次和一个 128 样本的批次统计特性不同可能造成轻微波动。设drop_lastTrue会丢掉这 96 个样本代价是每个 epoch 少看一点数据。对 MNIST 来说这点损失可以忽略所以我一般开着。4.2 num_workers 与 persistent_workers 的取舍num_workers是最容易出问题的一个参数。它指定用几个子进程来加载数据0 表示在主进程里加载。设成大于 0 可以并行读取理论上更快。但实际效果取决于任务复杂度。如果在transform里只做了ToTensor和Normalize计算量极小多进程的开销反而可能比收益大num_workers设成 2 或者 4 就足够设成 16 只会让启动变慢。真正需要多进程的是那些在transform里做复杂解码的图像任务或者磁盘 IO 很慢的场景。persistent_workersTrue的含义是每个 epoch 结束后不销毁工作进程下一个 epoch 直接复用。这能省掉反复创建进程的开销但必须配合num_workers 0使用否则会直接报错。注意在 Windows 上使用多进程加载你的训练代码必须包在if __name__ __main__:保护块里否则子进程会重新导入主模块导致无限递归创建进程。这个坑在 Linux 上不存在所以从 Linux 迁到 Windows 的人几乎都会踩一次。4.3 pin_memory、collate_fn 与迭代器的语义pin_memoryTrue的作用是把批数据放进锁页内存这样从内存拷贝到 GPU 显存时能走更快的通道。只有在使用 GPU 训练时才有意义纯 CPU 环境下设了不会报错但也没有收益。collate_fn决定了怎么把一批单独的样本拼成一个批次。默认实现会做两件事把一批(C, H, W)的图像沿第 0 维堆叠成(B, C, H, W)把一批标量标签堆叠成(B,)。所以从DataLoader里取出一个批次你会得到两个张量形状分别是(B, 1, 28, 28)和(B,)。如果你需要序列任务那种不等长填充就必须自己写collate_fn但在 MNIST 上完全用不到。还有一个语义要搞清楚DataLoader是可迭代对象不是列表。每次for循环都会重新从数据集开头迭代一遍所以你可以放心地在每个 epoch 用同一个DataLoader。但如果你用iter()手动拿了一个迭代器然后想复用那是行不通的迭代器耗尽就没了。from torch.utils.data import DataLoader train_loader DataLoader( train_set, batch_size128, shuffleTrue, num_workers2, drop_lastTrue, pin_memoryFalse ) imgs, labels next(iter(train_loader)) print(imgs.shape, labels.shape, imgs.dtype)5. 一条完整的读取链路与数据体检5.1 从文件到批次的完整代码把前面所有内容串起来一份能直接跑的脚本大概长这样。写得比我平时用的稍微啰嗦一点是为了让每一步都看得见。import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader def build_loaders(data_root./data, batch_size128): train_tf transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) eval_tf transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_set datasets.MNIST(rootdata_root, trainTrue, transformtrain_tf, downloadFalse) test_set datasets.MNIST(rootdata_root, trainFalse, transformeval_tf, downloadFalse) train_loader DataLoader(train_set, batch_sizebatch_size, shuffleTrue, num_workers2, drop_lastTrue) test_loader DataLoader(test_set, batch_sizebatch_size, shuffleFalse, num_workers2) return train_set, test_set, train_loader, test_loader if __name__ __main__: tr_set, te_set, tr_loader, te_loader build_loaders() print(train size:, len(tr_set), test size:, len(te_set)) for imgs, labels in tr_loader: print(batch:, imgs.shape, labels.shape) break这段代码里有两个刻意的设计。一个是我把构建逻辑封装成函数这样换数据集、换 batch size 都只改参数不改逻辑。另一个是if __name__ __main__:保护块即使现在num_workers2也能在任何系统上安全运行不会因为多进程产生问题。5.2 上手先做的四项数据体检跑通之后别急着写模型先花两分钟做体检。这四件事能帮你提前发现九成以上的数据问题。第一项检查样本数量。len(train_set)应该是 60000len(test_set)应该是 10000。数字不对说明文件有问题。第二项检查单张样本的形状和数值范围。取train_set[0]你会拿到一个元组第一个元素形状是(1, 28, 28)第二个元素是 0 到 9 的整数。第一个元素的值应该大致落在 -0.4242 到 2.8215 之间。这个区间是算出来的(0 - 0.1307) / 0.3081和(1 - 0.1307) / 0.3081。如果你的值域超出这个范围说明归一化参数用错了。第三项检查标签分布。用torch.bincount(torch.tensor(train_set.targets))看一下每类有多少张。MNIST 大致均衡每类在 5400 到 6800 之间。如果某类数量明显异常说明数据被破坏过。第四项可视化一批。把批次里的图反归一化回来再显示乘上标准差加上均值然后压缩掉通道维。这一步是我最推荐的因为很多预处理错误肉眼一看就发现比如图像上下颠倒、数值全黑、标签和图像对不上。import torch, matplotlib.pyplot as plt imgs, labels next(iter(tr_loader)) mean, std 0.1307, 0.3081 show imgs[:8] * std mean fig, axes plt.subplots(1, 8, figsize(12, 2)) for ax, img, lb in zip(axes, show, labels[:8]): ax.imshow(img.squeeze(0), cmapgray) ax.set_title(str(lb.item())) ax.axis(off) plt.show()5.3 划分训练集与验证集的两种写法严格来说 MNIST 并没有官方验证集只有训练集和测试集。把测试集当验证集调参是一种常见但有争议的做法因为调参多了之后测试集就不再干净。规范一点的做法是从训练集里切出一部分当验证集。第一种写法是random_split简单直接。但要注意它每次运行的结果都不一样做实验对比时必须固定随机种子否则两次实验的验证集不同指标没法比较。from torch.utils.data import random_split g torch.Generator().manual_seed(42) val_size 5000 train_size len(train_set) - val_size tr_sub, val_sub random_split(train_set, [train_size, val_size], generatorg)第二种写法是用Subset配合固定的索引数组。这样做的好处是索引可以保存下来跨机器、跨实验完全可复现。from torch.utils.data import Subset import numpy as np idx np.random.RandomState(42).permutation(len(train_set)) val_idx, tr_idx idx[:5000], idx[5000:] tr_sub Subset(train_set, tr_idx) val_sub Subset(train_set, val_idx)两种写法在功能上等价但第二个版本在需要和其它人复现结果的时候更靠谱。我的习惯是默认用第二种因为它把哪 5000 张是验证集这件事变成了一个显式的、可记录的数据。6. 踩过的坑与排查链路6.1 下载中断与文件完整性最典型的场景是下载到一半网络波动raw目录里留下了一个不完整的文件。下次运行时downloadTrue会认为文件已存在而跳过下载结果读取时直接抛格式异常。这类问题的排查链路是这样的。第一步先看raw目录的文件清单和大小。四个文件应该齐全图像包大约 9.5MB 上下标签包几十 KB。第二步如果发现某个文件明显偏小直接删掉它重新跑一次让它重新下载。第三步如果反复下载都失败找一份别人打包好的raw目录整体替换。这里有个容易忽略的点torchvision在下载后会做校验校验失败会抛异常但校验失败的原因是拿到的东西不对可能是网络返回错误页也可能是文件被截断。错误信息本身通常不会明说所以不要只盯着报错文字要去看文件本身的大小。还有一种更隐蔽的情况路径里存在同名但内容不同的旧数据。比如你之前在另一个项目里下过一次 MNIST路径是./data现在换了项目但路径没变脚本读到的可能是旧的那份而你以为读的是新的。所以我一直建议路径里带上项目标识或者干脆用同一个固定的数据根目录避免到处散落副本。6.2 Windows 下多进程与重复运行脚本num_workers 0在 Windows 上的行为差异是新手最容易卡住的地方。表现是脚本莫名其妙地反复启动、内存占用飙升、最后报一个和进程相关的错误或者干脆卡死。根因在于 Windows 用 spawn 方式创建子进程子进程会重新导入你的主模块。如果你的数据加载代码写在模块顶层、没有被if __name__ __main__:保护子进程在导入时又会创建新的DataLoader又会创建新的子进程无限递归。修法就是加保护块把所有实际执行的逻辑放进去。只留函数和类的定义在顶层。这个习惯养成之后在 Linux 和 Windows 上都能跑不用维护两个版本。顺带说一个相关的问题有些人在transform里用了不可被 pickle 的对象比如 lambda 函数。多进程加载时需要把Dataset和transform序列化传给子进程lambda 在很多情况下无法序列化于是报错。解决办法是把 lambda 换成顶层函数或者用Compose组合已有的类。6.3 常见报错速查下面这张表是我自己攒的覆盖了读取 MNIST 时最常遇到的几种报错。报错关键词大概率原因处理方式HTTP 错误 / 状态码异常下载地址不可达或返回错误页换torchvision版本或手动放置数据文件文件解压或格式相关异常压缩包不完整或内容被替换删除raw下对应文件后重下进程启动相关错误Windows 下缺少主模块保护加if __name__ __main__:序列化失败transform里含 lambda 或不可序列化对象改用顶层函数或类张量维度不匹配漏了ToTensor或漏了squeeze检查单样本形状是否为(1, 28, 28)损失一直不下降只做了ToTensor没做归一化补上Normalize评估结果每次都不同测试集套了随机增强拆分训练与评估两条管道这张表里最值钱的是最后两行。前面几行是环境问题搜一下就有答案后面两行是逻辑问题报错不会告诉你只能靠对流程的理解去发现。我见过太多人卡在模型不收敛上查了半天模型结构最后发现是数据管道里少了归一化。排查这类问题的通用思路是把数据管道和模型解耦先单独验证数据。具体做法是在没有模型的情况下把一批数据打印出来看形状、看值域、看可视化结果。数据对了再去怀疑模型。这个顺序如果反了你会浪费大量时间在正确的地方找错误。7. 从 MNIST 迁移到自己的数据集7.1 自定义 Dataset 的三件套等你把 MNIST 读通了下一步通常是用自己的数据。自定义数据集需要实现三样东西__init__负责记录文件列表和变换__len__返回样本总数__getitem__按下标返回一个样本。from torch.utils.data import Dataset from PIL import Image class MyDataset(Dataset): def __init__(self, samples, transformNone): self.samples samples # [(path, label), ...] self.transform transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] img Image.open(path).convert(RGB) if self.transform: img self.transform(img) return img, label这三个方法的职责边界很清楚。__init__里不要做重活尤其不要在__init__里读图像否则数据集一构建就要等很久而且内存会被撑满。真正的读取放在__getitem__里交给DataLoader的多进程并行处理。7.2 迁移时要动哪几个地方从 MNIST 迁到自己的数据集需要改的东西其实只有四处。第一处是数据集类的替换从datasets.MNIST换成你自己的类或者换成ImageFolder这种按目录结构自动识别标签的通用类。第二处是归一化参数MNIST 的 0.1307 和 0.3081 只对它自己成立新数据集必须重新统计。第三处是通道数相关的处理。MNIST 是单通道你的数据大概率是三通道模型第一层的输入通道数要跟着改ToTensor之后张量形状从(1, H, W)变成(3, H, W)。第四处是图像尺寸MNIST 是 28×28你的数据可能大得多通常需要加一步Resize或者在模型里加自适应池化。统计新数据集归一化参数的思路和 MNIST 一样跑一遍数据集累加均值和方差。数据量大时可以用一个子集做抽样估计精度损失很小但速度快很多。# 抽样估计均值方差三通道示例 import torch loader DataLoader(my_dataset, batch_size256, shuffleTrue, num_workers2) n, mean, m2 0, torch.zeros(3), torch.zeros(3) for imgs, _ in loader: b imgs.size(0) flat imgs.view(b, 3, -1) mean flat.mean(dim2).sum(dim0) m2 flat.var(dim2, unbiasedFalse).sum(dim0) n b if n 5000: break print(mean / n) # 粗略均值写到这里我把整套读取流程从里到外过了一遍。自己这些年最深的体会是数据管道这部分知识看十篇教程不如自己动手把一批数据打印出来看一眼。形状、值域、可视化这三样东西只要养成习惯每次都检查绝大多数隐藏问题会在写模型之前就暴露出来。另外还有一个实用的小习惯就是把数据体检写成一个独立的小函数新项目接新数据时先跑一遍比重头排查要省事得多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门