ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java IO流实战:从网络爬虫到假数据生成的关键技术

Java IO流实战:从网络爬虫到假数据生成的关键技术 开头直接说个我经历的小事。前阵子部门面实习生问了一圈java基础大部分人都能背出“IO流有字节流字符流InputStream、OutputStream、Reader、Writer”但当我让他们现场写一段代码拿到一个网页文本存到本地文件再生成一批假数据写进CSV很多人当场卡住。不是不会写而是压根没意识到这两件事本质上就是同一件事——IO流。这个认知一旦建立起来java基础里的IO流就再也不是八股文了。这篇文章我会用网络爬虫和假数据工具包这两个场景把IO流从头到尾拆一遍字节流和字符流怎么选、缓冲流为什么非用不可、网络响应怎么读、造数工具怎么落盘最后再讲几个我实际踩过、面试也爱问的坑。适合刚学完Java语法想动手做东西的人也适合准备面试想把这部分彻底讲清楚的人。1. 先聊清楚写完文件、收到响应为什么都在和流打交道1.1 从一次面试开场想起的那次面试我出的题很朴素用Java读取一个网络上公开页面的HTML内容保存成本地index.html。题干里我故意没提IO流就是想看对方能不能自己绕到这条路上。结果有候选人直接写了HttpClient拿到响应然后卡在“接下来怎么存文件”还有人问我“要引入什么jar包”最让我哭笑不得的是有个同学回答“下载网页应该用浏览器”。这其实暴露了一个很普遍的问题很多人把IO流当成“文件读写”的专属API没意识到网络、内存、标准输入输出全部都是通过“流”来交互的。IO流抽象出来的那个模型特别简单一切数据都可以看成字节序列你从一个源去读它或者往一个目的地去写它。至于这个源是硬盘上的文件、还是远端服务器返回的响应体在流的世界里它们长得一样——都给一个InputStream。学会IO流本质上是学会了“怎么跟所有数据源打交道”而不是学会几个File相关类就完事。1.2 IO流管的到底是哪几层事我用一个水管模型来理解IO流。程序是个水箱文件、网络响应、键盘输入都是水源流就是连接它们的水管。数据从水管进到程序里这个过程叫输入流程序把数据通过水管送出去落在文件、发送到远端、打在屏幕上这个过程叫输出流。Java里具体到类上分了两条主线。一条以字节为最小单位InputStream和OutputStream是老大凡是读图片、音频、视频、压缩包走这条线另一条以字符为最小单位Reader和Writer是老大凡是处理文本、HTML、JSON、日志走这条线。这两条线之间的关系不是割裂的字符流底层也是靠字节流在跑只是中间多了一层“编码解码器”。后面第2章我会专门展开这个换算过程先记住一个结论你最终操作的粒度和数据本身的格式决定了你选哪条线。二进制文件硬要用字符流去读轻则乱码重则数据损坏。1.3 网络爬虫和假数据工具包的共同点回到标题的两个场景。网络爬虫的第一步本质上就是用输入流把网页数据读进程序第二步用输出流把处理好的内容写入文件或者数据库。假数据工具包正好反过来核心是用输出流把程序生成的一堆记录写进CSV、JSON、SQL文件。一进一出全是IO的活。很多教程喜欢把爬虫讲得很玄又是正则匹配又是XPath解析搞得新手觉得门槛很高。其实你可以把爬虫的骨架简化成三步拿流、读数据、落盘。解析只是中间处理的一环IO才是从头贯穿到尾的地基。工具包造数也一样生成数据只是内存里的几行对象真正难的不是生成而是把千万条数据稳定、高效、不卡内存地写出去。所以我把这篇文章的重心放在IO流本身。转码、模拟数据、写文件这些只是载体目的是让你在一个真实场景里彻底理解流的用法。2. 字节流与字符流的选型逻辑读文件写数据的底层路径2.1 四个基类的现实对应物先把IO世界里最重要的四个抽象类摆出来抽象类作用常见实现InputStream按字节读入FileInputStream、ByteArrayInputStream、FilterInputStreamOutputStream按字节写出FileOutputStream、ByteArrayOutputStreamReader按字符读入FileReader、InputStreamReader、BufferedReaderWriter按字符写出FileWriter、OutputStreamWriter、BufferedWriter记住这四个名字以后你会发现Java IO类的命名规则极其直白前缀表示数据源类型后缀表示流类型。FileInputStream就是“从文件来的字节输入流”ByteArrayOutputStream就是“写到字节数组的输出流”。刚入门阶段直接在try块里new FileInputStream和new FileOutputStream是最快建立体感的写法。比如读取同一目录下的图片并复制为bak文件try (FileInputStream in new FileInputStream(avatar.png); FileOutputStream out new FileOutputStream(avatar_bak.png)) { byte[] buffer new byte[1024]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } catch (IOException e) { e.printStackTrace(); }read(buffer)这个方法会批量读取最多buffer.length个字节返回实际读到的数量返回-1说明到底了。之所以要写out.write(buffer, 0, len)而不是out.write(buffer)是因为最后一批数据往往凑不满缓冲区多写了会把空白字节也写进文件文件还能用但体积不对严重点的二进制文件直接损坏。2.2 为什么普遍要套一层缓冲流很多教程会告诉你“用BufferedInputStream性能更好”但不说为什么。这里我展开讲一下。不缓冲的FileInputStream每次调用read()都相当于直接向操作系统发起一次读取请求。而系统调用是有成本的就好比你每天要去银行办业务一次只取1块钱来回折腾几十次时间和手续费全浪费在路上了。缓冲区相当于你办了一个蓄水池一次性往里倒100万你从池子里取取完了再让系统往池子里补一次水。BufferedInputStream内部默认的缓冲区大小是8192字节也就是8KB。它做的事情很简单当你调用read()从它这里要一个字节时它一次性从底层FileInputStream里读走8KB存在内存里然后慢慢给你。这样系统调用的次数一下子缩小了几千倍IO性能自然就上去了。这个性能差异在读大文件时特别明显。我自己测过用一个几百MB的日志文件做纯读取累加字节数的操作FileInputStream裸读比BufferedInputStream慢好几倍数据越读系统调用越多差距越离谱。另一个容易感知的场景是BufferedReader.readLine()读文本时它内部按行缓存几乎是你写文本处理代码的标配。顺带说一句Scanner虽然也能读文件但内部也是包装了流对象而且类名一听就适合“解析”而非“搬运”性能差BufferedReader一截。2.3 编码问题字节和字符之间的换算规则字符流是建立在字节流之上的。InputStreamReader接收一个字节流和一个字符集负责把字节按规则翻译成字符OutputStreamWriter负责把字符反向翻译回字节。翻译规则不统一就会出现最常见的乱码事故。“UTF-8”现在基本是事实标准但Windows老机器上的中文文件很多还是GBK编码。你读取时如果用了UTF-8解码GBK文件中文就会化成一堆锟斤拷。极端情况是文件编码和程序指定编码一致但平台默认编码不一致比如在Linux上跑new String(bytes)它默认用UTF-8在Windows上跑默认可能是GBK——同一段代码换个环境就乱码。正确的做法是永远显式指定字符集。读文件try (BufferedReader reader new BufferedReader( new InputStreamReader(new FileInputStream(data.txt), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { System.out.println(line); } }写文件同理OutputStreamWriter的构造方法里第二个参数传StandardCharsets.UTF_8。你是从哪个源读到字节的在哪个环节解码写出去时又以什么编码编码三处必须口径一致。很多人只改读取端不改写出端照样乱码就是这个链条中间断了一节。3. 网络爬虫场景里的IO从URL读到InputStream再到落盘3.1 抓网页在IO层面到底是什么先说清楚边界本文涉及的“网络爬虫”只指对公开页面做少量、低频的读取用于学习IO流和网络编程。真实做爬虫业务还必须遵守目标网站的Robots协议、服务条款以及个人信息保护相关法律不去绕过访问控制不做高并发轮询不采集个人敏感信息。原理层面看爬虫的第一步就是IO流。Java里最朴素的网络请求方式不是HttpClient而是java.net.URL和URLConnection。创建一个URL对象调用openConnection()拿到一个连接再调用getInputStream()服务器返回的数据就以输入流的形式出现在你面前。这和我们读本地文件没有任何区别甚至代码结构都是一模一样的。本地文件的FileInputStream换成了网络连接的输入流数据源从硬盘换成了远程服务器剩下的读取逻辑完全复用。对使用者来说流把“距离”这个变量抹平了。3.2 一个最小可运行的抓取示例下面这个例子用HttpURLConnection请求一个公开页面读取HTML正文并保存到本地文件。我选了一个稳定、公开、内容简单的示例页面并把读网络流和写文件流拆得很清晰import java.io.*; import java.net.*; import java.nio.charset.StandardCharsets; public class SimplePageFetcher { public static void main(String[] args) { String urlStr https://www.example.com; String outputPath example.html; HttpURLConnection conn null; try { URL url new URL(urlStr); conn (HttpURLConnection) url.openConnection(); conn.setRequestMethod(GET); conn.setConnectTimeout(5000); conn.setReadTimeout(5000); conn.setRequestProperty(User-Agent, Mozilla/5.0 (Java IO learning demo)); int status conn.getResponseCode(); if (status ! 200) { System.err.println(请求失败HTTP状态码 status); return; } try (InputStream in conn.getInputStream(); BufferedReader reader new BufferedReader( new InputStreamReader(in, StandardCharsets.UTF_8)); BufferedWriter writer new BufferedWriter( new OutputStreamWriter( new FileOutputStream(outputPath), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { writer.write(line); writer.newLine(); } } System.out.println(页面已保存到 outputPath); } catch (Exception e) { e.printStackTrace(); } finally { if (conn ! null) { conn.disconnect(); } } } }这段代码有几个细节值得解释。setConnectTimeout和setReadTimeout必须设置否则网络挂了之后整个程序会一直卡在读流上生产环境里这就是线程泄漏。User-Agent设置成浏览器标识是因为不少服务器会拒绝无标识的请求这是网络服务端的正常防护不代表我们可以干套壳伪装的活。最后finally里disconnect很多人不写短时间跑一次没感觉但如果你的程序要连续抓取几十个页面不主动断开连接会导致本地端口耗尽。3.3 HTTP响应处理里容易忽略的IO问题第一个坑是输入流必须用完就关。HttpURLConnection默认是keep-alive的你关了输入流连接才能回到连接池里复用。你要是偷懒不关连接池被占满后续请求全部排队表现出来就是“程序跑了一会儿越跑越慢”。第二个坑是响应编码可能不是UTF-8。很多老网站用的是GBK或GB2312还不在响应头里声明。我的经验是先尝试按Content-Type里的charset参数去读如果没有这个参数就先用UTF-8读几行如果出现大量替换字符换GBK重试。当然这是在对方允许的前提下调试实际做数据采集前一定要先看网站的声明。第三个坑是网络流和文件流的关闭顺序。像上面代码里我用了try-with-resources多资源自动关闭多个资源会按声明顺序的逆序关闭也就是writer先关、reader接着关、in最后关。这符合逻辑必须先保证数据写完落盘成功再关闭输入源。如果你在一个try块里手动关顺序反了文件流写一半网络流断了可能连异常信息都拿不到。3.4 除了文本抓图片等二进制资源的IO处理爬虫业务里除了HTML最常碰的就是图片。图片和文本的区别就是底层不需要字符转换这一步。之前第2章那个文件复制示例直接把FileInputStream换成网络输入流就能把网络图片存到本地try (InputStream in conn.getInputStream(); FileOutputStream out new FileOutputStream(logo.png)) { byte[] buffer new byte[4096]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } }二进制读写不需要BufferedReader和BufferedWriter因为它们提供的是行级文本语义对图片来说没有意义。这里有个容易犯的错图片之类的大流量文件缓冲区大小适当放大比如改成8192甚至16384吞吐效率会明显提升。原因和缓冲流的原理一样单位时间内系统调用次数更少流水线搬运量更大。4. 工具包生成假数据把“造数逻辑”变成可复用的IO解决方案4.1 为什么需要一个造数工具包开发联调时最烦的一件事是数据库里没数据。你写了个分页查询的接口总得先造个几百条数据验证一下吧你写了个导出报表的定时任务总得有一个几十MB的输入文件测一下性能吧。每次现写循环插入SQL代码是能跑但一点不可复用下次想生成一万条不同格式的数据又得重写。工具包的价值是把“生成规则”和“输出通道”解耦。生成规则管的是“这行数据长什么样”比如姓名、年龄、手机号、地址、时间戳输出通道管的是“数据写到哪、用什么格式、以什么编码”比如UTF-8编码的CSV文件。两边各自独立以后想改成输出JSON只换输出通道想加一个字段只改生成规则。4.2 用成熟的工具包还是自己写Java生态里有现成的假数据生成库比如Faker底层很庞大覆盖人名、地址、公司、银行、互联网、文案等各国本地化内容。实际项目里用它最省时间。先加依赖dependency groupIdcom.github.javafaker/groupId artifactIdjavafaker/artifactId version1.0.2/version /dependency生成用户对象并输出到CSV的完整逻辑import com.github.javafaker.Faker; import java.io.*; import java.nio.charset.StandardCharsets; import java.util.Locale; public class FakeUserCsvGenerator { public static void main(String[] args) { Faker faker new Faker(Locale.CHINA); int count 10000; String path fake_users.csv; try (BufferedWriter writer new BufferedWriter( new OutputStreamWriter( new FileOutputStream(path), StandardCharsets.UTF_8))) { writer.write(id,name,phone,address,email); writer.newLine(); for (int i 1; i count; i) { String line String.join(,, String.valueOf(i), faker.name().fullName(), faker.phoneNumber().cellPhone(), faker.address().fullAddress().replace(,, ), faker.internet().emailAddress()); writer.write(line); writer.newLine(); } System.out.println(生成完成 count 条文件大小 new File(path).length() 字节); } catch (IOException e) { e.printStackTrace(); } } }这里有个我在真实项目里栽过的跟头地址字段里有逗号直接拼进CSV会把一列撕成两列。所以我写了个.replace(,, )把字段里的逗号替换成空格。CSV是没有严格转义标准的最稳妥的方案是字段一律清洗掉分隔符、换行符。4.3 写文件时编码与追加模式的选择造数工具包输出文件编码选择直接决定目标系统的体验。绝大多数场景选UTF-8但如果你的数据要给Excel用且用户用的是Windows中文环境Excel打开UTF-8的CSV会乱码。两个解法一是输出GBK编码的文件二是输出UTF-8文件但写入BOM头。我一般选后者因为UTF-8更通用加BOM反而会让老牌编辑器兼容性变好。写入BOM只需在文件最开头写三个字节EF BB BF。追加模式是另一个容易绕晕的点。new FileOutputStream(path)默认覆盖new FileOutputStream(path, true)才是追加。批量生成时如果要分段写后续打开一定记得带上第二个参数true。Java 7之后还提供了Files.newBufferedWriter(path, charset, StandardOpenOption.APPEND)语义更清晰不用自己包OutputStreamWriter。4.4 造大文件时的IO细节测试导出功能时我经常需要生成几百MB的文本文件。这时候光会BufferedWriter还不够有几个细节是必须注意的。一是不能每条记录都flush。flush是把缓冲区数据强制推到磁盘频繁触发等于把缓冲优势全部放弃。正确做法是大量写入后统一flush一次或者靠close时的自动刷新。二是不要逐条拼接大字符串。每个writer.write方法都会做一次字符转字节在循环里尽量把一行拼好再一次性写入。拼的时候别用循环拼接用StringBuilder或String.join。虽然BufferedWriter自己有缓冲但Java层字符串拼接的开销依然存在数据量过千万时差距很明显。三是预留可中断/可续跑的入口。造数工具跑一半挂了重跑一遍是灾难。我的做法是每次写完N条就flush一次并打印当前进度配合追加模式挂了可以从上次的记录数接着跑。这不算IO流的语法问题但如果没有IO刷新的概念你根本不知道什么时候数据真正落盘了。4.5 生成二进制假数据比如占位图片的思路有些项目需要一堆假图片来测上传、压缩、缩略图逻辑。用IO流生成纯色占位图可以直接用BufferedImage配合ImageIO写出来BufferedImage img new BufferedImage(800, 600, BufferedImage.TYPE_INT_RGB); Graphics2D g img.createGraphics(); g.setColor(new Color(135, 206, 235)); g.fillRect(0, 0, 800, 600); g.dispose(); ImageIO.write(img, png, new File(placeholder.png));ImageIO.write内部已经把OutputStream封装好了你只需要给它一个文件路径。这类场景要记住的IO要点是图片是二进制数据任何环节都不要用字符流去处理ImageIO默认支持png、jpg、gif等格式写之前确认目标格式对颜色模式的支持。5. 高频考点与真实踩坑经验flush、编码读取与资源异常5.1 不关流的代价与try-with-resources的正确姿势不关闭输入输出流下面这几个问题迟早找上门文件句柄耗光——运行一段时间后报Too many open filesWindows下文件被占锁——文件明明不在了但删除时提示被另一个程序使用网络连接无法释放——端口被占满。解决这个问题最干净的方式就是用try-with-resources语法。它的好处是不用写finally资源在try块结束会自动关闭而且是逆序关闭天然符合释放顺序。注意try-with-resources里的资源类必须实现AutoCloseable接口Java里所有的IO流类都实现了。有人习惯在里面写catch然后继续往下跑我建议catch里至少打日志不然文件写失败了程序还假装成功后面查问题会疯。一个更隐蔽的点是关闭资源时的异常比如磁盘满了close()可能抛异常。try-with-resources会把关闭异常一并捕获你可以在catch里区分到底是业务读写异常还是关闭异常虽然多数场景不需要区分但心里要有这个认知。5.2 flush和close别搞混数据不落盘的两类原因这两个词是新手问得最多的问题之一。我先说结论close()一定包含flush()但flush()不关闭流。为什么需要flush因为BufferedWriter把数据攒在8KB缓冲区里没满之前不会真正写进磁盘。你以为写完了其实数据还在内存里程序一崩全丢。比如造数工具里如果不做任何flush直接close数据也会落盘close会补一次刷新。但如果你的程序是常驻进程写一批数据后要立刻让另一个进程来读这个文件那必须flush()否则另一个进程读到的可能是空文件或者旧文件。两段代码做个对照// 写法1不flush数据留在缓冲区另一个进程立刻读不到 writer.write(hello); // 没有flush就 Thread.sleep(1000)另一个进程读文件可能是空的 // 写法2显式flush数据真正落盘其他进程可以立刻看到 writer.write(hello); writer.flush();注意FileWriter和FileOutputStream这种没有内置缓冲区的流write就是直接走系统调用数据会立即写出去不存在flush的烦恼。所以这个问题基本只出现在BufferedWriter/Writer这类带缓冲的流上。5.3 编码错乱排查的完整思路乱码问题在社区里永远有热度。我把完整的排查链路列出来你按顺序走基本都能定位。第一步确认文件真实编码而不是“你希望它是UTF-8”。Linux下用file -i命令看类型和编码Windows下可以用编辑器或一些工具查看文件字节比如打开十六进制视图看UTF-8的中文通常是以三个字节一组出现且首字节的二进制高位特征不同。第二步检查读取端代码是否显式指定了与文件真实编码一致的字符集。最常出事的代码是new InputStreamReader(new FileInputStream(path))这行代码没指定字符集会用平台默认值。服务器Linux上默认UTF-8本地Windows默认可能是GBK同一行代码两个环境两套行为。第三步检查写出端编码。读取端解码正确写出端如果以错误编码重新编码也会乱码。这个错误在“文件复制后再打开”的场景特别隐蔽因为是两份文件乱码可能只出现在其中一份。第四步检查数据在内存里的流转过程。比如读进来是String类型中间经过数据库、消息队列、HTTP传输每一跳都有编码转换的可能。IO流只负责文件链路两端中间环节的编码问题要在整个数据链路上统一。5.4 从基础IO到更上层的写法Files工具类与NIO最后聊一个面试里加分、实践里也常用的东西Java 7推出的java.nio.file.Files工具类。它把最常见的IO操作压缩成了几个静态方法比如// 整个文件一次读成ListString ListString lines Files.readAllLines(Paths.get(data.csv), StandardCharsets.UTF_8); // 一次性写入多行文本 Files.write(Paths.get(out.csv), lines, StandardCharsets.UTF_8); // 复制文件 Files.copy(Paths.get(a.txt), Paths.get(b.txt));readAllLines和write的底层还是包装了流本质上只是把样板代码替你写好了。我建议小文件、低频操作直接用Files掌握核心方法即可大文件、高频操作回到BufferedReader/BufferedWriter流式处理不要让readAllLines一次性把所有行加载进内存。面试时如果被问到这部分能把“高层API本质是包装IO流”这个点说出来说明你对底层是真理解而不是只背了一堆方法名。NIO的FileChannel、ByteBuffer属于另一套体系适合追求更高性能和手动控制缓冲区的场景。学习顺序上先把本文的字节流、字符流、缓冲流、网络流玩熟再去看NIO会顺畅得多。直接跳学NIO的人经常被Buffer的flip、clear、compact搞懵原因就是没理解流式的数据搬运模型。我个人在实际项目里的习惯是涉及IO的代码一定把“资源关闭”“编码明确”“缓冲合理”三条底线先立好再优化性能和写法。很多年前我第一次写爬虫文件流没关Windows机器上反复跑了几次后日志文件全部锁定最后只能重启电脑从那以后我写IO代码就再也不敢邋遢了。你自己写工具包的时候也建议在一开始就把try-with-resources和显式字符集养成分秒不忘的习惯这些基础动作积累多了IO流才能真正变成你工具箱里的顺手机械而不是面试时的背诵素材。
返回列表