Nodejs写一个web服务

前言:Nodejs很火,但是好像工作中很少见到大面积的招聘岗位。我也用了nodejs写web服务很多年。分享一套我用了很久的代码,我觉得挺好。有需要的朋友拿去看看,我们不去纠结到底nodejs好不好,适不适合做Server,这些问题,吵翻天有何意义。能解决问题就好了。

开发快也是一种优势,现在中小型创业公司,底子差,业务要求快。加上云服务商的api加持,为何我们还要维护一个笨重的服务体系。当我需要AI服务的时候,我就接入AI服务商的接口就好了。我需要cdn服务的时候我接云服务就好了,我需要支付的时候我调用支付服务API就好了。那么一个小巧,快速的业务方案就会让创业型企业快速铺开市场,2-3个月就可以快速上线。尤其是在小程序盛行的今天,都不用笨重的APP了,云开发也是不错的选择,但是企业总要有一定的数据和业务能力,不然也会被云开发商掣肘。

今天分享的就是一套代码,会放到github上,github.com/vincent-li/… 大家下载还需要根据项目调整配置文件。不敢说是非常牛逼,我从来都是土狗一个,看看各位如果没有思路的朋友,是不是可以拿去直接先把业务干起来。废话不多说。代码走起。

image.png

思路比较简单待我一步一步拆解。我把nodejs定位就是web服务程序,不要跟我讨论底层稳定性的问题,因为我利用云服务商全部解决了。服务框架用的是koa,数据库是mongodb,那么我们来讲讲一个web服务到底需要什么。

  • web服务最基本的是接收请求,查询数据库,返回结果给客户端。此类功能在controllers下面完成。
  • 上传文件,利用一个插件multer,www.npmjs.com/package/mul…
  • 静态文件服务,我会提到,但是还是不要写在服务器上面,讲过了,nodejs还是能力有限。利用cdn接口。
  • 登录验证逻辑,利用session来做。有人说咋不用token,一个意思,只是放的位置不一样。
  • log分析,利用守护进程pm2的log能力,具体后面讲。

讲了一堆估计没做过的人看懵了,不急,看看代码就明白了。入口index.js文件

const Koa = require('koa')
const koaBodyParser = require('koa-bodyparser')
const koaViews = require('koa-views')
const koaStatic = require('koa-static')
const koaSession = require('koa-session')
const mongoStore = require('koa-session-mongo2')
const koaLogger = require('koa-logger')

const _config = require('../config')
const router = require('./controllers')

global._ = require('lodash')
global.model = require('./models')

const app = new Koa()

// 服务器运行log
app.use(koaLogger())

// 指定静态目录
app.use(koaStatic(_config.static))

// 指定views路径
app.use(
    koaViews(_config.views, {
        map: {
            html: 'lodash',
        },
    })
)

// 解析报文的body
app.use(
    koaBodyParser({
        enableTypes: ['json', 'form', 'text'],
    })
)

// session初始化
app.use(
    koaSession(
        {
            key: 'makefuture_sess',
            store: new mongoStore(_config.sessionURL),
            signed: false,
            // cookie过期时间,由浏览器负责到时清除,单位毫秒
            maxAge: 5 * 24 * 60 * 60 * 1000,
        },
        app
    )
)

// 使用router
app.use(router.routes(), router.allowedMethods())

console.log('启动端口:', _config.port)

app.listen(_config.port)

复制代码

入口的js就是引入koa和一堆koa的插件,然后根据api装配起来就OK了。

由于项目都有各种环境,所以要根据启动环境把不同的配置项加载进来。const _config = require('../config'),配置项中包括一些敏感的数据库连接账号和密码,我都替换掉了。所以各位在开发的时候,不建议把production的配置上传到gitlab,最好是运维持有并配置。

const port = Number.parseInt(process.env.PORT) || 6060
const mongoUri =
    'mongodb://abcd:************@0.0.0.0:3717/?authSource=admin'
const root = '/Users/liwenqiang/codespace/git.ichoice.cc/makefuture/make'
const host = 'http://localhost:6060'
module.exports = {
    host,
    port,
    cdn: 'https://cdn.izelas.run',
    hostName: 'makefuture.app',
    home: root,
    static: `${root}/static`,
    views: `${root}/src/views`,
    mongoUri,
    dbName: 'makefuture',
    // 存放session的库和表配置
    sessionURL: {
        url: `${mongoUri}&poolSize=5&useUnifiedTopology=true&useNewUrlParser=true`,
        db: 'makefuture',
        collection: 'session',
        // 这里设置的是数据库session定期清除的时间,与cookie的过期时间应保持一致,
        // cookie由浏览器负责定时清除,需要注意的是索引一旦建立修改的时候需要删除旧的索引。
        // 此处的时间是秒为单位,cookie的maxAge是毫秒为单位
        maxAge: 24 * 60 * 60,
    }
}
复制代码

利用global把一些通用的库加入到开发体系,就不用一遍一遍的引用了。这里把lodash引入,我觉lodash的方法足够我们用了。还有加密算法crypto,其实做的久了,基于业务的所有开发都是各种成熟组件的装配。解决方案都很成熟,所以我不认为有难做的业务,至少难不在技术,而是产品形态。

为啥把models作为global全局,因为这块是可以独立于koa初始化的,也没有必要纳入整个koa的请求上下文,但是业务处理的时候又要频繁使用,那么干脆初始化到global里面,随时取用。

const mongoose = require('mongoose')
const { mongoUri, dbName } = require('../../config')

mongoose.Promise = global.Promise

const conn = mongoose.createConnection(mongoUri, {
    dbName,
    useNewUrlParser: true,
    useUnifiedTopology: true,
})

const db = {}

const sysinfo = require('./_sysinfo')
const user = require('./_user')
const project = require('./_project')
const page = require('./_page')
const component = require('./_component')
const file = require('./_file')
const models = [sysinfo, user, project, page, component, file]

models.forEach((item) => {
    let newSchema = new mongoose.Schema(
        (typeof item.schema === 'function' && item.schema(mongoose.Schema)) ||
            item.schema,
        { collection: item.name }
    )
    db[item.name] = conn.model(item.name, newSchema)
})

module.exports = db
复制代码

简单的不要不要的,就是把config里面配置的连接字符串,放到组件的方法里面,mongoose.createConnection,mongoose是用的比较好的mongodb连接组件,每中数据库都有相应的js帮助做操作。我比较喜欢mongodb是因为跟json结构完美契合,理解上比较一致,操作也基本符合js的操作规范。不用写sql真的舒服。随便找一个数据对象看下大家就明白了。

const model = {
    name: 'user',
    schema: {
        nick_name: String, // 昵称
        phone: String, // 手机
        pass: String, // 密码
        avatar_url: String, // 头像
        sms_code: Number, // 短信登录码
        expire_at: Number, // 验证码过期时间
        create_at: Number, // 创建时间
    },
}

module.exports = model
复制代码

name就是数据库中对应的表名,也是调用的时候的名字。跟文件名可以不一致。文件名我叫_user,name:user,调用的时候是model.user,非常方便。里面的字段名称和类型对应mongo的collection,也就是表。也可以跟对象和function,具体操作可以去看www.npmjs.com/package/mon… mongo的官方操作方法都支持,具体要看mongoose的版本和mongodb的版本,官方有的都可以用。创建完model就可以在方法中使用 await model.* 的方式调用了。方法是异步的,前面要加await,不喜欢可以直接调用同步方法。不建议,毕竟有语法支持,又不难理解。

回到index.js,后面的设置静态目录,设置模板,就比较简单,模板就是js的入口页面,为啥把js的入口放到服务,而不是做成静态放cdn,nodejs就是做静态渲染的,目的就是可以很好的把数据注入页面,所以js入口自己做方便很多。session这块要讲一下,这个图省事,大家都session概念不了解的麻烦自行阅读相关资料。这里的session模块干了几件事。

  • 在koa ctx上下文中注入一个session对象,ctx.session = {},可以把登录信息全部放进去。比如
 bo = {
    userid: u._id.toString(),
    nick_name: u.nick_name,
    avatar_url: u.avatar_url,
  }
  ctx.session = bo
复制代码
  • 自动把ctx.session中的数据存储到指定的数据库,这里就用的mongodb,这样做就不会因为服务宕机造成登录信息丢失。
  • 根据配置,在session超时之后清除session信息。

所以我们只要做一个全局的过滤器,每次检测ctx.session中有没有登录的信息就好了。比如userid,userid为空就直接redirect到login页面就好了。

下面就剩一个就是处理请求了,这才是web服务的重头戏。我们利用bodyparse插件,将所有请求过滤一遍,把请求参数整理成json对象。

// 解析报文的body
app.use(
    koaBodyParser({
        enableTypes: ['json', 'form', 'text'],
    })
)
复制代码

然后就可以在ctx.request.query或者ctx.request.body中取用。ctx.request.query代表的是get请求的参数。ctx.request.body存post请求参数。当然也可以处理head,put,delete等请求。其实我觉得用不到。各位自己看。

/**
 * 路由定义
 */
const Router = require('@koa/router')
const router = Router({
  prefix: '/api/user',
})

/**
 * 用户短信登录,朝用户手机中发送登录短息
 * @path - /api/user/smscode
 * @method - POST
 * @params
 *  phone - 用户手机号
 * @returns
 *  data - string | object 返回数据
 *  code - 0 || 500,
 *  success - true | false,
 *  message - ''
 */

router.post('/smscode', sendSmsCode)
复制代码

以上就是一个路由定义的方法,prefix表示统一的前缀。post就是方法名,意思就是初始化了一个接口,访问路径就是/api/user/smscode,具体看代码,对应的执行方法就是sendSmsCode,这就是一个向用户端发送短信验证码的接口。当然短息我不用写,直接用云服务就好。我用阿里云的短信接口,发的66的。

const sendSmsCode = async (ctx) => {
  const { phone } = ctx.request.body
  if (!phone) {
    ctx.body = getResponse(false, 'e501')
    return
  }
  if (!checkPhone(phone)) {
    ctx.body = getResponse(false, 'e552')
    return
  }
  let u = await model.user.findOne({ phone }).lean()
  // 是否已经发过
  if (u && u.sms_code && u.expire_at && u.expire_at > Date.now()) {
    ctx.body = getResponse(false, 'e553')
    return
  }
  // 获取阿里云access信息
  let aliyun = await model.sysinfo.findOne({ key: 'aliyun' }).lean()
  if (aliyun && aliyun.val.accessKeyId && aliyun.val.accessSecret) {
    const client = new Alicloud({
      accessKeyId: aliyun.val.accessKeyId,
      accessKeySecret: aliyun.val.accessSecret,
      endpoint: 'https://dysmsapi.aliyuncs.com',
      apiVersion: '2017-05-25',
    })
    // 获取随机的6个数字
    const code = +`${_.random(9)}${_.random(9)}${_.random(9)}${_.random(
      9
    )}${_.random(9)}${_.random(9)}`
    const params = {
      RegionId: 'cn-hangzhou',
      PhoneNumbers: phone,
      SignName: '码客未来',
      TemplateCode: 'SMS_203670207',
      TemplateParam: JSON.stringify({ code }),
    }
    const res = await client.request('SendSms', params, { method: 'POST' })
    if (res && res.Code === 'OK') {
      if (u) {
        await model.user.updateOne(
          { phone },
          { sms_code: code, expire_at: Date.now() + 15 * 60 * 1000 }
        )
      } else {
        await model.user.create({
          nick_name: '码客',
          phone,
          create_at: Date.now(),
          sms_code: code,
          expire_at: Date.now() + 15 * 60 * 1000,
        })
      }
      ctx.body = getResponse(true, '操作成功')
    } else {
      // fmtp('阿里云短息发送失败,原因:', res.Message)
      console.log('阿里云短息发送失败,原因:', res.Message)
      ctx.body = getResponse(false, 'e500')
    }
  } else {
    fmtp('aliyun --->', aliyun.val)
    ctx.body = getResponse(false, 'e500')
  }
  return
}
复制代码

大家自行看代码吧,懒得讲太多,很多东西上面提到过。这个代码基本上是可以跑起来的,但是呢,我上传的代码是修改了关键信息的,如果希望接数据库就把自己的数据库参数输入。具体的看配置文件。当然希望你很会玩代码,自行排查一些问题。没那个闲功夫的就看看文章就好了,其实业务层,根据很多公司的业务发展不一样,需要不一样的代码,还是那句话,产品形态决定代码形态。

精髓就是,站着云厂商的肩膀上,快速把自己业务落地,AI类的我喜欢接百度,语音分析接科大讯飞,服务器、存储、云数据库、短信接阿里云,IM一般接腾讯,毕竟小程序场景还是微信生态稳。我的创业经验就是,小程序(微信)+Nodejs服务+阿里云企业服务+腾讯智能服务,麻溜的干业务,之所以没有成功,就是特么这个操蛋的创业环境。

各位看官仔细想想,在云服务商竞争激烈的今天,给了很多小而美的企业生机。以前要想做到的事情需要各种各样的人才,耗时耗力,关键还未必做的好。现在大部分都是调用现有的云服务,我们小企业就做好自己的业务,方便又快捷。而且我不用讨论什么压力,什么稳定。我把安全稳定的问题全部用分润的方式,利用云服务上的能力解决了。是!我啥底层都不懂,但不妨碍咱们成为一家企业的顶梁柱。未必比一些人差,很多专注于技术的人员,不但没有解决企业的问题,还增加了企业的负担,最后还是说企业这没有那没有。想想问题在哪?牢骚两句,看官自便。