PostGraphile 入门 v0.2.0418

熊立丁 @ 2019-04-18

目录

1 项目概述

PostGraphile 是一个可以根据数据库生成 GraphQL 接口的工具。 它的自动化程度非常高,实现了表结构、关系、限制、访问权限等的自动映射,能有效提高服务器端的开发效率。

我在工作中使用了 PostGraphile,并取得了一定的成功,于是希望将这个好东西推荐给大家。 这里,我想通过为一个待办事项服务搭建 GraphQL 接口的过程,来展示如何使用 PostGraphile 。 如果能引起你想试着用一下它的兴趣,我的目的就算达到了。

这里涉及的大多数内容,在 PostGraphile 的官方文档中其实都能找到,毕竟我就是照着它的官方文档来学习的。 只不过官方文档是通过罗列功能的形式,而这里是顺着一个假想的项目需求来介绍。

我们从一个单机版的待办事项应用开始,逐步改造。 首先,把它改成将数据保存到数据库中的联机系统,并在这个过程中介绍 PostGraphile 的基本功能。 接着将其从单人系统扩展到多人,引入账号和登录的概念,在这个过程中介 PostGraphile 的权限管理, 然后进一步系统权限,让用户只能访问到自己的数据,这个过程中涉及 PostgreSQL 的行级权限。 还有更多……

2 技术背景

下面所列的是本项目中主要用到的技术和工具,以及你需要对它们掌握到什么程度才能顺利的阅读本文。

本文及相关代码在 macOS 下写作,如果你使用其他系统,下面的部分命令可能需要相应调整。

PostGraphile

PostGraphile 可以根据 PostgreSQL 数据库中的内容自动生成 GraphQL 接口。 开发人员设计好数据库中:表的结构、表之间的关系、用户的操作权限, PostGraphile 就能自动将这些关系映射到最终的 GraphQL 接口中。 表中的注释会被用于接口的说明,更有智能注释提供额外功能。 这被开发者称为 DDGD(Database-Driven GraphQL Development),即数据库驱动的 GraphQL 开发。

本文主要就是介绍这个工具,所以目前不要求你对它有所了解。

PostgreSQL

PostGraphile 只支持 PostgreSQL 数据库,并充分利用了它的强大能力。 比如通过行级权限实现细致的访问控制,通过触发器实现订阅等。

本文不要求你使用过 PostgreSQL ,但你必需要有最基本的数据库理论知识。 你可以从这里下载到 PostgreSQL 的安装包,也可以通过自己熟悉的包管理工具安装它。

GraphQL

GraphQL 是一种用于接口的查询语言,其最大的特点是灵活,数据按需取用。 甚至将原先只能在服务器上通过 SQL 查询实现的联表等能力转移给了客户端。

本文会对它的基本使用进行介绍,想要详细掌握可以考虑阅读 Learning GraphQL 一书。

Node.js 与 Express

PostGraphile 本身使用 Node.js 编写,既可以作为独立的命令行工具使用, 也可以嵌入到 Express 应用中作为大系统的一个组成部分使用。

阅读本文需要你对 Node.js 与它的包管理工具 npm 有基本的了解。

Apollo

一个功能强大的 GraphQL 客户端,可以结合 react、vue 等现代框架使用,也可以单独使用。

你不需要预先对其有所了解。

todomvc/vanillajs

本项目的主要目的是说明 PostGraphile 及相关技术的使用方法,为了便于理解,借用大家都熟悉的待办事项作为实例。 因为重点不在前端,所以这里直接使用现成的 Todo 项目进行改造,来对接 GraphQL 接口。

todomvc 是一个开源项目,用来比较各种前端框架的 Todo 实现,为了覆盖尽可能多的受众,我们采用 vanillajs 版本作为本项目演示的基础。

3 项目准备

本项目将通过一个待办事项系统的实现过程,来演示 PostGraphile 的使用。 之所以选择待办事项作演示,是因为大多数读者都接触过不同形式的待办事项软件,这样我们就对要做什么已经有了基本共识。 市面上不少框架也选择通过实现一个待办事项功能来演示自己的使用方法,可以说这是新时代的 Hello World。

因为本文的侧重点在于 PostGraphile 及相关工具,我们不需要从头编写一个待办事项软件。 在 github 上,你可以找到一个叫做 TodoMVC 的项目,里面有用各种前端框架实现的 Todo 软件,其主要目的是为了帮助用户选择适合自己的前端框架。 我们选择其中的 vanillajs 也就是最基础的 JavaScript 版本作为起点,确保覆盖尽可能多的读者群体。

我们先将这个项目克隆到本地:

cd ~
git clone --depth=1 https://github.com/tastejs/todomvc

上面命令中的 ~ 指代用户目录,你也可以选择其他目录,但务必在每个命令间保持一致。 接下来的文章中,我都会用命令描述进行的操作以求精确性,同时配以文字说明,你也可以选择用图形界面完成对等的操作。

克隆完成后,进入到 todomvc/example/vanillajs 目录,并用浏览器打开 index.html,这时你可以看到如下界面:

todo.png

图1  VanillaJS Todo

有些浏览器出于安全考虑,在通过文件路径访问本地页面时,会阻止这个页面中的 JavaScript。 为了看到完整的功能,我们需要建立一个简单的服务器。

回到你挑选的目录,新建一个目录 postodo 作为我们项目的起点; 接着将 todomvc 中的 vanillajs 目录拷贝到 postodo 中; 然后通过 npm 对项目进行初始化,并在 postodo 里面安装 express

cd ~
mkdir postodo
cp -R todomvc/example/vanillajs postodo/
cd postodo
npm init -y
npm install express --save

在 postodo 的根目录下新建一个文件 index.js,内容如下:

const express = require('express');
const app = express();

app.use('/todo', express.static('./vanillajs'));
app.listen('3000');

现在运行 node index.js 然后在浏览器中访问 http://127.0.0.1:3000/todo 就能看到我们的 Todo 应用了,

你可以在文本框里输入内容,然后按回车建立一条新的待办事项,也可将其标记成完成或者将完成的任务删除。

到目前位置,这个待办事项应用还是个“单机版”, 虽然通过网页的形式提供服务,但是数据是保存在浏览器中的。

打开 vanillajs/js/store.js 可以看到,你创建的待办事项是保存在 localStorage 中的。 在接下来的章节中,我们将修改 store.js ,让它可以通过 GraphQL 保存和读取数据。

4 联机版系统 —— PostGraphile 的基本使用

在这一章节,我们将介绍如何使用 PostGraphile 建立基本的 GraphQL 接口。

4.1 建立数据库

我们先来确定数据结构。 在浏览器的 todos 页面中新建一个叫做“查看数据结构”的任务。

struct.png

图2  一个任务

打开调试工具,找到 localStorage,可以看到里面有一项叫做 todos-vanillajs ,其内容如下:

[{title: "查看数据结构", completed: false, id: 1552359576677}]

根据这个结构,我们在 PostgreSQL 数据库中建立相应的数据库和表。

首先通过命令行工具 psql 连接你的数据库

psql -U postgres

-U 后面的是用户名,postgres 是 PostgreSQL 数据库的默认管理员账号。 连接成功后,命令行提示符会变成 postgres=#

创建数据库 postodo 并连接它

create database postodo;
\c postodo

按照前面获得的结构创建表 todo

create table todo (
  id serial primary key,
  title text not null,
  completed bool not null default false
)

基本的数据库准备完毕,接下来我们就用 PostGraphile 生成 GraphQL 接口。

4.2 PostGraphile 的基本使用

首先使用 npm 安装 postgraphile 和它的一个插件。

npm install --save postgraphile
npm install --save @graphile-contrib/pg-simplify-inflector

pg-simplify-inflector 这个插件可以用来简化接口和参数的名称,官方强烈推荐, 如果你的项目没有历史包袱,那么就该使用这个插件。

修改 index.js,将 postgraphile 以中间件的形式引入到项目中。

const express = require('express');
const app = express();
const {postgraphile} = require('postgraphile');

app.use(
    postgraphile('postgres://postgres@localhost/postodo', ['public'], {
        appendPlugins: [PgSimplifyInflectorPlugin],
        graphiql: true,
        simpleCollections: 'both',
        watchPg: true,
    }),
);

app.use('/todo', express.static('./vanillajs'));
app.listen('3000');

这里重点看一下 postgraphile 函数中的参数。

  • 第一个参数是连接字符串,描述了将要连接的数据库:使用角色 postgres 连接本地的 postodo 数据库;
  • 第二个参数是一个数组,用来指定 PostGraphile 的作用范围,即 postodo 中的名为 public 的 schema。 public 是 PostgreSQL 中的默认 schema ,我们在创建表 todo 时没有特别指定,因此它被创建于 public 中。
  • 第三个参数用来控制 PostGraphile 的行为,目前我们用到了 appendPlugins、graphile、watchPg 和 simpleCollections。 appendPlugins 用来加载插件,另几个我们会在后面用到相关功能时进一步对它们进行说明。

重新运行 node index.js ,并在浏览器中访问 http://127.0.0.1:3000/graphiql, 可以看到如下界面:

graphiql.png

图3  GraphiQL

4.3 GraphiQL 与查询语句

GraphiQL 是一个基于浏览器的 IDE,专门用于编写和调试 GraphQL 查询。代码中的 graphiql: true 就是用来启用这个工具。

点击右上角的 Docs 展开侧边栏,可以看到自动生成的接口文档。 ROOT TYPES 有两类主要的操作:query 和 mutation,另外还有一类 subscription 后面会讲到。 文档中黄色字体都是链接,点进去可以进一步查看接口、参数等的详细说明。

根据业务需求,我们先来找到新建待办事项的接口。 在 GraphQL 中,mutation 是插入、修改、删除等写操作的统称, 我们可以在 mutation 的下一级找到名为 createTodo 的接口。

createTodo(input: CreateTodoInput!): CreateTodoPayload

括号中的是参数,最后的一部分是返回值。

插入一个待办事项的查询语句如下

mutation($input: CreateTodoInput!) {
  createTodo(input: $input) {
    todo {
      id
      title
      completed
    }
  }
}

createTodo1.png

图4  在 GraphiQL 中添加一个待办事项

左下角的 QUERY VARIABLES 是实际用来创建项目的参数。

GraphQL 的灵活性在参数方面也有所体现,我们可以使用下面这组查询语句与参数的组合达到相同的目的。

createTodo2.png

图5  将复杂度从参数转移到查询语句

也就是说,你可以选择将结构放在查询语句还是参数中。 一般而言,单一复杂参数的方案,能让查询语句适用于更多的情况,但是 JavaScript 代码会更复杂; 多参数的方案会限制查询语句的灵活性,但是 JavaScript 代码更简洁。

GraphQL 与 Restful 风格的接口之间一个很大的不同是 Restful 强调读写分离,而 GraphQL 更加务实,在插入数据的同时,可以取回相关内容。 从上面的图中可以看到,输入的参数只有 title ,而返回时可以同时取得 id 和 completed 这两个在数据库中生成值的项目。

4.4 从数据库获取待办事项

刚才 GraphiQL IDE 中的操作已经使数据库中有了两条数据,现在看看怎么把它们取出来,体现到界面上。

打开 Docs 中的 Query ,你可以找到这两个接口:

  • todos
  • todosList

它们是批量读取数据的接口。

todos 与 todosList 的主要区别是: todos 的接口定义按照Relay的分页模式设计,在本体之外包裹了一层额外的数据以便相关工具能自动化的调用接口。 todosList 的返回格式比较简约,可以简化查询语句的结构,但是缺乏分页游标、数据条数等信息。 PostGraphile 默认不生成这种以 List 结尾的接口,只有在初始化选项中修改 simpleCollections 才能调出。

我们的应用将使用 todosList 接口来获取数据。

完整的查询语句如下:

query {
  todosList {
    id
    title
    completed
  }
}

查询结果入下:

todosList.png

图6  获取待办事项

接下来,我们要把查询语句整合到应用中, 这次需要修改 vanillajs/js/store.js 中的 findAll 函数,这个函数的功能是取出所有的待办事项。

Store.prototype.findAll = function (callback) {
    callback = callback || function () {};
    callback.call(this, JSON.parse(localStorage.getItem(this._dbName)));
};

不难看出,findAll 的功能是从 localStorage 中取出待办事项,再被回调函数消费。

我们将其改造成通过 GraphQL 接口获取数据,在 JavaScript 中执行 GraphQL 查询的本质, 就是通过 POST 请求将查询语句以特定的格式发送到服务器上。

我们可以使用 fetch 来实现这个过程。

Store.prototype.findAll = function (callback) {
    callback = callback || function () {};
    fetch('/graphql', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            query: `query {
                todosList {
                id
                title
                completed
                }
            }`
        })
    }).then(res => res.json()).then(({data}) => {
        callback.call(this, data.todosList);
    });
};

在浏览器中打开 http://127.0.0.1:3000/todo ,你就可以看到在 GraphiQL IDE 中录入的两条数据了。

todosList_app.png

4.5 增加查询条件

如果你仔细观察,会发现界面下方有三个选项 All、Active、Completed 用来筛选显示: All 显示所有条目; Active 显示 completed 属性为 false 的条目; Completed 显示 completed 属性为 true 的条目。

这个筛选功能是通过 store.js 中的 find 函数实现的。

Store.prototype.find = function (query, callback) {
    if (!callback) {
        return;
    }

    var todos = JSON.parse(localStorage.getItem(this._dbName));

    callback.call(this, todos.filter(function (todo) {
        for (var q in query) {
            if (query[q] !== todo[q]) {
                return false;
            }
        }
        return true;
    }));
};

当前的版本是通过 JavaScript 循环,从所有数据中过滤出符合条件的,现在我们要结合 GraphQL 接口,直接从数据库取出符合条件的条目。 所以只要在刚才的 findAll 函数上略加修改即可:

Store.prototype.find = function (callback) {
    callback = callback || function () {};
    fetch('/graphql', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            query: `query($condition: TodoCondition) {
                todosList(condition: $condition) {
                id
                title
                completed
                }
            }`,
            variables: {
                condition: query,
            },
        }),
    }).then(res => res.json()).then(({data}) => {
        callback.call(this, data.todosList);
    });
});

对 find 函数进行完改造后,可以发现,选择 Active 与 选择 All 一样,可以显示所有的数据,选择 Completed 则没有数据。 我们会在后面讲解如何将任务状态从未完成改为完成。倒时候就可以更好的验证查询条件的功能。

4.6 将插入语句整合到代码中

现在,我们再回过头来,将插入待办事项的功能整合到代码中。

再次打开项目中的文件 vanillajs/js/store.js ,找到函数

Store.prototype.save = function (updateData, callback, id) {
    var todos = JSON.parse(localStorage.getItem(this._dbName));

    callback = callback || function() {};

    // If an ID was actually given, find the item and update each property
    if (id) {
        for (var i = 0; i < todos.length; i++) {
            if (todos[i].id === id) {
                for (var key in updateData) {
                    todos[i][key] = updateData[key];
                }
                break;
            }
        }

        localStorage.setItem(this._dbName, JSON.stringify(todos));
        callback.call(this, todos);
    } else {
        // Generate an ID
        updateData.id = new Date().getTime();

        todos.push(updateData);
        localStorage.setItem(this._dbName, JSON.stringify(todos));
        callback.call(this, [updateData]);
    }
}

稍加理解就可以发现,判断语句的 else 分支是用来插入数据的,我们将其改造为调用 createTodo 接口。

在 JavaScript 中执行 GraphQL 查询本质上就是通过 POST 请求将查询语句和变量以特定的格式发送到服务器上,我们可以使用 fetch 来实现这个过程。

fetch('/graphql', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({
        query: `mutation($title: String!) {
            createTodo(input: {todo: {
                title: $title
            }}) {
                todo {
                    id
                    title
                    completed
                }
            }
        }`,
        variables: {
            title: updateData.title
        }
    })
}).then(res => res.json()).then(({data}) => {
    callback.call(this, [data.createTodo.todo]);
});

在浏览器中打开 http://127.0.0.1:3000/todo ,然后在输入框中填写“插入到数据库”并回车,就可以看到新代码的效果了。

createTodo_app.png

4.7 修改数据状态

接下来,我们要改造的功能,是将任务标记为完成,可以看到,每条待办事项前面都有个空心圆圈,点击之后,可以切换任务的完成状态。 这个功能,是通过 store.js 中的 save 函数完成的。

上面已经讲到,save 中的 else 分支是用来添加数据的,而前面部分则是用来修改已有数据。目前的 save 函数如下:

Store.prototype.save = function(updateData, callback, id) {
    var todos = JSON.parse(localStorage.getItem(this._dbName));

    callback = callback || function() {};

    // If an ID was actually given, find the item and update each property
    if (id) {
        fetch('/graphql', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                query: `mutation ($input: UpdateTodoByIdInput!) {
                    updateTodoById(input: $input) {
                        todo {
                            id
                            title
                            completed
                        }
                    }
                }`,
                variables: {
                    input: {
                        id: id,
                        patch: updateData,
                    },
                },
            }),
        }).then(res => res.json()).then(() => {
            this.findAll(callback);
        });
    } else {
        fetch('/graphql', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                query: `mutation($title: String!) {
                    createTodo(input: {todo: {
                        title: $title
                    }}) {
                        todo {
                            id
                            title
                            completed
                        }
                    }
                }`,
                variables: {
                    title: updateData.title,
                },
            }),
        }).then(res => res.json()).then(({data}) => {
            callback.call(this, [data.createTodo.todo]);
        });
    }
};

4.8 删除待办事项

现在增删改查里面,增、改、查都有了,还缺一个删。 通过 GraphQL 删除一条数据也非常简单:

mutation ($input: DeleteTodoByIdInput!) {
  deleteTodoById(input: $input) {
    deletedTodoId
  }
}

deleteTodo.png

现在将这个功能与界面对接起来,也就是修改 store.js 的 remove 函数,当前 remove 函数如下:

Store.prototype.remove = function (id, callback) {
    var todos = JSON.parse(localStorage.getItem(this._dbName));

    for (var i = 0; i < todos.length; i++) {
        if (todos[i].id == id) {
            todos.splice(i, 1);
            break;
        }
    }

    localStorage.setItem(this._dbName, JSON.stringify(todos));
    callback.call(this, todos);
};

我们将其改造成通过 GraphQL 接口从数据库中删除指定的条目

Store.prototype.remove = function (id, callback) {
    fetch('/graphql', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            query: `mutation($input: DeleteTodoByIdInput!) {
                deleteTodoById(input: $input) {
                    deletedTodoId
                }
            }`,
            variables: {
                input: {
                    id: id,
                },
            },
        }),
    }).then(res => res.json()).then(() => {
        this.findAll(callback);
    });
};

4.9 删除所有完成的任务

不过我们的待办事项应用里面,还有另一个删除功能,那就是批量删除所有已经完成的任务, 你会发现 PostGraphile 没有生成可以用于这个功能的接口,如果利用循环,重复调用单个删除的接口,又显得太浪费资源。 所以,这一节我们来介绍 PostGraphile 另一个强大的功能,将数据库中的函数,自动转化为接口。

首先,我们来添加一个数据库函数 remove_completed,其功能是,将 todo 表中所有 completed 为 true 的数据删除掉。

该函数的功能语句如下:

DELETE FROM todo WHERE completed = true;

在数据库中建立此函数的语句如下:

CREATE FUNCTION remove_completed() RETURNS void AS '
    DELETE FROM todo WHERE completed = true;
' LANGUAGE SQL

添加完毕后,数据库中多了一个新函数,remove_completed ,而通过 Graphile 的文档功能可以看到 mutation 里增加了一个新的接口 removeCompleted

这个接口的调用方式与 PostGraphile 根据表结构生成的接口没有任何区别,也就是说,我们想增加新的接口,只要在数据库里面增加一个函数就可以了。

将这个接口与待办事项上的清除按钮关联起来,所要做的就是修改 store.js 的函数……,好吧,store.js 里面没有与之相应的函数。 实际上,这个功能是通过 controller.js 中的 removeCompletedItems 函数来反复调用 Store.remove 来实现的。

现在我们有了更直接的方法,所以我们将对 controller.js 中的 removeCompleted 事件进行改造。让他调用 Store.removeCompletedItems。

self.view.bind('removeCompleted', function () {
  self.model.storage.removeCompletedItems();
});
Store.prototype.removecompletedItems = function (callback) {
    fetch('/graphql', {
        method: 'post',
        headers: {
            'content-type': 'application/json'
        },
        body: json.stringify({
            query: `mutation ($input: removecompletedinput!) {
                removecompleted(input: $input) {
                    clientmutationid
                }
            }`,
            variables: {
                input: {}
            }
        })
    }).then(res => res.json()).then(() => {
        this.findAll(callback);
    });
};

通过 removeCompletedItems 函数,我们调用了 mutation.removeCompleted,从而执行数据库函数 remove_completed 删除所有已经完成的待办事项。

4.10 关注数据库的更新

你可能已经注意到,上一节中当我们在数据库中添加了一个函数时,对应的 GraphQL 接口就立刻生成了,我们无需重新启动整个程序。 PostGraphile 是有能力监控数据库结构变化的,只要你在初始化时,添加一个选择 watchPg: true

这个功能在开发阶段尤其有用,即使在生产环境中加上这个参数,也不会对性能造成影响, 这个功能是通过往数据库中添加一个叫做 postgraphile_watch 的 schema 来实现的,里面包含一个事件触发器,能够监控数据库的各种结构性变化,从而相应改变接口。 当你用管理员账号(比如 postgres)连接数据库时,PostGraphile 会自动创建这个 schema,否则你就得自己动手创建这个 schema 。

4.11 小结

到目前为止,我们的联机版待办事项已经完成了,所有的数据被保存在数据库中,不过目前的接口和 Restful 风格的比起来,并没有明显的优势。 随后我们会继续升级这个程序,将其从单一用户系统改造成多用户系统,这样就能体现出 GraphQL 中 Graph 的一面。

5 多用户系统,用户的注册与登录

在上一章中,我们已经完成了待办事项系统从“单机”到“联机”的转变,在本章中,我们将继续对其进行升级改造,把它从“单用户”系统改造成“多用户”系统。 主要内容如下:

  • 提供账号的注册和登录功能
  • 将每个用户创建的待办事项归入其名下

5.1 账号注册

在本节中,我们将实现账号的注册功能,首先,我们需要一张表来存放用户信息,假设我们用邮箱作为账号:

CREATE TABLE "user" (
    id serial primary key,
    email text not null,
    password text not null
);

虽然系统自动生成了用来添加、修改和查询用户信息的接口,但我不打算直接使用它们,因为这会产生安全问题,因此我们需要自己创建注册和登录接口。至于自动生成的接口中影响到系统安全的那几个,我们会在后面的章节介绍如何屏蔽它们。

我们先来制作用来注册接口。首先添加一个数据库函数 register

CREATE EXTENSION pgcrypto;

CREATE FUNCTION register(email text, pass text) returns INTEGER AS $BODY$
    INSERT INTO "user" ("email", "password") VALUES (
        email,
        crypt(pass, gen_salt('md5'))
    ) RETURNING "id";
$BODY$ LANGUAGE SQL;

我们这里先引入了扩展 pgcrypto ,因为我们要用到其中的 crypt 函数对密码进行加密。 然后创建一个 register 函数用来插入一个新用户,并返回该用户在系统中的编号。

现在,我们通过 Graphiql IDE 来验证这个接口:

mutation ($email: String, $pass: String) {
  register(input: {email: $email, pass: $pass}) {
    integer
  }
}

register_graphiql.png

这里可以看到,账号注册成功了,并且被自动分配了编号 4。

5.1.1 注册界面

通过上面的接口,我们已经可以完成账号的注册功能,但是普通用户仍然需要一个界面来完成这部操作,所以我们接着改造待办事项应用。

在 vanillajs/index.html 中 body 顶部加入注册的表单:

<body>
    <form class="register" onsubmit="submitForm(event)">
        <input type="text" id="email" name="email" placeholder="email"/>
        <input type="password" id="password" name="password" placeholder="password"/>
        <button type="submit">注册</button>
    </form>
    ...
</body>

然后在 head 的尾部引入自定义的 css 和 js 文件,内容如下:

<head>
    ...
    <link rel="stylesheet" href="postodo.css">
    <script src="js/postodo.js"></script>
</head>
var graphqlRegister = function (email, password) {
    alert(email + ',' + password);
};

var submitForm = function (e) {
    e.preventDefault();
    var email = document.getElementById('email').value;
    var password = document.getElementById('password').value;
    graphqlRegister(email, password);
};
.register {
    position: fixed;
    top: 0;
    right: 0;
    width: 200px;
    padding: 10px;
    background: #fff;
}
.register input, .register button {
    width: 200px;
    box-sizing: border-box;
    margin-top: 8px;
}
.register button {
    background: #eee;
}

最终得到如下界面:

registerForm.png

填写邮箱与密码后,点击注册,会弹出窗口原样显示你输入的内容。

5.1.2 套用接口

接下来将 graphqlRegister 修改成调用 GraphQL 接口

#+CAPTION postodo.js graphqlRegister

var graphqlRegister = function (email, password) {
    fetch('/graphql', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            query: `mutation ($email: String, $pass: String) {
                register(input: {email: $email, pass: $pass}) {
                    integer
                }
            }`,
            variables: {
                email: email,
                pass: password
            }
        }),
    }).then(res => res.json()).then(({data}) => {
        alert(data.register.integer);
    });
});

经过修改之后,点击注册按钮会通过 mutation register 往数据库插入新用户,并返回用户的编号。

registerFormAlert.png

5.1.3 使用第三方库 graphql.js

到目前为止,我们一直在直接使用 fetch 函数来调用 GraphQL 接口,代码略显重复与啰嗦,虽然我们也可以自己包装一个函数,但市面上已经有许多成熟的解决方案。 从现在开始,我们将使用一个轻量级的第三方库 graphql.js 来简化调用 GraphQL 接口的代码。

cd postodo/vanillajs/js
wget https://raw.githubusercontent.com/f/graphql.js/master/graphql.min.js
<head>
    ...
    <link rel="stylesheet" href="postodo.css">
    <script src="js/graphql.min.js"></script>
    <script src="js/postodo.js"></script>
</head>
var graph = graphql('/graphql');

var graphqlRegister = async function (email, password) {
    var register = graph(`mutation ($email: String, $pass: String) {
        register(input: {email: $email, pass: $pass}) {
            integer
        }
    }`);

    register({
        email: email,
        pass: password
    });

    alert(data.register.integer);
});

如果你的浏览器不能支持 async/await,你也可以选择 Promise 语法:

var graphqlRegister = function (email, password) {
    var register = graph(`mutation ($email: String, $pass: String) {
        register(input: {email: $email, pass: $pass}) {
            integer
        }
    }`);

    register({
        email: email,
        pass: password
    }).then(data => {
        alert(data.register.integer);
    }).catch(err => {
        alert(err[0].message);
    });
});

5.1.4 阻止重复注册

如果你有留意上面截图中的邮箱,你会发现我们重复注册了相同的邮箱 example@gmail.com ,我们可以通过数据库中的唯一约束来避免重复注册。 我们先清空数据库中的用户,然后设置唯一索引。

TRUNCATE "user";
CREATE UNIQUE INDEX "unique_email" ON "public"."user" USING BTREE ("email");

现在用相同的邮箱重复调用这个接口,会得到这样的错误提示:

registerDuplicate.png

可以看到,data.register 的结果变成了 null,而从 errors.message 里面可以发现错误原因是唯一约束的字段中出现了重复的值。

5.2 登录

5.2.1 登录界面

简单起见,登录功能将重复利用上一章节中的注册界面,只要在其中加入一个按钮,就可以用作登录之用。

<form class="register">
  <input type="text" id="email" name="email" placeholder="email"/>
  <input type="password" id="password" name="password" placeholder="password"/>
  <button type="button" onclick="register()">注册</button>
  <button type="button" onclick="login()">登录</button>
</form>

5.2.2 绑定登录逻辑

为登录按钮绑定逻辑。

var graphqlLogin = function (email, password) {
    alert(JSON.stringify(arguments));
};

var login = function () {
    var email = document.getElementById('email').value;
    var password = document.getElementById('password').value;
    graphqlLogin(email, password);
};

我们将在 graphqlLogin 函数里面进行登录操作,但是目前,数据库方面还没有相应的接口。

5.2.3 JWT 与登录

与常规的后端解决方案不同,PostGraphile 没有使用 Session 做为身份验证手段,而是选择了 JWT。

JWT(JSON WEB TOKEN) 是基于 RFC 7519 的公开标准。 通俗来说,登录时,客户端向服务器索取一个经过签名的字符串(Token)。 里面包含了用户的身份信息等资料,虽然经过 Base64 编码,但原则上仍然可以认为是明码,所有获得这个字符串的人,都可以解析获得其中的信息。 但是反过来,客户端无法自己制造这样的字符串,因为这个字符串的最后一部分是签名,没有相应的加密秘钥是无法伪造的。

事实上,JWT 还可以用于在不同的平台间传递自己的用户信息。 因为其支持非对称加密,只要把公钥给合作伙伴,他就能确认一个自称来自你的平台的用户,是否是真实的。 这个功能可以用于单点登录或第三方用户登录授权。

不过在我们的例子中,我们只需要完成自己平台用的认证,只要用到对称秘钥就可以。

下面,我们就来看看如何在 PostGraphile 中实现 JWT 授权与登录。

5.2.4 定义数据结构

目前看来,我们只需要在 Token 中记录用户的 ID 与邮箱就够了。 以 JSON 格式表示,大概是这个样子:

{
    "id": 1,
    "email": "example@gmail.com"
}

我们需要在 postgresql 数据库中,建立一个与此 JSON 结构相符的类型,如此,后续的登录函数,就有了一个可以返回的数据类型。

CREATE TYPE public.jwt_token AS
(
    id int4,
    email text,
);

5.2.5 编写登录函数

然后是登录函数,我们要在函数中判断用户发过来的邮箱与密码的配对,是否存在于用户表。 如果存在,就将相应的 ID 与邮箱,做成 token 发回给客户端。

CREATE OR REPLACE FUNCTION public.login(
    email text,
    password TEXT
)
RETURNS jwt_token
LANGUAGE 'plpgsql'
COST 100
VOLATILE STRICT SECURITY DEFINER 
AS $BODY$
DECLARE
    u public.user;
BEGIN
    SELECT * INTO u 
    FROM public.user 
    WHERE public.user.email = login.email;

    IF FOUND THEN
        IF u.password = crypt(login.password, u.password) THEN
        RETURN (
                u.id,
                u.email
        )::public.jwt_token;
        END IF;
    END IF;

    RETURN null;
END;
$BODY$;

如此,我们的接口中就增加了 mutation.login

login_graphiql.png

但是你会发现,这个接口与一般的 mutation 并无不同,返回的仍然是常规的 JSON 数据,而不是 token 。

5.2.6 配置 PostGraphile

通过对 PostGraphile 进行配置,我们可以指定将某个类型用于生成 token

app.use(
  postgraphile('postgres://postgres@localhost/postodo', ['public'], {
    graphiql: true,
    appendPlugins: [PgSimplifyInflectorPlugin],
    simpleCollections: 'both',
    watchPg: true,
    jwtSecret: '3sKva3TKpXEYhgFn',
    jwtPgTypeIdentifier: 'public.jwt_token',
  }),
);

上面代码中的 jwtPgTypeIdentifier 即是用作这个目的,这里指定了 public.jwt_token ,也就是说数据库中的任何函数,如果其返回值类型是 jwt_token ,接口都会先转换成 token 字符串。 jwtSecret 则是生成签名用的秘钥。

重新启动这个应用,再次调用登录接口,我们已经可以直接查询 jwtToken 本身而不是它的下级数据,调用结果也发生了相应的变化,返回值 data.login.jwtToken 就是一个有效的 token

login_graphiql_token.png

如果你将其进行解析,可以发现其中的数据(两个“.”之间的部分正是 jwt_token 类型中定义的结构:

$ echo eyJpZCI6OCwiZW1haWwiOiJleGFtcGxlQGdtYWlsLmNvbSIsImlhdCI6MTU1NTMzNzUwMiwiZXhwIjoxNTU1NDIzOTAyLCJhdWQiOiJwb3N0Z3JhcGhpbGUiLCJpc3MiOiJwb3N0Z3JhcGhpbGUifQ | base64 -d
{"id":8,"email":"example@gmail.com","iat":1555337502,"exp":1555423902,"aud":"postgraphile","iss":"postgraphile"}

5.2.7 在前端获取、保存与使用 token

整体的过程大概是这样的,我们先通过 graphql 接口取到 token ,然后将 token 放在 http 请求头部的 authorization 中,生成一个新的 graph 对象替换掉原来不带 authorization 的,这样后续的请求就会以这里登录的身份进行。

var graphqlLogin = function (email, password) {
    var login = graph(`mutation (
        $email: String!, 
        $password: String!
    ) {
        login(input: {
            email: $email, 
            password: $password
        }) {
            jwtToken
        }
    }`);

    login({
        email: email,
        password: password,
    }).then(function (data) {
        setToken(data.login.jwtToken);
    }).catch(function (err) {
        alert(err[0].message);
    });
});

通过修改 graph 变量的定义,我们可以让后续的接口都带上 token

var setToken = function (token) {
    graph = graphql('/graphql', {
        headers: {
            Authonrization: `Bearder ${token}`
        },
    })
}

可以看到,这里的 graph 没有 var 修饰,也就是使用了文件开头定义的那个变量,重新附上新的值后,再使用 graph 去调用接口,请求头中就带上了身份。

以现在的做法,token 虽然被放到了 graph 对象中,但是一旦用户刷新页面,graph 就变回了最初的定义,所以我们需要把取到的 token 存在 sessionStorage 中, 刷新页面的时候也尝试从 sessionStorage 中恢复刚才的登录状态。

var graph;

var setToken = function (token) {
    token = token || sessionStorage.token || '';
    var options = {};
    if (token) {
        sessionStorage.token = token;
        options = {
            headers: {
                Authonrization: `Bearder ${token}`
            },
        };
    }
    graph = graphql('/graphql', options);
}

setToken();

将上面的代码放到 postodo.js 的顶部,我们就有了一个既可以设置新 token,也可以从 sessionStorage 中恢复登录状态的方法。

  • 没登录时,通过不带参数的 setToken,获得一个未设置 authorization 的 graph 对象
  • 登录时,调用 setToken(token) 获取一个设置了 authorization 的 graph 对象,并将 token 保存到 sessionStorage
  • 刷新时,通过不带参数的 setToken 从 sessionStorage 取回 token,创建出一个带 authonrization 的 graph 对象

现在我们有了 token,在我们发送请求时,服务器就可以识别出我们的身份,自然也就可以在创建待办事项时,知道是谁创建了它,在接下来的小节里,我们将展示如何在 PostgreSQL 数据库的函数中,使用token中的身份。

6 访问控制,行级权限与字段隐藏

7 用户注册验证,用订阅实现队列

8 其他(没考虑好如何融入整个产品,但又有用的解决方案)

8.1 查询唯一值

有时候要做一个列表或标签系统,其元素来自用户填写的内容,这时候需要通过 SELECT DISTINCT 取出所有独特的值做为后选项。

需要自定义一个查询函数如:

CREATE FUNCTION public.unique_zuoluo()
    RETURNS text[]
    LANGUAGE 'sql'
    STABLE 
AS $BODY$
    SELECT ARRAY(SELECT DISTINCT zuoluo FROM public.qrinfo)
$BODY$;

由其生成接口 Query.uniqueZuoluo

调用方式如下:

query {
  uniqueZuoluo
}

返回格式如下:

{
  "data": {
    "uniqueZuoluo": [
      "郭家峙村",
      "小郭家峙村"
    ]
  }
}