如何用ExpressJS和Typescript构建GraphQL APIs

110 阅读10分钟

如何使用ExpressJS和Typescript构建GraphQL APIs

GraphQL是一种API规范,类似于RESTSOAP规范。在GraphQL中,所有的请求都是通过一个单一的URL端点访问相同的HTTP方法。

在这篇文章中,你将学习如何使用Express.js构建一个GraphQL API。此外,你还将学习如何用Mongoose作为包装器将你的API连接到MongoDB这样的数据库系统。最后,我们将研究如何将代码库转换为Typescript。

gql

现在你已经看到了GraphQL的作用,你可能会想,为什么会有这么多关于它的噪音?为什么我们不能坚持使用REST或SOAP APIs?

GraphQL如何比REST或SOAP更好?

下面的原因并不意味着REST或SOAP规范已经过时了。相反,它们只是强调了GraphQL的优势领域。

只用一个端点访问API

假设你有一个图书集。

客户端将使用相同的URL来访问书籍集合中的所有书籍、关于一本书的作者信息,以及用一个端点访问更多的书籍。

这使得用一个请求获得多种资源成为可能。这很酷,对吗?

GraphQL根据查询来获取数据

假设我们想在一个标准的REST API中获取一个用户创建的所有书籍。我们可以通过访问api/v1/users/books/ ,来获取一个图书数组。

但是,在我们只需要每个书的书title 的情况下,API给我们的东西比我们需要的多。

我们不希望开始请求我们已经拥有的东西。相反,我们应该请求我们没有的东西,比如书的_id

但是通过GraphQL,我们可以查询所有的书,并且只获得每本书的title 。因此,我们可以做一些像下面这样的事情,服务器将返回一个只有title 字段的图书数组。

{
    books {
        title
    }
}

关于GraphQL的更多信息

GraphQL查询

GraphQL查询是用来在服务器上执行 "读取 "操作的。你通常会用GET 请求来进行这些操作。

gql queries

GraphQL突变

GraphQL突变通常用于在服务器上执行 "写 "操作,我们通常会在REST架构中使用POSTPUTDELETE

gql mutations

GraphQL订阅

如果你熟悉WebSockets,那么你会喜欢GraphQL订阅。GraphQL订阅是用来创建实时事件和连接的。

gql subscriptions

GraphQL模式

模式用于定义GraphQL API期望的数据种类以及它反馈给客户端的数据种类。

它也可以作为使用我们API的人的文档。因此,我们甚至可能不需要再为我们的API写文档。

这也有助于数据验证,因为GraphQL解析了请求,如果客户端的数据与服务器的期望值不一致,就会返回一个错误。

gql schema

构建GraphQL API

现在我们知道GraphQL是什么了,让我们讨论一下如何使用这个规范来构建一个简单的API。

我假设你对Express.js有一个基本的了解。如果你没有,你可以看看篇文章。

开始--安装Deps和设置项目

首先,我们将创建一个图书API,用于创建、更新、删除、获取所有和获取一本图书。

我们将不得不创建我们的项目并进行设置。

让我们把这个API命名为bookrr 。创建一个新的npm 项目并将其命名为bookrr 。你的项目结构应该看起来像我们下面的样子。

project structure

接下来,将start 脚本添加到你的package.json 文件中。

"start": "node server.js"

最后,我们需要安装我们的依赖项。

npm i express express-graphql dotenv graphql

我们需要express来运行服务器和管理路由,需要express-graphql来创建模式和路由的处理程序。

GraphQL是我们用来建立模式的,dotenv是用来访问环境变量的。

创建模板代码

让我们开始编写代码。

在你的server.js 文件中添加以下应用设置。

const express = require('express');
const { graphqlHTTP } = require('express-graphql');
const { schema, resolver } = require('./api/schema');
const envs = require('./envs');

const app = express();
app.use(express.json());

app.use(
    envs.graphqlPath,
    graphqlHTTP((request, response, graphQLParams) => ({
        schema,
        rootValue: resolver,
        graphiql: true,
        context: {
            request,
            response,
        },
    }))
);

app.listen(envs.port, () => {
    console.log(`Server is running at http://localhost:${envs.port} ${envs.graphqlPath}`);
});

前四行是我们为这个文件导入所有的依赖关系的地方。

  • 我们从导入express 开始,然后从express-graphql 导入graphqlHTTP 对象,它涉及到使处理程序成为GraphQL处理程序。
  • 它接收了一个带有三个参数的回调函数:request,response, 和graphQLParams ,并返回一个带有API的schema 的对象。
  • root 是API根据查询或突变返回的东西,graphiql ,它告诉GraphQL使我们的API有一个网络客户端,我们可以在浏览器上测试它,最后是context 对象。

建议创建一个envs.js 文件,封装我们将在应用程序中使用的所有环境变量。

现在,我们已经添加了PORTGRAPHQL_PATH

envs.js 文件将看起来像这样。

const { config } = require( 'dotenv');

config();

module.exports = {
    port: process.env.PORT || 3000,
    graphqlPath: process.env.GRAPHQL_PATH || '/graphql',
}

Lines 9 - 20 是大部分魔法发生的地方。

我们已经为GraphQL代码创建了一个路由,其中graphqlHTTP 为该路由创建了一个处理器。然而,如果我们现在运行这个应用程序,我们会得到错误,因为我们没有定义schemaroot

API模式

在根文件夹下创建一个名为schema 的文件夹,其中包含以下文件:schema.js,mutation.js,query.js, 和index.js

types.js 这是我们将为响应和输入编写应用程序模式的地方。在 ,我们写出突变的解析器, 导入这些文件并从 导出 。mutation.js index.js types.js schema

在你的schema.js ,添加以下内容。

const { buildSchema } = require('graphql');

const schema = buildSchema(`
    type Query {
        books(limit: Int): [Book]
        book(id: ID!): Book
    }
    type Mutation {
        addBook(title: String!, author: String!, description: String!): BookResponse
        updateBook(id: ID!, title: String, author: String, description: String): BookResponse
        deleteBook(id: ID!): BookResponse
    }
    type Book {
        id: ID!
        title: String!
        author: String!
        description: String!
    }
    type Books {
        books: [Book]
    }
    type BookResponse {
        data: Book
        error: String
        ok: Boolean
    }
`);

module.exports = schema;

buildSchema

如果你把鼠标悬停在buildSchema ,你应该看到我们上面的内容。

唯一需要的参数是一个字符串,它是我们应用程序的模式。

但是,如果我们有一个广泛的应用程序,有许多突变、查询和类型呢?那么,使用这个就不是一个好主意了。

在这种情况下,你总是可以使用apollo graphql,这已经超出了本文的范围。这里,模式包含QueryMutation ,以及一些其他类型,如BookBooks

Query 包含booksbook ,表示所有的书和单一的书。另外,我们为书籍查询添加了一个可选的参数。 limit (一个数字),它限制了输出的范围。最后,book 查询包含ID ,它搜索所请求的书。

Mutation 包含addBook,updateBook, 和deleteBook 的声明及其参数。它们都有相同的响应类型,Book ,因为我们处理的是一本书。

Book 定义了单一书籍的结构,[Book] 将书籍定义为一个Book 的数组。

请注意,感叹号意味着该字段是必须的。

所有用于突变的输入都是必须的。现在我们已经设置了所有的模式,是时候编写解析器了。

GraphQL 查询解析器

在你的query.js 文件中,添加以下内容。

const booksData = require('./data')

const query = {
    books: async ({limit}, context) => {
        return limit ? booksData.slice(0, limit) : booksData;
    },
    book: async ({id}, context) => {
        return booksData.find(book => book.id === id);
    }
};

module.exports = query;

query.js 文件中,我们导出一个包含两个函数的对象,booksbook ,这些函数是我们在模式中的Query 内定义的。每个函数都有两个参数。

根据模式,第一个是我们取消结构的参数。第二个参数是上下文对象,它包含我们请求的细节。

请记住,在我们的graphQLHttp 函数中,我们返回了contextresponse 作为我们的上下文。

另外,由于我们是在用假数据工作,我们创建了一个data.js ,在那里我们将存储与书籍有关的细节。data.js 文件应该看起来像这样。

在与数据库整合后,我们将不会使用这个文件。

module.exports = [
    {
        id: "1",
        title: "Building Data-Intensive Applications",
        description: "The big ideas behind reliable, scalable and maintainable systems",
        author: "Martin Kleppmann"
    },
    {
        id: "2",
        title: "Docker In Action",
        description: "Docker in action teaches you everything you need to know in docker",
        author: "Jeff KleNickoloffppmann"
    },
    {
        id: "3",
        title: "The Art of Unit Testing",
        description: "The Art of Unit Testing teaches you everything you need to know in unit testing",
        author: "Roy Osherove"
    },
    {
        id: "4",
        title: "Site Reliability Engineering",
        description: "How Google runs production systems",
        author: "Betsy Beyer"
    }
]

GraphQL变异解析器

我们还需要创建我们的突变。在你的mutation.js 文件中,添加以下内容。

let books = require('./data')

const mutation = {
    addBook: async ({ title, author, description }, context) => {
        const book = { id: `${books.length+1}`, title, author, description }
        books.push(book)
        return {
            data: book,
            ok: true,
            error: ''
        };
    },

    updateBook: async ({ id, title, author, description }, context) => {
        const book = books.find(book => book.id === id);
        if (!book) {
            return {
                data: null,
                ok: false,
                error: 'Book not found'
            };
        }

        if (author) book.author = author
        if (title) book.title = title
        if (description) book.description = description
        books = books.map(b => b.id === id ? book : b)
        return {
            data: book,
            ok: true,
            error: ''
        };
    },

    deleteBook: async ({ id }, context) => {
        const book = books.find(book => book.id === id)
        if (!book) {
            return {
                data: null,
                ok: false,
                error: 'Book not found'
            };
        }

        books = books.filter(book => book.id !== id)
        return {
            data: book,
            ok: true,
            error: ''
        };
    }
};

module.exports = mutation

突变与查询类似。我们不是进行读操作,而是根据需要进行写操作。我们不只是返回'book'对象,而是指定我们将返回三个键。data,ok, 和error 在模式中。

在运行我们的应用程序之前,需要做的最后一件事是向我们的schema/index.js 添加一些数据。

在你的schema/index.js 文件中添加以下内容。

const schema = require('./schema.js');
const query = require('./query.js');
const mutation = require('./mutation.js');

const resolvers = {
    ...query, ...mutation,
};

module.exports.resolver = resolvers;
module.exports.schema = schema;

这里,我们从schema 文件中创建一个模式对象,从querymutation 文件中创建一个resolver 对象。

server.js 文件中我们的graphQLHttp 中的rootValue 对象希望有一个包含我们模式的所有解析器的单一对象。

目前,我们把它们放在两个不同的文件中,querymutation ,所以我们需要把它们分散(合并)到一个对象中,如上图所示。

现在,我们拥有运行我们的应用程序所需的一切。

npm start 来运行你的应用程序。

在打开网址http://localhost:3000/graphql ,你应该看到以下内容。

gql server

如果你点击Docs ,你可以查看QueryMutation 的文档。

现在,让我们通过获取所有的书来测试我们的API。

books query

正如你所看到的,我们没有包括响应数据的任何描述,所以它不是我们响应的一部分。

让我们查询一本书,然后我们会同时进行两次查询。

book query

接下来,让我们测试一下突变的情况。

我们创建了一本新书,并从上面的图片中更新了一本现有的书。

如果书不存在,我们会得到一个 "没有找到书 "的错误。我们也可以看到,我们能够同时创建多个突变。

但是,我们唯一的限制是,我们不能同时创建一个查询和突变。

最后,让我们试着删除一本书。

delete book mutation

如果我们传递无效的数据,请求将不会被处理,因为GraphQL在处理请求之前会验证它。

将数据库集成到API中

本节涉及到将MongoDB和Mongoose添加到我们的应用程序中。

在根文件夹下创建一个名为db 的文件夹,并创建以下文件:books.js,index.js,dbUtils.js, 和connect.js

在你的books.js 文件中,添加以下内容。

const { Schema, model } = require('mongoose');

const bookSchema = new Schema({
    title: {
        type: String,
        required: true,
    },
    description: {
        type: String,
        required: true,
    },
    author: {
        type: String,
        required: true,
    },
});

module.exports = new model('Books', bookSchema);

在这里,我们创建了图书模型的模式并将其导出。

在你的connect.js 文件中,添加以下内容。

​​const mongoose = require('mongoose')
const envs = require('../envs')

mongoose.connect(envs.dbUrl, {
    useNewUrlParser: true,
    useUnifiedTopology: true,
})

module.exports = mongoose.connection

我们使用mongoose.connect 函数来连接到数据库。

同时,我们要在我们的envs.js 文件中添加另一个包含数据库连接URL的值,如图所示。

const { config } = require('dotenv');

config();

module.exports = {
    port: process.env.PORT || 3000,
    graphqlPath: process.env.GRAPHQL_PATH || '/graphql',
    dbUrl: process.env.DB_URL || 'mongodb://localhost:27017/graphql-starter',
}

最后,我们将db 对象和db 文件夹中的另外两个对象导出到db/index.js

const db = require('./connect');
const BookModel = require('./books');
const books = require('./dbUtils');

module.exports = {
    db,
    BookModel,
    books
};

为了使用server.js 文件中的这个连接,我们使用db.oncedb.on 方法导入db 对象。

添加以下内容,就在你的server.js 文件的最后一次导入之后。

const { db } = require('./db');
db.once('open', () => {
    console.log('Connected to MongoDB');
});

db.on('error', (err) => {
    console.log('Error connecting to MongoDB', err);
    process.exit(1);
});

我们需要添加辅助函数,执行所有的查询操作。这样做可以使查询和变异的逻辑更加整齐。

这将在db 目录下的dbUtils.js 文件中完成。例如,在这个文件内添加以下内容。

const BookModel = require('./books');

const getAllBooks = async (limit) => {
    return await BookModel.find({}).limit(limit);
}

const getBookById = async (id) => {
    return await BookModel.findById(id);
}

const createBook = async ({ title, description, author }) => {
    return await BookModel.create({ title, description, author });
}

const updateBook = async (id, { title, description, author }) => {
    const set = {};
    if (title) set.title = title;
    if (description) set.description = description;
    if (author) set.author = author;
    return await BookModel.findByIdAndUpdate(id, set);
}

const deleteBook = async (id) => {
    return await BookModel.findByIdAndDelete(id);
}

module.exports = {
    getAllBooks,
    getBookById,
    createBook,
    updateBook,
    deleteBook
}

这个文件很简单--我们有函数来获取所有的书,通过id获取书,创建一个新的书,更新一个书,以及删除一个书。

现在我们需要做的就是使用这些函数。

query.js 文件现在应该是这样的。

// const booksData = require('./data')
const { books} = require('../db/')

const query = {
    books: async ({limit}, context) => {
        // return limit ? booksData.slice(0, limit) : booksData;
        return await books.getAllBooks(limit)
    },

    book: async ({id}, context) => {
        // return booksData.find(book => book.id === id);
        return await books.getBookById(id)
    }
};

module.exports = query

同样地,更新mutation.js 文件,如图所示。

// let books = require('./data')
const { books } = require('../db')

const mutation = {
    addBook: async ({ title, author, description }, context) => {
        try {
            const book = await books.createBook({ title, author, description })
            // const book = { id: `${books.length+1}`, title, author, description }
            // books.push(book)
            return {
                data: book,
                ok: true,
                error: ''
            };
        } catch (error) {
            return {
                data: null,
                ok: false,
                error: error.message
            };
        }
    },

    updateBook: async ({ id, title, author, description }, context) => {
        // const book = books.find(book => book.id === id);
        // if (!book) {
        //     return {
        //         data: null,
        //         ok: false,
        //         error: 'Book not found'
        //     };
        // }
        // if (author) book.author = author
        // if (title) book.title = title
        // if (description) book.description = description
        // books = books.map(b => b.id === id ? book : b)
        try {
            const book = await books.updateBook(id, { title, author, description })
            if (!book) {
                return {
                    data: null,
                    ok: false,
                    error: 'Book not found'
                };
           }
            return {
                data: book,
                ok: true,
                error: ''
            };
        } catch (error) {
            return {
                data: null,
                ok: false,
                error: error.message
            };
        }
    },
    deleteBook: async ({ id }, context) => {
        // const book = books.find(book => book.id === id)
        // books = books.filter(book => book.id !== id)
        try {
            const book = await books.deleteBook(id)
            if (!book) {
                return {
                    data: null,
                    ok: false,
                    error: 'Book not found'
                };
            }
            return {
                data: book,
                ok: true,
                error: ''
            };
        }
        catch (error) {
            return {
                data: null,
                ok: false,
                error: error.message
            };
        }
    }
};

module.exports = mutation

现在,我们可以开始测试我们的应用程序了。

books query db

add books mutations

如果我们想多次创建同一个突变,我们需要使用一个别名。这是通过给每个突变一个独特的名字来实现的。

我们可以使用booksbook 查询,分别获得所有的书和单一的书。

books and book queries

最后,让我们测试一下updateBookdeleteBook 的突变。

update and delete book mutation

如果我们再运行一次这些突变,deleteBook 突变将返回 "未找到书籍错误"。

迁移到TypeScript

现在,让我们尝试将现有代码迁移到TypeScript,因为TypeScript允许我们指定数据类型,这有助于提高编译和执行时间。

如果你对Typescript不熟悉,你可以在这里查看官方文档。

我们首先将Typescript安装为一个开发依赖项。

然后,我们需要在我们的项目中设置Typescript,运行tsc --init ,生成一个tscconfig.json 文件。

在该文件中,将rootDir 的值设置为./ ,将outDir 的值设置为./dist 。由于我们不需要重新编译JavaScript文件,所以allowJs 行可以被注释。

rootDir 选项告诉Typescript找到你的源代码(Typescript)文件。outDir 选项告诉Typescript将编译后的文件放在哪里。

在你的package.json 文件中,在你的scripts 部分添加以下内容。

    "build": "tsc",
    "start": "node dist/server.js",
    "build:watch": "tsc --watch",
    "dev": "nodemon dist/server.js"

接下来,我们需要把所有的文件名从,比如说,xxx.js 改为xxx.ts

project structure

如果我们尝试编译我们的TypeScript代码,我们应该有几个错误,本文的剩余部分将涉及到修复这些类型错误。

我们还需要为我们的项目安装类型定义。这有助于TypeScript获得代码库中使用的函数和对象的数据类型。

npm i @types/express @types/mongoose @types/express-graphql @types/dotenv @types/graphql -D

我们还需要安装nodemon ,在开发模式下运行我们的应用程序。

npm i nodemon -D

我们需要将所有的require 转换成import 语句,将我们的module.exports 转换成export default

为了修复这些错误,我们必须在代码库中添加类型和接口,将所有require 语句改为import ,并为数据库模式和对象创建接口(或类型)。

为了运行应用程序,我们使用npm run build ,如果我们想在观察模式下运行代码,则使用npm run build:watch

然后,为了启动服务器,我们需要运行npm startnpm run dev ,以便在观察模式下运行它。现在一切都应该按预期工作。

总结

这篇文章已经指导你建立了一个简单的支持数据库的graphQL API。

向前看,现在我们已经有了我们的API并在运行,我们接下来可能要做的是添加一个认证中间件,或者只是一个向我们的request 对象添加中间件的方法。

我们构建schema 的方式甚至适用于大型应用程序。让我们举个例子,一个有查询和突变的应用程序,users,books,authors, 等等。首先,我们必须在schema/index.js 文件内的resolver 对象中取消所有的突变和查询。