バックグラウンド
一部のビジネスシナリオでは、ファイルが変更されたかどうかを監視したり、ファイルが変更されたときにファイルの内容を再読み込みしたりするなど、ファイルの内容の変更を自分で監視する機能を実装する必要があります。
比較的単純な機能のようですが、特定のJDKバージョンでは、予期しないバグが発生する場合があります。
この記事では、対応する関数の簡単な実装を紹介し、対応するバグと長所と短所を分析します。
初期実現のアイデア
ファイルの変更を監視してファイルを読み取るための簡単なアイデアは次のとおりです。
- シングルスレッドを開始し、ファイルの最後の更新のタイムスタンプを定期的に取得します(単位:ミリ秒)。
- 最後のタイムスタンプを比較します。一貫性がない場合は、ファイルが変更されたことを意味し、ファイルをリロードします。
これは、単純な関数実装のデモです(時限タスク部分を除く)。
public class FileWatchDemo {
/**
* 上次更新时间
*/
public static long LAST_TIME = 0L;
public static void main(String[] args) throws IOException {
String fileName = "/Users/zzs/temp/1.txt";
// 创建文件,仅为实例,实践中由其他程序触发文件的变更
createFile(fileName);
// 执行2次
for (int i = 0; i < 2; i++) {
long timestamp = readLastModified(fileName);
if (timestamp != LAST_TIME) {
System.out.println("文件已被更新:" + timestamp);
LAST_TIME = timestamp;
// 重新加载,文件内容
} else {
System.out.println("文件未更新");
}
}
}
public static void createFile(String fileName) throws IOException {
File file = new File(fileName);
if (!file.exists()) {
boolean result = file.createNewFile();
System.out.println("创建文件:" + result);
}
}
public static long readLastModified(String fileName) {
File file = new File(fileName);
return file.lastModified();
}
}
上記のコードでは、最初にファイルを作成し(テストに便利)、次にファイルの変更時刻を2回読み取り、最後の変更時刻をLAST_TIMEで記録します。ファイルの最新の変更時刻が前回の変更時刻と一致しない場合は、変更時刻が更新され、業務処理が行われます。
サンプルコードのforループは、変更の2つのケースと変更なしの2つのケースを示すためのものです。プログラムを実行し、次のようにログを印刷します。
文件已被更新:1653557504000
文件未更新
実行結果は期待通りです。
このソリューションには明らかに2つの欠点があります。
- ファイルの変更をリアルタイムで認識することは不可能です。結局のところ、プログラムのローテーションには時間差があります。
- lastModifiedによって返される時間単位はミリ秒です。同じミリ秒で2つの変更が発生し、スケジュールされたタスククエリが2つの変更の間にある場合、後者の変更は認識できません。
最初の不利な点はビジネスにほとんど影響を与えません。2番目の不利な点の可能性は比較的小さく、無視できます。
JDKバグデビュー
上記のコードが実装されており、通常は問題ありませんが、使用しているJavaのバージョンが8または9の場合、JDK自体のバグが原因で予期しないバグが発生する可能性があります。
JDK-8177809という番号のバグは次のように説明されています。
バグアドレスは次のとおりです。bugs.java.com/ bugdatabase…
このバグの基本的な説明は次のとおりです。Java8および9の一部のバージョンでは、lastModifiedメソッドによって返されるタイムスタンプはミリ秒ではなく秒です。つまり、返される結果の最後の3桁は常に0です。
検証するプログラムを書いてみましょう。
public class FileReadDemo {
public static void main(String[] args) throws IOException, InterruptedException {
String fileName = "/Users/zzs/temp/1.txt";
// 创建文件
createFile(fileName);
for (int i = 0; i < 10; i++) {
// 向文件内写入数据
writeToFile(fileName);
// 读取文件修改时间
long timestamp = readLastModified(fileName);
System.out.println("文件修改时间:" + timestamp);
// 睡眠100ms
Thread.sleep(100);
}
}
public static void createFile(String fileName) throws IOException {
File file = new File(fileName);
if (!file.exists()) {
boolean result = file.createNewFile();
System.out.println("创建文件:" + result);
}
}
public static void writeToFile(String fileName) throws IOException {
FileWriter fileWriter = new FileWriter(fileName);
// 写入随机数字
fileWriter.write(new Random(1000).nextInt());
fileWriter.close();
}
public static long readLastModified(String fileName) {
File file = new File(fileName);
return file.lastModified();
}
}
在上述代码中,先创建一个文件,然后在for循环中不停的向文件写入内容,并读取修改时间。每次操作睡眠100ms。这样,同一秒就可以多次写文件和读修改时间。
执行结果如下:
文件修改时间:1653558619000
文件修改时间:1653558619000
文件修改时间:1653558619000
文件修改时间:1653558619000
文件修改时间:1653558619000
文件修改时间:1653558619000
文件修改时间:1653558620000
文件修改时间:1653558620000
文件修改时间:1653558620000
文件修改时间:1653558620000
修改了10次文件的内容,只感知到了2次。JDK的这个bug让这种实现方式的第2个缺点无限放大了,同一秒发生变更的概率可比同一毫秒发生的概率要大太多了。
PS:在官方Bug描述中提到可以通过Files.getLastModifiedTime来实现获取时间戳,但笔者验证的结果是依旧无效,可能不同版本有不同的表现吧。
更新解决方案
Java 8目前是主流版本,不可能因为JDK的该bug就换JDK吧。所以,我们要通过其他方式来实现这个业务功能,那就是新增一个用来记录文件版本(version)的文件(或其他存储方式)。这个version的值,可在写文件时按照递增生成版本号,也可以通过对文件的内容做MD5计算获得。
如果能保证版本顺序生成,使用时只需读取版本文件中的值进行比对即可,如果变更则重新加载,如果未变更则不做处理。
如果使用MD5的形式,则需考虑MD5算法的性能,以及MD5结果的碰撞(概率很小,可以忽略)。
下面以版本的形式来展示一下demo:
public class FileReadVersionDemo {
public static int version = 0;
public static void main(String[] args) throws IOException, InterruptedException {
String fileName = "/Users/zzs/temp/1.txt";
String versionName = "/Users/zzs/temp/version.txt";
// 创建文件
createFile(fileName);
createFile(versionName);
for (int i = 1; i < 10; i++) {
// 向文件内写入数据
writeToFile(fileName);
// 同时写入版本
writeToFile(versionName, i);
// 监听器读取文件版本
int fileVersion = Integer.parseInt(readOneLineFromFile(versionName));
if (version == fileVersion) {
System.out.println("版本未变更");
} else {
System.out.println("版本已变化,进行业务处理");
}
// 睡眠100ms
Thread.sleep(100);
}
}
public static void createFile(String fileName) throws IOException {
File file = new File(fileName);
if (!file.exists()) {
boolean result = file.createNewFile();
System.out.println("创建文件:" + result);
}
}
public static void writeToFile(String fileName) throws IOException {
writeToFile(fileName, new Random(1000).nextInt());
}
public static void writeToFile(String fileName, int version) throws IOException {
FileWriter fileWriter = new FileWriter(fileName);
fileWriter.write(version +"");
fileWriter.close();
}
public static String readOneLineFromFile(String fileName) {
File file = new File(fileName);
String tempString = null;
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
//一次读一行,读入null时文件结束
tempString = reader.readLine();
} catch (IOException e) {
e.printStackTrace();
}
return tempString;
}
}
执行上述代码,打印日志如下:
版本已变化,进行业务处理
版本已变化,进行业务处理
版本已变化,进行业务处理
版本已变化,进行业务处理
版本已变化,进行业务处理
版本已变化,进行业务处理
版本已变化,进行业务处理
版本已变化,进行业务处理
版本已变化,进行业务处理
可以看到,每次文件变更都能够感知到。当然,上述代码只是示例,在使用的过程中还是需要更多地完善逻辑。
小结
本文实践了一个很常见的功能,起初采用很符合常规思路的方案来解决,结果恰好碰到了JDK的Bug,只好变更策略来实现。当然,如果业务环境中已经存在了一些基础的中间件还有更多解决方案。
而通过本篇文章我们学到了JDK Bug导致的连锁反应,同时也见证了:实践见真知。很多技术方案是否可行,还是需要经得起实践的考验才行。赶快检查一下你的代码实现,是否命中该Bug?
博主简介:《SpringBoot技术内幕》技术图书作者,酷爱钻研技术,写技术干货文章。
公众号:「程序新视界」,博主的公众号,欢迎关注~
技术交流:请联系博主微信号:zhuan2quan