- 安装相关依赖
yarn add koa koa-bodyparser koa-router apollo-server-koa graphql-tools graphql@0.10.5 mongoose jsonwebtoken graphql-subscriptions subscriptions-transport-ws dataloader
- 在
src/schema/index.js
中编写类型定义,注意要用字符串形式。
const typeDefs = `
type Link {
id: ID!
url: String!
description: String!
}
type Query {
allLinks: [Link!]!
}
`;
类型定义中,我们定义了一个有3个属性的类型Link
,同时定义了allLinks
,这个查询会返回一个含有若干Link
的数组。
- 在
src/schema/resolovers.js
中编写求解方法。在与数据库打交道之前,暂时先以硬编码的方式把数据内容写在此处。
const links = [
{
id: 1,
url: 'http://graphql.org/',
description: 'The Best Query Language'
},
{
id: 2,
url: 'http://dev.apollodata.com',
description: 'Awesome GraphQL Client'
},
];
module.exports = {
Query: {
allLinks: () => links,
},
};
求解方法告诉GraphQL
如何对allLinks
这个查询作出回应,这里是直接返回定义好的内容。其中的allLinks
名称应当与之前的类型定义里的对应。
- 在
src/schema/index.js
中使用graphql-tools
提供的工具,结合刚才的类型定义和求解方法生成schema并导出。
const {makeExecutableSchema} = require('graphql-tools');
const resolvers = require('./resolvers');
// ...
module.exports = makeExecutableSchema({typeDefs, resolvers});
- 在
src/index.js
中编写、应用路由和中间件。
const koa = require('koa');
const koaRouter = require('koa-router');
const koaBody = require('koa-bodyparser');
const { graphqlKoa, graphiqlKoa } = require('apollo-server-koa');
const schema = require('./schema');
const app = new koa();
const router = new koaRouter();
const PORT = 3000;
router.post('/graphql', koaBody(), graphqlKoa({ schema }));
router.get('/graphql', graphqlKoa({ schema }));
router.get('/graphiql', graphiqlKoa({ endpointURL: '/graphql' }));
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(PORT, () => {
console.log(`Server is running. Test server on http://localhost:${PORT}/graphiql .`);
});
GraphiQL
是一个浏览器内部的有图形化界面的交互式IDE,可以让我们方便地测试服务器的功能。
- 测试服务器
启动服务器。
node ./src/index.js
访问 http://localhost:3000/graphiql 。
- 在
src/schema/index.js
中添加类型定义。
type Mutation {
createLink(url: String!, description: String!): Link
}
这里定义了一个名为createLink
的变更,它接收2个字符串作为参数,同时返回一个Link
类型的对象。
- 在
src/schema/resolovers.js
中添加求解方式。
Mutation: {
createLink: (_, data) => {
const newLink = Object.assign({ id: links.length + 1 }, data);
links.push(newLink);
return newLink;
},
},
这里创建一个新对象并添加到links
数组里。data
参数是一个对象,包含了用于查询的参数(在本例中有url
和description
)。
- 测试服务器
-
开启mongoDB服务器。
-
在
src/mongo-connector.js
中定义Link
的模型并导出,同时进行连接mongoDB服务器的操作。
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const MONGO_URL = 'mongodb://localhost:27017/hackernews'
const linkSchema = new Schema({
url: String,
description: String
})
mongoose.Promise = global.Promise;
mongoose.connect(MONGO_URL, { useMongoClient: true });
module.exports = mongoose.model('Links', linkSchema)
- 在
src/index.js
里获取上面文件导出的内容,并包装在context
里。这样在求解方法的第三个参数中可以获取它,从而进行数据库操作。
const Links = require('./mongo-connector');
router.post(
'/graphql',
koaBody(),
graphqlKoa({
context: { Links },
schema,
})
);
router.get(
'/graphql',
graphqlKoa({
context: { Links },
schema,
})
);
- 把
src/schema/resolovers.js
里原来硬编码的内容删掉,因为已经使用数据库了。同时修改其内容,使用数据库的操作方式。
module.exports = {
Query: {
allLinks: async (root, data, { Links }) => {
return await Links.find();
},
},
Mutation: {
createLink: async (root, data, { Links }) => {
return await Links.create(data);
},
}
};
值得一提的是,mongoose
里面会自动给你添加一个_id
属性,而不是id
。但同时它也提供一个id
的setter
,使得你直接访问id
即可获得_id
的内容。因此这里不再需要编写Links.id
的求解方法。
另外,这里直接假定数据库操作成功,而暂时没有处理异步出错的情况,仅作为演示使用。
- 测试服务器
查看数据库内部:
- 新建
src/config/index.js
,导出配置。内容暂时只有一个,就是服务器的SECRET
。
module.exports = {
SECRET: "secret"
}
- 在
src/schema/index.js
里添加User
的类型定义
type User {
id: ID!
name: String!
password: String!
}
type SigninPayload {
token: String
user: User
}
type Mutation {
createUser(name: String!, password: String!): User
signinUser(name: String!, password: String!): SigninPayload!
}
上面除了添加了新建用户的方法,还添加了注册函数的方法,它们都接收name
和password
两个参数,前者返回一个User
对象,后者返回一个SigninPayload
对象,内含token和user。
- 在
src/mongo-connector.js
里添加mongoose
的User
定义。
const userSchema = new Schema({
name: String,
password: String,
});
module.exports = {
Links: mongoose.model('Links', linkSchema),
Users: mongoose.model('Users', userSchema),
};
- 在
src/schema/resolovers.js
里添加求解方法。
const jwt = require('jsonwebtoken');
const { SECRET } = require('../config');
Mutation: {
createUser: async (root, data, { mongo: { Users } }) => {
return await Users.create(data);
}
SigninUser: async (root, data, { mongo: { Users } }) => {
const user = await Users.findOne({ name: data.name });
if (data.password === user.password) {
return {
token: jwt.sign({ id: user.id }, SECRET),
user,
};
}
},
}
这里建立用户仅供演示,并没有增加什么验证方式(例如,可能出现用户名相同的情况,导致后续出错,但这里不考虑)。
在用户登入过程中,使用jsonwebtoken
库,用预定义的SECRET
把用户的id
生成一串token
作为响应。
在以后的请求中,应当带上这样的请求头:
"Authorization": "Bearer xxxx"
其中xxxx就是刚刚得到的token
,这是用来验证用户身份的。
- 新增
src/authentication.js
,他会检验传入的请求的header
里是否有我们希望的token
,并尝试解码出用户的id
。注意这里如果检验失败,返回值就是undefined
了。
const jwt = require('jsonwebtoken');
const { SECRET } = require('./config');
module.exports.authenticate = async (ctx, Users) => {
if (!ctx.header || !ctx.header.authorization) {
return;
}
const parts = ctx.header.authorization.split(' ');
if (parts.length === 2) {
const scheme = parts[0];
const token = parts[1];
if (/^Bearer$/i.test(scheme)) {
const { id } = jwt.verify(token, SECRET);
return await Users.findOne({ _id: id });
}
}
};
- 略微修改
src/index.js
,以适应src/mongo-connector.js
的exports
的改动。同时引入上面的授权手段。
const { authenticate } = require('./authentication');
const mongo = require('./mongo-connector');
const buildOptions = async ctx => {
const user = await authenticate(ctx, mongo.Users);
return {
context: { mongo, user },
schema,
debug: false,
};
};
router.post('/graphql', koaBody(), graphqlKoa(buildOptions));
这里用一个函数buildOptions
取代了原来固定的对象,这样可以根据ctx
的情况来动态生成context
。另外这里debug
的值的含义是,当有错误被抛出时,是否在控制台打印错误信息及其调用栈。默认值是true
,方便调试。
- 回到
src/schema/index.js
,修改Link
的定义,这样可以记录这个Link
是谁发布的。
type Link {
id: ID!
url: String!
description: String!
postedBy: User
}
- 回到
src/schema/resolovers.js
,更新求解方法。这里检测如果context
里没有user
,就抛出错误,否则就正常新建链接。另外要编写Link.postedBy
的求解方法,也就是根据id
去数据库里找出对应的用户就好了。
Mutation: {
createLink: async (root, data, { mongo: { Links }, user }) => {
if (!user)
throw new Error('Unauthorized');
const newLink = Object.assign({ postedById: user._id }, data);
return await Links.create(newLink);
}
}
Link: {
postedBy: async ({ postedById }, data, { mongo: { Users } }) => {
return await Users.findOne({ _id: postedById });
}
}
- 为了能使用
GraphiQL
正常测试,修改src/index.js
。下面的xxxx要替换成计算的token
。
router.get(
'/graphiql',
graphiqlKoa({
endpointURL: '/graphql',
passHeader: `'Authorization': 'Bearer xxxx'`,
})
);
- 测试服务器
新建用户
用户登入
新建链接-已授权
全部链接
新建链接-未授权
- 在
src/schema/index.js
里新增一个方法createVote
, 新增一个类型Vote
,并且修改Link
和User
的定义。
type Link {
id: ID!
url: String!
description: String!
postedBy: User!
votes: [Vote!]!
}
type User {
id: ID!
name: String!
password: String!
votes: [Vote!]!
}
type Mutation {
createLink(url: String!, description: String!): Link
createVote(linkId: ID!): Vote
createUser(name: String!, authProvider: AuthProviderSignupData!): User
signinUser(email: AUTH_PROVIDER_EMAIL): SigninPayload!
}
- 在
src/mongo-connector.js
里补充Vote
的模型。
const VoteSchema = new Schema({
userId: Schema.Types.ObjectId,
linkId: Schema.Types.ObjectId,
});
module.exports = {
Links: mongoose.model('Links', linkSchema),
Users: mongoose.model('Users', userSchema),
Votes: mongoose.model('Votes', VoteSchema),
};
注意这里我们只储存了id,到时候根据id去数据库拿出具体的对象。
- 在
src/schema/resolovers.js
里编写createVote
的求解方法,以及Vote.user
和Vote.link
的求解,还有Link.votes
和User.votes
的求解。
module.exports = {
Link: {
votes: async ({ _id }, data, { mongo: { Votes } }) => {
return await Votes.find({ linkId: _id });
},
},
User: {
votes: async ({ _id }, data, { mongo: { Votes } }) => {
return await Votes.find({ userId: _id });
},
},
Vote: {
user: async ({ userId }, data, { mongo: { Users } }) => {
return await Users.findOne({ _id: userId });
},
link: async ({ linkId }, data, { mongo: { Links } }) => {
return await Links.findOne({ _id: linkId });
},
},
Mutation: {
createVote: async (root, data, { mongo: { Votes }, user }) => {
if (!user) throw new Error('Unauthorized');
const newVote = Object.assign({ userId: user._id }, data);
return await Votes.create(newVote);
}
}
};
从上面的求解方法可以看出,其实就是根据id去查对应的对象而已。
- 测试服务器
新建投票
下面的例子中,huhk
用户为百度和网易链接各投一票,xiaoming
用户为百度链接投了一票。
查看为某个Link
投票的User
:
- 首先,在
src/mongo-connector.js
里,使用mongodb
自带的Logger
来记录数据库查询的次数。
const Logger = mongoose.mongo.Logger;
let logCount = 0;
Logger.setCurrentLogger((msg, state) => {
console.log(`MONGO DB REQUEST No. ${++logCount}`);
});
Logger.setLevel('debug');
Logger.filter('class', ['Cursor']);
- 启动服务器进行查询
{
allLinks {
url
votes {
user {
name
votes {
link {
url
}
}
}
}
}
}
查看结果,一共查询了15次。
-
dataloader
是一个用于简化数据库读写的库。它有可以把在一段时间内进行的若干次查询会合并成一次查询,同时还可以缓存已有结果,以此减少访问数据库的次数,提高效率。 -
新建文件
src/dataloader.js
。
const DataLoader = require('dataloader');
async function batch(Model, keys) {
return result = await Model.find({ _id: { $in: keys } });
}
const cacheKeyFn = key => key.toString();
module.exports = ({ Users, Links, Votes }) => ({
userLoader: new DataLoader(keys => batch(Users, keys), { cacheKeyFn }),
linkLoader: new DataLoader(keys => batch(Links, keys), { cacheKeyFn }),
voteLoader: new DataLoader(keys => batch(Votes, keys), { cacheKeyFn }),
});
dataloader
的构造函数里,第一个参数是一个函数,要求他能接收一个带有一系列key
的数组,并返回一个promise
,内容为与key
一一对应的数据内容。
具体而言,例如一小段时间内,我分别对id为1,2,3的用户进行3次查询,则可以合并为一次对id在[1,2,3]内的用户的查询,这样就可以节省资源了。
而cacheKeyFn
是一个映射函数,mongoose
返回的id
们其实是对象而不是字符串,这样在作相等比较的时候就会失败,因此要先转换为字符串。
最后,这个函数每次都会新建一个dataloader
。这是因为希望它只在一次查询中生效。
- 修改
src/index.js
,把dataloader
放进context
里。
const buildDataloaders = require('./dataloader');
const buildOptions = async ctx => {
const user = await authenticate(ctx, buildDataloaders(mongo));
return {
context: { mongo, user, dataloaders: buildDataloaders(mongo) },
schema,
debug: false,
};
};
- 修改
src/schema/resolovers.js
和src/authentication.js
,使用dataloader
获取数据。
module.exports.authenticate = async (ctx, {userLoader}) => {
// ......
if (/^Bearer$/i.test(scheme)) {
const { id } = jwt.verify(token, SECRET);
return await userLoader.load(id);
}
}
};
Link: {
postedBy: async ({ postedById }, data, { dataloaders: { userLoader } }) => {
return await userLoader.load(postedById);
},
},
Vote: {
user: async ({ userId }, data, { dataloaders: { userLoader } }) => {
return await userLoader.load(userId);
},
link: async ({ linkId }, data, { dataloaders: { linkLoader } }) => {
return await linkLoader.load(linkId);
},
},
- 测试服务器,再次使用刚才相同的内容进行查询。
可见查询次数从15次减少到了9次。
- 其实
GraphQL
自带了基础的错误处理,如图。
但我们可以在这个基础上补充一些错误。
- 在
src/schema/resolovers.js
里新定义2个Error
与一个检测url是否合法的方法:
const { URL } = require('url');
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.field = field;
}
}
class authenticationError extends Error {
constructor(message, hint) {
super(message);
this.hint = hint;
}
}
function assertValidLink({ url }) {
try {
new URL(url);
} catch (error) {
throw new ValidationError('Link validation error: invalid url.', 'url');
}
}
- 改写
createLink
方法。
Mutation: {
createLink: async (root, data, { mongo: { Links }, user }) => {
if (!user) {
throw new authenticationError(
'User authentication error: please bear your token in header.',
'You will get the token after login.'
);
}
assertValidLink(data);
const newLink = Object.assign({ postedById: user._id }, data);
return await Links.create(newLink);
},
}
- 新建
src/formatError.js
并在src/index.js
里引用它。
const { formatError } = require('graphql');
module.exports = error => {
const data = formatError(error);
const { originalError } = error;
if (originalError) {
delete data.locations;
data.field = originalError.field;
data.hint = originalError.hint;
}
return data;
};
这里使用formatError
后得到的对象就是之前响应中errors
的内容了。然后我们检查一下它是是否是派生的Error
,如果是我们刚才定义的url错误或者认证错误之一,就把没什么用的error.locations
给删掉,然后补充上我们自定义的内容。这样新的响应就能显示我们希望的内容了。
const formatError = require('./formatError');
const buildOptions = async ctx => {
const user = await authenticate(ctx, buildDataloaders(mongo));
return {
context: { mongo, user, dataloaders: buildDataloaders(mongo) },
schema,
formatError,
debug: false,
};
};
- 测试服务器
这是认证错误。
这是url错误。
由于数数错误被我跳过了……
- 在
src/schema/index.js
里新增定义。
type Subscription {
LinkCreated: Link!
}
- 在
src/schema/resolovers.js
里编写求解方法。
const { PubSub } = require('graphql-subscriptions');
const pubsub = new PubSub();
Mutation: {
createLink: async (root, data, { mongo: { Links }, user }) => {
if (!user) {
throw new authenticationError(
'User authentication error: please bear your token in header.',
'You will get the token after login.'
);
}
assertValidLink(data);
const newLink = Object.assign({ postedById: user._id }, data);
const response = await Links.create(newLink);
pubsub.publish('LinkCreated', { LinkCreated: response });
return response;
},
},
Subscription: {
LinkCreated: {
subscribe: () => pubsub.asyncIterator('LinkCreated'),
},
},
这里Subscription.LinkCreated
是一个对象,有一个键为subscribe
,键值为一个返回AsyncIterator
的函数,利用pubsub
的asyncIterator
即可。
之后,每当有新的Link
创建,就应当触发订阅,因此在创建Link
后publish
一下。
- 为了处理订阅的请求,需要一个
WebSocket
链接。改写src/index.js
。
const { execute, subscribe } = require('graphql');
const { createServer } = require('http');
const { SubscriptionServer } = require('subscriptions-transport-ws');
router.get(
'/graphiql',
graphiqlKoa({
endpointURL: '/graphql',
passHeader: `'Authorization': 'Bearer xxxx'`,
subscriptionsEndpoint: `ws://localhost:${PORT}/subscriptions`,
})
);
const server = createServer(app.callback());
server.listen(PORT, () => {
SubscriptionServer.create(
{ execute, subscribe, schema },
{ server, path: '/subscriptions' }
);
console.log(
`Server is running. Test server on http://localhost:${PORT}/graphiql .`
);
});
一方面是给graphiqlKoa
多加一个subscriptionsEndpoint
属性,方便调试。另外一方面是给listener
函数多传一个中间件SubscriptionServer
。
- 测试服务器。分2个tab打开测试页面。
第一个tab里订阅:
第二个tab里创建:
返回第一个tab:
- 打算增加搜链接时的过滤功能。先修改
src/schema/index.js
里的定义。
input LinkFilter {
description_contains: String
url_contains: String
}
type Query {
allLinks(filter: LinkFilter): [Link!]!
}
- 然后修改
src/schema/resolovers.js
。
Query: {
allLinks: async (root, { filter }, { mongo: { Links } }) => {
let query = {};
if (filter) {
let { url_contains, description_contains } = filter;
if (url_contains) {
query.url = { $regex: new RegExp(`${url_contains}`), $options: 'i' };
}
if (description_contains) {
query.description = {
$regex: new RegExp(`${description_contains}`),
$options: 'i',
};
}
}
return await Links.find(query);
},
}
要考虑一下filter
不存在或者url_contains
和description_contains
不存在的情况。这里使用正则表达式匹配的方式进行搜索。
- 测试服务器
不带参数:
搜不到结果:
url匹配:
description匹配:
共同匹配:
- 希望能在筛选结果的基础上,添加跳过前N项以及选择前N项的功能,以此配合前端实现分页。在
src/schema/index.js
里改写定义。
type Query {
allLinks(filter: LinkFilter, skip: Int, first: Int): [Link!]!
}
- 在
src/schema/resolovers.js
里略微修改求解方法。
Query: {
allLinks: async (root, { filter, first, skip }, { mongo: { Links } }) => {
let query = {};
first = first || 0;
skip = skip || 0;
if (filter) {
let { url_contains, description_contains } = filter;
if (url_contains) {
query.url = { $regex: new RegExp(`${url_contains}`), $options: 'i' };
}
if (description_contains) {
query.description = {
$regex: new RegExp(`${description_contains}`),
$options: 'i',
};
}
}
return await Links.find(query).limit(first).skip(skip);
}
}
- 测试服务器
完整的链接:
测试skip:
测试first:
同时测试:
配合filter: