使用NTP完成对主机的时钟同步
作者:互联网
使用NTP完成对主机的时钟同步
项目简介
网络时间协议,英文名称:Network Time Protocol(NTP)是用来使计算机时间同步化的一种协议,它可以使计算机对其服务器或时钟源(如石英钟,GPS 等等)做同步化。它建立在 UDP 协议上,端口号为123,在无序的 Internet 环境中提供了精确和健壮的时间服务。
NTP 的实现原理并不是本文章讨论的主要问题。读者有兴趣的话,可以自行搜索其原理实现。
理论上来讲,只要在目标服务器上安装 NTP 服务,任何接入互联网的其他主机都能够直接与该服务器进行时钟同步。
而笔者最近介入的项目中,甲方想要通过该协议,完成其客户的服务器主机定时与甲方服务器主机的时钟同步。
该方案中,后端采用 Vert.x 框架,提供强大的异步事件驱动功能,核心功能是前端发起的手工同步(客户服务器向甲方服务器发起的时钟同步请求),而在此基础上的自动同步则直接使用线程池的定时功能(ScheduledExecutorService)调用该接口。为了保证该功能的安全性,搭配一套登录验证功能以及 Vert.x 框架自带的抵御 CSRF 攻击的 CSRFHandler。
值得一提的是,由于NTP会修改本机服务器的时钟,而 Quartz 的定时任务严重依赖于本地时间,因此并不适合用 Quartz 来为 NTP 同步设置定时任务。事实上,用 ScheduledExecutorService 足矣。
这篇文章,主要是简单描述该核心功能在 Linux 服务器上的 Java 实现。
功能实现
1. 系统命令调用
系统命令调用是实现该功能最简单的方法。直接调用 java.lang.Runtime 类中的 exec() 方法即可:
Process process = Runtime.getruntime().exec(cmd);
在 Linux 服务器上直接执行的命令 cmd 可以直接放入 exec() 方法参数中。比如,想要查询 Linux 服务器的本地时间,可以通过以下命令实现:
而是用 exec() 方法执行的 Java demo 的演示如下:
Process process = Runtime.getRuntime().exec("date");
// 等待该子进程运行结束。returnValue为0表示该进程正常结束
int returnValue = process.waitFor();
System.out.println("returnValue = " + returnValue);
InputStream inputStream = process.getInputStream();
// 通过字符流读取缓冲池的内容
BufferedReader br = new BufferedReader(new InputStreamReader(inputStream));
String line = null;
while ((line = br.readLine()) != null) {
System.out.println("line = " + line);
}
将该 demo 打成 jar 包丢到服务器上运行,可以得到如下结果:
关于打 jar 包的方法,有一篇实践简单但操作性比较强的博客供读者参考:https://blog.csdn.net/kelekele111/article/details/123047189。
有了以上的实践基础,我们可以拓展出我们所需要的功能实现。
注意到 Linux 上执行 NTP 同步的命令为(注意,这里只是使用到了其最基础的功能,读者可以自行搜索 ”ntpdate“ 命令,或者在命令行中输入”man ntpdate“,或者”info ntpdate“,来获取更多参数设置方法。推荐阅读:https://www.tutorialspoint.com/unix_commands/ntpdate.htm):
# ntpdate "IP"
ntpdate asia.pool.ntp.org
ntpdate "17.253.84.253"
该命令的返回结果如下所示:
这里的 offset 是通过一定的公式推导得到的结果,表示的是本地服务器时钟与目标服务器时钟之间的时间差,单位为秒(如下图所示)。想要了解该公式推导过程的读者,可以自行搜索了解(推荐阅读:https://www.eecis.udel.edu/~mills/time.html)。
值得注意的是,NTP 只会给请求发起的主机返回 offset,而不会直接提供同步之后的时间戳。因此,如果想要手动通过相关系统调用来修改本地时钟,设置的目标时间戳应该是当前时间戳加上 offset:
Long goalDate = System.currentTimeMillis() + offset;
Date date = new Date(goalDate);
而 ”ntpdate“ 命令这个 Linux 的内核调用,已经将上述步骤囊括其中,不需要再进行额外操作。也就是说,只要执行 ”ntpdate“ 命令,便可直接完成时钟同步。
实际生产中,一般可以将 offset 单独提取出来,用来作为是否显示同步日志的判断根据。这个 offset 的提取,通过解析该命令的返回值实现(后面有代码展示)。
因为,如果每次同步都要给前端返回同步日志,显然会导致同步日志次数过多而导致用户产生错误的判断,认为程序运行存在问题。可以设置一个阈值,比如 20 ms。当 offset 大于该阈值时,同步日志才会返回给前端;否则则不返回。注意:该阈值的设置,与是否同步是没有任何关系的。
结合以上的知识,可以将该程序代码展示如下:
package com.max.runtime.solution;
import java.io.IOException;
import java.io.InputStream;
/**
* @author siyuan
* @description 执行本地Linux命令的工具类
*/
public class LinuxCommandUtil {
/**
* @description 默认执行本地Linux命令
*/
public static String executeLocalLinuxCommand(String[] command) {
Runtime runtime = Runtime.getRuntime();
StringBuilder result = new StringBuilder();
try {
Process process = runtime.exec(command);
try {
// 等待,在该线程上阻塞,直至该线程执行完毕
// 也可以获取该方法的返回值的int型变量,该变量为0时表示正常结束
process.waitFor();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 获取子进程的输入流
InputStream inputStream = process.getInputStream();
byte[] data = new byte[1024];
while (inputStream.read(data) != -1) {
result.append(new String(data, "UTF-8"));
}
if (result.toString().equals("")) {
// 获取子进程的错误流
InputStream errorStream = process.getErrorStream();
while (errorStream.read(data) != -1) {
result.append(new String(data, "UTF-8"));
}
}
// 子进程运行结束,将它摧毁
process.destroy();
return result.toString();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
package com.max.runtime.solution;
import org.apache.commons.lang3.StringUtils;
import java.util.Arrays;
import java.util.Date;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class Main {
private final static int CORE_POOL_SIZE = 2;
private final static long INITIAL_DELAY = 0;
private final static long PERIODIC = 10;
// 以向域名为"asia.pool.ntp.org"的服务器进行同步为例
private final static String[] COMMAND = {"ntpdate", "asia.pool.ntp.org"};
public static void main(String[] args) {
final ScheduledExecutorService scheduledExecutorService = Executors
.newScheduledThreadPool(CORE_POOL_SIZE);
if (StringUtils.containsIgnoreCase(System.getProperty("os.name"), "Linux")) {
System.out.println("该程序在Linux操作系统上运行");
// 调用scheduledAtFixedRate()方法执行定时任务,该定时任务每10 s进行一次时钟同步
scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
public void run() {
String res = LinuxCommandUtil.executeLocalLinuxCommand(COMMAND);
System.out.println(res);
if (res == null) {
throw new RuntimeException("Linux执行命令: " + Arrays.toString(COMMAND) + "失败.");
}
Long gap = ntpGetGap(res);
if (gap == null) {
throw new RuntimeException("偏移量offset解析失败.");
}
System.out.println("服务器时钟与本地时钟之间时间差为: " + (gap >= 0 ? gap : -gap) + "ms");
Date serverTime = new Date(System.currentTimeMillis() + gap);
System.out.println("本地时间为: " + new Date());
System.out.println("服务器时间为: " + serverTime);
}
}, INITIAL_DELAY, PERIODIC, TimeUnit.SECONDS);
} else {
throw new RuntimeException("该程序暂不支持在Windows操作系统上运行!");
}
}
/**
* @description 解析命令返回的String类型的参数
*/
private static Long ntpGetGap(String res) {
String[] s;
try {
s = res.split(" ");
} catch (Exception e) {
throw new RuntimeException("Linux命令执行结果解析失败!");
}
String stringGap;
Long gap;
try {
// 截取返回结果的倒数第二个字段,即所谓的偏移量offset
stringGap = s[s.length - 2].trim();
Number num = 1000 * Float.parseFloat(stringGap);
gap = (long) num.intValue();
} catch (Exception e) {
throw new RuntimeException("解析时间差offset失败!");
}
return gap;
}
}
有一个问题,在 main 方法中,为什么命令 COMMAND 以字符串数组的形式表示呢?直接写成如下的字符串是否也可行?
private final static String COMMAND = "ntpdate asia.pool.ntp.org";
实践表明,这样写是没问题的。实际上,Runtime.getRuntime.exec() 有六个重载方法,其中的两个,参数分别是 String 与 String[]。关于这些重载方法的用法,以及 Runtime 类其他方法的使用,读者可以自行在源码中查看、了解。
将上述代码打成 jar 包丢到服务器上运行,读取日志截图如下:
为了验证该程序的确在正常运行,可以手动修改一下本地时间(注意:该命令的执行必须有 root 权限,否则无法成功修改时间,因此需加上 sudo):
sudo date -s "2022-01-01 00:00:00"
显然,”ntpdate“ 命令能够正常工作。
到此为止,一个简单的 NTP 同步 demo 就完成了。
2. JNA调用
上述 demo,实现起来非常简单。”ntpdate“ 命令进行系统调用,在获取偏移量 offset 的同时,直接将本地时钟与目标服务器时钟同步,该实现对于 Java 程序员是透明的。而且,在生产环境中直接通过 Runtime.getRuntime.exec() 运行命令,是有一定风险的。甲方也禁止这么操作。既然如此,我们能否自己写一段代码,使得同步过程为我们所掌控呢?
(以下内容译自 GitHub 上的项目介绍:https://github.com/java-native-access/jna)
JNA 是 Java Native Access 的缩写,它使得 Java 程序可以轻松访问本地共享库,无需编写 Java 代码以外的任何内容——不需要 JNI 或原生代码。此功能可与 Windows 的 Platform/Invoke 和 Python 的 ctypes 相媲美。
JNA 允许开发者使用原汁原味的 Java 方法调用,以直接调用原生函数——这种调用,跟直接调用 Java 方法一样简单。大多数调用不需要特殊的处理或配置,也不需要样板文件或生成的代码(generated code)。
JNA 使用小型 JNI 库存根(stub)来动态调用原生代码。开发人员使用 Java 接口来描述目标本地库中的函数和结构。这使得利用本机平台特性变得非常容易,同时不会因为多个平台配置和构建 JNI 代码而产生较高开销。
点击同一页面上的 Getting Started,我们可以看到 JNA 在调用标准 C 库的一段代码:
package com.sun.jna.examples;
import com.sun.jna.Library;
import com.sun.jna.Native;
import com.sun.jna.Platform;
/** Simple example of JNA interface mapping and usage. */
public class HelloWorld {
// This is the standard, stable way of mapping, which supports extensive
// customization and mapping of Java to native types.
public interface CLibrary extends Library {
CLibrary INSTANCE = (CLibrary)
Native.load((Platform.isWindows() ? "msvcrt" : "c"),
CLibrary.class);
void printf(String format, Object... args);
}
public static void main(String[] args) {
CLibrary.INSTANCE.printf("Hello, World\n");
for (int i=0;i < args.length;i++) {
CLibrary.INSTANCE.printf("Argument %d: %s\n", i, args[i]);
}
}
}
显然,这里调用的是 libc 中的 printf 函数。
在 Linux 操作系统上,对于 C 语言标准库函数的调用核心代码:
CLibrary INSTANCE = (CLibrary) Native.load("c", CLibrary.class);
我们这里只讨论 Linux 操作系统,Windows 操作系统也可以通过 JNA 进行调用。有兴趣的读者可以结合搜索引擎,参考以下代码学习:
package com.sun.jna.examples.win32;
import com.sun.jna.*;
// kernel32.dll uses the __stdcall calling convention (check the function
// declaration for "WINAPI" or "PASCAL"), so extend StdCallLibrary
// Most C libraries will just extend com.sun.jna.Library,
public interface Kernel32 extends StdCallLibrary {
// Method declarations, constant and structure definitions go here
Kernel32 INSTANCE = (Kernel32)Native.load("kernel32", Kernel32.class);
// Optional: wraps every call to the native library in a
// synchronized block, limiting native calls to one at a time
Kernel32 SYNC_INSTANCE = (Kernel32)
Native.synchronizedLibrary(INSTANCE);
}
在 Linux 系统中,设置本地时间的函数是 settimeofday。与之搭配使用的是 gettimeofday。我们可以通过这两个函数,完成对于 Linux 系统本地时间的设置。实现的代码如下:
package com.max.method.call.impl;
import com.max.method.call.JNative;
import java.util.Date;
import com.sun.jna.Library;
import com.sun.jna.Native;
import com.sun.jna.NativeLong;
import com.sun.jna.Structure;
public class LinuxImpl implements JNative {
public static class TM extends Structure{
public static class ByReference extends TM implements Structure.ByReference{}
public static class ByValue extends TM implements Structure.ByValue{}
public int tm_sec;//seconds 0-61
public int tm_min;//minutes 1-59
public int tm_hour;//hours 0-23
public int tm_mday;//day of the month 1-31
public int tm_mon;//months since jan 0-11
public int tm_year;//years from 1900
public int tm_wday;//days since Sunday, 0-6
public int tm_yday;//days since Jan 1, 0-365
public int tm_isdst;//Daylight Saving time indicator
}
// 第一个参数,对应结构体timeval
public static class TimeVal extends Structure{
public static class ByReference extends TimeVal implements Structure.ByReference{}
public static class ByValue extends TimeVal implements Structure.ByValue{}
public NativeLong tv_sec; /* 秒数 */
public NativeLong tv_usec; /* 微秒数 */
}
// 第二个参数,对应结构体timezone
public static class TimeZone extends Structure{
public static class ByReference extends TimeZone implements Structure.ByReference{}
public static class ByValue extends TimeZone implements Structure.ByValue{}
public int tz_minuteswest;
public int tz_dsttime;
}
// 调用本地共享库函数的接口
public interface CLibrary extends Library{
int gettimeofday(TimeVal.ByReference tv, TimeZone.ByReference tz);
int settimeofday(TimeVal.ByReference tv, TimeZone.ByReference tz);
}
public static CLibrary cLibraryInstance = null;
public LinuxImpl(){
cLibraryInstance = (CLibrary)Native.loadLibrary("c", CLibrary.class);
}
public int setLocalTime(Date date) {
long ms = date.getTime();
long s = ms / 1000; //秒
long us = (ms % 1000) * 1000; //微秒
TimeVal.ByReference tv = new TimeVal.ByReference();
TimeZone.ByReference tz = new TimeZone.ByReference();
cLibraryInstance.gettimeofday(tv, tz);
tv.tv_sec = new NativeLong(s);
tv.tv_usec = new NativeLong(us);
// 返回值为0时,表示成功;为-1时表示失败
return cLibraryInstance.settimeofday(tv, tz);
}
}
package com.max.method.call;
import java.util.Date;
public interface JNative {
/**
* 设置系统时间的方法
* @param date Date
*/
int setLocalTime(Date date);
}
package com.max.method.call.factory;
import com.max.method.call.JNative;
import com.max.method.call.impl.LinuxImpl;
import com.sun.jna.Platform;
/**
* 根据操作系统的不同,生成对应的、调用本地方法的工厂
*/
public class NativeFactory {
public static JNative newNative() {
if (Platform.isLinux()) {
return new LinuxImpl();
} else {
// 暂不讨论 Windows 系统
return null;
}
}
}
创建一个调用本地共享库的接口 JNative ,并由 LinuxImpl 去实现它。在 LinuxImpl中,我们只需要关注两个内部类,即 TimeVal 与 TimeZone,和一个内部接口 CLibrary。这两个内部类都继承自 Structure 类,分别对应于 libc 中 settimeofday函数与 gettimeofday 函数中的俩参数(以 gettimeofday 为例):
int gettimeofday ( struct timeval *tp , struct timezone *tz )
第一个参数为结构体 timeval,在 <sys/time>.h 头文件中声明如下:
struct timeval {
time_t tv_sec; //used for seconds
suseconds_t tv_usec; //used for microseconds
}
注意到 TimeVal 中的两个成员变量,都是 NativeLong 类型的。因为 timeval 结构体中俩参数的数据类型就是 long。
第二个参数为结构体 timezone。由于该结构体已经过时,因此一般默认缺省为 null ——该结构体的存在,只是考虑到向后兼容性。
内部接口 CLibrary 调用了 gettimeofday 与 settimeofday 这两个函数,声明之后可以直接使用。
我们关注的重点是 setLocalTime() 方法:它通过 gettimeofday 函数获取本地时间,再通过 settimeofday 函数将传入的参数 date 赋值给本地时间,从而完成本地时间的设置。如果没有 gettimeofday,只能喜提 ”time out“ 的异常了。
读者可能注意到了 LinuxImpl 类中没有使用到的一个内部类 TM。它同样继承了 Structure 类,原型是 libc 的结构体 tm。它是否也能用于设置 Linux 的本地时间呢?
实际上,它也可以用来设置 Linux 的本地时间。但它的使用稍微复杂些,而且不是我们讨论的重点,此处只贴出使用 libc 的 mktime 函数(将结构体 tm 存储的时间转换为自纪元(the Epoch)以来的秒数)调用方法供参考:
public long getLocalTime(Date date) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
TM.ByReference byReference = new TM.ByReference();
byReference.tm_year = calendar.get(Calendar.YEAR);
byReference.tm_mon = calendar.get(Calendar.MONTH) + 1;
byReference.tm_mday = calendar.get(Calendar.DATE);
byReference.tm_hour = calendar.get(Calendar.HOUR_OF_DAY);
byReference.tm_min = calendar.get(Calendar.MINUTE);
byReference.tm_sec = calendar.get(Calendar.SECOND);
return cLibraryInstance.mktime(byReference);
}
注意:tm_mon 的取值范围是 0-11,因此要表示月份的时候,必须再加上 1。
到此为止,我们已经学会了如何使用函数调用的方式设置 Linux 系统本地时间。
而 NTP 实现的时钟同步代码,需要我们手动实现。我们已经在”系统命令调用“一节,讲清了 NTP 时钟同步的逻辑了:获取与服务器之间的时间差 offset,由此设置同步后的本地时间:
Long goalDate = System.currentTimeMillis() + offset;
Date date = new Date(goalDate);
同样地,我们在 main 函数中写一个定时任务来实现 NTP 同步:
package com.max.method.call;
import com.max.runtime.solution.NTPUtil;
import com.max.method.call.factory.NativeFactory;
import com.sun.jna.Native;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Date;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.apache.commons.net.ntp.NTPUDPClient;
import org.apache.commons.net.ntp.TimeInfo;
public class Main {
private final static int CORE_POOL_SIZE = 2;
private final static long INITIAL_DELAY = 0;
private final static long PERIODIC = 10;
private static JNative localImpl = NativeFactory.newNative();
private static TimeInfo timeInfo;
private static Long offset;
public static void main(String[] args) {
final ScheduledExecutorService scheduledExecutorService = Executors
.newScheduledThreadPool(CORE_POOL_SIZE);
final NTPUDPClient client = NTPUtil.getClient();
// 过期时间为10 s
client.setDefaultTimeout(10 * 1000);
InetAddress inetAddress = null;
try {
String SERVER_NAME = "asia.pool.ntp.org";
inetAddress = InetAddress.getByName(SERVER_NAME);
} catch (UnknownHostException e) {
e.printStackTrace();
}
final InetAddress finalInetAddress = inetAddress;
// 线程池定时任务
scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
public void run() {
try {
timeInfo = client.getTime(finalInetAddress);
} catch (IOException e) {
e.printStackTrace();
}
// computeDetails之后获得offset
timeInfo.computeDetails();
if (timeInfo.getOffset() != null) {
offset = timeInfo.getOffset();
} else {
throw new RuntimeException("无法获取offset!");
}
System.out.println("本地时钟与服务器时钟之间时间差为: " + offset + " ms");
Date currentDate = new Date();
System.out.println("本地时间为: " + currentDate);
long thisTime = System.currentTimeMillis() + offset;
Date now = new Date(thisTime);
System.out.println("服务器时间为: " + now);
// 通过 JNA,设置本地时间
try {
int returnValue = localImpl.setLocalTime(now);
if (returnValue != 0) {
// 如果返回值为-1,说明设置本地时间失败。一般来说,此时返回的错误码应该是1,表示权限不够。
throw new RuntimeException("设置本地时间失败!失败码为" + Native.getLastError());
}
System.out.println("本次时钟同步成功!");
} catch (Exception e) {
e.printStackTrace();
}
}
}, INITIAL_DELAY, PERIODIC, TimeUnit.SECONDS);
}
}
如果该程序顺利执行,我们将得到下图所示的结果:
实际上,如果我们打成的 jar 包运行在 Ubuntu 系统上,我们会获取以下的报错信息。
如果我们顺着 "invalid ELF header" 这个关键词去查找问题的解决方案,恐怕得不到正确的结论。
帮助我解决这个问题的,是组内的一位 C++ 大佬。他建议我使用链接 librt 而不是 libc,于是我将核心代码修改如下(顺带将类名也作了修改):
public static RTLibrary rtLibraryInstance = null;
public LinuxImpl() {
rtLibraryInstance = (RTLibrary) Native.loadLibrary("rt", RTLibrary.class);
}
修改完成。在自己的服务器上跑通了之后,我又将这段代码整合进项目代码中。公司使用的 Linux 服务器是 Redhat,无论链接 libc 还是 librt,期待出现的运行结果都出现了。类似地,我们依然使用 date -s 指令”捣蛋“:
对于 Ubuntu 和 Redhat 之间表现差异,我忍不住跑去 Oracle 官网上查询,这两个库之间的联系与区别。链接贴在这里:https://docs.oracle.com/cd/E86824_01/html/E54772/index.html
根据库函数列表,我们可以发现, gettimeofday 与 settimeofday 这俩函数仅存在于 libc 中。不过这两个库之间的关系,官网上有一段话介绍:
This functionality(librt) now resides in libc.This library is maintained to provide backward compatibility for both runtime and compilation environments. The shared object is implemented as a filter on libc.so.1. New application development need not specify `–lrt`.
即: librt 现在存在于 libc 中,用于提供运行时与编译时环境的向后兼容,作为 libc.so.1 的过滤器而存在。
再回到 Ubuntu 和 Red Hat 这俩系统间的区别上。在 Ubuntu 系统中,我们进入 libc 所在的目录 /usr/lib/x86_64-linux-gnu ,找到 libc.so 与 librt.so,进入查看:
类似地,Red Hat 系统上的 libc.so 与 librt.so 查到的内容与之相同。
由此看来,尽管内容一样,比起 Red Hat,Ubuntu 上的 libc.so 显然只是一个”假库“。这是否是作为企业版应用的 Red Hat 与免费版的 Ubuntu 之间一个差别呢?
在 Linux 上以非 root 用户运行 jar 包时,同样需要加 sudo,否则我们会发现,本地时间的修改是无法实现的。此时,方法 Native.getLastError() 会给我们返回错误码 1,表示权限不够。
sudo nohup java -jar linux-ntpdate.jar > hup.out
结语
以上便是本次文章分享的所有内容了。写这篇文章贼累,毕竟笔者只是一个”半路出家“的 Java coder,没有认真而系统学过 C/C++ 相关的知识,面对跨语言的函数(方法)调用时经常遇到一些无法理解的问题,表达上可能会有许多不严谨之处。
不管如何,还是希望这篇文章能够帮助到有需要的读者。
标签:int,NTP,new,static,主机,import,com,public,时钟 来源: https://www.cnblogs.com/max2022/p/16413062.html