其他分享
首页 > 其他分享> > 使用NTP完成对主机的时钟同步

使用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