RESTful & Koa实操

825 阅读8分钟

编写RESTful API

  • Representational State Transfer翻译古来就是 “ 表现层状态转化 ”,他是一种互联网软件的架构原则,因为符合REST风格的Web API设计,就称它为RESTful API
  • RESTful是目前最流行的API规范,适用于 Web 就扣规范的设计,让接口易读,且含义清晰,设计易于理解和使用的API,并且借助 Docker API 的实践说明

URL设计

动词 + 宾语

它的核心思想就是客户端发出的数据指令都是 [ 动词 + 宾语 ] 的结构,比如 GET /articles 这个命令,GET 是动词,/articles 是宾语

动词通常来说就是五种 HTTP 方法,对应我们业务接口的 CRUD 操作,而宾语就是我们要操作的资源,可以理解成面向资源设计,我们所关注的数据就是资源

  • GET:读取资源
  • POST:新建资源
  • PUT:更新资源
  • PATCH:资源部分数据更新
  • DELETE:删除资源

正确的例子

  • GET /zoos:列出所有动物园
  • POST /zoos:新建一个动物园
  • GET /zoos?zoos_id=1:获取某个指定动物园的信息
  • PUT /zoos?zoos_id=1:更新某个动物园的信息 ( 提供该动物园全部信息 )
  • PATCH /zoos?zoos_id=1:更新某个动物园的信息 ( 提供该动物园部分信息 )
  • DELETE /zoos?zoos_id=1:删除某个动物园
  • GET /zoos?zoos_id=1&animals_name=xxx:列出某个指定动物园的所有动物
  • DELETE /zoos?zoos_id=1&animals_name=xxx:删除某个指定动物园的指定动物

动词的覆盖

有些客户端智能使用GET和POST这两种方法,服务器继续接受POST模拟为他三个方法(PUT、PATCH、DELETE)

这时,客户端发出的HTTP请求,要加上 X-HTTP-Mthod-Override 属性,告诉服务器应该使用哪一个动词来覆盖 POST 方法

宾语必须是名词

API的url,用HTTP动词作用的对象,所以应该是名词,例如 /books 这个 URL 就是正确的,而下面的URL 不是名词,都是错误写法

GET /getAllUsers?name=jl POST /createUser POST /deleteUser

复数名词

URL 是名词,通常操作的数据多数是一个集合,比如 GET /books,所以使用复数

避免出现多级URL

有时候需要操作的资源可能是有多个层级,因此很容易写多级URL,比如获取某个作者某个分类的文章

GET /authors/2/vategories/2 获取作者ID =2 分类=2 的文章

这种不利于扩展,语义也不清晰

更好的方法是,除了第一级,其他级别都是通过查询字符串表达,查询已发布的文章:

错误写法:GET /artichels/published

正确写法:GET /artichels?published=true

过滤信息 ( Filtering )

如果数据条数过多,服务器不可能将它们都返回给用户,API应该提供参数,过滤返回的结果

下面是一些常见的参数

  • ?limit=10:指定返回数据的条数
  • ?offset=10:指定返回数据开始的位置
  • ?page=2&per_page=100:指定第几页,以及每页能容纳的数据条数
  • ?sortby=name&order=asc:指定返回结果的排序属性依据,以及正序或倒序,倒序为dsc
  • ?animal_type_id:指定筛选条件

参数的设计允许存在冗余,即允许API路劲和URL参数偶尔有重复,比如,GET /zoos/ID/animals 与 GET /animals?zoo_id=ID 的含义是相同的,推荐后者,避免出现多级URL

状态码

五个类别

客户端的请求,服务器都必须响应,包含HTTP状态码和数据

HTTP状态码是一个三位数,分成五个类别:

  • 1xx:相关信息 - 前端不常见
  • 2xx:操作成功
  • 3xx:重定向
  • 4xx:客户端错误
  • 5xx:服务器错误

2xx状态码

200状态码表示操作成功,但是不同的方法可以返回更精确的状态码

  • GET:200 OK
  • POST:201 Created
  • PUT:200 OK
  • PATCH:200 OK
  • DELETE:204 No Content

4xx状态码

4xx状态码表示客户端错误,主要有以下几种

  • 400 Bad Request:服务器不理解客户端的请求,未做任何处理
  • 401 Unauthorized:用户未提供身份验证凭据,或者没有通过身份验证
  • 403 Forbidden:用户通过了身份验证,但是不具有访问资源所需的权限
  • 404 Not Found:所请求的资源不存在,或不可用
  • 405 Methor Not Allowed:用户已通过身份验证,但是所用的http方法不在用户的权限之内
  • 410 Gone:所请求的资源已从这个地址转移,不再可用
  • 415 Unsupported Media Type:客户端要求的返回格式不支持,比如,API只能返回JSON格式,但是客户端要求返回XML格式
  • 422 Unprocessable Entity:客户端上传的附件无法处理,导致请求失败
  • 429 Too Many Requests:客户端的请求次数超过限额

5xx状态码

5xx状态码表示服务器错误,一般来说,API不会向用户透露服务器详细信息,所以只要两个状态码就够了

  • 500 Internal Server Error:客户端请求有效,服务器处理时发生了意外,代码编写出现了问题
  • 503 Serveice Unavailable:服务器无法处理请求,一般用于网站维护状态

服务器响应

不要返回纯文本

API返回的数据格式,不应该是纯文本,而应该是一个JSON对象,因为需要返回标准的结构化数据,所以,服务器回应的HTTP headers 要设置 Content-Type: application/json

客户端请求时也要明确告诉服务器,可以接受JSON格式,即请求的HTTP headers 要设置

ACCEPT: application/json

发生错误时,不要返回200状态码

有一种不恰当的做法是,即使发生错误,也返回200状态码,把错误信息放在数据体里面,比如下面的例子:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": "fail",
  "msg": "错误"
}

上面的代码中,解析数据体后才能得知操作失败

这种做法实际上取消了状态码,这是完全不可取的,正确的做法是,状态码反映发生的错误,具体错误信息放在数据体里面返回,比如下面的例子:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "status": "fail",
  "msg": "错误"
}

API示例

// 模拟数据,将来要从数据库获取
const users = [
  {
    id: 1,
    name: 'tom'
  },
  {
    id: 2,
    name: 'jack'
  }
]
// 用户信息管理api实现query查询
router.get('/', (ctx, next) => {
  console.log('GET /users')
  const { name } = ctx.query // ?name=tom
  // 从数据库中获取数据
  let data = users
  if (name) {
    data = users.filter(u => u.name === name)
  }
  ctx.body = { ok: 1, data }
})
// params获取
router.get('/:id', (ctx, next) => {
  console.log('GET /user/:id')
  const { id } = ctx.params // user/1
  console.log(id)
  const data = users.find(u => u.id == id)
  ctx.body = { ok:1, data }
})
// post请求增加
router.post('/', (ctx, next) => {
  console.log('POST /user')
  const { body: user } =ctx.request
  user.id = users.length + 1
  users.push(user)
  console.log(users)
  ctx.body = { ok: 1 }
})
// put请求更改整条数据
router.put('/', (ctx, next) => {
  console.log('PUT /user')
  const { body: user } = ctx.request
  // findOneAndUpdate()
  // 修改数据
  const idx = users.findIndex(u => u.id == user.id)
  if (idx > -1) {
    users[idx] = user
  }
  consoe.log(users)
  ctx.body = { ok: 1 }
})
router.delete('/:id'. (ctx, next) => {
  const { id } = ctx.params
  // 删除数据
  const idx = users.findIndex(u => u.id == id)
  if (idx > -1) {
    users.splice(idx, 1)
  }
  console.log(users)
  ctx.body = { ok: 1 }
})

Koa脚手架

// 全局下载脚手架
npm i koa2-generator -g
// 创建koa2服务器
koa2 --hbs
// 安装依赖
npm install

会立即生成app

修改配置

将start命令改为 nodemon app.js启用实时监听 修改配置map: {'hbs': 'hbs'}为 map: {'hbs': 'handlebars'}否则会报错

// 运行服务器
npm start

打开页面

解决跨域

安装跨域中间件

npm i koa2-cors

导入

const koa = require('koa')
const cors = require('koa2-cors')
const app = new Koa()
app.use(cors())

文件上传

安装文件上传模块

npm i koa-multer -s

配置 ./routes/users.js

const multer = require('koa-multer')
const upload = multer({ dest: './public/images'})
router.post('/upload', upload.single('avater'), (ctx, next) => {
  // ctx.req.file 是avatar文件信息
  // ctx.req.body 文本域数据(如果存在的话
  // ctx.body配置返回给客户端的信息
  ctx.body = {
    ok: 1,
    msg: '文件上传成功'
  }
})

实例

/public/upload.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
  <title>上传文件</title>
  <style>
    img{
      width: 60vw;
    }
  </style>
</head>
<body>
  <div id="app">
    <el-upload name='avatar' action="/users/upload/" :show-file-list="true" :on-success="handleAvatarSuccess" :before-upload="beforeAvatarUpload">
      <img v-if="imageUrl" :src="imageUrl">
      <i v-else class="el-icon-plus avatar-uploader-icon"></i>
    </el-upload>
  </div>
  
  <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
  <script src="https://unpkg.com/element-ui/lib/index.js"></script>
  <script>
    new Vue({
      el: "#app",
      data() {
        return {
          imageUrl: ''
        }
      },
      methods: {
        handleAvatarSuccess(req, file) {
          this.imageUrl = URL.createObjectURL(file.raw)
        },
        beforeAvatarUpload(file) {
          const isJPG = file.type === 'image/jpeg'
          const isLt2M = file.size / 1024 / 1024 < 2
          if(!isJPG) {
            this.$message.error('上传图片只能是JPG格式!')
          }
          if(!isLt2M) {
            this.$message.error('上传头像图片大小不能超过2M')
          }
          return isJPG && isLt2M
        }
      }
    })
  </script>
</body>
</html>

/router/users.js

const multer = require('koa-multer')

// 配置磁盘存储
const storage = multer.diskStorage({
  // 文件保存路径
  destination: function (req, file, cb) {
    cb(null, 'public/images/')
  },
  // 修改文件名称
  filename: function (req, file, cb) {
    var fileFormat = (file.originalname).split(".")// 以.分割成数组,数组的最后一项就是扩展名
    cb(null, Date.now() + "." + fileFormat[fileFormat.length - 1])
  }
})
// 加载配置
const upload = multer({ storage: storage})
// single()方法单文件上传,array()方法为多文件上传,none() 方法只处理文字数据
router.post('/users/upload',upload.single('avatar'), (ctx, next) => {
  // ctx.req.file 是avatar的文件信息
  // ctx.req.body 文本域数据,如果存在
  
  console.log(ctx.req.file);
  // ctx.body 返回给客户端状态和真实地址
  ctx.body = {
    ok: 1,
    imgSrc: "localhost:3000/images/" + ctx.req.file.filename
  }
})

ctx.req.file:

{
  fieldname: 'avatar',
  originalname: '324190.jpg',
  encoding: '7bit',
  mimetype: 'image/jpeg',
  destination: 'public/images/',
  filename: '1635433095851.jpg',
  path: 'public\\images\\1635433095851.jpg',
  size: 8292164
}

表单验证

表单验证有很多不同的模块,这里演示 koa-bouncer

npm i koa-bouncer -s

app.js 注册koa-bouncer,为了给ctx提供一些帮助方法

const bouncer = require('koa-bouncer')
app.use(bouncer.middleware())

/router/users.js

router.post('/users', async (ctx, next) => {
  // ctx.request.body
  // uname pwd1 pwd2
  try {
    ctx.validateBody('uname')
      .required('用户名是必须的') // 只要求uname不为空
      .isString() // 确保数据可以转换为字符串
      .trim() // 去除字符串头尾空格
      .isLength(6,12, '用户名必须是6~12位')

    ctx.validateBody('email')
      .optional()
      .isString()
      .trim()
      .isEmail('非法的邮箱格式')

    ctx.validateBody('pwd1')
      .required('密码是必填项')
      .isString()
      .isLength(6,16,'密码长度必须是6~16位字符')

    ctx.validateBody('pwd2')
      .required('密码确认为必填项')
      .isString()
      .eq(ctx.vals.pwd1, '两次密码不一致')

    // 校验数据库是否存在相同值
    ctx.validateBody('uname')
    .check(await db.findUserByUname(ctx.vals.uname), 'Username taken')
    // ctx.validateBody('uname').check('tom', '用户名已存在')

    // 如果代码执行到这里,校验通过
    // 校验器会用净化后的值填充 ctx.vals 对象
    console.log(ctx.vals);
    ctx.body = {
      ok: 1
    }   
  } catch (error) {
    // 处理验证错误
    if(error instanceof bouncer.ValidationError) {
      ctx.status = 400
      ctx.body = '校验失败' + error.message
      return
    }
    throw error
  }
})

form.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>表单验证</title>
</head>
<body>
  <!-- 传统的表单 -->
  <form action="/users" method="post">
    <input type="text" name="uname" label="用户名: "><br>
    <input type="password" name="pwd1" label="密码: "><br>
    <input type="password" name="pwd2" label="确认密码: "><br>
    <input type="submit" value="提交">
  </form>
</body>
</html>

图形验证码

安装trek-captcha

npm i trek-captcha -s

/router/index.js

const catcha = requie('trek-chatcha')

router.get('/captcha',async (ctx, next) => {
  const { token, buffer } = await captcha({size: 4})
  // token的作用 前端输入完验证码与此时的token做对比
  ctx.body = buffer
})

vue组件

<template>
  <img src="/captcha" ref="captcha" alt="">
  <button @click="changeCaptcha">换一张</button>
</template>
//------------------------
<script>
  methods: {
    changeCaptcha() {
      // 通过不同的时间差别发送请求,取新的验证码
      this.$refs.captcha.src = '/captcha?r=' + Date.now()
    }
</script>

之后再通过前端post请求获取到输入的字符对比服务端的token来确定输入是否正确

云短信

短信服务需要网站信息认证,无法测试