基于 Laravel + Vue + GraphQL 实现前后端分离的博客应用(三) —— 文章发布及浏览


用户认证

我们设定只有认证通过的用户才能发布新文章,因此需要通过某种方式将用户认证头信息和其他请求信息一起发送到发布文章接口,以便顺利发布新文章。通过 apollo-link-context 我们可以轻松实现这个功能。在 src/main.js 中的合适位置插入如下代码:

import { setContext } from 'apollo-link-context'

const authLink = setContext((_, { headers }) => {
    // 从 LocalStorage 中获取认证 token(如果存在的话)
    const token = localStorage.getItem('blog-app-token')

    // return the headers to the context so httpLink can read them
    return {
        headers: {
            ...headers,
            authorization: token ? `Bearer ${token}` : null
        }
    }
})

// 更新 apollo client 如下
const apolloClient = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache()
})

首先需要引入 apollo-link-context,然后通过它来创建一个从 LocalStorage 中获取用户令牌的 authLink 并返回包含认证头的请求头。最后我们在 Apollo 客户端中引入并使用 authLink

这样,每次请求 GraphQL 服务端时都会带上认证信息。服务端通过解析 token 信息判断用户是否已经登录。

发布新文章

后端发布文章接口

首先创建文章发布变更类:

php artisan make:graphql:mutation AddPostMutation

然后编写 AddPostMutation 类代码,通过重写父类 authenticated 方法实现认证逻辑,表明这是个需要认证才能调用的接口;通过重写父类 rules 方法对提交表单字段进行简单验证:

namespace App\GraphQL\Mutation;

use App\Article;
use Folklore\GraphQL\Support\Mutation;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use GraphQL;
use JWTAuth;
use Auth;

class AddPostMutation extends Mutation
{
    protected $attributes = [
        'name' => 'AddPostMutation',
        'description' => 'A mutation'
    ];

    public function authenticated($root, $args, $context)
    {
        return JWTAuth::parseToken()->authenticate() ? true : false;
    }

    public function type()
    {
        return GraphQL::type('Article');
    }

    public function args()
    {
        return [
            'title' => ['name' => 'title', Type::string()],
            'content' => ['name' => 'content', Type::string()],
        ];
    }

    public function rules()
    {
        return [
            'title' => 'required',
            'content' => 'required'
        ];
    }

    public function resolve($root, $args, $context, ResolveInfo $info)
    {
        $article = new Article();
        $article->title = $args['title'];
        $article->body = $args['content'];
        $article->user_id = Auth::user()->id;
        $article->save();
        return $article;
    }
}

不要忘了在 config/graphql.php 中注册这个 Mutation 类:

'schemas' => [
    'default' => [
        'query' => [
            // queries
        ],
        'mutation' => [
            ... // other mutations
            'addPost' => \App\GraphQL\Mutation\AddPostMutation::class,
        ]
    ]
],

此外我们还要在 Article 模型类中新增关联关系方法以便后续查询:

public function author()
{
    return $this->belongsTo(User::class, 'user_id', 'id');
}

然后在 GraphQL 的 ArticleType 类的 fields 返回字段中新增 user 字段以及与之对应的字段解析方法:

public function fields()
{
    return [
        ...
        'user' => [
            'type' => GraphQL::type('User'),
            'description' => 'The author of the article'
        ]
    ];
}

protected function resolveUserField($root, $args)
{
    if (isset($args['id'])) {
        return $root->author->where('id', $args['id']);
    }
    return $root->author;
}

由于这个接口需要认证,所以我们把测试工作延迟到与前端组件一起联调时进行。

前端发布文章组件

定义前端 GraphQL Mutation

src/graphql.js 中加入下面的变更语句:

export const ADD_POST_MUTATION = gql`
    mutation AddPostMutation($title: String!, $content: String!) {
        addPost(
            title: $title,
            content: $content
        ) {
            id
            title
            content
            user {
                id
                name
                email
            }
        }
    }
`

创建 AddPost 组件

然后在 components/Admin 目录下创建 AddPost.vue

<template>
    <section class="section">
        <div class="container">
            <div class="columns">
                <div class="column is-3">
                    <Menu/>
                </div>
                <div class="column is-9">
                    <h2 class="title">发布新文章</h2>

                    <form method="post" @submit.prevent="addPost">
                        <div class="field">
                            <label class="label">标题</label>

                            <p class="control">
                                <input
                                        class="input"
                                        v-model="title"
                                        placeholder="Post title">
                            </p>
                        </div>

                        <div class="field">
                            <label class="label">内容</label>

                            <p class="control">
                                <textarea
                                        class="textarea"
                                        rows="10"
                                        v-model="content"
                                        placeholder="Post content"
                                ></textarea>
                            </p>
                        </div>

                        <p class="control">
                            <button class="button is-primary">发布</button>
                        </p>
                    </form>
                </div>
            </div>
        </div>
    </section>
</template>

<script>
import Menu from '@/components/Admin/Menu'
import { ADD_POST_MUTATION, ALL_POSTS_QUERY } from '@/graphql'

export default {
  name: 'AddPost',
  components: {
    Menu
  },
  data () {
    return {
      title: '',
      content: ''
    }
  },
  methods: {
    addPost () {
      this.$apollo
        .mutate({
          mutation: ADD_POST_MUTATION,
          variables: {
            title: this.title,
            content: this.content
          },
          update: (store, { data: { addPost } }) => {
            // 从缓存中读取所有文章数据
            const data = store.readQuery({ query: ALL_POSTS_QUERY })

            // 将新发布文章添加到已有文章列表
            data.allPosts.push(addPost)

            // 回写文章数据到缓存
            store.writeQuery({ query: ALL_POSTS_QUERY, data })
          }
        })
        .then(response => {
          // 重定向到文章列表页
          this.$router.replace('/admin/posts')
        })
    }
  }
}
</script>

该组件用于渲染发布新文章表单,我们通过 ADD_POST_MUTATION 发送必要字段信息,由于 Apollo 客户端会缓存查询信息,所以需要一种执行变更请求后更新缓存的机制。注意到上述代码中有一个 update 方法将最新发布的文章更新到缓存,实现逻辑是先通过 ALL_POSTS_QUERY 从缓存中获取匹配数据,然后添加新发布文章数据到 allPosts 数组,最后将新数据回写到缓存。文章发布成功后,会重定向到文章列表。

注册发布文章路由

src/router/index.js 的合适位置插入以下代码:

import AddPost from '@/components/Admin/AddPost'

// 将下面的代码插入 `routes` 数组
{
    path: '/admin/posts/new',
    name: 'AddPost',
    component: AddPost
}

这样我们运行 npm run build 之后就可以通过 http://apollo-blog.test/#//admin/posts/new 在浏览器看到文章发布表单了:

由于文章发布后会跳转到文章列表,所以我们将发布操作延迟到文章列表功能完成后进行。

文章列表

后端文章列表接口

在 Laravel 应用中新增一个 GraphQL 查询类 ArticleQuery

php artisan make:graphql:query ArticleQuery

然后编写 ArticleQuery 类代码如下:

namespace App\GraphQL\Query;

use App\Article;
use Folklore\GraphQL\Support\Query;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use GraphQL;

class ArticleQuery extends Query
{
    protected $attributes = [
        'name' => 'articles',
        'description' => 'articles query'
    ];

    public function type()
    {
        return Type::listOf(GraphQL::type('Article'));
    }

    public function args()
    {
        return [
            'id' => ['name' => 'id', 'type' => Type::string()],
            'title' => ['name' => 'title', 'type' => Type::string()],
            'user_id' => ['name' => 'user_id', 'type' => Type::string()]
        ];
    }

    public function resolve($root, $args, $context, ResolveInfo $info)
    {
        $fields = $info->getFieldSelection($depth = 3);

        if (isset($args['id'])) {
            $articles = Article::where('id', $args['id']);
        } elseif (isset($args['title'])) {
            $articles = Article::where('title', 'like', $args['title'] . '%');
        } else {
            $articles = Article::query();
        }

        foreach ($fields as $field => $keys) {
            if ($field === 'author') {
                $articles->with('author');
            }
        }

        return $articles->get();
    }
}

最后不要忘了在 config/graphql.php 中注册新的查询类:

'schemas' => [
    'default' => [
        'query' => [
            ... // other queries
            'allPosts' => \App\GraphQL\Query\ArticleQuery::class,
        ],
        ... // mutations 
    ]
],

这样就可以在 GraphiQL 中进行文章列表接口测试了:

前端文章列表组件

定义 GraphQL Query

照例还是在 src/graphql.js 中加入以下语句:

export const ALL_POSTS_QUERY = gql`
    query AllPostsQuery {
        allPosts {
            id
            title
            slug
            user {
                name
            }
        }
    }
`

创建 Posts 组件

然后在 components/Admin 目录下创建 Posts.vue 作为文章列表组件:

<template>
    <section class="section">
        <div class="container">
            <div class="columns">
                <div class="column is-3">
                    <Menu/>
                </div>
                <div class="column is-9">
                    <h2 class="title">文章列表</h2>

                    <table class="table is-striped is-narrow is-hoverable is-fullwidth">
                        <thead>
                        <tr>
                            <th>标题</th>
                            <th>作者</th>
                            <th></th>
                        </tr>
                        </thead>
                        <tbody>
                        <tr
                                v-for="post in allPosts"
                                :key="post.id">
                            <td>{{ post.title }}</td>
                            <td>{{ post.user.name }}</td>
                        </tr>
                        </tbody>
                    </table>
                </div>
            </div>
        </div>
    </section>
</template>

<script>
import Menu from '@/components/Admin/Menu'
import { ALL_POSTS_QUERY } from '@/graphql'

export default {
  name: 'Posts',
  components: {
    Menu
  },
  data () {
    return {
      allPosts: []
    }
  },
  apollo: {
    // fetch all posts
    allPosts: {
      query: ALL_POSTS_QUERY
    }
  }
}
</script>

注册文章列表路由

src/router/index.js 的合适位置插入下面的代码:

import Posts from '@/components/Admin/Posts'

// 将下面这段代码插入 `routes` 数组内
{
    path: '/admin/posts',
    name: 'Posts',
    component: Posts
}

运行 npm run build 之后就可以在浏览器中通过 http://apollo-blog.test/#/admin/posts 查看文章列表了:

此时,还可以测试文章发布功能,发布成功后页面会跳转到这个列表页,并且可以在列表中看到新发布的文章。

文章详情页

后端接口提供

在 Laravel 应用根目录运行下面的 Artisan 命令创建新的查询类:

php artisan make:graphql:query ArticleQueryById

然后编写刚生成的 ArticleQueryById 类代码如下:

namespace App\GraphQL\Query;

use App\Article;
use Folklore\GraphQL\Support\Query;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use GraphQL;

class ArticleQueryById extends Query
{
    protected $attributes = [
        'name' => 'ArticleQueryById',
        'description' => 'A query'
    ];

    public function type()
    {
        return GraphQL::type('Article');
    }

    public function args()
    {
        return [
            'id' => ['name' => 'id', 'type' => Type::string()],
        ];
    }

    public function resolve($root, $args, $context, ResolveInfo $info)
    {
        if (empty($args['id'])) {
            throw new \InvalidArgumentException('请传入文章ID!');
        }
        return Article::find($args['id']);
    }
}

最后在配置文件 config/graphql.php 中注册新的查询类:

'schemas' => [
    'default' => [
        'query' => [
            ... // other queries
            'post' => \App\GraphQL\Query\ArticleQueryById::class,
        ],
        'mutation' => [
            // mutations
        ]
    ]
],

这样就可以在 GraphiQL 中进行接口测试了:

前端文章详情组件

定义 GraphQL Query

首先在 src/graphql.js 中定义 GraphQL 查询语句:

export const POST_QUERY = gql`
    query PostQuery($id: String!) {
        post(id: $id) {
            id
            title
            content
            user {
                id
                name
                email
            }
        }
    }
`

创建 PostDetail 组件

然后在 src/components 目录下新建 PostDetail.vue

<template>
    <section class="section">
        <div class="columns">
            <div class="column is-6 is-offset-3">
                <router-link class="button is-link is-small" to="/">回到首页</router-link>

                <h1 class="title">
                    {{ post.title }}
                </h1>

                <div class="content">
                    {{ post.content }}
                </div>
            </div>
        </div>
    </section>
</template>

<script>
import { POST_QUERY } from '@/graphql'

export default {
  name: 'PostDetail',
  data () {
    return {
      post: '',
      id: this.$route.params.id
    }
  },
  apollo: {
    // 通过 id 获取文章
    post: {
      query: POST_QUERY,
      variables () {
        return {
          id: this.id
        }
      }
    }
  }
}
</script>

注册文章详情路由

src/router/index.js 的合适位置插入下面的代码:

import SinglePost from '@/components/PostDetail'

// 将下面的代码插入 `routes` 数组
{
    path: '/posts/:id',
    name: 'PostDetail',
    component: PostDetail,
    props: true
}

运行 npm run build 后通过形如 http://localhost:8081/#/posts/1 的链接访问文章详情页:

我们这里有一个回到首页的链接,所以我们接下来完成博客应用的最后一个页面 —— 博客首页。

博客首页

我们设定在博客首页也展示文章列表,所以无需提供额外的后端接口,也不需要编写前端额外的 GraphQL 查询,直接创建组件即可。

前端组件实现

创建 Home 组件

src/components 目录下新建 Home.vue

<template>
    <section class="section">
        <div class="columns">
            <div class="column is-6 is-offset-3">
                <h1 class="title">最新文章</h1>

                <h3 v-for="post in allPosts"
                        :key="post.id"
                        class="title is-5">
                    <router-link :to="`/posts/${post.id}`">
                        {{ post.title }}
                    </router-link>
                </h3>
            </div>
        </div>
    </section>
</template>

<script>
import { ALL_POSTS_QUERY } from '@/graphql'

export default {
  name: 'Home',
  data () {
    return {
      allPosts: []
    }
  },
  apollo: {
    // fetch all posts
    allPosts: {
      query: ALL_POSTS_QUERY
    }
  }
}
</script>

注册首页路由

然后在 src/router/index.js 的合适位置插入下面的代码:

import Home from '@/components/Home'

// 将下面的代码插入 `routes` 数组
{
    path: '/',
    name: 'Home',
    component: Home
}

在 Vue 应用根目录下运行 npm run build 后,就可以在浏览器中通过 http://apollo-blog.test 访问博客首页了:

通过每篇文章的链接点击进去可以进入文章详情页。

至此,一个简单的、带认证功能的、前后端分离的博客系统就已经全部开发完成,如果你有兴趣,可以继续为其添砖加瓦,让其更加有血有肉。


点赞 取消点赞 收藏 取消收藏

<< 上一篇: 基于 Laravel + Vue + GraphQL 实现前后端分离的博客应用(二) —— 用户列表及详情页

>> 下一篇: 使用 Dingo API 快速构建 RESTful API(一)—— 安装配置篇