
时间比较为什么容易踩坑
先看一个很常见的需求:判断当前时间是否在每天晚上的23:00到第二天凌晨06:00之间。如果直接用strtotime('23:00')和strtotime('06:00')比较,会得到两个基于当天日期的完整时间戳。当当前时间跨过午夜时,06:00对应的日期已经变成了第二天,但代码中仍然拿到的是当天的时间戳,于是23:00的数值反而大于06:00的数值。条件$now >= $start && $now <= $end永远不成立,导致夜间时段判断失败。
不少开发者第一反应是判断两个时间点的大小,如果开始时间大于结束时间就自动加上86400秒。这种做法在固定时区、没有夏令时切换的场景下确实能用,但一旦服务器时区涉及夏令时,日期加减86400秒可能产生偏差。另外代码里到处写+ 86400会让逻辑变得分散,后续维护时容易忘记跨天的边界条件。更稳妥的思路是把时间部分单独提取出来,变成一天内的相对秒数,再根据业务规则灵活处理。
把时间转换为一天内的秒数
PHP的DateTime对象提供了format('H')、format('i')、format('s')等方法,可以分别取小时、分钟、秒。把它们相乘相加就得到了从当天00:00:00开始经过的总秒数。例如$seconds = $hour * 3600 + $minute * 60 + $second。这个数值范围是0到86399,任何时间点都能映射到一个整数,比较起来非常直观。
function timeToSeconds(string $time): int
{
$parts = explode(':', $time);
$hour = (int)($parts[0] ?? 0);
$minute = (int)($parts[1] ?? 0);
$second = (int)($parts[2] ?? 0);
return $hour * 3600 + $minute * 60 + $second;
}
$start = timeToSeconds('23:00:00'); // 82800
$end = timeToSeconds('06:00:00'); // 21600
$now = (int)date('G') * 3600 + (int)date('i') * 60 + (int)date('s');
有了秒数之后,跨零点区间的判断就变得清晰:如果开始秒数小于等于结束秒数,说明区间没有跨天,直接判断$now >= $start && $now <= $end;如果开始秒数大于结束秒数,说明区间跨越了午夜,此时当前时间要么大于等于开始秒数,要么小于等于结束秒数。这个逻辑可以用一个简洁的条件表达:$inRange = ($start <= $end) ? ($now >= $start && $now <= $end) : ($now >= $start || $now <= $end);。这样完全避开了日期加减,也不依赖具体时区的夏令时规则。
如果业务只关心分钟粒度,可以只取小时和分钟,计算分钟总数。比如判断是否在12:30到14:00之间,把小时乘以60加分钟得到750和840,当前分钟为(int)date('G') * 60 + (int)date('i')。秒数通常可以忽略,但为了严谨起见还是推荐保留秒级精度,避免边界时刻(如刚好在06:00:00)的误判。
借助DateTime对象提取相对时间
上面的手动计算方式简单直接,但代码中散落着date('G')、date('i')等函数调用,可读性一般。其实DateTime对象内部已经保存了完整的时间信息,只需要把日期部分“归零”再比较。例如创建一个DateTime实例,设置为今天零点,然后把要比较的时间通过modify方法加上小时、分钟、秒,最后用diff或者直接比较时间戳差值。
$midnight = new DateTime('today 00:00:00');
$now = new DateTime('now');
$secondsSinceMidnight = $now->getTimestamp() - $midnight->getTimestamp();
// $secondsSinceMidnight 的值就是当前时间距离零点的秒数
这种方式的好处是不用手动解析时间字符串,而且时区信息自动包含在DateTime对象里。如果服务器时区通过date_default_timezone_set设置正确,today 00:00:00会基于该时区计算。如果传递了带时区的DateTimeImmutable,还可以进一步避免对象被意外修改。不过要留意today这个相对格式在某些PHP版本中的表现,稳妥的做法是用(new DateTime('now'))->setTime(0, 0, 0)来获取零点。
另一种思路是直接把时间字符串拼到固定日期上,例如new DateTime('2000-01-01 ' . $time),这样所有时间都基于同一天,比较时间戳就等价于比较时间部分。因为2000-01-01没有夏令时切换,也没有闰秒干扰。这个技巧在测试中很常用,但生产代码里建议还是使用秒数计算,语义更明确。
封装一个可复用的时间范围比较类
把上面的逻辑整理成一个类,可以传入开始时间、结束时间、当前时间(默认为now),返回布尔值。类内部处理跨零点判断,并支持字符串格式或者整数秒数输入。构造参数可以设计为灵活模式:既可以传'23:00'、'23:00:00',也可以传['hour' => 23, 'minute' => 0]数组。为了方便框架集成,还可以提供一个静态方法返回闭包,用于array_filter或者Collection::filter。
class TimeRangeChecker
{
private int $startSeconds;
private int $endSeconds;
public function __construct(string $start, string $end)
{
$this->startSeconds = $this->parse($start);
$this->endSeconds = $this->parse($end);
}
private function parse(string $time): int
{
$parts = array_map('intval', explode(':', $time));
return ($parts[0] ?? 0) * 3600 + ($parts[1] ?? 0) * 60 + ($parts[2] ?? 0);
}
public function contains(?int $timestamp = null): bool
{
$timestamp = $timestamp ?? time();
$nowSeconds = (int)date('G', $timestamp) * 3600
+ (int)date('i', $timestamp) * 60
+ (int)date('s', $timestamp);
if ($this->startSeconds <= $this->endSeconds) {
return $nowSeconds >= $this->startSeconds && $nowSeconds <= $this->endSeconds;
}
return $nowSeconds >= $this->startSeconds || $nowSeconds <= $this->endSeconds;
}
}
$checker = new TimeRangeChecker('23:00', '06:00');
if ($checker->contains()) {
echo '当前在夜间时段';
}
这个类没有依赖任何全局状态,测试时可以直接传入指定的Unix时间戳来验证边界。比如想确认06:00:00是否包含在区间内,可以构造strtotime('today 06:00:00')作为参数传入。如果区间两端相等(比如'12:00'到'12:00'),根据业务语义可以返回false或者true,这里默认返回包含当前秒的情况。实际使用时建议明确文档说明,或者在构造函数里抛出异常防止歧义。
对于需要处理多个时间段的场景,比如一天内有多个休息时段,可以把多个TimeRangeChecker实例放在数组里循环判断。每个实例独立管理自己的跨天逻辑,互不干扰。如果存在多个区间重叠,还可以进一步封装一个TimeRangeCollection类,提供anyContains方法。不要试图把所有时间段合并成一个大的范围,那样会丢失跨零点区间的独立性。
时间范围比较的核心在于把“一天内的位置”抽象成数值,绕开日期带来的复杂度。无论是直接计算秒数还是利用固定日期比较时间戳,本质都是消除日期维度。掌握了这个思路之后,类似的场景如判断是否在午餐时间、判断是否处于系统维护窗口,都能用几行代码稳定解决,不用再为时区转换和跨天逻辑纠结。下次遇到这类需求时,可以先问自己:我真正需要比较的是时间点还是包含日期的完整时刻?如果答案只是时间点,那就坚决剥离日期。