Laravel 多语言详解

要实现多语言,必定是对文本进行提取,表示为key,然后根据语言寻找到到语言文件,定位key对应的值。

先链接一些用法:

/resources
    /lang
        /en
            messages.php
        /zh-CN
            messages.php

# messages.php文件
<?php

return [
    'welcome' => 'Welcome to our application'
];

默认需要使用什么语言,在config/app.php中配置:

'locale' => 'zh-CN',
'fallback_locale' => 'en',

默认语言是zh-CN,备用是en,备用是指当在默认找不到时,切换为备用的寻找语言文件。可以在运行中修改:

$locale = App::getLocale();

if (App::isLocale('en')) {
    App::setLocale($locale);
}

定义翻译文件,根据语言,在resources/lang下面建立JSON文件:

/resources/lang/zh-CN.json

{
    'hello' => '你好' 
}

使用翻译:

echo __("hello")
echo __('messages.welcome');
#在模板中
{{ __("hello") }}
@lang("hello")

双下滑线函数首先到zh-CN.json中定位,如果没有再到lang/zh-CN中根据一定规则定位,如果无法定位,返回key本身。具体来说,如果messages.welcome这个key在zh-CN.json中有定义,就直接使用,如果没有就到lang/zh-CN中定位message文件,然后从这个文件中定位welcome这个key,如还是无法定位,直接返回messages.welcome。

另外,key对应的值可以有占位符,所以双下滑线函数第二参数可以携带一个关联数组:

'welcome' => 'Welcome, :name',

echo __('messages.welcome', ['name' => 'laravel']);

注意:占位符可以是首字母大写,也可以全大写,对应传入的字符串也会做对应的转换(首字母大写或全大写)

具体实现。在config/app.php中会载入serviceProvider(Illuminate\Translation\TranslationServiceProvider):

<?php

namespace Illuminate\Translation;

use Illuminate\Support\ServiceProvider;

class TranslationServiceProvider extends ServiceProvider
{
    protected $defer = true;

    public function register()
    {
        $this->registerLoader();

        $this->app->singleton('translator', function ($app) {
            $loader = $app['translation.loader'];

            $locale = $app['config']['app.locale'];

            $trans = new Translator($loader, $locale);

            $trans->setFallback($app['config']['app.fallback_locale']);

            return $trans;
        });
    }

    protected function registerLoader()
    {
        $this->app->singleton('translation.loader', function ($app) {
            return new FileLoader($app['files'], $app['path.lang']);
        });
    }

    public function provides()
    {
        return ['translator', 'translation.loader'];
    }
}

首先注意到这个serviceProvider是defer的(在需要时才注册服务)。translation.loader到一个FileLoader实例,它需要把文件系统对象注入,它负责怎么取文件,而去哪里取则是第二参数指定的。 loader只是解决了如何load文件,根据什么条件load,然后怎么取回key,是Translator负责,它需要把loader注入,还要告诉默认什么语言和备用语言(对应代码中的translator)。

首先看一下Loader:

<?php

namespace Illuminate\Translation;

use RuntimeException;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Contracts\Translation\Loader;

class FileLoader implements Loader
{
    public function load($locale, $group, $namespace = null)
    {
        if ($group == '*' && $namespace == '*') {
            return $this->loadJsonPaths($locale);
        }

        if (is_null($namespace) || $namespace == '*') {
            return $this->loadPath($this->path, $locale, $group);
        }

        return $this->loadNamespaced($locale, $group, $namespace);
    }
}

主要就是这个load方法,用法举例:

// 解析resources/lang/zh-CN.json
load('zh-CN', '*', '*');

// 解析resources/lang/zh-CN/下的message.php文件
load('zh-CN', 'message') 
load('zh-CN', 'message', '*') 

// 解析ifeeline命名空间(需要先设置ifeeline => vender/ifeeline/ifeeline/src/lang/)中zh-CN/下的message.php
// 然后用resources/lang/vender/ifeeline/zh-CN/message.php覆盖后返回
load('zh-CN', 'message', 'ifeeline') 

第三种用法看起来有点不好理解。这个主要用来对付一些包,它自己本身处理了多语言,举例:

ifeeline/example => vender/ifeeline/example/src 

包名称和命名空间可一样,也可以不一样(勿混淆)。在这个包装中,通常暴露一个serviceProvider,其中会调用translation.loader的addNamespace方法,告诉loader自己对应的包名对应的语言路径,实际就是:

ifeeline-example => vender/ifeeline/example/src/lang

public function addNamespace($namespace, $hint)
    {
        $this->hints[$namespace] = $hint;
    }

当要取回某个文件时,通过loader.load(‘zh-CN’, ‘message’, ‘ifeeline-example’)取回vender/ifeeline/example/src/lang/zh-CN/message.php文件,然后通过translator再定位key。

所以为了可以扩展(添加没有的语言),和修改key对应的值,translator还会操作loader去resource/lang/vender/ifeeline-example/zh-CN下寻找message文件进行覆盖(原来没有就是新增),所以可以把语言包放入到对应位置就实现了覆盖或添加新语言包。一般包中的serviceProvider会提供publish钩子,可以把语言文件拷贝对对应位置(我们进行对应修改以实现覆盖)。

注意到loader的load()方法还是非常涩的,而且没有定位key,这个就是translator的具体实现。

Illuminate\Translation\Translator继承自Illuminate\Support\NamespacedItemResolver,它的parseKey方法用法:

# 返回[ifeeline-example, message, hello]
parseKey("ifeeline-example::message.hello")

# 返回[ifeeline-example, message, hello.hi]]
parseKey("ifeeline-example::message.hello.hi")

# 返回[ifeeline-example, message, null]
parseKey("ifeeline-example::message")

# 返回[null, message, hello]
parseKey("message.hello")

# 返回[null, message, hello.hi]]
parseKey("message.hello.hi")

# 返回[null, message, null]
parseKey("message")

最终来到Illuminate\Translation\Translator:

<?php

namespace Illuminate\Translation;

use Countable;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\Collection;
use Illuminate\Support\Traits\Macroable;
use Illuminate\Contracts\Translation\Loader;
use Illuminate\Support\NamespacedItemResolver;
use Illuminate\Contracts\Translation\Translator as TranslatorContract;

class Translator extends NamespacedItemResolver implements TranslatorContract
{
    public function get($key, array $replace = [], $locale = null, $fallback = true)
    {
        list($namespace, $group, $item) = $this->parseKey($key);

        // Here we will get the locale that should be used for the language line. If one
        // was not passed, we will use the default locales which was given to us when
        // the translator was instantiated. Then, we can load the lines and return.
        $locales = $fallback ? $this->localeArray($locale)
                             : [$locale ?: $this->locale];

        foreach ($locales as $locale) {
            if (! is_null($line = $this->getLine(
                $namespace, $group, $locale, $item, $replace
            ))) {
                break;
            }
        }

        // If the line doesn't exist, we will return back the key which was requested as
        // that will be quick to spot in the UI if language keys are wrong or missing
        // from the application's language files. Otherwise we can return the line.
        if (isset($line)) {
            return $line;
        }

        return $key;
    }

    protected function getLine($namespace, $group, $locale, $item, array $replace)
    {
        $this->load($namespace, $group, $locale);

        $line = Arr::get($this->loaded[$namespace][$group][$locale], $item);

        if (is_string($line)) {
            return $this->makeReplacements($line, $replace);
        } elseif (is_array($line) && count($line) > 0) {
            return $line;
        }
    }

}

首先,如果无法定时,直接返回$key。循环语言(指定语言和备用),所以主语言找不到,会去备用语言中找。注意这里的$this->parseKey($key),如果namespace为null,这个方法把其改为*。

具体实现在getLine()方法: 首先load()文件,三个情况,1 /lang/zh-CN.json 2 /lang/zh-CN/message.php 3 包语言文件(以及覆盖文件)。然后到文件中定位具体的key。比如是a => b.c, 那么a文件中应该有一个b数组,它有一个c元素。当然还包括占位符替换的处理。

翻译器get方法最终调用getLine()方法,而get方法都是先解析key的,意味着”hello word.”这样的key会被解析成[null,hello word,”],很明显,无法定位到,所以这里就要说到双下划线函数了:

if (! function_exists('__')) {
    /**
     * Translate the given message.
     *
     * @param  string  $key
     * @param  array  $replace
     * @param  string  $locale
     * @return string|array|null
     */
    function __($key, $replace = [], $locale = null)
    {
        return app('translator')->getFromJson($key, $replace, $locale);
    }
}

这个双下划线函数是从getFromJson()进入的:

    public function getFromJson($key, array $replace = [], $locale = null)
    {
        $locale = $locale ?: $this->locale;

        // For JSON translations, there is only one file per locale, so we will simply load
        // that file and then we will be ready to check the array for the key. These are
        // only one level deep so we do not need to do any fancy searching through it.
        $this->load('*', '*', $locale);

        $line = $this->loaded['*']['*'][$locale][$key] ?? null;

        // If we can't find a translation for the JSON key, we will attempt to translate it
        // using the typical translation file. This way developers can always just use a
        // helper such as __ instead of having to pick between trans or __ with views.
        if (! isset($line)) {
            $fallback = $this->get($key, $replace, $locale);

            if ($fallback !== $key) {
                return $fallback;
            }
        }

        return $this->makeReplacements($line ?: $key, $replace);
    }

先解析zh-CN.json,没有在调用get()。换个说法就是,双下划线函数总是去loader对应json文件,然后才开始常规定位。如果不需要考虑这个全局的json文件,可以使用trans()方法。

写了那么多,实际使用时,只要把全局的key(翻译文件),写入到json, 提炼出来的key写入到某个文件,使用双下划线函数加上完整的key(message.hello)即可搞定多语言问题。