Java模拟话单数据并写入billing.txt:字段设计、文件IO与校验技巧 1. 话单任务拆解先搞清楚要交付什么这个任务看起来很像大学Java课程里“文件IO”那一章的课后作业构造出多个话单每个话单里包含主叫号码、被叫号码、通话时长、通话时间然后把多个话单持久化到文件里文件名固定叫billing.txt格式是“主叫号码|被叫号码|通话时长(单位s)|通话时间(yy……)”。我第一次做类似题目时觉得这不就是把几行字符串写进文件吗半小时就能搞定。真正动手之后才发现光是一个时间格式怎么写、分隔符选什么、编码用哪种就足够让新手折腾一晚上。这篇文章我把整个过程拆开揉碎来讲从需求分析、字段设计、实体类建模、随机数据生成到三种文件写入方式对比再到读回文件校验给出一套可以直接照着敲的Java实现。如果你用的是Python、C或者其他语言思路完全一致只需要替换对应的IO写法。1.1 需求里的四个关键名词先别急着写代码把题目里这几样东西一个个拆开看主叫号码发起通话的一方在真实电信系统里通常是一个手机号或者固话号码。被叫号码接听电话的一方可能是手机号、固话、400客服号等。通话时长单位是秒表示这次通话一共持续了多久。通话时间题目只写了yy开头完整的格式需要我们自己补全。最常见的两种写法是yyyy-MM-dd HH:mm:ss或者yyyyMMddHHmmss前者可读性好后者适合程序解析我后面默认采用yyyy-MM-dd HH:mm:ss。这四样东西组合在一起就是一条完整的话单记录。在电信计费领域话单的专业叫法是CDRCall Detail Record翻译过来是“呼叫详细记录”。不管是手机通话、VoIP还是5G语音计费系统都是靠一条条CDR来算钱的所以话单里的每个字段都有明确业务含义不能随便拼凑。1.2 “构造”和“采集”是两回事题目说的是“构造出多个话单”这个词很关键。它意味着我们不是从真实交换机或者运营商接口拿数据而是自己写程序生成模拟数据。这种做法的好处是你可以完全控制数据的分布、数量、边界值用来测试后续的计费逻辑、入库流程或展示功能。实际工作中我们经常要造这样的测试数据。比如上游系统还没联调好下游系统又想先跑通流程那就得自己写一个数据生成器。所以这个任务别看简单它背后的能力是“造数据”这项能力在开发测试环境里特别常见。1.3 输出文件的目录和命名约定题目要求文件名固定为billing.txt但没有指定目录。我的建议是放在项目的根目录或者data目录下避免和源码混在一起。比如project/ ├── src/ │ └── com/example/ │ ├── BillingRecord.java │ └── BillingGenerator.java └── data/ └── billing.txt这里有个小经验文件路径最好用相对路径或者用一个可配置的常量。如果你把绝对路径写死成C:/Users/xxx/...换一台机器编译运行就会报“系统找不到指定的文件”或者NoSuchFileException。代码里我是这样处理的private static final String FILE_PATH data/billing.txt;然后在main方法里先创建data目录再写文件这样就不需要手动去磁盘上建目录了。2. 主叫|被叫|时长|时间话单字段设计逻辑与数据规则2.1 为什么话单长这样你可以把每条话单理解成一次通话的“发票”主叫是买方被叫是卖方通话时长是使用量通话时间是发生时间。计费系统拿到这张“发票”后结合费率表就能算出这次通话该收多少钱。正因为它是计费的依据所以字段必须满足几个基本要求无歧义主叫、被叫不能弄反否则费用就算反了。可校验时长必须是正数时间必须是合法的时间点。可审计有了主叫、被叫、时间就能回溯某段时间内某个号码的所有通话记录。2.2 号码生成的业务规则构造模拟数据时号码不能随便写。虽然题目不要求绝对真实但为了后面测试方便最好让号码看起来“像真的”。国内手机号的基本规则是1开头第二位是3、4、5、6、7、8、9后面还有9位数字总共11位比如13812345678、15912345678。我生成主叫号码时用了这样一个方法private static String generateCallerNumber() { String[] secondDigits {3, 4, 5, 6, 7, 8, 9}; StringBuilder sb new StringBuilder(1); sb.append(secondDigits[random.nextInt(secondDigits.length)]); for (int i 0; i 9; i) { sb.append(random.nextInt(10)); } return sb.toString(); }被叫号码可以也用手机号但我建议混入一些固话或者特殊号码这样数据更接近真实场景。固话一般是“区号号码”比如01088886666、057188889999。特服号则有10086、4008008888之类的。生成被叫时我用了一个加权随机大部分时候生成手机号偶尔生成固话或者特服号。当然如果只是应付最基础的任务被叫和主叫用同一套手机号生成逻辑也完全没问题。关键是你自己要清楚这些号码是模拟的不是真实用户数据。在真实项目里如果要用人真实号码必须做脱敏处理这是另一个话题了。2.3 时长与时间的合理范围通话时长的单位是秒取值范围我一般控制在1到3600之间也就是最长一小时。为什么不定成最大86400秒因为实际业务里很少有一次通话超过一小时的场景而且过大的边界值容易让后续统计出现“异常亮点”。通话时间我选择了java.time.LocalDateTime来生成随机取最近30天内的任意一个时刻。具体做法是取当前日期时间随机减去0到30天随机减去0到23小时随机减去0到59分钟随机减去0到59秒这样做的好处是生成的数据不会“穿越”到未来也更像真实话单。3. 话单建模与模拟数据生成Java实现3.1 实体类BillingRecord设计用Java写这类任务第一步永远是定义一个实体类。有人喜欢把所有逻辑堆在main里虽然能跑但代码工程性很差。我建议单独创建一个BillingRecord.javaimport java.time.LocalDateTime; public class BillingRecord { private String caller; // 主叫号码 private String callee; // 被叫号码 private int duration; // 通话时长单位秒 private LocalDateTime callTime; // 通话时间 public BillingRecord(String caller, String callee, int duration, LocalDateTime callTime) { this.caller caller; this.callee callee; this.duration duration; this.callTime callTime; } public String getCaller() { return caller; } public String getCallee() { return callee; } public int getDuration() { return duration; } public LocalDateTime getCallTime() { return callTime; } Override public String toString() { return caller | callee | duration | callTime.format(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)); } }这里有一个重点我在toString()里直接拼接了文件行格式。这样做的好处是方便调试System.out.println(record)就直接打印出类似于13812345678|13912345678|120|2025-06-01 10:30:00的内容。坏处是实体类耦合了文件格式如果以后文件格式变了这个类也要改。对于这个小任务来说完全可以接受如果你追求更清晰的架构可以把格式化方法单独拆出去。3.2 随机数据生成器接下来是核心的生成器。我创建一个BillingGenerator.java里面写一个静态方法接收“要生成几条数据”的参数import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Random; public class BillingGenerator { private static final Random random new Random(); public static ListBillingRecord generateBillingRecords(int count) { ListBillingRecord list new ArrayList(); for (int i 0; i count; i) { String caller generateCallerNumber(); String callee generateCalleeNumber(); int duration generateDuration(); LocalDateTime callTime generateCallTime(); list.add(new BillingRecord(caller, callee, duration, callTime)); } return list; } private static String generateCallerNumber() { String[] secondDigits {3, 4, 5, 6, 7, 8, 9}; StringBuilder sb new StringBuilder(1); sb.append(secondDigits[random.nextInt(secondDigits.length)]); for (int i 0; i 9; i) { sb.append(random.nextInt(10)); } return sb.toString(); } private static String generateCalleeNumber() { int type random.nextInt(10); if (type 7) { // 70%概率生成手机号 return generateCallerNumber(); } else if (type 9) { // 20%概率生成以400开头的服务电话 return 400 String.format(%08d, random.nextInt(100000000)); } else { // 10%概率生成长途固话 int areaCode 10 random.nextInt(90); return 0 areaCode String.format(%07d, random.nextInt(10000000)); } } private static int generateDuration() { return 1 random.nextInt(3600); // 1到3600秒 } private static LocalDateTime generateCallTime() { LocalDateTime now LocalDateTime.now(); return now.minusDays(random.nextInt(30)) .minusHours(random.nextInt(24)) .minusMinutes(random.nextInt(60)) .minusSeconds(random.nextInt(60)); } }这里每个方法各管一件事代码可读性比全部塞在main里好很多。你可能会问random.nextInt(3600)为什么是0到3599而不是1到3600因为Random.nextInt(n)返回的是[0, n)区间的整数所以要生成1到3600必须写出1 random.nextInt(3600)。这个细节在调试时很容易被忽略先提醒一下。3.3 生成多条话单并跑起来现在写一个最简单的main方法生成10条话单并打印到控制台import java.util.List; public class Main { public static void main(String[] args) { ListBillingRecord records BillingGenerator.generateBillingRecords(10); for (BillingRecord record : records) { System.out.println(record); } } }运行结果可能长这样13812345678|01012345678|365|2025-06-18 14:22:31 15912345678|40012345678|89|2025-06-22 09:01:47 ...控制台能打印说明数据对象已经构造好了下一步就是把它们持久化到文件里。4. 写入billing.txt三种持久化写法对比4.1 最传统的FileWriter BufferedWriter这是教科书里最经典的写法import java.io.BufferedWriter; import java.io.FileWriter; import java.io.IOException; import java.util.List; public class BillingFileWriter { public static void writeWithFileWriter(ListBillingRecord records, String filePath) { try (BufferedWriter writer new BufferedWriter(new FileWriter(filePath))) { for (BillingRecord record : records) { writer.write(record.toString()); writer.newLine(); } } catch (IOException e) { e.printStackTrace(); } } }try-with-resources语法会在代码块结束后自动关闭流这是JDK 7之后的标准写法能避免忘记关闭连接导致的文件占用问题。writer.newLine()会根据当前操作系统自动生成换行符Windows下是\r\nLinux和macOS下是\n。4.2 更简洁的Files.write如果你不想处理BufferedWriter的细节可以用JDK 8之后提供的Files.writeimport java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.util.List; import java.util.stream.Collectors; public static void writeWithFiles(ListBillingRecord records, String filePath) throws IOException { ListString lines records.stream() .map(BillingRecord::toString) .collect(Collectors.toList()); Files.write(Paths.get(filePath), lines, StandardCharsets.UTF_8); }它把“字符串列表”一次性写入文件非常适合小数据量场景。我这里把IOException直接抛给调用方处理比在方法内部printStackTrace更干净。4.3 用PrintWriter实现带缓冲的写入第三种写法是用PrintWriter它最大的好处是支持println方法像打印控制台一样写文件import java.io.PrintWriter; import java.util.List; public static void writeWithPrintWriter(ListBillingRecord records, String filePath) { try (PrintWriter writer new PrintWriter(filePath, UTF-8)) { for (BillingRecord record : records) { writer.println(record); } } catch (Exception e) { e.printStackTrace(); } }三种写法的核心思路完全一样把对象转成一行字符串然后按行写入。我整理了一张对比表供你选型写方式代码量编码控制性能推荐场景FileWriter BufferedWriter中等需要额外指定好教学、自定义逻辑多Files.write很少简单清晰较好数据量小、追求简洁PrintWriter少构造时指定较好习惯println风格我实际使用时小任务喜欢用Files.write大任务会手动创建BufferedWriter做批量写入。不管用哪种编码必须统一这是最容易踩坑的地方。5. 实测最容易踩的坑编码、日期格式、分隔符与空行5.1 排查链路一中文乱码真凶有同学直接在Windows上运行打开billing.txt后发现中文注释或者字段内容变成了乱码。原因很明显FileWriter默认使用操作系统平台编码Windows中文版默认是GBK而你的代码文件可能是UTF-8。IDE控制台也是UTF-8最终拼出来的字符串和文件编码不一致乱码就产生了。解决方法是显式指定字符集不要依赖平台默认值。用OutputStreamWriter包一层import java.io.FileOutputStream; import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; try (OutputStreamWriter writer new OutputStreamWriter( new FileOutputStream(filePath), StandardCharsets.UTF_8)) { writer.write(record.toString()); writer.write(\n); }如果你用Files.write直接传StandardCharsets.UTF_8就行。我的习惯是不管在哪个平台写文件一律显式指定UTF-8。这样至少在跨平台测试时数据不会出现“为啥我本地能跑到服务器上全乱码”的问题。5.2 排查链路二时间全部变成1970-01-01生成数据时有一个典型错误把时间戳直接当成LocalDateTime去格式化。比如你从System.currentTimeMillis()拿到一个long类型的毫秒数然后期望它变成2025-06-18 14:22:31结果格式化出来全是1970-01-01附近的时间。这是时间基准问题。1970-01-01 00:00:00被称为Unix纪元毫秒数0对应的就是这个时间。如果你发现生成的话单时间全是1970年先检查两个地方你用来生成随机时间的“基准点”是不是new Date(0)或LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC)你格式化的是不是一个long值而不是Date或LocalDateTime对象正确做法是像我前面写的那样以LocalDateTime.now()为基准往回随机偏移或者取一个合理的时间范围。另外SimpleDateFormat里的yyyy和YYYY有区别yyyy是日历年份YYYY是“周周年份”在跨年那一周容易出现2025年显示成2024年的怪事。所以时间格式化尽量用yyyy-MM-dd HH:mm:ss不要图省事写YYYY。5.3 排查链路三多了一个分隔符或者最后多了一个空行很多新手写拼接字符串时喜欢在每条记录后面手动加“|”或者在写文件时循环里都加换行符结果文件尾部多出一个空行。这个空行看着无所谓但等你写程序逐行读回来解析时split(\\|)会得到只有空字符串的数组或者抛ArrayIndexOutOfBoundsException。我处理这个问题的经验是拼接时只在字段之间加|不要在开头或结尾额外加。写入文件时每条记录占一行用newLine()或println但不要自己在字符串尾部再拼\n。读文件时遇到空行直接跳过。更稳妥的做法是让实体类的toString()只负责生成“字段 分隔符”的中间内容真正换行交给写入工具类。这样职责清晰出问题也好定位。5.4 处理建议汇总问题根因解决手段文件内容中文乱码平台默认编码不一致统一使用UTF-8时间变成1970时间基准错误或格式化对象错误基于LocalDateTime.now()或明确指定纪元文件末尾多空行在字符串里手动加换行换行交给newLine()/println分隔符解析出错字段里混入或行尾空格程序终止但文件被占用流未关闭使用try-with-resources6. 读回billing.txt校验数据、统计指标与真实计费场景的启发6.1 读回文件的校验步骤文件写完不是终点我还要把billing.txt读回来确认数据没有丢、格式没有乱。这一步我把它叫做“自校验”。只有读回来能正确解析才算真正完成了持久化。import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.util.List; public static void validateFile(String filePath) throws Exception { ListString lines Files.readAllLines(Paths.get(filePath), StandardCharsets.UTF_8); System.out.println(文件总行数: lines.size()); int validCount 0; for (String line : lines) { if (line null || line.trim().isEmpty()) { continue; } String[] fields line.split(\\|); if (fields.length 4) { validCount; } else { System.out.println(异常行: line); } } System.out.println(合法话单条数: validCount); }注意line.split(\\|)竖线在正则表达式里是“或”的意思直接split(|)会把字符串拆成单个字符所以必须写成\\|。这一点是新手最容易踩的隐藏坑。6.2 简单统计指标计算校验通过后可以做几个简单统计验证数据是否符合预期总话单条数所有通话时长总和平均通话时长最长时间通话通话时间范围这些统计看似简单但它们是后续所有计费逻辑的基础。比如你要算“某主叫号码总费用”就是先按主叫分组再把时长乘以费率。代码实现上可以用MapString, Integer按主叫聚合这是Java入门阶段的常见练习。6.3 从练习任务到真实话单系统做完这个小任务后我建议你往真实场景再迈一步真实运营商的话单字段远远不止这四个。通常还会有IMSI、IMEI、基站编号、呼叫类型、通话状态、计费标识等字段。格式上也不一定是竖线分隔的文本可能会用二进制编码或者标准协议传输从而减少存储体积、提高处理速度。但不管系统怎么演进话单的“四个基本盘”始终没变谁打给谁、打了多久、什么时候打的。理解了这个基本盘你再去看billing.txt这种练习任务会发现它正是真实计费系统的一个最小切片。我在实际处理数据文件时养成的一个习惯是写文件之前先定义好格式规范写完之后立刻读一遍校验。这个习惯能帮你省掉大量排查时间。因为很多时候数据丢失、字段错位、编码混乱早发现一分钟就能少查一个小时的日志。这个小任务做到这里你已经能构造话单、写入文件、读回校验整个闭环走通了。接下来可以根据自己的需要把数据量放大到十万条、百万条看看写入耗时和内存占用有什么变化也可以试着按主叫号码做话单聚合模拟一个最简单的计费输出。我曾经在一次测试环境联调中就是用这样一份自己生成的billing.txt把下游计费引擎的流程从零跑到了全通省去了等上游真实数据的时间。造数据这个能力真的值得好好练一练。