作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
伊奥拉姆·戈达泽的头像

Ioram Gordadze

Ioram设计并开发了数据库, API services, 私人和政府机构也有更多.

Previously At

WorkSpan
Share

Foreword

A few years ago, Facebook引入了一种名为GraphQL的构建后端api的新方法, 哪个基本上是用于数据查询和操作的特定于领域的语言. At first, 我没怎么注意它, but eventually, 我发现自己参与了Toptal的一个项目, 我必须实现基于GraphQL的后端api. 从那时起,我开始学习如何将所学的REST知识应用到GraphQL中.

这是一次非常有趣的经历, 在实施期间, 我不得不以一种对graphql更友好的方式重新考虑REST api中使用的标准方法和方法. In this article, 我试图总结在第一次实现GraphQL api时需要考虑的常见问题.

Required Libraries

GraphQL是由Facebook内部开发的,并于2015年公开发布. 2018年晚些时候,GraphQL项目从Facebook转移到新成立的Facebook GraphQL Foundation,由非营利组织Linux基金会主办,该基金会负责维护和开发 GraphQL查询语言规范 and JavaScript的参考实现.

由于GraphQL仍然是一项年轻的技术,并且最初的参考实现可用于JavaScript, 大多数成熟的库都存在于Node中.js ecosystem. 还有另外两家公司, Apollo and Prisma,为GraphQL提供开源工具和库. 本文中的示例项目将基于以下两家公司提供的面向JavaScript的GraphQL参考实现和库:

在GraphQL的世界里, 使用GraphQL模式描述api, and for these, 该规范定义了自己的语言,称为GraphQL模式定义语言(SDL)。. SDL使用起来非常简单和直观,同时又非常强大和富有表现力.

有两种方法可以创建GraphQL模式:代码优先方法和模式优先方法.

  • 在代码优先的方法中,将GraphQL模式描述为基于的JavaScript对象 graphql-js 库,并且SDL是从源代码自动生成的.
  • 在模式优先方法中, 使用SDL描述GraphQL模式,并使用Apollo连接业务逻辑 graphql-tools library.

就我个人而言,我更喜欢模式优先的方法,并将其用于 sample project in this article. 我们将实现一个经典的书店示例,并创建一个后端,该后端将提供用于创建作者和图书的CRUD api,以及用于用户管理和身份验证的api.

创建基本的GraphQL服务器

运行一个基本的GraphQL服务器, 我们必须创建一个新项目, 用NPM初始化它, 并配置Babel. 要配置Babel,首先使用以下命令安装所需的库:

NPM install——save-dev @babel/core @babel/cli @babel/preset-env @babel/node 

在安装Babel之后,创建一个文件名如下的文件 .babelrc 在我们的项目根目录中复制以下配置:

{
    "presets": [
        [
            "@babel/env",
            {"targets": {"node": "current"}}
        ]
    ]
}

Also edit the package.json 文件并将以下命令添加到 scripts section:

{
    ...
    "scripts": {
        “服务”:“babel节点索引”.js"
    },
    ...
}

一旦我们配置了Babel,用下面的命令安装所需的GraphQL库:

NPM install——save express apollo-server-express graphql

在安装所需的库之后, 以最少的设置运行GraphQL服务器, 将此代码片段复制到 index.js file:

从'graphql-tag'导入GQL;
从“express”中输入express;
从'apollo-server-express'中导入{ApolloServer, makeExecutableSchema};

端口=进程.env.PORT || 8080;

//使用GraphQL SDL定义api
const typeDefs = gql '
   type Query {
       sayHello(名称:字符串!): String!
   }

   type Mutation {
       sayHello(名称:字符串!): String!
   }
`;

//在SDL中为API定义定义解析器映射
Const解析器= {
   Query: {
       sayHello: (obj, args, context, info) => {
           返回`Hello ${参数.name }!`;
       }
   },

   Mutation: {
       sayHello: (obj, args, context, info) => {
           返回`Hello ${参数.name }!`;
       }
   }
};

//配置express
Const app = express();

//基于SDL定义和解析器映射构建GraphQL模式
const schema = makeexecutablesschema ({typeDefs, resolvers});

//创建Apollo服务器
const apolloServer = new apolloServer ({schema});
apolloServer.applyMiddleware({app});

// Run server
app.listen({ port }, () => {
   console.${apolloServer . log('🚀服务器已在http://localhost:${端口就绪}.graphqlPath }`);
});

在此之后,我们可以使用命令运行服务器 npm run serve,如果我们在web浏览器中导航到URL http://localhost:8080/graphql, GraphQL的交互式可视化shell, called Playground, will open, 在哪里我们可以执行GraphQL查询和修改并查看结果数据.

在GraphQL的世界里, API函数分为三组, called queries, mutations, 和订阅:

  • Queries 是客户端用来向服务器请求所需数据的吗.
  • Mutations 是客户端用来在服务器上创建/更新/删除数据的.
  • Subscriptions 客户端用来创建和维护与服务器的实时连接. 这使客户机能够从服务器获取事件并进行相应的操作.

在本文中,我们将只讨论查询和突变. 订阅是一个巨大的主题——值得专门写一篇文章,并且不是每个API实现都需要订阅.

高级标量数据类型

在使用GraphQL之后, 您将发现SDL只提供基本数据类型, 以及高级标量数据类型,如Date, Time, and DateTime, 哪些是每个API的重要组成部分, are missing. 幸运的是,我们有一个库可以帮助我们解决这个问题,它叫做 graphql-iso-date. 安装后, 我们需要在模式中定义新的高级标量数据类型,并将它们连接到标准库提供的实现:

从“graphql-iso-date”导入{GraphQLDate, GraphQLDateTime, GraphQLTime};

//使用GraphQL SDL定义api
const typeDefs = gql '
   scalar Date
   scalar Time
   scalar DateTime
  
   type Query {
       sayHello(名称:字符串!): String!
   }

   type Mutation {
       sayHello(名称:字符串!): String!
   }
`;

//在SDL中为API定义定义解析器映射
Const解析器= {
   日期:GraphQLDate,
   时间:GraphQLTime,
   DateTime: GraphQLDateTime,

   Query: {
       sayHello: (obj, args, context, info) => {
           返回`Hello ${参数.name }!`;
       }
   },

   Mutation: {
       sayHello: (obj, args, context, info) => {
           返回`Hello ${参数.name }!`;
       }
   }
};

还有日期和时间, 还有其他有趣的标量数据类型实现, 根据您的用例,哪些对您有用. 例如,其中一个是 graphql-type-json, 它使我们能够在GraphQL模式中使用动态类型,并使用API传递或返回无类型的JSON对象. 还有图书馆 graphql-scalar, 它使我们能够定义带有高级清理/验证/转换的自定义GraphQL标量.

If needed, 您还可以定义自定义标量数据类型并在模式中使用它, as shown above. 这并不难, 但是对它的讨论超出了本文的范围——如果感兴趣的话, 您可以在。中找到更多高级信息 阿波罗的文档.

Splitting Schema

在向模式添加更多功能之后, 它将开始增长,我们将理解在一个文件中保存所有定义是不可能的, 我们需要把它分成小块来组织代码,让它在更大的规模下更具可扩展性. 幸运的是,模式构建器函数 makeExecutableSchema,也接受模式定义,并以数组的形式解析映射. 这使我们能够将模式和解析器映射拆分为更小的部分. This is exactly what I have done in my sample project; I have divided the API into the following parts:

  • auth.api.graphql -用于用户认证和注册的API
  • author.api.graphql -作者条目的CRUD API
  • book.api.graphql -用于图书条目的CRUD API
  • root.api.graphql -模式的根和公共定义(如高级标量类型)
  • user.api.graphql -用于用户管理的CRUD API

在拆分模式期间,有一件事我们必须考虑. 其中一个部分必须是根模式,其他部分必须扩展根模式. 这听起来很复杂,但实际上很简单. 在根模式中,查询和突变是这样定义的:

type Query {

    ...
}

type Mutation {
    ...
}

在其他的例子中,它们是这样定义的:

扩展类型查询{
    ...
}

扩展型突变{
    ...
}

And that’s all.

身份验证和授权

在大多数API实现中, 需要限制全局访问并提供某种基于规则的访问策略. 为此,我们必须在代码中引入: Authentication-确认用户身份-和 Authorization,以执行基于规则的访问策略.

在GraphQL的世界里,和REST的世界一样,我们通常使用 JSON Web Token. 验证传递的JWT令牌, 我们需要拦截所有传入请求并检查它们的授权头. For this, 在创建阿波罗服务器期间, 我们可以将函数注册为上下文钩子, 创建跨所有解析器共享的上下文的当前请求将调用哪个. 这可以这样做:

//配置express
Const app = express();

//基于SDL定义和解析器映射构建GraphQL模式
const schema = makeexecutablesschema ({typeDefs, resolvers});

//创建Apollo服务器
const apolloServer = new apolloServer ({
    schema,
    
    context: ({ req, res }) => {
            Const context = {};

            //验证jwt令牌
            const parts = req.headers.authorization ? req.headers.authorization.Split (' '): ["];
            Const token = parts.length === 2 && parts[0].toLowerCase() === 'bearer' ? 部分[1]:未定义;
            context.authUser = token ? Verify (token):未定义;

            return context;
    }
});
apolloServer.applyMiddleware({app});

// Run server
app.listen({ port }, () => {
   console.${apolloServer . log('🚀服务器已在http://localhost:${端口就绪}.graphqlPath }`);
});

Here, 用户是否会传递正确的JWT令牌, 我们验证它并将用户对象存储在上下文中, 在请求执行期间,所有解析器都可以访问哪一个.

我们验证了用户身份, 但我们的API仍然是全局可访问的,没有什么可以阻止我们的用户在未经授权的情况下调用它. 防止这种情况的一种方法是在每个解析器中直接检查上下文中的user对象, 但这是一种非常容易出错的方法,因为我们必须编写大量样板代码,而且在添加新的解析器时可能会忘记添加检查. 如果我们看一下REST API框架, 通常这类问题是通过HTTP请求拦截器来解决的, 但在GraphQL的情况下, 这是没有意义的,因为一个HTTP请求可以包含多个GraphQL查询, 如果我们还加上它, 我们只能访问查询的原始字符串表示形式,并且必须手动解析它, 这绝对不是一个好方法. 这个概念不能很好地从REST转换到GraphQL.

所以我们需要某种方式来拦截GraphQL查询,这种方式被称为 prisma-graphql-middleware. 该库允许我们在调用解析器之前或之后运行任意代码. 它通过支持代码重用和清晰的关注点分离来改进我们的代码结构.

GraphQL社区已经基于Prisma中间件库创建了一堆很棒的中间件, 哪一个解决了一些特定的用例, 对于用户授权, 有一个库叫做 graphql-shield,它可以帮助我们为API创建权限层.

在安装graphql-shield之后,我们可以像这样为我们的API引入一个权限层:

从'graphql-shield'中导入{allow};

const isAuthorized = rule()
   (obj, args, { authUser }, info) => authUser && true
);

导出const权限= {
    Query: {
        ‘*’:isAuthorized,
          sayHello: allow
    },

    Mutation: {
        ‘*’:isAuthorized,
        sayHello: allow
    }
}

我们可以像这样将这一层作为中间件应用到我们的模式中:

//配置express
Const app = express();

//基于SDL定义和解析器映射构建GraphQL模式
const schema = makeexecutablesschema ({typeDefs, resolvers});
const schemaWithMiddleware = applyMiddleware(schema, 盾(权限, {allowExternalErrors: true}));

//创建Apollo服务器
const apolloServer = new apolloServer ({schemaWithMiddleware});
apolloServer.applyMiddleware({app});

// Run server
app.listen({ port }, () => {
    console.${apolloServer . log('🚀服务器已在http://localhost:${端口就绪}.graphqlPath }`);
})

在这里,当创建一个盾牌对象时,我们设置 allowExternalErrors to true, 因为默认情况下, 屏蔽的作用是捕获和处理解析器内部发生的错误, 对于我的示例应用程序来说,这是不可接受的行为.

在上面的例子中, 我们只限制经过身份验证的用户访问我们的API, 但是护盾非常灵活, using it, 我们可以为用户实现非常丰富的授权模式. 例如,在我们的示例应用程序中,我们有两个角色: USER and USER_MANAGER,并且只有具有该角色的用户 USER_MANAGER 能否调用用户管理功能. 这是这样实现的:

导出const isUserManager = rule()
    (obj, args, { authUser }, info) => authUser && authUser.role === 'USER_MANAGER'
);

导出const权限= {
    Query: {
        userById: isUserManager,
        用户:isUserManager
    },

    Mutation: {
        editUser: isUserManager,
        deleteUser: isUserManager
    }
}

我想提的另一件事是如何在项目中组织中间件功能. 与模式定义和解析器映射一样, 最好将它们拆分为每个模式,并保存在单独的文件中, 但与阿波罗服务器不同, 哪一个接受模式定义数组,解析映射并为我们缝合它们, Prisma中间件库不会这样做,它只接受一个中间件映射对象, 所以如果我们把它们分开,我们必须手工缝合. 要查看我对这个问题的解决方案,请参阅 ApiExplorer 类.

Validation

GraphQL SDL provides very limited functionality to validate user input; we can only define which field is required and which is optional. 任何进一步的验证需求,我们都必须手动实现. 我们可以直接在解析器函数中应用验证规则, 但是这个功能真的不属于这里, 这是使用GraphQL中间件的另一个很好的用例. For example, 让我们使用用户注册请求输入数据, 我们必须验证用户名是否为正确的电子邮件地址, 如果密码输入匹配, 密码也足够强. 这可以像这样实现:

从'apollo-server-express'中导入{UserInputError};
从'password-validator'导入passwordValidator;
从'validator'导入{isEmail};

const passwordSchema = new passwordValidator()
    .is().min(8)
    .is().max(20)
    .has().letters()
    .has().digits()
    .has().symbols()
    .has().not().spaces();

导出const验证器= {
    Mutation: {
        signup: (resolve, parent, args, context) => {
            const {email, password, rePassword} = args.signupReq;

            if (!isEmail(email)) {
                抛出新的UserInputError('无效的电子邮件地址!');
            }

            if (password !== rePassword) {
                throw new UserInputError('密码不匹配!');
            }

            if (!passwordSchema.验证(密码)){
                throw new UserInputError('密码不够强!');
            }

            返回resolve(parent, args, context);
        }
    }
}

我们可以将验证器层作为中间件应用到我们的模式中, 还有这样的权限层:

//配置express
Const app = express();

//基于SDL定义和解析器映射构建GraphQL模式
const schema = makeexecutablesschema ({typeDefs, resolvers});
const schemaWithMiddleware = applyMiddleware(schema, validators, 盾(权限, {allowExternalErrors: true}));

//创建Apollo服务器
const apolloServer = new apolloServer ({schemaWithMiddleware});
apolloServer.applyMiddleware({app})

N + 1 Queries

另一个需要考虑的问题, 这在GraphQL api中经常发生,并且经常被忽视, is N + 1 queries. 当模式中定义的类型之间存在一对多关系时,就会出现这个问题. 为了演示它,例如,让我们使用示例项目中的book API:

扩展类型查询{
    books: [Book!]!
    ...
}

扩展型突变{
    ...
}

type Book {
    id: ID!
    creator: User!
    createdAt: DateTime!
    updatedAt: DateTime!
    authors: [Author!]!
    title: String!
    about: String
    language: String
    genre: String
    isbn13: String
    isbn10: String
    publisher: String
    publishDate: Date
    hardcover: Int
}

type User {
    id: ID!
    createdAt: DateTime!
    updatedAt: DateTime!
    fullName: String!
    email: String!
}

Here, we see the User 类型具有一对多关系 Book 类型,此关系表示为中的创建者字段 Book. 此模式的解析器映射定义如下:

导出const解析器= {
    Query: {
        books: (obj, args, context, info) => {
            返回bookService.findAll();
          },
        ...
    },

    Mutation: {
        ...
    },

    Book: {
        creator: ({ creatorId }, args, context, info) => {
            返回userService.findById (creatorId);
          },
        ...
    }
}

如果我们使用这个API执行图书查询, 查看SQL语句日志, 我们会看到这样的东西:

select `books`.* from `books`
select `users`.*源自' users ' where ' users '.`id` = ?
select `users`.*源自' users ' where ' users '.`id` = ?
select `users`.*源自' users ' where ' users '.`id` = ?
select `users`.*源自' users ' where ' users '.`id` = ?
select `users`.*源自' users ' where ' users '.`id` = ?
...

在执行过程中很容易猜测, 首先为图书查询调用解析器, 哪个返回图书列表,然后每个图书对象被称为创建者字段解析器, 这种行为导致了N + 1次数据库查询. 如果我们不想让我们的数据库爆炸,这种行为就不是很好.

为了解决N + 1查询问题,Facebook开发者创造了一个非常有趣的解决方案 DataLoader,在其README页面上是这样描述的:

DataLoader是一个通用的实用程序,可以作为应用程序数据获取层的一部分,通过批处理和缓存为各种远程数据源(如数据库或web服务)提供简化和一致的API。

要理解DataLoader的工作原理并不是很简单, 因此,让我们先看一个解决上述问题的例子,然后解释其背后的逻辑.

在我们的示例项目中,DataLoader是这样为creator字段定义的:

导出类UserDataLoader扩展DataLoader
   constructor() {
       const batchLoader = userIds => {
           返回userService
               .findbyid(用户id)
               .then(
                   users => userIds.map(
                       userId => users.filter(user => user.id === userId)[0]
                   )
               );
       };

       超级(batchLoader);
   }

   静态getInstance(context) {
       if (!context.userDataLoader) {
           context.userDataLoader = new userDataLoader ();
       }

       return context.userDataLoader;
   }
}

一旦我们定义了UserDataLoader,我们就可以像这样改变创建者字段的解析器:

导出const解析器= {
   Query: {
      ...
   },

   Mutation: {
      ...
   },

   Book: {
      creator: ({ creatorId }, args, context, info) => {
         const userDataLoader = userDataLoader.getInstance(上下文);

         返回userDataLoader.load(creatorId);
      },
      ...
   }
}

在应用更改之后, 如果我们再次执行books查询并查看SQL语句日志, 我们会看到这样的东西:

select `books`.* from `books`
select `users`.*源自where ' id ' in (?)

Here, 我们可以看到N + 1个数据库查询被减少为2个查询, 其中第一个选择图书列表,第二个选择在图书列表中显示为创建者的用户列表. 现在让我们来解释DataLoader是如何实现这个结果的.

DataLoader的主要特性是批处理. 在单次执行阶段, DataLoader将收集所有单独加载函数调用的所有不同id,然后使用所有请求的id调用批处理函数. 需要记住的一件重要事情是,dataloader的实例不能被重用, 一旦调用批处理函数, 返回的值将永远缓存在实例中. 由于这种行为,我们必须在每个执行阶段创建DataLoader的新实例. 为了实现这一点,我们创建了一个静态 getInstance function, 哪个检查DataLoader的实例是否呈现在上下文对象中, if not found, creates one. 请记住,将为每个执行阶段创建一个新的上下文对象,并在所有解析器之间共享.

DataLoader的批量加载函数接受一个由不同请求id组成的数组,并返回一个promise,该promise解析为一个由相应对象组成的数组. 在编写批处理加载函数时,我们必须记住两件重要的事情:

  1. 结果数组的长度必须与请求id数组的长度相同. 例如,如果我们请求id [1, 2, 3],返回的结果数组必须包含三个对象: [{ "id": 1, " fullName ": " user1 "}, { “id”: 2, " fullName ": " user2 "}, { “id”: 3, " fullName ": " user3 "}]
  2. 结果数组中的每个索引必须对应于请求id数组中的相同索引. 例如,如果请求的id数组的顺序如下: [3, 1, 2],则返回的结果数组必须以完全相同的顺序包含对象: [{ "id": 3, " fullName ": " user3 "}, { “id”: 1, " fullName ": " user1 "}, { “id”: 2, " fullName ": " user2 "}]

In our example, 我们通过以下代码确保结果的顺序与请求id的顺序相匹配:

then(
   users => userIds.map(
       userId => users.filter(user => user.id === userId)[0]
   )
)

Security

最后但并非最不重要的是,我想提一下安全问题. With GraphQL, 我们可以创建非常灵活的api,并为用户提供丰富的功能来查询数据. 这赋予了应用程序的客户端相当大的权力, as Uncle Ben said, “权力越大,责任越大。.“没有适当的安全措施, 恶意用户可以提交昂贵的查询并导致对我们服务器的DoS(拒绝服务)攻击.

为了保护我们的API,我们可以做的第一件事是禁用GraphQL模式的自省. By default, GraphQL API服务器公开了内省其整个模式的功能, 它通常被graphhiql和Apollo Playground等交互式可视化shell所使用, 但对于恶意用户来说,基于我们的API构造复杂的查询也是非常有用的. 我们可以通过设置 introspection 参数为false时,创建阿波罗服务器:

//配置express
Const app = express();

//基于SDL定义和解析器映射构建GraphQL模式
const schema = makeexecutablesschema ({typeDefs, resolvers});

//创建Apollo服务器
const apolloServer = new apolloServer ({schema, 自省:假});
apolloServer.applyMiddleware({app});

// Run server
app.listen({ port }, () => {
   console.${apolloServer . log('🚀服务器已在http://localhost:${端口就绪}.graphqlPath }`);
})

为了保护API,我们可以做的下一件事是限制查询的深度. 如果数据类型之间存在循环关系,这一点尤为重要. 例如,在我们的示例中,项目类型 Author 有野外书籍和打字吗 Book has field authors. 这显然是一个循环关系, 没有什么可以阻止恶意用户编写这样的查询:

query {
   authors {
      id, fullName
      books {
         id, title
         authors {
            id, fullName
              books {
             id, title,
             authors {
                  id, fullName
                  books {
                       id, title
                       authors {
                      ...
                      }
                  }
               }
            }
         }
      }
   }
}

很明显,如果有足够的嵌套,这样的查询很容易使我们的服务器崩溃. 为了限制查询的深度,我们可以使用一个叫做 graphql-depth-limit. 一旦我们安装了它,我们可以在创建阿波罗服务器时应用深度限制,如下所示:

//配置express
Const app = express();

//基于SDL定义和解析器映射构建GraphQL模式
const schema = makeexecutablesschema ({typeDefs, resolvers});

//创建Apollo服务器
const apolloServer = new apolloServer ({schema, 自省:假, validationRules: [depthLimit(5)]});
apolloServer.applyMiddleware({app});

// Run server
app.listen({ port }, () => {
   console.${apolloServer . log('🚀服务器已在http://localhost:${端口就绪}.graphqlPath }`);
})

在这里,我们将查询的最大深度限制为5.

Post Scriptum:从REST转向GraphQL很有趣

In this tutorial, 我试图演示在开始实现GraphQL api时会遇到的常见问题. However, 它的某些部分提供了非常肤浅的代码示例,并且只触及所讨论问题的表面, due to its size. Because of this, 查看更完整的代码示例, 请参考我的示例GraphQL API项目的Git存储库: graphql-example.

最后,我想说GraphQL是一项非常有趣的技术. 它会取代REST吗?? Nobody knows, 也许明天在快速变化的IT世界, 会有更好的方法来开发api, 但GraphQL确实属于值得学习的有趣技术.

了解基本知识

  • What is GraphQL?

    GraphQL是用于api的特定于领域的数据查询和操作语言, 以及用于对现有数据执行查询的运行时.

  • GraphQL的优势是什么?

    一个主要优势是GraphQL SDL, 用来描述强类型api并作为一个好的文档. 另一个优点是GraphQL将数据模型转换为图形,并使客户机能够从服务器准确地查询所需的数据.

  • GraphQL比REST好吗?

    这取决于用例. GraphQL解决了一些REST问题, 比如在读取数据上和在读取数据下, 但代价是复杂性, 它不像REST那样简单直接. 另一个缺点是我们不能使用HTTP状态码,这使得错误处理更加困难.

  • GraphQL使用REST吗?

    No, GraphQL doesn’t use REST; it’s an alternative approach how to create APIs. 但是您的REST api可以使用GraphQL进行包装.

  • GraphQL取代REST吗?

    No GraphQL hasn’t replaced REST; it’s still a young technology and isn’t as mature as REST.

  • GraphQL比REST慢吗?

    一般来说,GraphQL并不比REST慢. 有时它可以比REST更快, 因为我们可以准确地请求我们需要的数据,并避免过度获取或不足获取数据.

  • 为什么要使用GraphQL?

    因为它是一项有趣的技术,并且很好地解决了一些用例.

聘请Toptal这方面的专家.
Hire Now
伊奥拉姆·戈达泽的头像
Ioram Gordadze

Located in Tbilisi, Georgia

Member since June 3, 2017

About the author

Ioram设计并开发了数据库, API services, 私人和政府机构也有更多.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Previously At

WorkSpan

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

Toptal Developers

Join the Toptal® community.