Laravel应用处理跨时区问题的解决思路
面向读者
- 有一定Laravel经验的开发者
背景
在许多应用场景中,如航班查询、跨境电商领域,跨时区是开发中一定会碰到的问题。以跨境电商为例,最常见的场景就是商家在管理后台查阅订单数据时,希望订单时间都按北京时间,而美国客户和英国客户在下单时更想看到他们的订单上显示的是当地时间。另外一种常见场景是,管理后台的使用者不仅来自中国,也可能是其他国家不同时区,则管理后台还需要支持时区的动态切换。为了能够对跨时区这个问题提供一个较通用的解决思路,本文以跨境电商领域为例,罗列几个常见的应用场景,然后讨论不同场景的具体设计和实现,以覆盖所有这些应用场景。
应用场景
- 管理后台-管理员预设置的时区,支持切换时区
- 商城-根据用户的浏览器显示本地化的时间
- 注册用户的时区-在用户注册时根据浏览器判断时区
- 邮件内容的时区
管理后台支持动态切换时区
Laravel自身支持通过修改config('app.timezone')
改变时区,整个应用范围内获取到的时间均认为是该时区的时间。然而,这个便利的功能并不能解决我们的问题,数据库保存的时间是本地时间,当配置改变时,得到的本地时间就是错的。比如时区为Asia/Shanghai
,在2020-04-01 08:00:00
创建一个记录,created_at
的值为2020-04-01 08:00:00
,将时区改为Asia/Tokyo
,created_at
的值也不会变,得到的仍然是2020-04-01 08:00:00
,正确的应该是2020-04-01 09:00:00
。因此,Laravel自带的时区设置功能只适合于应用不会动态切换时区的情况,一旦开始使用就不能去改变,否则时间都是错误的。
要支持动态切换时区,需要做以下3个方面的改造:
- 将数据库存储的时间总是为UTC时间;
- Laravel的
Model
在获取本地时间时,应根据UTC时间config('app.timezone')
动态计算; - 从数据库配置读取时区信息,设置
config('app.timezone')
,以支持动态切换时区;
将数据库存储的时间总是为UTC时间
一般来说,只要约定created_at
,updated_at
保存的是UTC时间就行了,但是许多系统使用created_at
,updated_at
都是表示本地时间,为避免造成语义上的混乱。原来使用的时间字段仍然保留本地时间的语义,单独增加created_at_gmt
,updated_at_gmt
用于表示UTC时间,同时约定所有需要表示成UTC时间的字段都必须以_gmt结尾。
时间字段在mysql是存储为datetime格式,还是存储为timestamp,这是个需要注意的问题,推荐datetime,以避开timestamp的2038年问题。
给Model层使用下面的trait可直接解决获取/设置GMT的创建和更新时间的问题:
<?php
namespace App\Models\Traits;
trait CustomTimestamps {
/**
* @param mixed $value
* @return mixed
*/
public function setCreatedAt($value)
{
$gmt_field = static::CREATED_AT . '_gmt';
$this->{$gmt_field} = $value;
return parent::setCreatedAt($value);
}
/**
* @param mixed $value
* @return mixed
*/
public function setUpdatedAt($value)
{
$gmt_field = static::UPDATED_AT . '_gmt';
$this->{$gmt_field} = $value;
return parent::setUpdatedAt($value);
}
/**
* 直接返回原始数据。
*
* @remark 如果套一层Carbon,默认使用的是本地时区
* 就算明确改成UTC,在toArray的时候会出现Carbon对象转换成JSON的问题,需要定义一个toArray返回正确的数据
* 这里直接简单处理,返回原始数据,可考虑用toArray优化。
*
* https://github.com/laravel/framework/issues/16083
*/
public function getCreatedAtGmtAttribute()
{
return $this->attributes['created_at_gmt'];
}
/**
* *_gmt字段存储到数据库固定使用UTC时间
*/
public function setCreatedAtGmtAttribute($value)
{
$this->attributes['created_at_gmt'] = (new Carbon($value))->timezone('UTC')->toDateTimeString();
}
public function getUpdatedAtGmtAttribute()
{
return $this->attributes['updated_at_gmt'];
}
/**
* *_gmt字段存储到数据库固定使用UTC时间
*/
public function setUpdatedAtGmtAttribute($value)
{
$this->attributes['updated_at_gmt'] = (new Carbon($value))->timezone('UTC')->toDateTimeString();
}
}
Model在获取本地时间时,应根据UTC时间和config('app.timezone')
动态计算
created_at
和updated_at
虽然保留着,但是实际上已经用不到了,本地时间需要根据created_at_gmt
和updated_at_gmt
结合当前时区计算。直接给CustomTimestamps
增加对应的动态属性实现这个功能:
/**
* 实际的本地时间,根据时区动态计算,不使用直接存储的时间
*/
public function getCreatedAtAttribute()
{
$postDate = new Carbon($this->attributes['created_at_gmt'], 'UTC');
return $postDate->tz(config('app.timezone'))->toDateTimeString();
}
public function getUpdatedAtAttribute()
{
$postModified = new Carbon($this->attributes['updated_at_gmt'], 'UTC');
return $postModified->tz(config('app.timezone'))->toDateTimeString();
}
从数据库配置读取时区信息,设置config('app.timezone')
,以支持动态切换时区;
Laravel默认的时区配置是手动的。要支持动态切换时区,需要在数据库的配置表(假设配置项的表名为options
,对应的Model
为Option
)增加timezone
的配置项。在初始化阶段,读取该配置项,去设置config('app.timezone')
的值和通过date_default_timezone_set
改变时区。
这里有个重要的注意点:
boot
阶段应该从配置表生成的缓存而不要直接从数据库读取,以避开一种情况——Laravel
初始化时通过php artisan migrate
执行表迁移,这个时候配置项的表还没创建,boot
去读取数据库配置必须报数据库表找不到而导致应用挂掉。
下面为AppServiceProvider
执行boot
时,对时区的初始化示例代码:
// app/Providers/AppServiceProvider.php
<?php
namespace App\Providers;
use App\Models\Option;
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
// 一定要从从缓存中读取配置,1个是性能问题,1个是防止php artisan migrate的问题
// Option::fromPreCache()为自定义函数,见Option的代码
$store = Option::fromPreCache();
if ($store->get('timezone', null)) {
$timeZone = $store->get('timezone');
config(['app.timezone' => $timeZone]);
// 防止$timeZone值无效导致应用挂掉
@date_default_timezone_set($timeZone);
}
}
}
Option的Model关键代码:
// app/Models/Option.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Schema;
use Cache;
use Log;
class Option extends Model {
//...省略其他代码
const PRE_CACHE_KEY = 'options-pre-cache';
/**
* 从预生成的缓存读取配置
*
* @warning 注意没有缓存的话,在生成缓存之前,要判断表是否存在,否则创建新应用时,使用php artisan migrate会有问题
*/
public static function fromPreCache()
{
if (!Cache::has(static::PRE_CACHE_KEY)) {
static::savePreCache();
}
return collect(Cache::get(static::PRE_CACHE_KEY, []));
}
/**
* 保存配置到预生成的缓存
*/
public static function savePreCache()
{
// 判断表存在是必要的
if (!Schema::hasTable('options')) {
Log::warning('options table is not found in wordpress');
return;
}
Cache::forever(static::PRE_CACHE_KEY, Option::where('name', 'timezone')->get());
}
}
经过这些处理以后,管理后台既可支持时区的动态切换了。
商城-根据用户的浏览器显示本地化的时间
使用moment可以简洁地处理这个问题:
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.20.1/moment.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.14/moment-timezone-with-data.min.js">
这里需要注意3点:第一点是moment-timezone-with-data引用的是带有本地化数据的包,没有本地化数据时在执行moment.tz.guess()可能会报错;第二点是这2个包占用的空间有点大(近50K),对大小敏感的要考虑直接用原生JS处理。
直接在浏览器控制台测试下输入UTC时间,输出本地时间:
moment.utc('2020-04-01 00:00:02').tz(moment.tz.guess()).format('YYYY-MM-DD HH:mm:ss')
输出
"2020-04-01 08:00:02"
注册用户的时区
获取用户注册时的时区,有2种方法,一种是通过浏览器判断,一种是通过IP判断。通过浏览器判断更简单也更准确。这里只展示浏览器判断的方法。
给注册用户的表单增加一个隐藏字段:
<input type="hidden" name="timezone" value="UTC" id="tz"/>
然后同上一节一样,在提交的时候,使用moment的包获取时区信息,填写隐藏字段
var tz = moment.tz.guess();
$('#tz').val(tz);
Laravel后端相对应地将该字段保存到数据库即可,由于是比较通用的基础内容,这里就不展开了。
邮件内容的时区
发送邮件时,特别是发给非注册用户时,其实是缺乏用于判断时区的信息的。因此要显示时间,可考虑以下的策略
- 根据用户操作时自动识别的时区用户保存的时区
- 使用与管理后台相同的时区
- 直接使用UTC时间
另外,有那种根据收件人EMAIL判断它的时区的服务,不过对于一般应用,没有使用的必要。
参考资料
管理用户时区
Storing Date Time In Database
laravel怎么处理不同时区的时间
为什么wordpress数据库会保存post_date和post_date_gmt两个时间
Auto detect a time zone with JavaScript
laravel怎么设置Carbon的时区
怎么更新Laravel Model中时间字段的时区
moment format date in a specific timezone