Laravel API 系列教程(一): 基于 Laravel 5.5 构建 & 测试 RESTful API
随着移动开发和 JavaScript 框架的日益流行,使用 RESTful API 在数据层和客户端之间构建交互接口逐渐成为最佳选择。在本系列教程中,学院君将会带领大家基于 Laravel 5.5 来构建并测试带认证功能的 RESTful API。
RESTful API
开始之前,我们先要了解什么是 RESTful API。REST 是 REpresentational State Transfer 的缩写,表示一种应用之间网络通信的架构风格,依赖于无状态的协议(通常是HTTP)进行交互。通过 HTTP 动词表示操作
在 RESTful API 中,我们使用 HTTP 动词表示操作,而端点是操作的资源,HTTP 动词的语义如下:GET
:获取资源POST
:创建资源PUT
:更新资源DELETE
:删除资源
更新操作:PUT vs. POST
关于 RESTful API 有很多争议,比如更新资源使用POST
、PATCH
还是 PUT
哪一个更好,或者创建资源是否最好用 PUT
等。在本教程中,我们使用 PUT
进行更新操作,因为基于 HTTP RFC 标准,PUT
的含义是在指定位置上创建/更新资源;使用 PUT
的另一个原因是幂等,这意味着不管你发送一次、两次还是上千次请求,操作结果一致。
资源
资源指的是操作的对象,在我们的例子中就是文章(Articles)和用户(Users),它们各自的端点是:/articles
/users
关于一致性的注意项
使用 REST 的最大好处是可以更容易消费和开发 API,一些端点非常直截了当,这样相较于类似 GET /get_article?id_article=12
这样的端点 RESTful API 更容易使用和维护。
不过,在某些案例中映射到 Create/Retrieve/Update/Delete
可能会很困难,需要牢记的是 URL 中不要包含任何动词而且资源并不一定非得是数据表的某一行数据。另一个需要记住的是不必为每个资源实现所有操作。
设置一个新的 Laravel 项目
创建新应用
我们通过 Composer 来安装这个新项目:
composer create-project --prefer-dist laravel/laravel apidemo 5.5.*
安装完成后,如果使用 Valet 作为开发环境的话就可以直接在浏览器中通过 http://apidemo.test
访问了,如果使用 Laradock 或 Homestead 作为开发环境的话,还需要配置域名绑定:
127.0.0.1 apidemo.test
然后还要配置 Nginx 的配置文件,以 Laradock 为例,就是在 laradock/nginx/sites
目录下新建一个 apidemo.conf
配置文件,编辑其内容如下:
server {
listen 80;
listen [::]:80;
server_name apidemo.test;
root /var/www/apidemo/public;
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ {
try_files $uri /index.php =404;
fastcgi_pass php-upstream;
fastcgi_index index.php;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
#fixes timeouts
fastcgi_read_timeout 600;
include fastcgi_params;
}
location ~ /\.ht {
deny all;
}
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt/;
log_not_found off;
}
error_log /var/log/nginx/apidemo_error.log;
access_log /var/log/nginx/apidemo_access.log;
}
之后重新启动 Nginx:
docker-compose up --build -d nginx
就可以在浏览器中通过 http://apidemo.test
访问应用了:
迁移和模型
在编写第一个迁移之前,需要将 .env
文件中的环境变量调整为开发环境数据库配置值(以 Laradock 为例):
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=apidemo
DB_USERNAME=root
DB_PASSWORD=root
接下来就可以开始创建我们的第一个 Article
模型及其对应迁移文件了,我们在项目根目录运行如下 Artisan 命令一步到位:
php artisan make:model Article -m
-m
是 --migration
的缩写,告知 Artisan 在创建模型同时创建与之对应的迁移文件(我使用的是 Laradock 作为开发环境):
当然,还需要编辑默认生成的迁移文件:
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateArticlesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('articles', function (Blueprint $table) {
$table->increments('id');
$table->string('title');
$table->text('body');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('articles');
}
}
然后我们运行如下命令创建对应数据表:
php artisan migrate
现在我们回到 Article
模型类添加如下属性到 $fillable
字段以便可以在 Article::create
和 Article::update
方法中可以使用它们:
class Article extends Model
{
protected $fillable = ['title', 'body'];
}
数据库填充
Laravel 通过 Faker 库可以快速为我们生成格式正确的测试数据:
php artisan make:seeder ArticlesTableSeeder
生成的填充器类位于 /database/seeds
目录下,我们编辑填充器类如下:
<?php
use Illuminate\Database\Seeder;
use App\Article;
class ArticlesTableSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
// Let's truncate our existing records to start from scratch.
Article::truncate();
$faker = \Faker\Factory::create();
// And now, let's create a few articles in our database:
for ($i = 0; $i < 50; $i++) {
Article::create([
'title' => $faker->sentence,
'body' => $faker->paragraph,
]);
}
}
}
然后运行填充命令:
php artisan db:seed --class=ArticlesTableSeeder
重复上述过程创建一个用户填充器:
<?php
use Illuminate\Database\Seeder;
use App\User;
class UsersTableSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
// Let's clear the users table first
User::truncate();
$faker = \Faker\Factory::create();
// Let's make sure everyone has the same password and
// let's hash it before the loop, or else our seeder
// will be too slow.
$password = Hash::make('toptal');
User::create([
'name' => 'Administrator',
'email' => 'admin@test.com',
'password' => $password,
]);
// And now let's generate a few dozen users for our app:
for ($i = 0; $i < 10; $i++) {
User::create([
'name' => $faker->name,
'email' => $faker->email,
'password' => $password,
]);
}
}
}
编辑 DatabaseSeeder
类:
<?php
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
$this->call(UsersTableSeeder::class);
$this->call(ArticlesTableSeeder::class);
}
}
然后运行 php artisan db:seed
就可以执行所有填充器填充数据。
路由和控制器
注册路由
有了数据之后,接下来我们来为应用创建基本接口:创建、获取列表、获取单条记录、更新以及删除。在 routes/api.php
中,注册路由如下:
<?php
use Illuminate\Http\Request;
use App\Article;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
Route::middleware('auth:api')->get('/user', function (Request $request) {
return $request->user();
});
Route::get('articles', function() {
// If the Content-Type and Accept headers are set to 'application/json',
// this will return a JSON structure. This will be cleaned up later.
return Article::all();
});
Route::get('articles/{id}', function($id) {
return Article::find($id);
});
Route::post('articles', function(Request $request) {
return Article::create($request->all);
});
Route::put('articles/{id}', function(Request $request, $id) {
$article = Article::findOrFail($id);
$article->update($request->all());
return $article;
});
Route::delete('articles/{id}', function($id) {
Article::find($id)->delete();
return 204;
});
在 api.php
中定义的路由在访问时需要加上 /api/
前缀,并且 API 限流中间件会自动应用到所有路由上:
创建控制器
定义好路由之后,接下来我们来创建控制器并将业务逻辑迁移过去:
php artisan make:controller ArticleController
然后编辑控制器如下:
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Article;
class ArticleController extends Controller
{
public function index()
{
return Article::all();
}
public function show($id)
{
return Article::find($id);
}
public function store(Request $request)
{
return Article::create($request->all());
}
public function update(Request $request, $id)
{
$article = Article::findOrFail($id);
$article->update($request->all());
return $article;
}
public function delete(Request $request, $id)
{
$article = Article::findOrFail($id);
$article->delete();
return 204;
}
}
调整 routes/api.php
文件中的 articles
相关路由:
Route::get('articles', 'ArticleController@index');
Route::get('articles/{id}', 'ArticleController@show');
Route::post('articles', 'ArticleController@store');
Route::put('articles/{id}', 'ArticleController@update');
Route::delete('articles/{id}', 'ArticleController@delete');
还可以通过隐式路由模型绑定来改写路由定义:
Route::get('articles', 'ArticleController@index');
Route::get('articles/{article}', 'ArticleController@show');
Route::post('articles', 'ArticleController@store');
Route::put('articles/{article}', 'ArticleController@update');
Route::delete('articles/{article}', 'ArticleController@delete');
相应的,需要调整控制器代码:
class ArticleController extends Controller
{
public function index()
{
return Article::all();
}
public function show(Article $article)
{
return $article;
}
public function store(Request $request)
{
$article = Article::create($request->all());
return response()->json($article, 201);
}
public function update(Request $request, Article $article)
{
$article->update($request->all());
return response()->json($article, 200);
}
public function delete(Article $article)
{
$article->delete();
return response()->json(null, 204);
}
}
访问指定路由与之前返回结果一样:
关于 HTTP 状态码的注意项
我们还添加了response()->json()
调用到端点,这可以让我们在显示返回 JSON 数据的同时发送可以被客户端解析的 HTTP 状态码,最常用的状态码如下:
200
:OK,标准的响应成功状态码201
:Object created,用于store
操作204
:No content,操作执行成功,但是没有返回任何内容206
:Partial content,返回部分资源时使用400
:Bad request,请求验证失败401
:Unauthorized,用户需要认证403
:Forbidden,用户认证通过但是没有权限执行该操作404
:Not found,请求资源不存在500
:Internal server error,通常我们并不会显示返回这个状态码,除非程序异常中断503
:Service unavailable,一般也不会显示返回,通常用于排查问题用
发送 404 响应
如果你试图获取不存在的资源,会返回 404
页面:
如果想要将其改造成返回 JSON 响应,可以编辑异常处理器 app/Exceptions/Handler.php
的 render
方法:
public function render($request, Exception $exception)
{
if ($exception instanceof ModelNotFoundException) {
return response()->json([
'error' => 'Resource not found.'
],404);
}
return parent::render($request, $exception);
}
再次访问不存在的资源,返回结果如下:
认证
新增 api_token 字段
在 Laravel 中实现 API 认证有多种方式(例如 Passport),但是本教程会使用一个非常简化的方式。
开始之前,首先添加 api_token
到 users
表:
php artisan make:migration --table=users adds_api_token_to_users_table
然后编写这个迁移文件:
class AddsApiTokenToUsersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->string('api_token', 60)->unique()->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['api_token']);
});
}
}
最后执行迁移命令作用于数据表:
php artisan migrate
创建注册接口
我们使用 RegisterController
来根据注册请求返回正确的响应。尽管 Laravel 开箱提供了认证功能,但是我们还是需要对其进行调整以便返回我们想要的响应数据。该控制器使用 RegistersUsers
来实现注册,实现逻辑如下:
public function register(Request $request)
{
// Here the request is validated. The validator method is located
// inside the RegisterController, and makes sure the name, email
// password and password_confirmation fields are required.
$this->validator($request->all())->validate();
// A Registered event is created and will trigger any relevant
// observers, such as sending a confirmation email or any
// code that needs to be run as soon as the user is created.
event(new Registered($user = $this->create($request->all())));
// After the user is created, he's logged in.
$this->guard()->login($user);
// And finally this is the hook that we want. If there is no
// registered() method or it returns null, redirect him to
// some other URL. In our case, we just need to implement
// that method to return the correct response.
return $this->registered($request, $user)
?: redirect($this->redirectPath());
}
我们只需要在 RegisterController
中实现 registered
方法即可。该方法接收 $request
和 $user
参数:
protected function registered(Request $request, $user)
{
$user->generateToken();
return response()->json(['data' => $user->toArray()], 201);
}
在 routes/api.php
中注册路由如下:
Route::post('register', 'Auth\RegisterController@register');
在上面的示例代码中,我们调用了 User
模型上的生成令牌方法,该方法现在不存在,需要手动添加:
public function generateToken()
{
$this->api_token = str_random(60);
$this->save();
return $this->api_token;
}
至此,注册接口编写完成,用户现在可以通过注册接口进行注册了,感谢 Laravel 开箱提供的认证字段验证功能,如果你需要调整验证规则的话可以到 RegisterController
中查看 validator
方法。
下面我们来简单测试下注册接口:
curl -X POST http://apidemo.test/api/register \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"name": "学院君", "email": "admin@laravelacademy.org", "password": "test123", "password_confirmation": "test123"}'
创建登录接口
和注册接口一样,可以编辑 LoginController
控制器来支持 API 认证。为此,我们需要在 LoginController
覆盖 AuthenticatesUsers
trait 提供的 login
方法:
public function login(Request $request)
{
$this->validateLogin($request);
if ($this->attemptLogin($request)) {
$user = $this->guard()->user();
$user->generateToken();
return response()->json([
'data' => $user->toArray(),
]);
}
return $this->sendFailedLoginResponse($request);
}
然后在 routes/api.php
中注册登录路由:
Route::post('login', 'Auth\LoginController@login');
现在,基于我们上面注册的新用户,我们来测试下登录接口:
curl -X POST http://apidemo.test/api/login \
-H "Accept: application/json" \
-H "Content-type: application/json" \
-d "{\"email\": \"admin@laravelacademy.org\", \"password\": \"test123\" }"
登录成功返回结果:
后面就可以拿着这个 api_token
作为令牌来请求需要认证的资源了。使用我们现有的策略,请求认证资源时,如果没有 token 或 token 错误,用户将会接收到未认证响应(401)。
创建退出接口
为了形成完整闭环,下面我们来编写退出登录接口,实现思路是用户发起退出登录请求时,我们将其对应的 token 字段值从数据库移除。
首先,在 routes/api.php
中注册路由:
Route::post('logout', 'Auth\LoginController@logout');
然后在 Auth\LoginController.php
中编写 logout
方法:
public function logout(Request $request)
{
$user = Auth::guard('api')->user();
if ($user) {
$user->api_token = null;
$user->save();
}
return response()->json(['data' => 'User logged out.'], 200);
}
使用该策略,一旦退出,用户的所有令牌都会失效,访问需要认证的接口都会拒绝访问(通过中间件实现),这需要和前端配合来避免用户在没有访问任何内容的权限下保持登录状态。
使用中间件限制访问
api_token
创建之后,我们就可以在路由文件中应用认证中间件了:
Route::middleware('auth:api')
->get('/user', function (Request $request) {
return $request->user();
});
我们可以使用 $request->user()
或 Auth
门面访问当前用户:
Auth::guard('api')->user(); // 登录用户实例
Auth::guard('api')->check(); // 用户是否登录
Auth::guard('api')->id(); // 登录用户ID
接下来,我们将之前定义的文章相关路由进行分组:
Route::group(['middleware' => 'auth:api'], function() {
Route::get('articles', 'ArticleController@index');
Route::get('articles/{article}', 'ArticleController@show');
Route::post('articles', 'ArticleController@store');
Route::put('articles/{article}', 'ArticleController@update');
Route::delete('articles/{article}', 'ArticleController@delete');
});
这样就不需要为每个路由设置中间件,现在看来虽然节省不了多少时间,但随着应用体量的增长,这样做的好处是保持路由的DRY(Don't Repeat Yourself)。
再访问文章接口就需要认证了:
测试接口
初始化设置
Laravel 开箱集成了 PHPUnit 进行测试,并且在项目根目录下为我们配置好了 phpunit.xml
。本教程中,我们使用内置的测试方法来测试上面编写的 API。
开始之前,我们需要做一些小调整以便使用内存级的 SQLite 数据库进行数据存储。这样做的好处是可以让测试更快运行,但缺点是某些迁移命令可能不能正常运行,我的建议是当你遇到运行迁移命令出错或者更倾向于更加健壮的测试而不是高性能时不要使用 SQLite。
我们还会在每个测试之前运行迁移,这样就可以为每次测试构建数据库然后销毁掉,从而避免不同组测试间的相互干扰。
在 config/database.php
文件中,设置 sqlite
配置项中的 database
字段值为 :memory:
:
...
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
...
]
然后在 phpunit.xml
中通过新增 DB_CONNECTION
环境变量来启用 SQLite:
<php>
<env name="APP_ENV" value="testing"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
<env name="DB_CONNECTION" value="sqlite"/>
</php>
基本配置已经完成,生下来要做的就是配置 TestCase
在每次测试前运行迁移并填充数据库。为此,我们需要添加 DatabaseMigrations
trait 然后在 setUp()
方法中添加 Artisan
调用:
namespace Tests;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Artisan;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication, DatabaseMigrations;
public function setUp()
{
parent::setUp();
Artisan::call('db:seed');
}
}
最后一件事就是添加测试命令到 composer.json
:
"scripts": {
"test" : [
"vendor/bin/phpunit"
],
...
},
我们通过这个命令来运行测试:
composer test
如果运行过程中抛出异常:
Error: Class 'Doctrine\DBAL\Driver\PDOSqlite\Driver' not found
这是因为没有安装 doctrine/dbal
扩展包,使用 Composer 安装即可:
composer require doctrine/dbal
下面是运行结果:
为测试设置模型工厂
模型工厂可以让我们快速生成测试数据,Laravel 开箱自带了 User
模型工厂,下面我们为 Article
类添加工厂:
php artisan make:factory ArticleFactory
然后编辑 ArticleFactory
类:
use Faker\Generator as Faker;
use App\Article;
$factory->define(Article::class, function (Faker $faker) {
return [
'title' => $faker->sentence,
'body' => $faker->paragraph,
];
});
编写测试用例
我们可以使用 Laravel 的断言方法对请求和响应进行测试。下面我们来创建第一个测试用例 —— 登录测试:
php artisan make:test LoginTest
编写 LoginTest
代码如下:
class LoginTest extends TestCase
{
public function testRequiresEmailAndLogin()
{
$this->json('POST', 'api/login')
->assertStatus(422)
->assertJson([
'email' => ['The email field is required.'],
'password' => ['The password field is required.'],
]);
}
public function testUserLoginsSuccessfully()
{
$user = factory(User::class)->create([
'email' => 'test@laravelacademy.org',
'password' => bcrypt('test123'),
]);
$payload = ['email' => 'test@laravelacademy.org', 'password' => 'test123'];
$this->json('POST', 'api/login', $payload)
->assertStatus(200)
->assertJsonStructure([
'data' => [
'id',
'name',
'email',
'created_at',
'updated_at',
'api_token',
],
]);
}
}
然后编写注册测试用例:
php artisan make:test RegisterTest
编写 RegisterTest
代码如下:
class RegisterTest extends TestCase
{
public function testsRegistersSuccessfully()
{
$payload = [
'name' => 'John',
'email' => 'john@toptal.com',
'password' => 'toptal123',
'password_confirmation' => 'toptal123',
];
$this->json('post', '/api/register', $payload)
->assertStatus(201)
->assertJsonStructure([
'data' => [
'id',
'name',
'email',
'created_at',
'updated_at',
'api_token',
],
]);;
}
public function testsRequiresPasswordEmailAndName()
{
$this->json('post', '/api/register')
->assertStatus(422)
->assertJson([
'name' => ['The name field is required.'],
'email' => ['The email field is required.'],
'password' => ['The password field is required.'],
]);
}
public function testsRequirePasswordConfirmation()
{
$payload = [
'name' => 'John',
'email' => 'john@toptal.com',
'password' => 'toptal123',
];
$this->json('post', '/api/register', $payload)
->assertStatus(422)
->assertJson([
'password' => ['The password confirmation does not match.'],
]);
}
}
最后,编写退出测试用例:
php artisan make:test LogoutTest
编辑 LogoutTest
代码如下:
class LogoutTest extends TestCase
{
public function testUserIsLoggedOutProperly()
{
$user = factory(User::class)->create(['email' => 'user@test.com']);
$token = $user->generateToken();
$headers = ['Authorization' => "Bearer $token"];
$this->json('get', '/api/articles', [], $headers)->assertStatus(200);
$this->json('post', '/api/logout', [], $headers)->assertStatus(200);
$user = User::find($user->id);
$this->assertEquals(null, $user->api_token);
}
public function testUserWithNullToken()
{
// Simulating login
$user = factory(User::class)->create(['email' => 'user@test.com']);
$token = $user->generateToken();
$headers = ['Authorization' => "Bearer $token"];
// Simulating logout
$user->api_token = null;
$user->save();
$this->json('get', '/api/articles', [], $headers)->assertStatus(401);
}
}
注:在测试期间,Laravel 应用并不会在发起新请求时再次初始化,所以会在请求之间保存当前用户到 TokenGuard
实例,也因此我们不得不将退出测试一分为二,以避免受之前缓存用户的影响。
测试文章 API 接口的代码也很简单:
php artisan make:test ArticleTest
编写 ArticleTest
代码如下:
class ArticleTest extends TestCase
{
public function testsArticlesAreCreatedCorrectly()
{
$user = factory(User::class)->create();
$token = $user->generateToken();
$headers = ['Authorization' => "Bearer $token"];
$payload = [
'title' => 'Lorem',
'body' => 'Ipsum',
];
$this->json('POST', '/api/articles', $payload, $headers)
->assertStatus(200)
->assertJson(['id' => 1, 'title' => 'Lorem', 'body' => 'Ipsum']);
}
public function testsArticlesAreUpdatedCorrectly()
{
$user = factory(User::class)->create();
$token = $user->generateToken();
$headers = ['Authorization' => "Bearer $token"];
$article = factory(Article::class)->create([
'title' => 'First Article',
'body' => 'First Body',
]);
$payload = [
'title' => 'Lorem',
'body' => 'Ipsum',
];
$response = $this->json('PUT', '/api/articles/' . $article->id, $payload, $headers)
->assertStatus(200)
->assertJson([
'id' => 1,
'title' => 'Lorem',
'body' => 'Ipsum'
]);
}
public function testsArtilcesAreDeletedCorrectly()
{
$user = factory(User::class)->create();
$token = $user->generateToken();
$headers = ['Authorization' => "Bearer $token"];
$article = factory(Article::class)->create([
'title' => 'First Article',
'body' => 'First Body',
]);
$this->json('DELETE', '/api/articles/' . $article->id, [], $headers)
->assertStatus(204);
}
public function testArticlesAreListedCorrectly()
{
factory(Article::class)->create([
'title' => 'First Article',
'body' => 'First Body'
]);
factory(Article::class)->create([
'title' => 'Second Article',
'body' => 'Second Body'
]);
$user = factory(User::class)->create();
$token = $user->generateToken();
$headers = ['Authorization' => "Bearer $token"];
$response = $this->json('GET', '/api/articles', [], $headers)
->assertStatus(200)
->assertJson([
[ 'title' => 'First Article', 'body' => 'First Body' ],
[ 'title' => 'Second Article', 'body' => 'Second Body' ]
])
->assertJsonStructure([
'*' => ['id', 'body', 'title', 'created_at', 'updated_at'],
]);
}
}
要运行测试的话的随时运行 composer test
即可。
至此,我们已经完成了 API 接口的编写和测试,下一篇我们会基于 JWT 对 API 进行认证同时整合进 Vue SPA 做一个更偏向实战的教程,敬请期待。
87 Comments