Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

让我们用Nestjs来重写一个CNode(中) #19

Open
jiayisheji opened this issue Aug 29, 2018 · 18 comments
Open

让我们用Nestjs来重写一个CNode(中) #19

jiayisheji opened this issue Aug 29, 2018 · 18 comments
Labels
Nest nest相关实践

Comments

@jiayisheji
Copy link
Owner

jiayisheji commented Aug 29, 2018

我发现比我想象要长,打算把实战部分拆分成中和下来讲解。

通过上篇学习,相信大家对 Nest 有大概印象,但是你还是看不出它有什么特别的地方,下篇将为你介绍项目实战中Nest如何使用各种特性和一些坑和解决方案。源码

这篇主要内容:

  • 项目架构规划
  • 入口文件配置说明
  • 依赖安装
  • 配置模板引擎和静态文件
  • 静态模板
  • 系统配置和应用配置
  • 数据库之用户表
  • 注册
  • 使用node-mailer发送邮件
  • 登录和第三方认证github登录
  • session和cookie
  • 找回密码和登出

项目架构规划设计

一个好的文件结构约定,会让我们开发合作、维护管理,节省很多不必要沟通。

这里我scr文件规划:

文件 说明
main.ts 入口
main.hmr.ts 热更新入口
app.service.ts APP服务(选择)
app.module.ts APP模块(根模块,必须)
app.controller.ts APP控制器(选择)
app.controller.spec.ts APP控制器单元测试用例(选择)
config 配置模块
core 核心模块(申明过滤器、管道、拦截器、守卫、中间件、全局模块)
feature 特性模块(主要业务模块)
shared 共享模块(共享mongodb、redis封装服务、通用服务)
tools 工具(提供一些小工具函数)

这是我参考我Angular项目的结构,写了几个nest项目后发现这个很不错。把mongodb服务和业务模块分开,还有一个好处就是减少nest循环依赖注入深坑,后面会讲怎么解决它。

入口文件配置说明

打开main.ts文件

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

NestFactory 创建一个app实例,监听3000端口。

/**
 * Creates an instance of the NestApplication
 * @returns {Promise}
 */
create(module: any): Promise<INestApplication & INestExpressApplication>;
create(module: any, options: NestApplicationOptions): Promise<INestApplication & INestExpressApplication>;
create(module: any, httpServer: FastifyAdapter, options?: NestApplicationOptions): Promise<INestApplication & INestFastifyApplication>;
create(module: any, httpServer: HttpServer, options?: NestApplicationOptions): Promise<INestApplication & INestExpressApplication>;
create(module: any, httpServer: any, options?: NestApplicationOptions): Promise<INestApplication & INestExpressApplication>;

create方法有1-3参数,第一个是入口模块AppModule, 第二个是一个httpServer,如果要绑定Express中间件,需要传递Express实例。第三个全局配置:

  • logger 打印日志
  • cors 跨域配置
  • bodyParser post和put解析body中间件配置
  • httpsOptions https配置

app 带方法有哪些
INestApplication

  • init 初始化应用程序,直接调用此方法并非强制。(效果不明)
  • use 注册中间件
  • enableCors 启用CORS(跨源资源共享)
  • listen 启动应用程序。
  • listenAsync 同步启动应用程序。
  • setGlobalPrefix 注册每个HTTP路由路径的前缀
  • useWebSocketAdapter 安装将在网关内部使用的Ws适配器。使用时覆盖,默认socket.io库。
  • connectMicroservice 将微服务连接到NestApplication实例。 将应用程序转换为混合实例。
  • getMicroservices 返回连接到NestApplication的微服务的数组。
  • getHttpServer 返回基础的本地HTTP服务器。
  • startAllMicroservices 异步启动所有连接的微服务
  • startAllMicroservicesAsync 同步启动所有连接的微服务
  • useGlobalFilters 将异常过滤器注册为全局过滤器(将在每个HTTP路由处理程序中使用)
  • useGlobalPipes 将管道注册为全局管道(将在每个HTTP路由处理程序中使用)
  • useGlobalInterceptors 将拦截器注册为全局拦截器(将在每个HTTP路由处理器中使用)
  • useGlobalGuards 注册警卫作为全局警卫(将在每个HTTP路由处理程序中使用)
  • close 终止应用程序(包括NestApplication,网关和每个连接的微服务)
    INestExpressApplication
  • set 围绕本地express.set()方法的包装函数。
  • engine 围绕本地express.engine()方法的包装函数。
  • enable 围绕本地express.enable()方法的包装函数。
  • disable 围绕本地express.disable()方法的包装函数。
  • useStaticAssets 为静态资源设置基础目录。围绕本地express.static(path, options)方法的包装函数。
  • setBaseViewsDir 设置模板(视图)的基本目录。围绕本地express.set('views', path)方法的包装函数。
  • setViewEngine 为模板(视图)设置视图引擎。围绕本地express.set('view engine', engine)方法的包装函数。

依赖安装

核心依赖

因为目前CNode采用Egg编写,里面大量使用与Egg集成的egg-xxx包,这里我把相关的连对应的依赖都一一来出来。

模板引擎

Egg-CNode使用egg-view-ejs,本项目使用ejs包,唯一缺点没有layout功能,可以麻烦点,在每个文件引入头和尾即可,也有另外一个包ejs-mate,它有layout功能,后面会介绍它怎么使用。

redis

Egg-CNode使用egg-redis操作redis,其实它是包装的ioredis包,我也一直在nodejs里使用这个包,需要安装生产ioredis和开发@types/ioredis

mongoose

Egg-CNode使用egg-mongoose操作mongodbNest提供了@nestjs/mongoose,需要安装生产mongoose和开发@types/mongoose

passport

Egg-CNode使用egg-passport、egg-passport-github、egg-passport-local做身份验证,Nest提供了@nestjs/passport,需要安装生产passport、passport-github、passport-local

其他依赖在后面用到时候在详细介绍,这几个是比较重要的依赖。

配置 Views 视图模板和 public 静态资源

CNode 使用的是egg-ejs,为了简单点,减少静态文件编写,我也用ejs。发现区别就是少了layout功能,需要我们拆分layout/header.ejslayout/footer.ejs在使用了。
但是有一个包可以做到类似的功能ejs-mate,这个是@JacksonTian 朴灵大神的作品。

新建模板存放views文件夹(root/views)和静态资源存放public文件夹(root/public)

注意nest-cli默认只处理src里面的ts文件,如有其他文件需要自己写脚本处理,gulp或者webpack都可以,这里就简单一点,直接把viewspublic放在src平级的根目录里面了。后面会说怎么处理它们设置问题。

模板引擎

安装ejs-mate依赖:

npm install ejs-mate --save

用法很简单了,关于文件名后缀,默认使用.ejs.ejs虽然会让它语法高亮,有个坑就html标签不会自动补全提示。那需要换成.html后缀。

设置模板引擎:

import { join } from 'path';
import * as ejsMate from 'ejs-mate';
async function bootstrap() {
    ....
      // 获取根目录 nest-cnode
    const rootDir = join(__dirname, '..');
    // 指定视图引擎 处理.html后缀文件
    app.engine('html', ejsMate);
    // 视图引擎
    app.set('view engine', 'html');
    // 配置模板(视图)的基本目录
    app.setBaseViewsDir(join(rootDir, 'views'));
    ...
}

注意:当前启动程序是src/main.ts,因为viewspublic在根目录,所有我们就需要去获取获取根目录。其他注释已经说明,就不再赘述。

使用模板引擎:

  1. 我们在views文件夹里面新建一个layout.html和一个index.html

  2. 写通用的layout.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>我是layout模板</title>
</head>
<body>
    <%- body -%>
</body>
  1. 写的index.html
<% layout('layout') -%>
<h1>我是首页</h1>  
  1. 渲染模板引擎
import { Get, Controller, Render } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  @Render('index')
  root() {
    return {};
  }
}

注意@Render()里面一定要写模板文件名(可以省略后缀),不然访问页面显示是json对象。

访问首页http://localhost:3000/看结果。

3nc4l2 l_nbp_di0fkomamc

ejs-mate语法:

ejs-mate兼容ejs语法,语法很简单,这里顺便带一下:

  • <% '脚本' 标签,用于流程控制,无输出。
  • <%_ 删除其前面的空格符
  • <%= 输出数据到模板(输出是转义 HTML 标签)
  • <%- 输出非转义的数据到模板
  • <%# 注释标签,不执行、不输出内容
  • <%% 输出字符串 '<%'
  • %> 一般结束标签
  • -%> 删除紧随其后的换行符
  • _%> 将结束标签后面的空格符删除

说几个常用的写法:

<% 直接写js代码,不输出:%>

<ul>
  <% users.forEach(function(user){ %>
    <%- include('user/show', {user: user}); %>
  <% }); %>
</ul>

<%# 输出变量:%>

<%= '变量' %>

<%# 输出HTML%>

<%- '<h1>标题</h1>' %>

<%# 引入其他ejs文件(注意:2个参数,第一个是路径:相对于当前模板路径中的模板片段包含进来;第二个是传递数据对象。):%>

<%- include('user/show', {user: user}); %>

说明:

注意:以上语法基本一样,有一样不一样,include需要用partial代替。他们俩用法一模一样。

layout功能,需要在引用的页面,比如index.html里面使用<% layout('layout') -%>注意:这里'layout'是指layout.html

还有一个比较重要的功能是block。它是在指定的位置插入自定义内容。类似于angularjstranscludeangular<ng-content select="[xxx]"></ng-content>vue<slot></slot>

slot写法:

<%- block('head').toString() %>

block('head'),是一个占位标识符,toString是合并所有的插入使用join转成字符串。

使用:

<% block('head').append('<link type="text/css" href="/append.css">') %>
<% block('head').prepend('<link type="text/css" href="/prepend.css">') %>

appendprepend是插入的顺序,append总是插槽位置插入在最后,prepend总是插槽位置插入在最前。

我们来验证一下。

现在layout.htmlhead里面写上

<head>
    ...
    <link type="text/css" href="/style.css">
    <%- block('head').toString() %>
</head>

index.html的结尾写上

...
<% block('head').append('<link type="text/css" href="/append.css">') %>
<% block('head').prepend('<link type="text/css" href="/prepend.css">') %>
<% block('head').prepend('<link type="text/css" href="/prepend2.css">') %>
<% block('head').append('<link type="text/css" href="/append2.css">') %>  

访问首页http://localhost:3000/看结果。

7d kp 0rh t 7 i4

注意index.html里书写block('head').append的位置不影响它显示插槽的位置,只受定义插槽<%- block('head').toString() %>

还有一个方法replace,没看懂怎么用的,文档里面也没有说明,基本appendprependtoString就够用了。

总结:toString是定义插槽位置,appendprepend往插槽插入指定的内容。他们主要做什么了,layout载入公共的cssjs,如果有的页面有不一样地方,就需要插入当前页面的js了,那么一来这个插槽功能就有用,如果使用layout功能插入,就会包含在layout位置,无论是语义还是加载都是不合理的。就有了block的功能,在另一款模板引擎Jade里面也有同样的功能也叫block功能。

静态资源

public文件夹里面内容直接拷贝egg-cnode下的public的静态资源

还需要安装几个依赖:

npm i --save loader loader-connect loader-builder

这几个模块是加载css和js使用,也是@JacksonTian 朴灵大神的作品。

main.ts配置

import { join } from 'path';

import * as loaderConnect from 'loader-connect';

async function bootstrap() {
  ...
  // 根目录 nest-cnode
  const rootDir = join(__dirname, '..');
  // 注意:这个要在express.static之前调用,loader2.0之后要使用loader-connect
  // 自动转换less为css
  if (isDevelopment) {
    app.use(loaderConnect.less(rootDir));
  }
  // 所有的静态文件路径都前缀"/public", 需要使用“挂载”功能
  app.use('/public', express.static(join(rootDir, 'public')));
  // 官方指定是这个 默认访问根目录
  // app.useStaticAssets(join(__dirname, '..', 'public'));
  ...
}

注意:如果静态文件路径都前缀/public,需要使用use去挂载express.static路径。只有express是这样的

  useStaticAssets(path: string, options: ServeStaticOptions) {
    return this.use(express.static(path, options));
  }

它的源码是这样写的,如果这样的,你的静态资源路径就是从根目录开始,如果需要加前缀/public,就需要express提供的方式

测试我们静态资源路径设置是否正常工作

index.html里面引入public/images/logo.png图片

...
<img src="/public/images/logo.png" alt="logo">
...

l8p0 psc xxuk6 zyea0nvs

如果有问题,请找原因,路径是否正确,设置是否正确,如果都ok,还是不能访问,可以联系我。

关于loader使用:

  <!-- style -->
  <%- Loader('/public/stylesheets/index.min.css')
  .css('/public/libs/bootstrap/css/bootstrap.css')
  .css('/public/stylesheets/common.css')
  .css('/public/stylesheets/style.less')
  .css('/public/stylesheets/responsive.css')
  .css('/public/stylesheets/jquery.atwho.css')
  .css('/public/libs/editor/editor.css')
  .css('/public/libs/webuploader/webuploader.css')
  .css('/public/libs/code-prettify/prettify.css')
  .css('/public/libs/font-awesome/css/font-awesome.css')
  .done(assets, config.site_static_host, config.mini_assets)
  %>

  <!-- scripts -->
  <%- Loader('/public/index.min.js')
  .js('/public/libs/jquery-2.1.0.js')
  .js('/public/libs/lodash.compat.js')
  .js('/public/libs/jquery-ujs.js')
  .js('/public/libs/bootstrap/js/bootstrap.js')
  .js('/public/libs/jquery.caret.js')
  .js('/public/libs/jquery.atwho.js')
  .js('/public/libs/markdownit.js')
  .js('/public/libs/code-prettify/prettify.js')
  .js('/public/libs/qrcode.js')
  .js('/public/javascripts/main.js')
  .js('/public/javascripts/responsive.js')
  .done(assets, config.site_static_host, config.mini_assets)
  %>
  • Loader可以加载.js方法也可以加载.coffee.es类型的文件,.css方法可以加载.less.styl文件。
  • Loader('/public/index.min.js')是合并后名字
  • .js('/public/libs/jquery-2.1.0.js')是加载每一个文件地址
  • .done(assets, config.site_static_host, config.mini_assets)是处理文件,第一个参数合并压缩后的路径(后面讲解),第二个参数静态文件服务器地址,第三个参数是否压缩

assets从哪里来

package.jsonscripts配置

{
    ...
    "assets": "loader /views /"
}

loader的写法是:loader <views_dir> <output_dir>views_dir是模板引擎目录,output_dirassets.json文件输出的目录,/表示根目录。

npm run assets

直接运行会报错,这个问题在egg-node有人提issues

z90d4zb w t t26e_2 l

主要是静态资源css引用的背景图片和字体地址有错误,需要修改哪些文件:

错误信息:

no such file or directory, open 'E:\github\nest-cnode\E:\public\img\glyphicons-halflings.png'

谁引用了它 Error! File:/public/libs/bootstrap/css/bootstrap.css

/public/libs/bootstrap/css/bootstrap.css

...
[class^="icon-"],
[class*=" icon-"] {
    display: inline-block;
    width: 14px;
    height: 14px;
    margin-top: 1px;
    *margin-right: .3em;
    line-height: 14px;
    vertical-align: text-top;
    background-image: url("/public/libs/bootstrap/img/glyphicons-halflings.png");
    background-position: 14px 14px;
    background-repeat: no-repeat;
}
...

.icon-white,
.nav-pills > .active > a > [class^="icon-"],
.nav-pills > .active > a > [class*=" icon-"],
.nav-list > .active > a > [class^="icon-"],
.nav-list > .active > a > [class*=" icon-"],
.navbar-inverse .nav > .active > a > [class^="icon-"],
.navbar-inverse .nav > .active > a > [class*=" icon-"],
.dropdown-menu > li > a:hover > [class^="icon-"],
.dropdown-menu > li > a:focus > [class^="icon-"],
.dropdown-menu > li > a:hover > [class*=" icon-"],
.dropdown-menu > li > a:focus > [class*=" icon-"],
.dropdown-menu > .active > a > [class^="icon-"],
.dropdown-menu > .active > a > [class*=" icon-"],
.dropdown-submenu:hover > a > [class^="icon-"],
.dropdown-submenu:focus > a > [class^="icon-"],
.dropdown-submenu:hover > a > [class*=" icon-"],
.dropdown-submenu:focus > a > [class*=" icon-"] {
    background-image: url("/public/libs/bootstrap/img/glyphicons-halflings-white.png");
}
...

大约22962320行位置,你可以用查找搜索glyphicons-halflings.png,默认是background-image: url("../img/glyphicons-halflings.png");, 替换为上面写法。

/public/stylesheets/style.less

...
.navbar .search-query {
  -webkit-box-shadow: none;
  -moz-box-shadow: none;
  background: #888 url('/public/images/search.png') no-repeat 4px 4px;
  padding: 3px 5px 3px 22px;
  color: #666;
  border: 0px;
  margin-top: 2px;

  &:hover {
    background-color: white;
  }
  transition: all 0.5s;

  &:focus, &.focused {
    background-color: white;
  }
}
...

大约850行位置

简单解释就是换成相对于根目录的路径,后面错误就类似。

45 7el q a5ph tf g84r 0

打包成功以后会输出一个assets.json在根目录。assets指的就是这个json文件,后面我们会讲如果把它们关联起来。

静态模板

我们上面已经配置好了模板引擎和静态资源,我们先要去扩展他们,先让页面好看点。

打开cnode,然后右键查看源代码。把里面内容复制,拷贝到index.html里去。

访问http://localhost:3000/就可以瞬间看到和cnode首页一样的内容了。

1

有模板以后,我们需要改造他们:

  1. 使用HTML5推荐的DOCTYPE申明
<!DOCTYPE html>
<html lang="zh-CN">
  1. 拆分body标签之外到layout.html

浏览cnode所有页面head内容,除了title标签内容其他一样

基础layout.html模板

<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <title>我是layout模板</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
</head>

<body>
    <%- body -%>
</body>

</html>

index.html里的head标签内容都移动到layout.htmlhead,同名的直接替换。

替换之后的layout.html模板

<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <title>CNode:Node.js专业中文社区</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <meta name='description' content='CNode:Node.js专业中文社区'>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="keywords" content="nodejs, node, express, connect, socket.io" />
    <!-- see http://smerity.com/articles/2013/where_did_all_the_http_referrers_go.html -->
    <meta name="referrer" content="always">
    <meta name="author" content="EDP@TaoBao" />
    <meta property="wb:webmaster" content="617be6bd946c6b96" />
    <meta content="_csrf" name="csrf-param">
    <meta content="vlUgGvkx-SgmuzendL9gAP3DHXVS3834IpC4" name="csrf-token">
    <link title="RSS" type="application/rss+xml" rel="alternate" href="/rss" />
    <link rel="icon" href="//o4j806krb.qnssl.com/public/images/cnode_icon_32.png" type="image/x-icon" />
    <!-- style -->
    <link rel="stylesheet" href="//o4j806krb.qnssl.com/public/stylesheets/index.min.23a5b1ca.min.css" media="all" />
    <%- block('styles').toString() %>
</head>

<body>
    <%- body -%>
    <!-- scripts -->
    <script src="//o4j806krb.qnssl.com/public/index.min.f7c13f64.min.js"></script>
    <%- block('scripts').toString() %>
</body>

</html>

style放头部,script放底部,并且利用模板引擎做了2个插槽,一个stylesscripts

  1. 拆分body标签之内到layout.html

浏览cnode所有页面内容,发现头部黑色部分和底部白色部分都是一样的。那么我们需要把它们提取出来。

cnode模板

...
<body>
    <div class='navbar'></div>
    <div id='main'></div>
    <div id='backtotop'></div>
    <div id='footer'></div>
    <div id='sidebar-mask'></div>
</body>
  • backtotopsidebar-mask是2个和js相关的功能标签,直接保留它们。
  • classnavbar对应到header标签
  • idmain对应到main标签
  • idfooter对应到footer标签
  • 并且把除了main标签之外内容都放到对应的标签里面
  • 模板里面关于网站访问统计的代码,我们就不需要了,直接去掉了。

改版后的layout.html模板

...
<body>
    <header id="navbar">...</header>
    <main id="main">
        <%- body -%>
    </main>
    <footer id="footer">...</footer>
    <div id="backtotop">...</div>
    <div id="sidebar-mask">...</div>
    ...
</body>

把剩下index.html里面的stylesscripts使用

<% block('styles').append(``) %>
<% block('scripts').append(``) %>

最好是写成scriptstyle文件。

  1. 拆分main标签之内到sidebar.html

浏览cnode所有主体内容,发现右边侧边栏除了api页面没有,注册登录找回密码,是另外一种模板内容,其他页面都是一样。

当前index.html模板

...
<% layout('layout') -%>
<div id='sidebar'>...</div>
<div id='content'>...</div>
...

替换后的index.html模板

...
<% layout('layout') -%>
<%- partial('./sidebar.html') %>
<article id="content">...</article>
...

这样我们首页模板已经完成了。

系统配置和应用配置

系统配置是系统级别的配置,如数据配置,端口,host,签名,加密keys等

应用配置是应用级别的配置,如网站标题,关键字,描述等

系统配置使用.env文件,大部分语言都有这个文件,我们需要用dotenv读取它们里面的内容。

dotenv支持的.env语法:

# 测试单行注释
KEY=
KEY=''
KEY=value
KEY='value'
KEY={"foo": "bar"}
KEY='{"foo": "bar"}'
KEY=["foo", "bar"]
KEY='["foo", "bar"]'
KEY=true
KEY=0
KEY='0'
KEY=null
KEY='null'

.env 语法非常简单,key 只能是字符串(ps:最好大写带下划线分割单词),value 可以是空、字符串、数字、布尔值、字典对象、数组,dotenv最后获取也是字符串,需要你做相应处理。

注意.env 文件主要的作用是存储环境变量,也就是会随着环境变化的东西,比如数据库的用户名、密码、静态文件的存储路径之类的,因为这些信息应该是和环境绑定的,不应该随代码的更新而变化,所以一般不会把 .env 文件放到版本控制中;

我们需要在.gitignore文件中排除它们:

# dotenv environment variables file
*.env
.env

.env配置文件,关于隐私配置,可以看README.md说明。.env文件模板

ConfigModule(配置模块)

当我们使用process global对象时,很难保持测试的干净,因为测试类可能直接使用它。另一种方法是创建一个抽象层,即一个ConfigModule,它公开了一个装载配置变量的ConfigService

关于配置模块,官网有详细的栗子,这里也是基本类似。这里说一些关键点:

  1. 需要用到依赖:
npm i --save dotenv  // 用来解析`.env`配置文件

npm install --save joi  // 用来验证`.env`配置文件
npm install --save-dev @types/joi
  1. 需要创建.env配置文件
 development.env  开发配置
 production.env  生产配置
 test.env  测试配置
 .env.tmp  .env配置文件模板
  1. 怎么设置NODE_ENV

windowsmac不一样

windows设置

"scripts": {
    "start:dev": "set NODE_ENV=development&& nodemon",
    "start:prod": "set NODE_ENV=production&& node dist/main.js",
    "test": "set NODE_ENV=test&& jest",
}

mac设置

"scripts": {
    "start:dev": "export NODE_ENV=development&& nodemon",
    "start:prod": "export NODE_ENV=production&& node dist/main.js",
    "test": "export NODE_ENV=test&& jest",
}

你会发现这个很麻烦,有没有什么方便地方了,可以通过cross-env来解决问题,它就是解决跨平台设置NODE_ENV的问题,默认情况下,windows不支持NODE_ENV=development的设置方式,加上cross-env就可以跨平台。

安装cross-env依赖

npm i --save-dev cross-env

cross-env设置

"scripts": {
    "start:dev": "cross-env NODE_ENV=development nodemon",
    "start:prod": "cross-env NODE_ENV=production node dist/main.js",
    "test": "cross-env NODE_ENV=test jest",
}
  1. 创建config模块:
$ nest generate module config
OR
$ nest g mo config
  • 创建全局模块,全局模块不需要在注入到该模块,就能使用该模块导出的服务。
  • 创建动态模块,动态模块可以创建可定制的模块,动态做依赖注入关系。
import { Module, DynamicModule, Global } from '@nestjs/common';
import { ConfigService } from './config.service';
import { ConfigurationToken } from './config.constants';
import { EnvConfig } from './config.interface';

@Global()
@Module({})
export class ConfigModule {
    static forRoot<T = EnvConfig>(filePath?: string, validator?: (envConfig: T) => T): DynamicModule {
        return {
            module: ConfigModule,
            providers: [
                {
                    provide: ConfigService,
                    useValue: new ConfigService(filePath || `${process.env.NODE_ENV || 'development'}.env`, validator),
                },
                {
                    provide: ConfigToken,
                    useFactory: () => new ConfigService(filePath || `${process.env.NODE_ENV || 'development'}.env`, validator),
                },
            ],
            exports: [
                ConfigService,
                ConfigToken,
            ],
        };
    }
}

<T = EnvConfig>是一种什么写法,T是一个泛型,EnvConfig是一个默认值,如果使用者不传递就是默认类型,作用类似于函数默认值。

默认用2种注册服务的写法,一种是类,一种是工厂。前面基础篇已经提及了,后面讲怎么使用它们。

  1. 创建config服务:
$ nest generate service config/config
OR
$ nest g s config/config

首先,让我们写ConfigService类。

import * as fs from 'fs';
import { parse } from 'dotenv';
import { EnvConfig } from './config.interface';

export class ConfigService<T = EnvConfig> {
    // 系统配置
    private readonly envConfig: T;

    constructor(filePath: string, validator?: (envConfig: T) => T) {
        // 解析配置文件
        const configFile: T = parse(fs.readFileSync(filePath));
        // 验证配置参数
        if (typeof validator === 'function') {
            const envConfig: T = validator(configFile);
            if (typeof envConfig !== 'object') {
                throw Error('validator return value is not object');
            }
            this.envConfig = envConfig;
        } else {
            this.envConfig = configFile;
        }
    }

    /**
     * 获取配置
     * @param key
     * @param defaultVal
     */
    get(key: string, defaultVal?: any): string {
        return process.env[key] || this.envConfig[key] || defaultVal;
    }

    /** 获取系统配置 */
    getKeys(keys: string[]): any {
        return keys.reduce((obj, key: string) => {
            obj[key] = this.get(key);
            return obj;
        }, {});
    }

    /**
     * 获取数字
     * @param key
     */
    getNumber(key: string): number {
        return Number(this.get(key));
    }

    /**
     * 获取布尔值
     * @param key
     */
    getBoolean(key: string): boolean {
        return Boolean(this.get(key));
    }

    /**
     * 获取字典对象和数组
     * @param key
     */
    getJson(key: string): { [prop: string]: any } | null {
        try {
            return JSON.parse(this.get(key));
        } catch (error) {
            return null;
        }
    }

    /**
     * 检查一个key是否存在
     * @param key
     */
    has(key: string): boolean {
        return this.get(key) !== undefined;
    }

    /** 开发模式 */
    get isDevelopment(): boolean {
        return this.get('NODE_ENV') === 'development';
    }
    /** 生产模式 */
    get isProduction(): boolean {
        return this.get('NODE_ENV') === 'production';
    }
    /** 测试模式 */
    get isTest(): boolean {
        return this.get('NODE_ENV') === 'test';
    }
}

解析数据都存在envConfig里,封装一些获取并转义value的方法。

传递2个参数,一个是.env文件路径,一个是验证器,配合Joi使用,nest官网文档把配置服务和验证字段放在一起,我觉得这样不是很科学。
我在.env加一个配置就需要去修改ConfigService类,它本来就是不需要修改的,我就把验证部分提取出来,这样就不用关心验证问题了。ConfigService只关心取值问题。

上面模块里面还有一个ConfigToken服务,它是做什么的了,它叫做令牌。

  1. 我们创建一个常量文件:
$ touch src/config/config.constants.ts
OR
编辑器新建文件config.constants.ts

里面写入常量configToken并导出

export const ConfigToken = 'ConfigToken';

ConfigModuleconfigToken也是它。

  1. 我们创建一个装饰器文件:
$ touch src/config/config.decorators.ts
OR
编辑器新建文件config.decorators.ts
import { Inject } from '@nestjs/common';

import { ConfigToken } from './config.constants';

export const InjectConfig = () => Inject(ConfigToken);

使用Inject依赖注入器注入令牌对应的服务

InjectConfig是一个装饰器。装饰器在nestangular有大量实践案例,各种装饰器,让你眼花缭乱。

简单科普一下装饰器:

写法:(总共四种:类,属性,方法,方法参数)

declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;

declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;

declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;

declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;

执行顺序:

  • 类装饰器总是最后执行。
  • 有多个方法参数装饰器时:从最后一个参数依次向前执行。
  • 方法参数装饰器中参数装饰器先执行,方法参数装饰器执行完以后,方法装饰器执。
  • 方法和属性装饰器,谁在前面谁先执行。(ps:方法参数属于方法一部分,参数会一直紧紧挨着方法执行。)
  1. 如何使用config

2种方式:

// 装饰器依赖注入
constructor(
    @InjectConfig() private readonly config: ConfigService<EnvConfig>,
) {
    this.name = this.config.get('name');
}
// 普通依赖注入
constructor(
    private readonly config: ConfigService<EnvConfig>,
) {
    this.name = this.config.get('name');
}
// 通过app实例取
const config: ConfigService<EnvConfig> = app.get(ConfigService);

...
  if (config.isDevelopment) {
    app.use(loaderConnect.less(rootDir));
  }
...
await app.listen(config.getNumber('PORT'));

普通依赖注入就够玩了,这里用装饰器依赖注入有些画蛇添足,只是说明装饰器和注入器注入令牌用法。
通过app实例取,一般用于系统启动初始化配置,后面还要其他的获取方式,用到在介绍。

Config(应用配置)

应用配置对比系统配置就没有这么麻烦了,大多数数据都可以写死就行了。

$ touch src/core/constants/config.constants.ts
OR
编辑器新建文件config.constants.ts

参考cnode-eggconfig/config.default.js

export const Config = {
    // 网站名字、标题
    name: 'CNode技术社区',
    // 网站关键词
    keywords: 'nodejs, node, express, connect, socket.io',
    // 网站描述
    description: 'CNode:Node.js专业中文社区',
    // logo
    logo: '/public/images/cnodejs_light.svg',
    // icon
    icon: '/public/images/cnode_icon_32.png',
    // 版块
    tabs: [['all', '全部'], ['good', '精华'], ['share', '分享'], ['ask', '问答'], ['job', '招聘'], ['test', '测试']],
    // RSS配置
    rss: {
        title: this.description,
        link: '/',
        language: 'zh-cn',
        description: this.description,
        // 最多获取的RSS Item数量
        max_rss_items: 50,
    },
    // 帖子配置
    topic: {
        // 列表分页20
        list_count: 20,
        // 每天每用户限额计数10
        perDayPerUserLimitCount: 10,
    },
    // 用户配置
    user: {
        // 每个 IP 每天可创建用户数
        create_user_per_ip: 1000,
    },
    // 默认搜索方式
    search: 'baidu', // 'google', 'baidu', 'local'
};

哪里需要直接导入就行了,这个比较简单。

系统配置和应用配置告一段落了,那么接下来需要配置数据。

mongoose连接

关于mongoDB安装,创建数据库,连接认证等操作,这里就展开了,这里有篇文章

.env文件里面,我们已经配置mongoDB相关数据。

  1. 创建核心模块
$ nest generate module core
OR
$ nest g mo core

核心模块,只会注入到AppModule,不会注入到featureshared模块里面,专门做初始化配置工作,不需要导出任何模块。

它里面包括:守卫,管道,过滤器、拦截器、中间件、全局模块、常量、装饰器

其中全局中间件和全局模块需要模块里面注入和配置。

  1. 配置ConfigModule

前面我们已经定义好了ConfigModule,现在把它添加到CoreModule

import { Module } from '@nestjs/common';
import { ConfigModule, EnvConfig } from '../config';
import { ConfigValidate } from './config.validate';

@Module({
    imports: [
        ConfigModule.forRoot<EnvConfig>(null, ConfigValidate.validateInput),
    ],
})
export class CoreModule {
}

ConfigValidate.validateInput 是一个验证 .env 方法,nest和官网文档一样.

  1. 配置mongooseModule

nest为我们提供了@nestjs/mongoose

安装依赖:

$ npm install --save @nestjs/mongoose mongoose
$ npm install --save-dev @types/mongoose

配置模块:文档

...
import { MongooseModule } from '@nestjs/mongoose';

@Module({
    imports: [
        ...
        MongooseModule.forRoot(url, config)
    ],
})
export class CoreModule {
}

MongooseModule提供了2个静态方法:

  • forRoot(url, config): 对应的Mongoose.connect()方法
  • forRootAsync({
    imports,
    useFactory,
    inject
    }): useFactory返回对应的Mongoose.connect()方法参数,imports依赖模块,inject依赖服务
  • forFeature([{ name, schema }]): 对应的mongoose.model()方法
  • constructor(@InjectModel('Cat') private readonly catModel: Model) {}:@InjectModel获取mongoose.model,参数和forFeaturename一样。

根模块使用: (forRoot和forRootAsync,只能注入一次,所以要在根模块导入)

这里我们需要借助配置模块里面获取配置,需要用到forRootAsync

...
import { MongooseModule } from '@nestjs/mongoose';

@Module({
    imports: [
        ...
        MongooseModule.forRootAsync({
            imports: [ConfigModule],
            useFactory: async (configService: ConfigService) => ({
                uri: configService.get('MONGODB_URI'),
                useNewUrlParser: true,
            }),
            inject: [ConfigService],
        })
    ],
})
export class CoreModule {
}

如果要写MongooseOptions怎么办

直接在uri后面写,有个必须的配置要写:

DeprecationWarning: current URL string parser is deprecated, and will be removed in a future version. To use the new parser, pass option { useNewUrlParser: true } to MongoClient.connect.

其他配置根据自己需求来添加

如果启动失败会显示:

MongoError: Authentication failed.

请检查uri是否正确,如果启动验证,账号是否验证通过,数据库名是否正确等等。

数据库连接成功,我们进行下一步,定义用户表。

用户数据库模块

建立数据模型为后面控制器提供服务

生成文件

  1. 创建shared模块
$ nest generate module shared
OR
$ nest g mo shared
  1. 创建mongodb模块
$ nest generate module shared/mongodb
OR
$ nest g mo shared/mongodb
  1. 创建user模块
$ nest generate module shared/mongodb/user
OR
$ nest g mo shared/mongodb/user
  1. 创建user服务
$ nest generate service shared/mongodb/user
OR
$ nest g s shared/mongodb/user
  1. 创建userinterfaceschemaindex

这三个文件无法用命令创建需要自己手动创建。

$ touch src/shared/mongodb/user/user.interface.ts
$ touch src/shared/mongodb/user/user.schema.ts
$ touch src/shared/mongodb/user/index.ts
OR
编辑器新建文件`user.interface.ts`
编辑器新建文件`user.schema.ts`
编辑器新建文件`index.ts`
  • interfacets接口定义
  • schema是定义mongodbschema

最后完整的user文件夹是:

index.ts
user.module.ts
user.service.ts
user.schema.ts
user.interface.ts

基本所有的mongodb模块都是这样的结构,后面不在介绍生成文件这项。

定义服务

默认生产的模块文件

import { Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
    constructor() { }
}

在正式写UserService之前,我们先思考一个问题,因为操作数据库服务基本都类似,常用几个方法如:

  • findAll 获取指定条件全部数据
  • paginator 带分页结构数据
  • findOne 获取一个数据
  • findById 获取指定id数据
  • count 获取指定条件个数
  • create 创建数据
  • delete 删除数据
  • update 更新数据

一个基本表应该有增删改查这样8个快捷操作方法,如果每个表都写一个这样的,就比较多余了。Typescript给我们提供一个抽象类,我们可以把这些公共方法写在里面,然后用其他服务来继承。那我们开始写base.service.ts:

base.service.ts

/**
 * 抽象CRUD操作基础服务
 * @export
 * @abstract
 * @class BaseService
 * @template T
 */
export abstract class BaseService<T extends Document> {
    constructor(private readonly _model: Model<T>) {}

    /**
     * 获取指定条件全部数据
     * @param {*} conditions
     * @param {(any | null)} [projection]
     * @param {({
     *         sort?: any;
     *         limit?: number;
     *         skip?: number;
     *         populates?: ModelPopulateOptions[] | ModelPopulateOptions;
     *         [key: string]: any;
     *     })} [options]
     * @returns {Promise<T[]>}
     * @memberof BaseService
     */
    findAll(conditions: any, projection?: any | null, options?: {
        sort?: any;
        limit?: number;
        skip?: number;
        populates?: ModelPopulateOptions[] | ModelPopulateOptions;
        [key: string]: any;
    }): Promise<T[]> {
        const { option, populates } = options;
        const docsQuery = this._model.find(conditions, projection, option);
        return this.populates<T[]>(docsQuery, populates);
    }

    /**
     * 获取带分页数据
     * @param {*} conditions
     * @param {(any | null)} [projection]
     * @param {({
     *         sort?: any;
     *         limit?: number;
     *         offset?: number;
     *         page?: number;
     *         populates?: ModelPopulateOptions[] | ModelPopulateOptions;
     *         [key: string]: any;
     *     })} [options]
     * @returns {Promise<Paginator<T>>}
     * @memberof BaseService
     */
    async paginator(conditions: any, projection?: any | null, options?: {
        sort?: any;
        limit?: number;
        offset?: number;
        page?: number;
        populates?: ModelPopulateOptions[] | ModelPopulateOptions;
        [key: string]: any;
    }): Promise<Paginator<T>> {
        const result: Paginator<T> = {
            data: [],
            total: 0,
            limit: options.limit ? options.limit : 10,
            offset: 0,
            page: 1,
            pages: 0,
        };
        const { offset, page, option } = options;
        if (offset !== undefined) {
            result.offset = options.offset;
            options.skip = offset;
        } else if (page !== undefined) {
            result.page = page;
            options.skip = (page - 1) * result.limit;
            result.pages = Math.ceil(result.total / result.limit) || 1;
        } else {
            options.skip = 0;
        }
        result.data = await this.findAll(conditions, projection, option);
        result.total = await this.count(conditions);
        return Promise.resolve(result);
    }

    /**
     * 获取单条数据
     * @param {*} conditions
     * @param {*} [projection]
     * @param {({
     *         lean?: boolean;
     *         populates?: ModelPopulateOptions[] | ModelPopulateOptions;
     *         [key: string]: any;
     *     })} [options]
     * @returns {(Promise<T | null>)}
     * @memberof BaseService
     */
    findOne(conditions: any, projection?: any, options?: {
        lean?: boolean;
        populates?: ModelPopulateOptions[] | ModelPopulateOptions;
        [key: string]: any;
    }): Promise<T | null> {
        const { option, populates } = options;
        const docsQuery = this._model.findOne(conditions, projection, option);
        return this.populates<T>(docsQuery, populates);
    }

    /**
     * 根据id获取单条数据
     * @param {(any | string | number)} id
     * @param {*} [projection]
     * @param {({
     *         lean?: boolean;
     *         populates?: ModelPopulateOptions[] | ModelPopulateOptions;
     *         [key: string]: any;
     *     })} [options]
     * @returns {(Promise<T | null>)}
     * @memberof BaseService
     */
    findById(id: any | string | number, projection?: any, options?: {
        lean?: boolean;
        populates?: ModelPopulateOptions[] | ModelPopulateOptions;
        [key: string]: any;
    }): Promise<T | null> {
        const { option, populates } = options;
        const docsQuery = this._model.findById(this.toObjectId(id), projection, option);
        return this.populates<T>(docsQuery, populates);
    }

    /**
     * 获取指定查询条件的数量
     * @param {*} conditions
     * @returns {Promise<number>}
     * @memberof UserService
     */
    count(conditions: any): Promise<number> {
        return this._model.countDocuments(conditions).exec();
    }

    /**
     * 创建一条数据
     * @param {T} docs
     * @returns {Promise<T>}
     * @memberof BaseService
     */
    async create(docs: Partial<T>): Promise<T> {
        return this._model.create(docs);
    }

    /**
     * 删除指定id数据
     * @param {string} id
     * @returns {Promise<T>}
     * @memberof BaseService
     */
    async delete(id: string, options: {
        /** if multiple docs are found by the conditions, sets the sort order to choose which doc to update */
        sort?: any;
        /** sets the document fields to return */
        select?: any;
    }): Promise<T | null> {
        return this._model.findByIdAndRemove(this.toObjectId(id), options).exec();
    }

    /**
     * 更新指定id数据
     * @param {string} id
     * @param {Partial<T>} [item={}]
     * @returns {Promise<T>}
     * @memberof BaseService
     */
    async update(id: string, update: Partial<T>, options: ModelFindByIdAndUpdateOptions = { new: true }): Promise<T | null> {
        return this._model.findByIdAndUpdate(this.toObjectId(id), update, options).exec();
    }

    /**
     * 删除所有匹配条件的文档集合
     * @param {*} [conditions={}]
     * @returns {Promise<WriteOpResult['result']>}
     * @memberof BaseService
     */
    async clearCollection(conditions = {}): Promise<WriteOpResult['result']> {
        return this._model.deleteMany(conditions).exec();
    }

    /**
     * 转换ObjectId
     * @private
     * @param {string} id
     * @returns {Types.ObjectId}
     * @memberof BaseService
     */
    private toObjectId(id: string): Types.ObjectId {
        return Types.ObjectId(id);
    }

    /**
     * 填充其他模型
     * @private
     * @param {*} docsQuery
     * @param {*} populates
     * @returns {(Promise<T | T[] | null>)}
     * @memberof BaseService
     */
    private populates<R>(docsQuery, populates): Promise<R | null> {
        if (populates) {
            [].concat(populates).forEach((item) => {
                docsQuery.populate(item);
            });
        }
        return docsQuery.exec();
    }
}

这里说几个上面没有提到的属性和方法:

  • _model:当前模型的实例,我们使用它去扩展其他方法,如果上面方法不满足我们需求,我们可以随时自定义
  • clearCollection:删除所有匹配条件的文档集合
  • toObjectId:字符串 id 转换ObjectId

那么我们接下来的UserService就简单多了

user.service.ts

import { Injectable } from '@nestjs/common';
import { BaseService } from '../base.service';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { User } from './user.interface';

@Injectable()
export class UserService extends BaseService<User> {
    constructor(
        @InjectModel('User') private readonly userModel: Model<User>,
    ) {
        super(userModel);
    }
}

BaseService是一个泛型,泛型是什么,简单理解就是你传什么它就是什么。T需要把我们User类型传进去,返回都是User类型,使用@InjectModel('User')注入模型实例,最后赋值给_model

我们现在数据库UserService就已经完成了,接下来就需要定义schemainterface

定义schema

有了上面服务的经验,现在是不是你会说schema有没有公用的,当然可以呀。

我们定一个base.schema.ts,思考一下需要抽出来,好像唯一可以抽出来就是:

  • create_at:创建时间
  • update_at: 更新时间

这2个我们可以用抽出来,可以使用schema配置参数里面的timestamps属性,可以开启它,它默认createdAtupdatedAt。我们修改它们字段名,使用它们好处,创建自动赋值,修改时候自动更新。
注意:它们的存的时间和本地时间相差8小时,这个后面说怎么处理。

那么我们最终的配置就是:

export const schemaOptions: SchemaOptions = {
    toJSON: {
        virtuals: true,
        getters: true,
    },
    timestamps: {
        createdAt: 'create_at',
        updatedAt: 'update_at',
    },
};

toJSON是做什么的,我们需要开启显示virtuals虚拟数据,getters获取数据。

关于schema定义

在创建表之前我们需要跟大家说一下mongoDB的数据类型,具体数据类型如下:

  • 字符串 - 这是用于存储数据的最常用的数据类型。MongoDB中的字符串必须为UTF-8
  • 整型 - 此类型用于存储数值。 整数可以是32位或64位,具体取决于服务器。
  • 布尔类型 - 此类型用于存储布尔值(true / false)值。
  • 双精度浮点数 - 此类型用于存储浮点值。
  • 最小/最大键 - 此类型用于将值与最小和最大BSON元素进行比较。
  • 数组 - 此类型用于将数组或列表或多个值存储到一个键中。
  • 时间戳 - ctimestamp当文档被修改或添加时,可以方便地进行录制。
  • 对象 - 此数据类型用于嵌入式文档。
  • 对象 - 此数据类型用于嵌入式文档。
  • Null - 此类型用于存储Null值。
  • 符号 - 该数据类型与字符串相同; 但是,通常保留用于使用特定符号类型的语言。
  • 日期 - 此数据类型用于以UNIX时间格式存储当前日期或时间。您可以通过创建日期对象并将日,月,年的日期进行指定自己需要的日期时间。
  • 对象ID - 此数据类型用于存储文档的ID。
  • 二进制数据 - 此数据类型用于存储二进制数据。
  • 代码 - 此数据类型用于将JavaScript代码存储到文档中。
  • 正则表达式 - 此数据类型用于存储正则表达式。

mongoose使用Schema所定义的数据模型,再使用mongoose.model(modelName, schema)将定义好的Schema转换为Model
Mongoose的设计理念中,Schema用来也只用来定义数据结构,具体对数据的增删改查操作都由Model来执行

import { Schema } from 'mongoose';
export const UserSchema = new Schema({
    // 定义你的Schema
});
UserSchema.index()  // 索引
UserSchema.virtual() // 虚拟值
UserSchema.pre() // 中间件
UserSchema.methods.xxx = function(){} // 实例方法
UserSchema.statics.xxx = function(){} // 静态方法
UserSchema.query.xxx = function(){} // 查询助手
UserSchema.query.xxx = function(){} // 查询助手

注意:这里面都要使用普通函数function(){},不能使用()=>{},原因你懂的。

user.schema.ts

// 引入mongoose包
import { Schema } from 'mongoose';
// 一个工具包,使用MD5方法加密
import * as utility from 'utility';
// 引入user接口
import { User } from './user.interface';

// 定义schema并导出
export const UserSchema = new Schema({
    name: { type: String },
    loginname: { type: String },
    pass: { type: String },
    email: { type: String },
    url: { type: String },
    profile_image_url: { type: String },
    location: { type: String },
    signature: { type: String },
    profile: { type: String },
    weibo: { type: String },
    avatar: { type: String },
    githubId: { type: String },
    githubUsername: { type: String },
    githubAccessToken: { type: String },
    is_block: { type: Boolean, default: false },
    ...
}, schemaOptions);

// 设置索引
UserSchema.index({ loginname: 1 }, { unique: true });
UserSchema.index({ email: 1 }, { unique: true });
UserSchema.index({ score: -1 });
UserSchema.index({ githubId: 1 });
UserSchema.index({ accessToken: 1 });

// 设置虚拟属性
UserSchema.virtual('avatar_url').get(function() {
    let url =
        this.avatar ||
        `https://gravatar.com/avatar/${utility.md5(this.email.toLowerCase())}?size=48`;

    // www.gravatar.com 被墙
    url = url.replace('www.gravatar.com', 'gravatar.com');

    // 让协议自适应 protocol,使用 `//` 开头
    if (url.indexOf('http:') === 0) {
        url = url.slice(5);
    }

    // 如果是 github 的头像,则限制大小
    if (url.indexOf('githubusercontent') !== -1) {
        url += '&s=120';
    }
    return url;
});
...

注意:这里面使用utility工具包,需要安装一下,npm install utility --save

定义interface

因为有些公共的字段,我们在定义interface时候也需要抽离出来。使用base.interface.ts

base.interface.ts

import { Document, Types } from 'mongoose';

export interface BaseInterface extends Document {
    _id: Types.ObjectId;  // mongodb id
    id: Types.ObjectId; // mongodb id
    create_at: Date; // 创建时间
    update_at: Date; // 更新时间
}

interface 文件内容和 schema 的基本一样,只需要字段名和类型就好了。

user.interface.ts

import { BaseInterface } from '../base.interface';

export interface User extends BaseInterface {
    name: string;  // 显示名字
    loginname: string;  // 登录名
    pass: string; // 密码
    age: number;  // 年龄
    email: string;  // 邮箱
    active: boolean;  // 是否激活
    collect_topic_count: number;  // 收集话题数
    topic_count: number;  // 发布话题数
    score: number;   // 积分
    is_star: boolean;  //
    is_block: boolean; // 是否黑名单
    ...
}

注意:如果是schema里面不是定义必填或者有默认值的字段,需要这样写is_admin?: boolean;?表示该字段可选的。最好在interface里面写上每个字段加上注释,方便查看。

定义模块

默认生产的模块文件

import { Module } from '@nestjs/common';

@Module({
    imports: [],
    providers: [],
    exports: [],
})
export class UserModule {}

上面schemaservice,都定义好了,接下来我们需要在模块里面注册。

user.module.ts

import { Module } from '@nestjs/common';

// 引入 nestjs 提供的 mongoose 模块
import { MongooseModule } from '@nestjs/mongoose';

// 引入自己写的 schema 和 service 在模块里面注册
import { UserSchema } from './user.schema';
import { UserService } from './user.service';

@Module({
    imports: [
        MongooseModule.forFeature([{ name: 'User', schema: UserSchema }]),
    ],
    providers: [UserService],
    exports: [UserService],
})
export class UserModule {}

forFeature([{ name: 'User', schema: UserSchema }])就是MongooseModule为什么提供的mongoose.model(modelName, schema)操作

注意providers是注册服务,如果想要给其他模块使用,需要在exports导出。

定义索引文件

index.ts

export * from './user.module';
export * from './user.interface';
export * from './user.service';

注意:不是所有的文件都需要导出的,一些关键的文件,其他模块需要使用的,如果interfaceservice都是需要导出的。

其他文件访问

xxx.service.ts

import { UserService , User } from './user';

是不是很方便。

shared 模块和 mongodb 模块

mongodb模块

mongodb模块是管理所有mongodb文件夹里模块导入导出

mongodb.module.ts

import { Module } from '@nestjs/common';
import { UserModule } from './user';

@Module({
    imports: [UserModule],
    exports: [UserModule],
})
export class MongodbModule { }

建立索引文件index.ts导出mongodb文件夹下所有文件夹

shared模块

shared模块是管理所有shared文件夹里模块导入导出

shared.module.ts

import { Module } from '@nestjs/common';
import { MongodbModule } from './mongodb';

@Module({
    imports: [MongodbModule],
    exports: [MongodbModule],
})
export class SharedModule { }

建立索引文件index.ts导出shared文件夹下所有文件夹

到这里我们user数据表模块就基本完成了,接下来就需要使用它们。我们也可以运行npm run start:dev,不会出现任何错误,如果有错,请检查你的文件是否正确。如果找不到问题,可以联系我。

注意:后面我们搭建数据库就不再如此详细说明,只是一笔带过,大家可以看源码。

注册和使用node-mailer发送邮件

如果有用户模块功能,登陆注册应该说是必备的入门功能。

先说一下我们登陆注册逻辑:

  1. 我们主要使用passport、passport-github、passport-local这三个模块,做身份认证。
  2. 支持本地注册登陆和github第三方认证登陆(后面会介绍github认证登陆怎么玩)
  3. 使用sessioncookie,30天内免登陆
  4. 退出后清除sessioncookie
  5. 支持电子邮箱找回密码

这里注册、登录、登出、找回密码都放在这个模块里面

生成文件

  1. 创建feature模块
$ nest generate module feature
OR
$ nest g mo feature
  1. 创建auth模块
$ nest generate module feature/auth
OR
$ nest g mo feature/auth
  1. 创建auth服务
$ nest generate service feature/auth
OR
$ nest g s feature/auth
  1. 创建auth控制器
$ nest generate controller feature/auth
OR
$ nest g co feature/auth
  1. 创建authdto

dto是字段参数验证的验证类,需要配合各种功能,等下会讲解。

最后完整的auth文件夹是:

index.ts
auth.module.ts
auth.service.ts
auth.controller.ts
dto

基本所有的feature模块都是这样的结构,后面不在介绍生成文件这项。

科普知识:async/await

ES7发布async/await,也算是异步的解决又一种方案,

看一个简单的栗子:

const sleep =  (time) => {
    return new Promise( (resolve)=> {
        setTimeout( () => {
            resolve();
        }, time);
    })
};

const start = async () => {
    // 在这里使用起来就像同步代码那样直观
    console.log('start');
    await sleep(3000);
    console.log('end');
};

const startFor = async function () {
    for (var i = 1; i <= 10; i++) {
        console.log(`当前是第${i}次等待..`);
        await sleep(1000);
    }
};

start();

// startFor();

控制台先输出start,稍等3秒后,输出了end

看栗子也能知道async/await基本使用规则和条件

  1. async 表示这是一个async函数,await只能用在这个函数里面
  2. await 表示在这里等待promise返回结果了,再继续执行。
  3. await 等待的虽然是promise对象,但不必写.then(..),直接可以得到返回值。
  4. 捕捉错误可以直接用标准的try catch语法捕捉错误
  5. 循环多个await 可以写在for循环里,不必担心以往需要闭包才能解决的问题 (注意不能使用forEach,只可以用for/for-of)

注意await必须在async函数的上下文中

在开始之前,前面数据操作有基础服务抽象类,这里控制器和服务也可以抽象出来。是可以抽象出来,但是本项目不决定这么来做,但会做一些抽象的辅助工具。

auth模块

auth.module.ts

import { Module } from '@nestjs/common';
// 引入共享模块 访问user数据库
import { SharedModule } from 'shared';
// 引入控制和服务进行在模块注册
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';

@Module({
    imports: [
        SharedModule,
    ],
    controllers: [AuthController],
    providers: [AuthService],
})
export class AuthModule { }

注意feature 模块尽量不要导出服务,避免循环依赖。

feature模块

feature.module.ts

import { Module } from '@nestjs/common';
// 引入Auth模块导入导出
import { AuthModule } from './auth/auth.module';

@Module({
    imports: [
        AuthModule,
    ],
    exports: [
        AuthModule,
    ],
})
export class FeatureModule { }

注意feature 模块功能就是导入导出所以的业务模块。

app模块

如果是按我顺序用命令行创建的文件,feature 模块会自动添加到 APP 模块里面,
如果不是,需要手动把 feature 模块引入到 APP 模块里面。

app.module.ts

import { Module } from '@nestjs/common';
// 引入核心模块 只能在AppModule导入,nest 没有 angular 模块检查机制,只能自觉遵守吧。
import { CoreModule } from './core/core.module';
// 引入特性模块
import { FeatureModule } from 'feature';

@Module({
  imports: [
    CoreModule,
    FeatureModule,
  ],
})
export class AppModule { }

注意APP 模块不需要引入 shared 模块,shared 模式给业务模块引用的,APP 模块只需要引入 CoreModule, feature 模块就可以了。

auth控制器

默认控制器文件

import { Controller } from '@nestjs/common';

@Controller()
export class AuthController {

}

注册

要想登录,就要先注册,那我们先从注册开始。

auth.controller.ts

import {
    ...
    Get,
    Render
 } from '@nestjs/common';

@Controller()
export class AuthController {
    @Get('/register')
    @Render('auth/register')
    async registerView() {
        return { pageTitle: '注册' };
    }
}

前面介绍控制器时候已经介绍了Get,那么Render是什么,渲染模板,对应是Expressres.render('xxxx');方法。

提示

  1. 关于控制器方法命名方式,因为本项目是服务的渲染的,所有会有模板页面和页面请求。模板页面统一加上View后缀

  2. 模板页面请求都是get,返回数据会带一个必须字段pageTitle,当前页面的title标签使用。

  3. 页面请求方法命名根据实际情况来。

现在就可以运行开发启动命令看看效果,百分之两百的会报错,为什么?因为找不到模板auth/register.ejs文件。

那我们就去views下去创建一个auth/register.ejs,随便写的什么,在运行就可以了,浏览器访问:http://localhost:3000/register

2

我们需要完善里面的内容了,因为cnode
屏蔽注册功能,全部走github第三方认证登录,所以看不到https://cnodejs.org/signin这个页面,那么我们可以在源码找到这个页面结构,直接拷贝div#content里的内容过来。

一刷新就页面报错了:

{
    "statusCode": 500,
    "message": "Internal server error"
}

查看命令行提示:

[Nest] 22132   - 2018-9-4 16:21:11   [ExceptionsHandler] E:\github\nest-cnode\views\auth\register.html:61
    59|                         <% } %>
    60|                     </div>
 >> 61|                 </div>
    62|                 <input type='hidden' name='_csrf' value='<%= csrf %>' />
    63|
    64|                 <div class='form-actions'>

csrf is not defined

提示我们csrf这个变量找不到。csrf是什么,
跨站请求伪造(CSRF或XSRF)是一种恶意利用的网站,未经授权的命令是传播从一个web应用程序的用户信任。
减轻这种攻击可以使用csurf包。这里有篇文章浅谈cnode社区如何防止csrf攻击

安装所需的包:

$ npm i --save csurf

在入口文件启动函数里面使用它。

import * as csurf from 'csurf';
async function bootstrap() {
  const app = await NestFactory.create(AppModule, application);
  ...
  // 防止跨站请求伪造
  app.use(csurf({ cookie: true }));
  ...
}  

直接这么写肯定有问题,刷新页面控制台报错Error: misconfigured csrf

下面来说个我经常解决问题方法:

  1. 首先如果我们用的github的开源依赖包,我们把这个错误复制到它的issues的搜索框里,如果有类似的问题,就进去看看,能不能找到解决方案,如果没有一个问题,你就可以提issues

把你的问题的和环境依赖、最好有示例代码,越详细越好,运气好马上有人给你解决问题。

  1. 搜索引擎解决问题比如:谷歌、必应、百度。如果有条件首选谷歌,没条件优先必应,其次百度。也是把问题直接复制到输入框,回车就好有一些类似的答案。

  2. 就是去一些相关社区提问,和1一样,把问题描述清楚。

使用必应搜索,发现结果第一个就是问题,和我们一模一样的。

3

点击链接进去的,有人回复一个收到好评最高,说app.use(csurf())要在app.use(cookieParser())app.use(session({...})之后执行。

其实我们的这个问题,在csurf说明文档里面已经有写了,使用之前必须依赖cookieParsersession中间件。

session中间件可以选择express-sessioncookie-session

我们需要安装2个中间件:

$ npm i --save cookie-parser express-session connect-redis

在入口文件启动函数里面使用它。

import * as cookieParser from 'cookie-parser';
import * as expressSession from 'express-session';
import * as connectRedis from 'connect-redis';
import * as csurf from 'csurf';
async function bootstrap() {
  const app = await NestFactory.create(AppModule, application);
  ...
  const RedisStore = connectRedis(expressSession);
  const secret = config.get('SESSION_SECRET');
  // 注册session中间件
  app.use(expressSession({
    name: 'jiayi',
    secret,  // 用来对sessionid 相关的 cookie 进行签名
    store: new RedisStore(getRedisConfig(config)),  // 本地存储session(文本文件,也可以选择其他store,比如redis的)
    saveUninitialized: false,  // 是否自动保存未初始化的会话,建议false
    resave: false,  // 是否每次都重新保存会话,建议false
  }));
  // 注册cookies中间件
  app.use(cookieParser(secret));
  // 防止跨站请求伪造
  app.use(csurf({ cookie: true }));
  ...
}  

里面有注释,这里就不解释了。

现在刷新还是一样报错csrf is not defined

上面已经ok,现在是没有这个变量,我们去registerView方法返回值里面加上

async registerView() {
    return { pageTitle: '注册', csrf: '' };
}

key是csrf,value随便写,返回最后都会被替换的。

4

如果每次都要写一个那就比较麻烦了,需要写一个中间件来解决问题。

在入口文件启动函数里面使用它。

async function bootstrap() {
  const app = await NestFactory.create(AppModule, application);
  ...
  // 设置变量 csrf 保存csrfToken值
  app.use((req: any, res, next) => {
    res.locals.csrf = req.csrfToken ? req.csrfToken() : '';
    next();
  });
  ...
}  

在刷新又报了另外一个错误:ForbiddenError: invalid csrf token。验证token失败。

文档里面也有,读取令牌从以下位置,按顺序:

  • req.body._csrf - typically generated by the body-parser module.
  • req.query._csrf - a built-in from Express.js to read from the URL query string.
  • req.headers['csrf-token'] - the CSRF-Token HTTP request header.
  • req.headers['xsrf-token'] - the XSRF-Token HTTP request header.
  • req.headers['x-csrf-token'] - the X-CSRF-Token HTTP request header.
  • req.headers['x-xsrf-token'] - the X-XSRF-Token HTTP request header.

前端向后端提交数据,常用有2种方式,formajaxajax无刷新,这个比较常用,基本是主流操作了。form是服务端渲染使用比较多,不需要js处理直接提交,我们项目大部分都是form直接提交。

一般服务端渲染常用就2种请求,get打开一个页面,post直接form提交。

post提交都是把数据放在body体里面,Express,解析body需要借助中间件body-parser

nest已经自带body-parser配置。但是我发现好像有bug,原因不明,给作者提issues

作者回复速度很快,需要调用app.init()初始化才行。

还有一个重要的东西layout.html模板需要加上csrf这个变量。

<meta content="<%= csrf %>" name="csrf-token">

接下来要写表单验证了:

我们在dto文件夹里面创建一个register.dto.tsindex.ts文件

$ touch src/feature/auth/dto/register.dto.ts
$ touch src/feature/auth/dto/index.ts
OR
编辑器新建文件register.dto.ts
编辑器新建文件index.ts

register.dto.ts是一个导出的类,typescript类型,可以是class,可以interface,推荐class,因为它不光可以定义类型,还可以初始化数据。

export class RegisterDto {
    readonly loginname: string;
    readonly email: string;
    readonly pass: string;
    readonly re_pass: string;
    readonly _csrf: string;
}

什么叫dto, 全称数据传输对象(DTO)(Data Transfer Object),简单来说DTO是面向界面UI,是通过UI的需求来定义的。通过DTO我们实现了控制器与数据验证转化解耦。

dto中定义属性就是我们要提交的数据,控制器里面这样获取他们。

@Post('/register')
@Render('auth/register')
async register(@Body() register: RegisterDto) {
    return await this.authService.register(register);
}

这样是不是很普通,也没有太大用处。如果真的是这样的,我就不会写出来了。如果我提交数据之前需要验证字段合法性怎么办。nest也为我们想到了,使用官方提供的ValidationPipe,并安装2个必须的依赖:

npm i --save class-validator class-transformer

因为数据验证是非常通用的,我们需要在入口文件里全局去注册管道。

async function bootstrap() {
  const app = await NestFactory.create(AppModule, application);
  ...
  // 注册并配置全局验证管道
  app.useGlobalPipes(new ValidationPipe({
    transform: true,
    whitelist: true,
    forbidNonWhitelisted: true,
    skipMissingProperties: false,
    forbidUnknownValues: true,
  }));
  ...
}  

配置信息官网都有介绍,说一个重点,transform是转换数据,配合class-transformer使用。

开始写验证规则,对于这些装饰器使用方法,可以看文档也可以看.d.ts文件。

...
@IsNotEmpty({
        message: '用户名不能为空',
    })
    @Matches(/^[a-zA-Z0-9\-_]{5, 20}$/i, {
        message: '用户名不合法',
    })
    @Transform(value => value.toLowerCase(), { toClassOnly: true })
    readonly loginname: string;
    @IsNotEmpty({
        message: '邮箱不能为空',
    })
    @IsEmail({}, {
        message: '邮箱不合法',
    })
    @Transform(value => value.toLowerCase(), { toClassOnly: true })
    readonly email: string;
    @IsNotEmpty({
        message: '密码不能为空',
    })
    @IsByteLength(6, 18, {
        message: '密码长度不是6-18位',
    })
    readonly pass: string;
    @IsNotEmpty({
        message: '确认密码不能为空',
    })
    readonly re_pass: string;
    @IsOptional()
    readonly _csrf?: string;
...
  • IsNotEmpty不能为空
  • Matches使用正则表达式
  • Transform转化数据,这里把英文转成小写。

发现一个问题,默认的提供的NotEquals、Equals只能验证一个写死的值,那么我验证确认密码怎么办,这是动态的。我想到一个简单粗暴的方式:

    @Transform((value, obj) => {
        if (obj.pass === value) {
            return value;
        }
        return 'PASSWORD_INCONSISTENCY';
    }, { toClassOnly: true })
    @NotEquals('PASSWORD_INCONSISTENCY', {
        message: '两次密码输入不一致。',
    })

先用转化装饰器,去判断,obj拿到就当前实例类,然后去取它对应属性和当前的值对比,如果是相等就直接返回,如果不是就返回一个标识,再用NotEquals去判断。

这样写不是很友好,我们需要自定义一个装饰器来完成这个功能。

在core新建decorators文件夹下建validator.decorators.ts文件

import { registerDecorator, ValidationOptions, ValidationArguments, Validator } from 'class-validator';
import { get } from 'lodash';

const validator = new Validator();

export function IsEqualsThan(property: string[] | string, validationOptions?: ValidationOptions) {
    return (object: object, propertyName: string) => {
        registerDecorator({
            name: 'IsEqualsThan',
            target: object.constructor,
            propertyName,
            constraints: [property],
            options: validationOptions,
            validator: {
                validate(value: any, args: ValidationArguments): boolean{
                    // 拿到要比较的属性名或者路径 参考`lodash#get`方法
                    const [comparativePropertyName] = args.constraints;
                    // 拿到要比较的属性值
                    const comparativeValue = get(args.object, comparativePropertyName);
                    // 返回false 验证失败
                    return validator.equals(value, comparativeValue);
                },
            },
        });
    };
}

官方文字里面有栗子:直接拷贝过来就行了,改改就好。我们需要改的就是namevalidate函数里面的内容,

validate函数返回true验证成功,false验证失败,返回错误消息。

...
@IsNotEmpty({
    message: '确认密码不能为空',
})
@IsEqualsThan('pass', {
    message: '两次密码输入不一致。',
})
readonly re_pass: string;
...

注意IsEqualsThan第一个参数参考[lodash#get(https://lodash.com/docs/4.17.10#get)方法

验证规则搞定了,现在又有2个新问题了,

  1. 默认返回全部错误格式是数组json,我们需要格式化自定义错误。
  2. 我们需要把错误信息显示到当前页面,并且有些字段还需要显示在里面,有些字段不需要(比如密码),需要Render方法,可以实现数据显示,但是拿不到当前错误控制器的模板地址。这个是比较致命的问题,其他问题都好解决。

解决这个问题,我纠结了很久,想到了2个方法来解决问题。

自定义装饰器+配合ValidationPipe+HttpExceptionFilter实现

借助class-validator配置参数的context字段。

我们可以在上面写2个字段,一个是render,一个是locals

在实现render功能之前,我们需要借助typescript的一个功能enum枚举。

Nest里面HttpStatus状态码就是enum

我们把所有的视图模板都存在enum里面,枚举好处就是映射,类似于key-value对象。

// js 模拟 enum 写法
const Enum = {
    a: 'a',
    b: 'b'
}

// 取值
Enum[Enum.a]
// 'a'

// 字符串赋值
enum Enum {
    a = 'a',
    b = 'b'
}

// 取值
Enum.a
// 'a'

// 索引赋值
enum Enum {
    a,
    b
}

// 取值
Enum.a
// 0

typescript转成javascript,枚举取值Enum[Enum.a]就是这样的。

创建视图模板路径枚举

$ touch src/core/enums/views-path.ts
OR
编辑器新建文件views-path.ts

在里面写上:

export enum ViewsPath {
    Register = 'auth/register',
}

auth.controller.ts换上枚举:

...
@Post('/register')
@Render(ViewsPath.Register)
async register(@Body() register: RegisterDto, @Res() res) {
    return await this.authService.register(register);
}
...

解决问题之前,我们先看,ValidationPipe源码,验证失败之后干了些什么:

...
const errors = await classValidator.validate(entity, this.validatorOptions);
if (errors.length > 0) {
    throw new BadRequestException(
    this.isDetailedOutputDisabled ? undefined : errors,
    );
}
...

返回是一个ValidationError[],那ValidationError里面有什么:

class ValidationError {
    target?: Object; // 目标对象,就是我们定义验证规则那个对象。这里是`RegisterDto`
    property: string; // 当前字段
    value?: any;  // 当前的值
    constraints: {   // 验证规则错误提示,我们定义的装饰 @IsNotEmpty,显示的key是 isNotEmpty,value是定义配置里的`message`,定义多少显示多少。如果想一次只显示一个错误怎么办,后面讲怎么处理
        [type: string]: string;
    };
    children: ValidationError[]; // 嵌套
    contexts?: {  // 装饰器里面配置定义的`context`内容,key是 isNotEmpty ,value是 context内容
        [type: string]: any;
    };
    toString(shouldDecorate?: boolean, hasParent?: boolean, parentPath?: string): string; // 这玩意就不解释了。
}

最开始我想到是使用context来配置3个字段:

// context定义内容
interface context {
    render: string;  // 视图模板路径
    locals: boolean; // 字段是否显示
    priority: number;  // 验证规则显示优先级
}

// Render需要参数
interface Render {
    view: string;  // 视图模板路径
    locals: {   // 模板显示的变量
        error: string;   // 必须有的错误消息
        [key: string]: any;
    };
}

折腾一遍,功能实现了,就是太麻烦了。每个规则验证装饰器里面都要写context一坨。

能不能简便一点了。如果我在这个类里面只定义一次是不是好点。

就想到了在RegisterDto里写个私有属性,把相关的字段存进去,改进了context配置:

export interface ValidatorFilterContext {
    render: string;
    locals: { [key: string]: boolean };
    priority: { [key: string]: string[] };
}

就变成这样的:

...

__validator_filter__: {
    render: ViewsPath.Register,
    locals: {
        loginname: true,
        pass: false,
        re_pass: false,
        email: true,
    },
    priority: {
        loginname: ['IsNotEmpty', 'Matches'],
        pass: ['IsNotEmpty', 'IsByteLength'],
        re_pass: ['IsNotEmpty', 'IsEqualsThan'],
        email: ['IsNotEmpty', 'IsEmail'],
    },
}
...

这样就比每个规则验证装饰器写context配置好了很多,但是这样又有一个问题,会在target里面多一个__validator_filter__,有点多余了。

需要改进一下,我就想到类装饰器。

export const VALIDATOR_FILTER = '__validator_filter__';

export function ValidatorFilter(context: ValidatorFilterContext): ClassDecorator {
    return (target: any) => Reflect.defineMetadata(VALIDATOR_FILTER, context, target);
}

类装饰器前面已经说过了,它是装饰器里面最后执行的,用来装饰类。这里有个比较特殊的Reflect

Reflect翻译叫反射,应该说叫映射靠谱点。为什么了,它基本就是类似此功能。

defineMetadata定义元数据,有3个参数:第一个是标识key,第二个是存储的数据(获取就是它),第三个就是一个对象。

翻译过来就是在 a 对象里面定一个标识 b 的数据为c。有定义就有获取

getMetadata获取元数据,有2个参数:第一个是标识key,第三个就是一个对象。

翻译过来就是在 a 对象里去查一个b 标识,如果有就返回原数据,如果没有就是Undefined。或者是b标识里面去查找a对象。理解差不多。目的是2个都匹配就返回数据。

这玩意简单理解Reflect是一个全局对象,defineMetadata定一个特定标识的数据,getMetadata根据特定标识获取数据。这里Reflect用的比较简单就不深入了,Reflectes6新特性一部分。

Nest的装饰器大量使用Reflect。在nodejs使用,需要借助reflect-metadata,引入方式import 'reflect-metadata';

处理完了,dot问题,那么我们接下来要处理异常捕获过滤器问题了。

前面也说,Nest执行顺序:客户端请求 ---> 中间件 ---> 守卫 ---> 拦截器之前 ---> 管道 ---> 控制器处理并响应 ---> 拦截器之后 ---> 过滤器

因为ValidationPipe源码里,只要验证错误就直接抛异常new BadRequestException(),然后就直接跳过控制器处理并响应,走拦截器之后和过滤器了。

那么我们需要在过滤器来处理这些问题,这是为什么要这么麻烦原因。

Nest已经提供一个自定义HttpExceptionFilter的栗子,我们需要改良一下这个栗子。

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
    catch(exception: HttpException, host: ArgumentsHost) {
        const ctx = host.switchToHttp();
        const response: Response  = ctx.getResponse();
        const request: Request = ctx.getRequest();
        const status = exception.getStatus();
        // 如果错误码 400
        if (status === HttpStatus.BAD_REQUEST) {
            const render = validationErrorMessage(exception.message.message);
            return response.render(render.view, render.locals);
        }
    }
}

render接受3个参数,平常只用前个,第一个是模板路径或者模板,第二个提供给模板显示的数据。

这里核心地方在validationErrorMessage里:

function validationErrorMessage(messages: ValidationError[]): Render {
    const message: ValidationError = messages[0];
    const metadata: ValidatorFilterContext = Reflect.getMetadata(VALIDATOR_FILTER, message.target.constructor);
    if (!metadata) {
        throw Error('context is not undefined, use @ValidatorFilter(context)');
    }
    // 处理错误消息显示
    const priorities = metadata.priority[message.property] || [];
    let error = '';
    const notFound = priorities.some((key) => {
        key = key.replace(/\b(\w)(\w*)/g, ($0, $1, $2) => {
            return $1.toLowerCase() + $2;
        });
        if (!!message.constraints[key]) {
            error = message.constraints[key];
            return true;
        }
    });
    // 没有找到对应错误消息,取第一个
    if (!notFound) {
        error = message.constraints[Object.keys(message.constraints)[0]];
    }
    // 处理错误以后显示数据
    const locals = Object.keys(metadata.locals).reduce((obj, key) => {
        if (metadata.locals[key]) {
            obj[key] = message.target[key];
        }
        return obj;
    }, {});

    return {
        view: metadata.render,
        locals: {
            error,
            ...locals,
        },
    };
}
  • 我们拿到的messages是一个数组,我们每次只显示一个错误消息,总是取第一个即可
  • metadata是我们根据标识获取的元数据,如果找不到,就抛出异常。注意message.target是一个{},我们需要获取它的constructor才行。
  • priorities获取当前错误字段显示错误提取的优先级列表
  • priority里面没有配置获取配置[], 就直接返回验证规则第一个。提示:这也是{}坑,默认按字母顺序排列属性的位置。
  • locals直接去判断配置的locals,哪些key可以显示哪些key不能显示。
  • 最后数据拼装在一起返回,供render使用。

自定义装饰器+自定义ViewValidationPipe实现

装饰器部分就不用说了,和上面一样,虽然不需要但是后面有用。

ViewValidationPipe实现:

import { Injectable, Optional, ArgumentMetadata, PipeTransform } from '@nestjs/common';

import * as classTransformer from 'class-transformer';
import * as classValidator from 'class-validator';
import { ValidatorOptions } from '@nestjs/common/interfaces/external/validator-options.interface';

import { isNil } from 'lodash';
import { ValidationError } from 'class-validator';
import { VALIDATOR_FILTER } from '../constants/validator-filter.constants';
import { ValidatorFilterContext } from '../decorators';

export interface ValidationPipeOptions extends ValidatorOptions {
    transform?: boolean;
    disableErrorMessages?: boolean;
}

@Injectable()
export class ViewValidationPipe implements PipeTransform<any> {
    protected isTransformEnabled: boolean;
    protected isDetailedOutputDisabled: boolean;
    protected validatorOptions: ValidatorOptions;

    constructor(@Optional() options?: ValidationPipeOptions) {
        options = Object.assign({
            transform: true,
            whitelist: true,
            forbidNonWhitelisted: true,
            skipMissingProperties: false,
            forbidUnknownValues: true,
        }, options || {});
        const { transform, disableErrorMessages, ...validatorOptions } = options;
        this.isTransformEnabled = !!transform;
        this.validatorOptions = validatorOptions;
        this.isDetailedOutputDisabled = disableErrorMessages;
    }

    public async transform(value, metadata: ArgumentMetadata) {
        const { metatype } = metadata;
        if (!metatype || !this.toValidate(metadata)) {
            return value;
        }
        const entity = classTransformer.plainToClass(
            metatype,
            this.toEmptyIfNil(value),
        );
        const errors = await classValidator.validate(entity, this.validatorOptions);
        // 重点实现 start
        if (errors.length > 0) {
            return validationErrorMessage(errors).locals;
        }
        // 重点实现 end
        return this.isTransformEnabled
            ? entity
            : Object.keys(this.validatorOptions).length > 0
                ? classTransformer.classToPlain(entity)
                : value;
    }

    private toValidate(metadata: ArgumentMetadata): boolean {
        const { metatype, type } = metadata;
        if (type === 'custom') {
            return false;
        }
        const types = [String, Boolean, Number, Array, Object];
        return !types.some(t => metatype === t) && !isNil(metatype);
    }

    toEmptyIfNil<T = any, R = any>(value: T): R | {} {
        return isNil(value) ? {} : value;
    }
}

我们这里把validationErrorMessage函数直接拿过来了。

控制器就需要这么写:

@Post('/register')
@Render(ViewsPath.Register)
async register(@Body(new ViewValidationPipe({
    transform: true,
    whitelist: true,
    forbidNonWhitelisted: true,
    skipMissingProperties: false,
    forbidUnknownValues: true,
})) register: RegisterDto) {
    if ((register as any).view) {
        return register.locals;
    }
    return await this.authService.register(register);
}
  • 拿到是pipe转换后的结果
  • 如果有view表示出错了,就直接返回locals,如果没有就接着处理服务逻辑。

注意(register as any).view这个view是不靠谱的,需要返回一个特殊标识,不然页面出现一个view字段,就挂了。

这里我们使用第一种,接着实现服务逻辑。

...
async register(register: RegisterDto) {
    const { loginname, email } = register;
    // 检查用户是否存在,查询登录名和邮箱
    const exist = await this.userService.count({
        $or: [
            { loginname },
            { email },
        ],
    });
    // 返回1存在,0不存在
    if (exist) {
        return {
            error: '用户名或邮箱已被使用。',
            loginname,
            email,
        };
    }
    // hash加密密码,不能明文存储到数据库
    const passhash = hashSync(register.pass, 10);
    // 错误捕获 async/await 科普已经说明
    try {
        // 保存用户到数据库
        await this.userService.create({ loginname, email, pass: passhash });
        // 预留发送激活邮箱实现

        // 返回注册成功信息
        return {
            success: `欢迎加入 ${Config.name}!我们已给您的注册邮箱发送了一封邮件,请点击里面的链接来激活您的帐号。`,
        };
    } catch (error) {
        throw new InternalServerErrorException(error);
    }
}

里面注释也说明的我们要操作的步骤,注册逻辑还是比较简单:

  • 验证参数是否合法
  • 查询用户是否注册
  • 加密密码
  • 保存到数据库
  • 发送激活邮箱
  • 返回注册成功信息

做登录之前完成邮箱激活的功能。

邮箱模块

前面基础已经介绍过nest模块,这里邮箱模块是一个通用的功能模块,我们需要抽离出来写成可配置的动态模块。nest目前没有提供发邮箱的功能模块,我们只能自己动手写了,nodejs发送邮件最出名使用node-mailer。我们这里也把node-mailer封装一下。

对于一个没有写过动态模块的我,是一脸懵逼,还好作者写很多包装的功能模块:

  • graphql
  • typeorm
  • terminus
  • passport
  • elasticsearch
  • mongoose
  • jwt
  • cqrs

既然不会写我们可以copy一个来仿写,实现我们要功能就ok了,卷起袖子就是干。

通过观察上面几个模块他们文件结构都是这样的:

index.ts  // 导出快捷文件
mailer-options.interface.ts  // 定义配置接口
mailer.constants.ts  // 定义常量
mailer.providers.ts  // 定义供应商
mailer.module.ts     // 定义导出模块
mailer.decorators.ts  // 定义装饰器

我们也来新建一个这样的结构,core/mailer建文件就不说了。

这一个模块,就需要先从模块开始:

  • 动态可配置模块,而且还是全局模块,只需要导入一次即可。
  • 同步配置可以是直接填写,异步配置可以是依赖其他模块

这是我们要实现的2个重要功能,作者写的模块基本是这个套路,有些东西我们不会写,可以先模仿。

import { DynamicModule, Module, Provider, Global } from '@nestjs/common';
import { MailerModuleAsyncOptions, MailerOptionsFactory } from './mailer-options.interface';
import { MailerService } from './mailer.service';
import { MAILER_MODULE_OPTIONS } from './mailer.constants';
import { createMailerClient } from './mailer.provider';

@Module({})
export class MailerModule {
    /**
     * 同步引导邮箱模块
     * @param options 邮箱模块的选项
     */
    static forRoot<T>(options: T): DynamicModule {
        return {
            module: MailerModule,
            providers: [
                { provide: MAILER_MODULE_OPTIONS, useValue: options },
                createMailerClient<T>(),
                MailerService,
            ],
            exports: [MailerService],
        };
    }

    /**
     * 异步引导邮箱模块
     * @param options 邮箱模块的选项
     */
    static forRootAsync<T>(options: MailerModuleAsyncOptions<T>): DynamicModule {
        return {
            module: MailerModule,
            imports: options.imports || [],
            providers: [
                ...this.createAsyncProviders(options),
                createMailerClient<T>(),
                MailerService,
            ],
            exports: [MailerService],
        };
    }
}
  • forRoot配置同步模块
  • forRootAsync配置异步模块

我们先说和node-mailer相关的,node-mailer主要分2块:

  • 创建node-mailer实例,node-mailer新版解决很多问题,自动去识别不同邮件配置,这对我们来说是一个非常好的消息,不用去做各种适配配置了,只需要按官网的相关配置即可。
  • 使用node-mailer实例,set设置配置和use注册插件,sendMail发送邮件

创建在createMailerClient方法里面完成

import { MAILER_MODULE_OPTIONS, MAILER_TOKEN } from './mailer.constants';
import { createTransport } from 'nodemailer';

export const createMailerClient = <T>() => ({
    provide: MAILER_TOKEN,
    useFactory: (options: T) => {
        return createTransport(options);
    },
    inject: [MAILER_MODULE_OPTIONS],
});

这个方法是一个工厂方法,在介绍这个方法之前,先要回顾一下,nest依赖注入自定义服务:

  • Use value
const connectionProvider = {
  provide: 'Connection',
  useValue: connection,
};

值服务:这个一般作为配置,定义全局常量使用,单纯key-value形式

  • Use class
const configServiceProvider = {
  provide: ConfigService,
  useClass: process.env.NODE_ENV === 'development'
    ? DevelopmentConfigService
    : ProductionConfigService,
}

类服务:这个比较常用,默认就是类服务,如果provideuseClass一样,直接注册在providers数组里即可。我们只关心provide注入是谁,不关心useClass依赖谁。

  • Use factory
const connectionFactory = {
  provide: 'Connection',
  useFactory: (optionsProvider: OptionsProvider) => {
    const options = optionsProvider.get();
    return new DatabaseConnection(options);
  },
  inject: [OptionsProvider],
};

工厂服务:这个比较高级,一般需要依赖其他服务,来创建当前服务的时候,操作使用。定制服务经常用到。

我们在回过头来说上面这个createMailerClient方法

本来我们可以直接写出一个Use factory例子一样的,考虑它需要forRootforRootAsync都需要使用,我们写成一个函数,使用时候直接调用即可,也可以写成一个对象形式。

provide引入我们定义的常量,至于这个常量是什么,我们不需要关心,如果它变化这个注入者也发生变化,这里不需要改任何代码。也算是配置和程序分离,一种比较好编程方式。

inject依赖其他服务,这里依赖是一个useValue服务,我们把邮箱配置传递给MAILER_MODULE_OPTIONS,然后把它放到inject,这样我们在useFactory方法里面就可以取到依赖列表。

注意inject是一个数组,useFactory参数和inject一一对应,简单理解,useFactory是形参,inject数组是实参。

useFactory里面,我们可以根据参数做相关的操作,这里我们直接获取这个服务即可,然后使用nodemailer提供的邮件创建方法createTransport即可。

依赖注入和服务重点,我不关心依赖者怎么处理,我只关心注入者给我提供什么。

我们在来说上面这个MAILER_MODULE_OPTIONS值服务

MAILER_MODULE_OPTIONSforRoot里是一个值服务{ provide: MAILER_MODULE_OPTIONS, useValue: options },保存传递的参数。
MAILER_MODULE_OPTIONSforRootAsync里是一个特殊处理...this.createAsyncProviders(options),后面会讲解这个函数。

注意:因为createMailerClient依赖它,所以一定要在createMailerClient方法完成注册。

说完通用的创建服务,来说forRootAsync里的createAsyncProviders方法:

createAsyncProviders主要完成的工作是把邮箱配置和邮箱动态模块配置剥离开来,然后根据给定要求分别去处理。

createAsyncProviders方法

    /**
     * 根据给定的模块选项返回异步提供程序
     * @param options 邮箱模块的选项
     */
    private static createAsyncProviders<T>(
        options: MailerModuleAsyncOptions<T>,
    ): Provider[] {
        if (options.useFactory) {
            return [this.createAsyncOptionsProvider<T>(options)];
        }
        return [
            this.createAsyncOptionsProvider(options),
            {
                provide: options.useClass,
                useClass: options.useClass,
            },
        ];
    }

    /**
     * 根据给定的模块选项返回异步邮箱选项提供程序
     * @param options 邮箱模块的选项
     */
    private static createAsyncOptionsProvider<T>(
        options: MailerModuleAsyncOptions<T>,
    ): Provider {
        if (options.useFactory) {
            return {
                provide: MAILER_MODULE_OPTIONS,
                useFactory: options.useFactory,
                inject: options.inject || [],
            };
        }
        return {
            provide: MAILER_MODULE_OPTIONS,
            useFactory: async (optionsFactory: MailerOptionsFactory<T>) => await optionsFactory.createMailerOptions(),
            inject: [options.useClass],
        };
    }

解释这个函数之前,先看配置参数有接口:

export interface MailerModuleAsyncOptions<T> extends Pick<ModuleMetadata, 'imports'> {
    /**
     * 模块的名称
     */
    name?: string;
    /**
     * 应该用于提供MailerOptions的类
     */
    useClass?: Type<T>;
    /**
     * 工厂应该用来提供MailerOptions
     */
    useFactory?: (...args: any[]) => Promise<T> | T;
    /**
     * 应该注入的提供者
     */
    inject?: any[];
}

这里面支持2种写法,一种是自定义类,然后使用useClass, 一种是自定义工厂,然后使用useFactory

使用在MailerService服务里面完成并且把它导出给其他模块使用

import { Inject, Injectable, Logger } from '@nestjs/common';
import { MAILER_TOKEN } from './mailer.constants';
import * as Mail from 'nodemailer/lib/mailer';
import { Options as MailMessageOptions } from 'nodemailer/lib/mailer';

import { from, Observable } from 'rxjs';
import { tap, retryWhen, scan, delay } from 'rxjs/operators';

const logger = new Logger('MailerModule');

@Injectable()
export class MailerService {
    constructor(
        @Inject(MAILER_TOKEN) private readonly mailer: Mail,
    ) { }
    // 注册插件
    use(name: string, pluginFunc: (...args) => any): ThisType<MailerService> {
        this.mailer.use(name, pluginFunc);
        return this;
    }

    // 设置配置
    set(key: string, handler: (...args) => any): ThisType<MailerService> {
        this.mailer.set(key, handler);
        return this;
    }

    // 发送邮件配置
    async send(mailMessage: MailMessageOptions): Promise<any> {
        return await from(this.mailer.sendMail(mailMessage))
            .pipe(handleRetry(), tap(() => {
                logger.log('send mail success');
                this.mailer.close();
            }))
            .toPromise();
    }
}

export function handleRetry(
    retryAttempts = 5,
    retryDelay = 3000,
): <T>(source: Observable<T>) => Observable<T> {
    return <T>(source: Observable<T>) => source.pipe(
        retryWhen(e =>
            e.pipe(
                scan((errorCount, error) => {
                    logger.error(`Unable to connect to the database. Retrying (${errorCount + 1})...`);
                    if (errorCount + 1 >= retryAttempts) {
                        logger.error('send mail finally error', JSON.stringify(error));
                        throw error;
                    }
                    return errorCount + 1;
                }, 0),
                delay(retryDelay),
            ),
        ),
    );
}

@Inject是一个注入器,接受一个provide标识、令牌,这里我们拿到了node-mailer实例

send方法使用rxjs写法,this.mailer.sendMail(mailMessage)返回是一个PromisePromise有一些缺陷,rxjs可以去弥补一下这些缺陷。

比如这里使用是rxjs作用就是,handleRetry()去判断发送有没有错误,如果有错误,就去重试,默认重试5次,如果还错误就直接抛出异常。tap()类似一个console,不会去改变数据流。
有2个参数,第一个是无错误的处理函数,第二个是有错误的处理函数。如果发送成功我们需要关闭连接。toPromise就更简单了,看名字也知道,把rxjs转成Promise

介绍完这个这个模块,那么接下来要说一下怎么使用它们:

模块注册:我们需要在核心模块里面imports,因为邮件需要一些配置信息,比如邮件地址,端口号,发送邮件的用户和授权码,如果不知道邮箱配置可参考nodemailer官网

MailerModule.forRootAsync<SMTPTransportOptions>({
    imports: [ConfigModule],
    useFactory: async (configService: ConfigService) => {
        const mailer = configService.getKeys(['MAIL_HOST', 'MAIL_PORT', 'MAIL_USER', 'MAIL_PASS']);
        return {
            host: mailer.MAIL_HOST,     // 邮箱smtp地址
            port: mailer.MAIL_PORT * 1, // 端口号
            secure: true,
            secureConnection: true,
            auth: {
                user: mailer.MAIL_USER,  // 邮箱账号
                pass: mailer.MAIL_PASS,  // 授权码
            },
            ignoreTLS: true,
        };
    },
    inject: [ConfigService],
}),

先使用注入依赖ConfigService,拿到配置服务,根据配置服务获取对应的配置。进行邮箱配置即可。

在页面怎么使用它们,因为本项目比较简单,只有2个地方需要使用邮箱,注册成功和找回密码时候,单独写一个mail.services服务去处理它们,并且模板里面内容除了用户名,token等特定的数据是动态的,其他都是写死的。

mail.services

/**
 * 激活邮件
 * @param to 激活人邮箱
 * @param token token
 * @param username 名字
 */
sendActiveMail(to: string, token: string, username: string){
    const name = this.name;
    const subject = `${name}社区帐号激活`;
    const html = `<p>您好:${username}</p>
        <p>我们收到您在${name}社区的注册信息,请点击下面的链接来激活帐户:</p>
        <a href="${this.host}/active_account?key=${token}&name=${username}">激活链接</a>
        <p>若您没有在${name}社区填写过注册信息,说明有人滥用了您的电子邮箱,请删除此邮件,我们对给您造成的打扰感到抱歉。</p>
        <p>${name}社区 谨上。</p>`;
    this.mailer.send({
        from: this.from,
        to,
        subject,
        html,
    });
}

这里是实现激活邮件方法,前面写的mailer模块,服务里面提供的send方法,接受四个最基本的参数。

  • this.name是配置里面获取的name
  • this.from是配置里面获取的数据,拼接而成,具体看源码
  • this.host是配置里面获取的数据,拼接而成,具体看源码
  • from邮件发起者,to邮件接收者,subject显示在邮件列表的标题,html邮件内容。

我们在注册成功时候直接去调用它就好了。

注意:我在本地测试,使用163邮箱作为发送者,用qq注册,就会被拦截,出现在垃圾邮箱里面。

验证注册邮箱

我们实现了发现邮箱的功能,接下来就来尝试验证走注册的功能及验证邮箱验证完成注册。

因为我只要一个发送邮箱的账号,和一个测试邮箱的的账号,我需要去数据库把我之前注册的账号删除了,从新完成注册。

填写信息,点击注册,就会发送一封邮件,是这个样子的:

I1A1)WG%(TW 532FZ)(AME9

点击激活链接链接跳回来激活账号:

2BAJ14WL_L}K{Z GZE{Q7`2

接下来我们就来实现active_account路由的逻辑

创建一个account.dto

@ValidatorFilter({
    render: ViewsPath.Notify,
    locals: {
        name: true,
        key: true,
    },
    priority: {
        name: ['IsNotEmpty'],
        key: ['IsNotEmpty'],
    },
})
export class AccountDto {
    @IsNotEmpty({
        message: 'name不能为空',
    })
    @Transform(value => value.toLowerCase(), { toClassOnly: true })
    readonly name: string;
    @IsNotEmpty({
        message: 'key不能为空',
    })
    readonly key: string;
}

这个很简单理解:需要2个参数,一个name,一个key,name是用户名,key是注册时候我们创建的标识,邮箱,密码,自定义盐混合一起加密。

通用消息模板:

<% layout('layout') -%>

<article id="content">
    <div class='panel'>
        <div class='header'>
            <ul class='breadcrumb'>
                <li><a href='/'>主页</a><span class='divider'>/</span></li>
                <li class='active'>通知</li>
            </ul>
        </div>
        <div class='inner'>
            <% if (typeof error !== 'undefined' && error) { %>
                <div class="alert alert-error">
                    <strong><%= error %></strong>
                </div>
            <% } %>
                <% if (typeof success !== 'undefined' && success) { %>
                    <div class="alert alert-success">
                        <strong><%= success %></strong>
                    </div>
                <% } %>
                <a href="<%- typeof referer !== 'undefined' ? referer : '/' %>"><span class="span-common">返回</span></a>
        </div>
    </div>
</article>

这模板直接拿cnode的页面。

接下来就是控制器:

@Controller()
export class AuthController {
    constructor(
        private readonly authService: AuthService,
    ) {}
    ....
    /** 激活账号 */
    @Get('/active_account')
    @Render(ViewsPath.Notify)
    async activeAccount(@Query() account: AccountDto) {
        return await this.authService.activeAccount(account);
    }
}

我们需要获取url?后面的参数,需要用到@Query()装饰器,配合参数验证,最后拿到数据参数,丢给对应的服务去处理业务逻辑。

@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name, true);
constructor(
private readonly userService: UserService,
private readonly config: ConfigService,
private readonly mailService: MailService,
) { }
...

/** 激活账户 */
async activeAccount({ name, key }: AccountDto) {
    const user = await this.userService.findOne({
        loginname: name,
    });
    // 检查用户是否存在
    if (!user) {
        return { error: '用户不存在' };
    }
    // 对比key是否正确
    if (!user || utility.md5(user.email + user.pass + this.config.get('SESSION_SECRET')) !== key) {
        return { error: '信息有误,帐号无法被激活。' };
    }
    // 检查用户是否激活过
    if (user.active) {
        return { error: '帐号已经是激活状态。', referer: '/login' };
    }

    // 如果没有激活,就激活操作
    user.active = true;
    await user.save();
    return { success: '帐号已被激活,请登录', referer: '/login' };
}

}

注释已经写的很清晰的,就不在叙述的问题。接下来讲我们这篇文章的最后一个问题登录,在讲到登录之前需要简单科普一下怎么才算登录,它的凭证是什么?

登录

登录凭证

目前来说比较常用有2种一种是session+cookie,一种是JSON Web Tokens

session+cookie

session+cookie是比较常见前后端一起那种。它是流程大概是这样的:

  1. 前端发起 http 请求时有携带 cookie
  2. 后端拿到此 cookie 对比服务器 session,有登陆则放过此请求,无登录,redirect 到登录页面
  3. 前端登录,后端比对用户名密码,成功则生成唯一标识符,放在 session,并且存入浏览器 cookie
  4. 用户可以拿到自己的 cookie,就可以发起任何的客户端 http 请求

注意:以上操作都是合法操作,如果个人过失暴露 cookie 给其他人,属于用户个人的行为,比如你在网吧里登录 QQ,服务端没有办法不允许这样操作。而客户端的人应有安全意识,在公共场所及时清空 cookie,或者停止使用一切 [不随 session 关闭而 cookie 失效] 的应用。

JSON Web Tokens

JSON Web Tokens是比较常见前后分离那种。它是流程大概是这样的:

  1. 登录时候,客户端通过用户名与密码请求登录
  2. 服务端收到请求区验证用户名与密码
  3. 验证通过,服务端会签发一个Token,再把这个Token发给客户端.
  4. 客户端收到Token,存储到本地,如Cookie,SessionStorage,LocalStorage.
  5. 客户端每次像服务器请求API接口时候,都要带上Token.
  6. 服务端收到请求,验证Token,如果通过就返回数据,否则提示报错信息.

注意:前端是无设防的,不可以信任; 全部的校验都由后端完成

我们这里是前后端一体的,当然选择session+cookie。这里有篇文章介绍还行,传送门

我们这里登录需要实现2个,一个是本地登录,一个是第三方github登录。

本地登录

nestjs已经帮我们封装好了@nestjs/passport,我们前面已经说了需要下载相关包。本地登录使用passport-local完成。

新写个模板,需要去定义一个枚举ViewsPath 登录地址

@Controller()
export class AuthController {
    constructor(
        private readonly authService: AuthService,
    ) {}
    ....
        /** 登录模板 */
    @Get('/login')
    @Render(ViewsPath.Login)
    async loginView(@Req() req: TRequest) {
        const error: string = req.flash('loginError')[0];
        return { pageTitle: '登录', error};
    }
}

和正常注册模板控制器一样,这里多了一项req.flash('loginError')[0],其实它是connect-flash中间件。其实我们自己写一个也完全没有问题,本身就没有几行代码,既然有轮子就用呗,它是做什么,就是帮我们去session记录消息,然后去获取,绑定在Request上。你需要安装它npm install connect-flash -S

模板直接拷贝cnode的登录模板,改了一下请求地址。

     /** 本地登录提交 */
    @Post('/login')
    @UseGuards(AuthGuard('local'))
    async passportLocal(@Req() req: TRequest, @Res() res: TResponse) {
        this.logger.log(JSON.stringify(req.user));
        this.verifyLogin(req, res, req.user);
    }
    /** 验证登录 */
    private verifyLogin(@Req() req, @Res() res, user: User) {
        // id 存入 Cookie, 用于验证过期.
        const auth_token = user._id + '$$$$'; // 以后可能会存储更多信息,用 $$$$ 来分隔
        // 配置 Cookie
        const opts = {
            path: '/',
            maxAge: 1000 * 60 * 60 * 24 * 30,
            signed: true,
            httpOnly: true,
        };
        res.cookie(this.config.get('AUTH_COOKIE_NAME'), auth_token, opts); // cookie 有效期30天
        // 调用 passport 的 login方法 传递 user信息
        req.login(user, () => {
            // 重定向首页
            res.redirect('/');
        });
    }

这里使用守卫,AuthGuard首页是@nestjs/passport。verifyLogin是登录以后操作。为什么封装一个方法,等下github登录成功也是一样的操作。login方法是passport的方法,user就是我们拿到的用户信息。

注意:这里的passport-local是网上的栗子实现有差别,网上栗子都可以配置,重定向的功能,

这是passport文档里面的栗子。

app.post('/login', 
  passport.authenticate('local', 
   { 
       successRedirect: '/',
      failureRedirect: '/login',
   }),
  function(req, res) {
    res.redirect('/');
  });

这个坑我也捣鼓很久,无论成功还是失败重定向都需要手动去处理它。成功就是上面我那个login

我们需要新增一个passport文件夹,里面放passport相关的业务。

新建一个local.strategy.ts,处理passport-local

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
    constructor(private readonly authService: AuthService) {
        super({
            usernameField: 'name',
            passwordField: 'pass',
            passReqToCallback: false,
        });
    }

    // tslint:disable-next-line:ban-types
    async validate(username: string, password: string, done: Function) {
        await this.authService.local(username, password)
            .then(user => done(null, user))
            .catch(err => done(err, false));
    }
}

这里就比较简单,就这么几行代码,自定义一个本地策略,去继承@nestjs/passport一个父类,super需要传递是new LocalStrategy('配置对象')validate是一个抽象方法,我们必须要去实现的,因为@nestjs/passport也不知道我们是怎么样查询用户是否存在,这个验证方法暴露给我们的去实现。done就相当于是callback,标准nodejs回调函数参数,第一个是表示错误,第二个是用户信息。

放到AuthModule里面去做服务申明。

@Module({
  imports: [SharedModule],
  providers: [
    AuthService,
    AuthSerializer,
    LocalStrategy,
  ],
  controllers: [AuthController],
})
export class AuthModule {}

AuthSerializer也是和passport相关的,它里面需要实现2个方法serializeUser,deserializeUser

  • serializeUser:将用户信息序列化后存进 session 里面,一般需要精简,只保存个别字段
  • deserializeUser:反序列化后把用户信息从 session 中取出来,反查数据库拿到完整信息
import { PassportSerializer } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';

@Injectable()
export class AuthSerializer extends PassportSerializer {
    /**
     * 序列化用户
     * @param user
     * @param done
     */
    serializeUser(user: any, done: (error: null, user: any) => any) {
        done(null, user);
    }

    /**
     * 反序列化用户
     * @param payload
     * @param done
     */
    async deserializeUser(payload: any, done: (error: null, payload: any) => any) {
        done(null, payload);
    }
    constructor() {
        super();
    }
}

我们这里先简单粗暴把所有信息全部存到session,先实现功能,其他后面再优化。

接下来去服务实现local方法:

// Validation methods
const validator = new Validator();

@Injectable()
export class AuthService {
    ...
    async local(username: string, password: string) {
        // 处理用户名和密码前后空格,用户名全部小写 保证和注册一致
        username = username.trim().toLowerCase();
        password = password.trim();
        // 验证用户名
        // 可以用户名登录 /^[a-zA-Z0-9\-_]\w{4,20}$/
        // 可以邮箱登录 标准邮箱格式
        // 做一个验证用户名适配器
        const verifyUsername = (name: string) => {
            // 如果输入账号里面有@,表示是邮箱
            if (name.indexOf('@') > 0) {
                return validator.isEmail(name);
            }
            return validator.matches(name, /^[a-zA-Z0-9\-_]\w{4,20}$/);
        };
        if (!verifyUsername(username)) {
            throw new UnauthorizedException('用户名格式不正确。');
        }
        // 验证密码 密码长度是6-18位
        if (!validator.isByteLength(password, 6, 18)) {
            throw new UnauthorizedException('密码长度不是6-18位。');
        }
        // 做一个获取用户适配器
        const getUser = (name: string) => {
            // 如果输入账号里面有@,表示是邮箱
            if (name.indexOf('@') > 0) {
                return this.userService.getUserByMail(name);
            }
            return this.userService.getUserByLoginName(name);
        };
        const user = await getUser(username);
        // 检查用户是否存在
        if (!user) {
            throw new UnauthorizedException('用户不存在。');
        }
        const equal = compareSync(password, user.pass);
        // 密码不匹配
        if (!equal) {
            throw new UnauthorizedException('用户密码不匹配。');
        }
        // 用户未激活
        if (!user.active) {
            // 发送激活邮件
            const token = utility.md5(user.email + user.pass + this.config.get('SESSION_SECRET'));
            this.mailService.sendActiveMail(user.email, token, user.loginname);
            throw new UnauthorizedException('此帐号还没有被激活,激活链接已发送到 ' + user.email + ' 邮箱,请查收。');
        }
        // 验证通过
        return user;
    }
}

上面都有注释,这里说明一下为什么需要在这里去验证字段信息,这也是使用@nestjs/passport坑。

验证使用class-validator提供的验证器类Validator,其他验证方法和我们注册保持一致。注释都已经一一说明。

错误都使用throw new UnauthorizedException('错误信息');这样的方式去抛出,这也是在AuthGuard源码里面,有个处理请求方法:

handleRequest(err, user, info): TUser {
      if (err || !user) {
        throw err || new UnauthorizedException();
      }
      return user;
    }

只要有错误,就回去走错误,这个错误就被ExceptionFilter捕获,我们有自定义的HttpExceptionFilter,等下就来讲它。
只有没有错误,成功才会返回user,这时候去走,serializeUser, deserializeUser, passportLocal最后重定向到首页。

注意:抛出异常一定要用throw,不用使用return。用return就直接走serializeUser,然后报错了。

错误处理,因为这个身份认证只要出错返回都是401,那么我们需要去捕获处理一下,

...
            case HttpStatus.UNAUTHORIZED: // 如果错误码 401
                request.flash('loginError', exception.message.message || '信息不全。');
                response.redirect('/login');
                break;
 ...

默认handleRequest返回是一个空的,exception.message.messageundefined,这是passport返回,只要用户名或者密码没有填,都会返回这个错误信息,对应我们来捕获错误也是一脸懵逼,我看cndoe是直接返回信息不全。,这里就一样简单粗暴处理了。

说多了都是眼泪,这个地方卡了我很久。这篇文章卡壳,它需要付50%责任,因为网上没有关于@nestjs/passportpassport-local的栗子。大多数都是jwt栗子,比较折腾,试过各种方法方式。

github登录

这个玩意就本地登录简单多了。先说下流程:

我们网站叫nest-cnode

  1. nest-cnode 网站让用户跳转到 GitHub。
  2. GitHub 要求用户登录,然后询问"nest-cnode 网站要求获得 xx 权限,你是否同意?"
  3. 用户同意,GitHub 就会重定向回 nest-cnode 网站,同时发回一个授权码。
  4. nest-cnode 网站使用授权码,向 GitHub 请求令牌。
  5. GitHub 返回令牌.
  6. nest-cnode 网站使用令牌,向 GitHub 请求用户数据。

接下来我们就去实现一下:

先github申请一个认证,应用登记。

一个应用要求 OAuth 授权,必须先到对方网站登记,让对方知道是谁在请求。

所以,我们要先去 GitHub 登记一下。这是免费的。

访问这个网址,填写登记表。

%V}9$D4LD_4YLFKXRTVJ7QP

应用的名称随便填,主页 URL 填写http://localhost:3000,跳转网址填写 http://localhost:3000/github/callback

提交表单以后,GitHub 应该会返回客户端 ID(client ID)和客户端密钥(client secret),这就是应用的身份识别码。

我们创建一个github.strategy.ts

@Injectable()
export class GithubStrategy extends PassportStrategy(Strategy) {
    constructor(private readonly config: ConfigService) {
        super({
            clientID: config.get('GITHUB_CLIENT_ID'),
            clientSecret: config.get('GITHUB_CLIENT_SECRET'),
            callbackURL: `${config.get('HOST')}:${config.get('PORT')}/github/callback`,
        });
    }

    // tslint:disable-next-line:ban-types
    async validate(accessToken, refreshToken, profile: GitHubProfile, done: Function) {
        profile.accessToken = accessToken;
        done(null, profile);
    }
}

需要配置clientID, clientSecret, callbackURL, 这3个东西,我们上面图里面都有。把它申明到模块里面去。

github2个必备的路由:

    /** github登录提交 */
    @Get('/github')
    @UseGuards(AuthGuard('github'))
    async github() {
        return null;
    }

    @Get('/github/callback')
    async githubCallback(@Req() req: TRequest, @Res() res: TResponse) {
        this.logger.log(JSON.stringify(req.user));
        const existUser = await this.authService.github(req.user);
        this.verifyLogin(req, res, existUser);
    }

我们需要github登录时候就去请求/github路由,使用守卫,告诉守卫使用github策略。这个方法随便写,返回都会重定向到github.com,填完登录信息,就会自动跳转到githubCallback方法里面,req.user返回就是github给我们提供的所有信息。我们需要去和我们用户系统做关联。

服务github方法:

async github(profile: GitHubProfile) {
        if (!profile) {
            throw new UnauthorizedException('您 GitHub 账号的 认证失败');
        }
        // 获取用户的邮箱
        const email = profile.emails && profile.emails[0] && profile.emails[0].value;
        // 根据 githubId 查找用户
        let existUser = await this.userService.getUserByGithubId(profile.id);

        // 用户不存在则创建
        if (!existUser) {
            existUser = new this.userService.getMode();
            existUser.githubId = profile.id;
            existUser.active = true;
            existUser.accessToken = profile.accessToken;
        }

        // 用户存在,更新字段
        existUser.loginname = profile.username;
        existUser.email = email || existUser.email;
        existUser.avatar = profile._json.avatar_url;
        existUser.githubUsername = profile.username;
        existUser.githubAccessToken = profile.accessToken;

        // 保存用户到数据库
        try {
            await existUser.save();
            // 返回用户
            return existUser;
        } catch (error) {
            // 获取MongoError错误信息
            const errmsg = error.errmsg || '';
            // 处理邮箱和用户名重复问题
            if (errmsg.indexOf('duplicate key error') > -1) {
                if (errmsg.indexOf('email') > -1) {
                    throw new UnauthorizedException('您 GitHub 账号的 Email 与之前在 CNodejs 注册的 Email 重复了');
                }

                if (errmsg.indexOf('loginname') > -1) {
                    throw new UnauthorizedException('您 GitHub 账号的用户名与之前在 CNodejs 注册的用户名重复了');
                }
            }
            throw new InternalServerErrorException(error);
        }
    }

注意profile返回信息可能是个undefined,因为认证可能会失败,需要去处理一下,不然后面代码全挂了。O(∩_∩)O哈哈~。

登录功能基本完成了,需要判断用户登录。

我们需要写一个中间件,current_user.middleware.ts

import { Injectable, NestMiddleware, MiddlewareFunction } from '@nestjs/common';

@Injectable()
export class CurrentUserMiddleware implements NestMiddleware {
    constructor() { }
    resolve(...args: any[]): MiddlewareFunction {
        return (req, res, next) => {
            res.locals.current_user = null;
            const { user } = req;
            if (!user) {
                return next();
            }
            res.locals.current_user = user;
            next();
        };
    }
}

因为passport登录成功以后,会自动给req添加一个属性user,我们只需要去判断它就可以了。

注意nestjs中间件和express中间件有区别:

express定义的中间件,如果全局可以直接通过express.use(中间件)去申明使用。

nestjs定义的中间件不能这么玩,需要在模块里面去申明使用。

export class AppModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(CurrentUserMiddleware)
      .forRoutes({ path: '*', method: RequestMethod.ALL });
  }
}

我们把全局的中间件都丢到AppModule,里面去申明使用。

修改一下AppController首页:

@Get()
  @Render('index')
  root() {
    return {};
  }

登录前:

KZWPT O)_GKL`$6 RAISBOX

登录后:

0SJTB2VL`C)C7P6F34KT6V5

在弄个退出就完美了:它就更简单了:

@Controller()
export class AuthController {
    /** 登出 */
    @All('/logout')
    async logout(@Req() req: TRequest, @Res() res: TResponse) {
        // 销毁 session
        req.session.destroy();
        // 清除 cookie
        res.clearCookie(this.config.get('AUTH_COOKIE_NAME'), { path: '/' });
        // 调用 passport 的 logout方法
        req.logout();
        // 重定向到首页
        res.redirect('/');
    }
}

就是一波清空操作,调用passportlogout方法。

代码已更新,传送门

欲知后事如何,请听下回分解。

中篇就到此为止了,最后感谢大家暴力吹更,让我坚持不懈的把它写完。后面就比较容易了。Typeorm比较火,等我把全部业面写完了,会更新typeorm版操作MongoDB。回馈大家不离不弃的关注,再次感谢大家阅读。

@SirM2z
Copy link

SirM2z commented Sep 22, 2018

兄弟,啥时候更新呀,坐等,强烈需要

@zuohuadong
Copy link

+1

@terenceYu1997
Copy link

大佬快更新呀

@cike8899
Copy link

围观

@ywachao
Copy link

ywachao commented Jan 3, 2019

谢谢,学习了!

@ciey
Copy link

ciey commented Feb 12, 2019

更新(下)

@sofakeer
Copy link

围观 兄弟你太厉害了!!学习

@Dashsoap
Copy link

暴力催更!

@theskyfvcker
Copy link

大佬太棒啦,帮了不少忙呢,太感谢了,希望快点更新(下)!

@jiayisheji jiayisheji added the Nest nest相关实践 label Apr 25, 2019
@jiayechao
Copy link

大佬,坐等下篇,太牛逼了

@qiushi0908
Copy link

Property 'engine' does not exist on type 'INestApplication'.
第一步,设置engine就报错了,jiayisheji/nest-cnode#3

@xianjun-li
Copy link

下部呢?不更新了?

@think2011
Copy link

爆肝长文啊

@nmsn
Copy link

nmsn commented Jan 8, 2020

之前一直没找到好的nestjs实践教程,感谢大佬

@Tennesseesunshine
Copy link

是大佬 先点赞后学习吧

@simon-shi87
Copy link

请问下,gRPC的拦截器要怎么写,我useGlobalInterceptors,拦截不到gPRC的请求,只能在Controller上@UseInterceptors 才能生效,有什么好办法做全局的拦截吗?

@cheekhan
Copy link

// 加上这个泛型就好了
import { NestExpressApplication } from '@nestjs/platform-express';
async function bootstrap() {
const app = await NestFactory.create(
AppModule,
);
// code ...
}
bootstrap();

@Dashsoap
Copy link

Dashsoap commented Jun 24, 2022 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Nest nest相关实践
Projects
None yet
Development

No branches or pull requests