Skip to content

A demo for building a Koa server which support Graphql

Notifications You must be signed in to change notification settings

huhk-sysu/try-koa-graphgl

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

11 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

编写简单的GraphQL服务器

教程来源

GraphQL官方文档

koa相关

jsonwebtoken相关

apollo-server相关

dataloader相关

0. 准备工作

  • 安装相关依赖
yarn add koa koa-bodyparser koa-router apollo-server-koa graphql-tools graphql@0.10.5 mongoose jsonwebtoken graphql-subscriptions subscriptions-transport-ws dataloader

1. 查询

  • 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

2. 变更

  • 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参数是一个对象,包含了用于查询的参数(在本例中有urldescription)。

  • 测试服务器

3. 连接数据库

  • 开启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。但同时它也提供一个idsetter,使得你直接访问id即可获得_id的内容。因此这里不再需要编写Links.id的求解方法。

另外,这里直接假定数据库操作成功,而暂时没有处理异步出错的情况,仅作为演示使用。

  • 测试服务器

查看数据库内部:

4. 授权

  • 新建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!
  }

上面除了添加了新建用户的方法,还添加了注册函数的方法,它们都接收namepassword两个参数,前者返回一个User对象,后者返回一个SigninPayload对象,内含token和user。

  • src/mongo-connector.js里添加mongooseUser定义。
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.jsexports的改动。同时引入上面的授权手段。
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'`,
  })
);
  • 测试服务器

新建用户

用户登入

新建链接-已授权

全部链接

新建链接-未授权

5. 更多更改

  • src/schema/index.js里新增一个方法createVote, 新增一个类型Vote,并且修改LinkUser的定义。
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.userVote.link的求解,还有Link.votesUser.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

查看某个User投过的Link(还有一部分响应未截图):

6. 使用dataloader

  • 首先,在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.jssrc/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次。

7. 错误处理

  • 其实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错误。

8. ???

由于数数错误被我跳过了……

9. 订阅

  • 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的函数,利用pubsubasyncIterator即可。

之后,每当有新的Link创建,就应当触发订阅,因此在创建Linkpublish一下。

  • 为了处理订阅的请求,需要一个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:

10. 过滤

  • 打算增加搜链接时的过滤功能。先修改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_containsdescription_contains不存在的情况。这里使用正则表达式匹配的方式进行搜索。

  • 测试服务器

不带参数:

搜不到结果:

url匹配:

description匹配:

共同匹配:

11. 分页。

  • 希望能在筛选结果的基础上,添加跳过前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:

About

A demo for building a Koa server which support Graphql

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published