面向讀者
- 有一定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
