本文来实现一个查询城市天气预报的服务。
使用的是和风天气的免费 api。
免费的接口一天可以请求 1000 次,自己的项目足够用了:
最多可以查询未来 7 天的天气预报。
首先,登录和风天气,
然后在用户中心绑定邮箱和手机号:
之后进入控制台,点击创建项目:
这里大家选择免费订阅(我别的项目用了,就没免费名额了),指定 key 的名字:
然后就可以看到你的 key 了:
如果要查询青岛未来 7 天的天气。
需要先查询青岛的 id:
location 是城市名字的拼音,然后带上刚刚的 key:
https://devapi.qweather.com/v7/weather/7d?location=101120606&key=你的key返回了青岛市和各个区的信息。
有了城市 id 之后就可以查询天气了:
https://api.qweather.com/v7/weather/7d?location=101120201&key=你的key这里返回了从 2024-5-1 到 2024-5-7 的天气。
具体字段的解释可以看文档:
这样就实现了查询某个城市未来 7 天的天气的功能。
还有个问题,现在是先用城市的拼音查的 id,再用 id 查的天气。
那直接让用户输入城市拼音么?
这样也不好,可以用 pinyin 这个包:
它可以拿到中文的拼音:
这样,整个流程就串起来了。
当然,如果你想让用户直接选择城市,然后查询城市的天气,这种就要拿到所有城市的信息了。
网上有挺多这种 JSON 数据的:
有所有城市名和它的 id。
思路理清了,来写下代码:
npm install -g @nestjs/cli
nest new city-weather安装 pinyin 包和它的类型:
npm install --save pinyin@alpha
npm install --save-dev @types/pinyin然后加个接口测试下:
@Get('pinyin')
pinyin(@Query('text') text: string) {
return pinyin(text).join('')
}把服务跑起来:
npm run start:dev访问下试试:
可以看到,确实返回了拼音。
但是不需要知道是几声。
改下参数:
这样就好了:
然后 nest 服务里怎么访问三方接口呢?
直接用 axios 么?
可以,但是希望统一配置 axios,然后各个模块都用同一个 axios 实例。
所以用 @nestjs/axios 这个包:
npm install --save @nestjs/axios axios在 AppModule 引入下 HttpModule:
这里可以填入各种请求配置,比如 baseURL 等,其实就是 new 了一个 Axios 实例:
然后在用到的地方注入 httpService:
@Inject(HttpService)
private httpService: HttpService;
@Get('weather/:city')
async weather(@Param('city') city: string) {
const cityPinyin = pinyin(city, { style: 'normal'}).join('');
const { data } = await firstValueFrom(
this.httpService.get(`https://geoapi.qweather.com/v2/city/lookup?location=${cityPinyin}&key=你的key`)
)
return data;
}用 @Param 取路径中的参数。
然后用 pinyin 拿到 city 的拼音,然后调用和风天气的接口。
这里为啥用 firstValueFrom 的 rxjs 操作符呢?
因为 HttpModule 把 axios 的方法返回值封装成了 rxjs 的 Observable。
好处是你可以用 rxjs 的操作符了。
坏处是转成 promise 还得加一层 firstValueFrom。
它就是用来把 rxjs Observable 转成 promise 的:
测试下:
没啥问题。
然后继续调用天气预报接口:
@Get('weather/:city')
async weather(@Param('city') city: string) {
const cityPinyin = pinyin(city, { style: 'normal'}).join('');
const { data } = await firstValueFrom(
this.httpService.get(`https://geoapi.qweather.com/v2/city/lookup?location=${cityPinyin}&key=你的key`)
)
const location = data?.['location']?.[0];
if(!location) {
throw new BadRequestException('没有对应的城市信息');
}
const { data: weatherData } = await firstValueFrom(
this.httpService.get(`https://api.qweather.com/v7/weather/7d?location=${location.id}&key=你的key`)
)
return weatherData;
}如果没查到 location,返回 400 错误。
否则用 location.id 查询该城市天气预报。
这样,我们的城市天气预报服务就完成了。
当然,这里最好用 redis 做一层缓存,同一个城市的一天内只查一次,避免接口反复调用。这个大家可以自己去优化。
案例代码上传了
基于和风天气的 api 实现了天气预报查询服务。
主要用到了 pinyin 这个包来完成中文转拼音,然后用 pinyin 去请求和风天气的 api 查询城市 id。
接下来用城市 id 请求天气数据。
和风天气的 api 免费版一天可以调用 1000 次,足够用了。
Nest 里发送 http 请求,用的是 @nestjs/axios 包的 HttpModule 来做的。
它可以统一配置,然后注入 HttpService 到用到的地方,并且 httpService 方法的返回值封装成了 rxjs 的 Observable,可以直接用 rxjs 的操作符。
比如用 firstValueFrom 来把 rxjs 的 Observable 转为 Promise。
这样,你就可以在你的应用中集成天气预报功能了。
生活中经常遇到需要短链的场景。
比如一段很长的 url:
分享出去很不方便。
这时候就可以通过短链服务把它缩短:
点击短链会跳转到原链接:
这种在短信里很常见:
因为短信是按照字数收费的,太长不但阅读体验不好,费用也高。
所以都会生成短链之后再加到短信里。
那短链是怎么实现的呢?
很容易想到的思路是这样的:
用 0、1、2、3、4、5 的递增 id 标识每个 url,把映射关系存到数据库里。
这样访问短链的时候从数据库中查出对应的长链接,返回 302 重定向即可。
比如刚才的短链服务就是通过 302 把短链重定向到长链:
这里也可以用 301。
301 是永久重定向,就是重定向一次之后,下次浏览器就不会再访问短链,会直接访问长链接。
302 是临时重定向,下次访问短链依然会先访问短链服务,返回 302 后再重定向到长链。
这两种都可以,301 的话,短链服务压力小,不过 302 每次都会先访问短链服务,这样可以记录链接的访问次数等数据。
比如刚才用的短链服务,就会记录这个链接的访问记录:
还可以做一些分析:
所以访问记录也挺有价值的。
一般短链服务都是用 302 来重定向。
每个 url 的 id 会用 Base64 或者 Base62 编码:
const data = '123456';
const buff = Buffer.from(data);
const base64data = buff.toString('base64');
console.log(base64data);base64 就是 26 个大写字母、26 个小写字母、10 个数字、2 个特殊字符,一共 64 个字符。
而 base62 则是去掉了两个特殊字符,一共 62 个字符。
做短链的话,用 base62 比较多。
安装用到的包:
npm install base62测试下:
const base62 = require("base62/lib/ascii");
const res = base62.encode(123456);
console.log(res);按照这个思路,就能实现一个短链服务。
在 mysql 里创建压缩码和长链接的对应关系的表,用 mysql 的自增 id 然后进行 base62 之后作为压缩码。
访问短链的时候,根据压缩码查询这个表,找到长链接,通过 302 重定向到这个链接,并且记录短链访问记录。
这样是可以的,但有个问题:
用自增 id 作为压缩码,那别人很容易拿到上一个、下一个压缩码,从而拿到别的短链,万一这个短链是用来兑奖之类的呢?
这样就会有安全问题。
所以自增 id 的方案不太好。
那如果对 url 做 hash 呢?
也就是这样:
const crypto = require('crypto');
function md5(str) {
const hash = crypto.createHash('md5');
hash.update(str);
return hash.digest('hex');
}
console.log(md5('111222'))这样太长了,有 32 位呢:
倒是可以每 4 位取一个数字,然后组成一个 8 位的压缩码。
但是,这样是有碰撞的可能的。
也就是两个不同的 url 生成的压缩码一样。
所以,hash 的方案也不行。
还有一种方案,就是通过随机数的方式生成压缩码。
比如这样:
const base62 = require("base62/lib/ascii");
function generateRandomStr(len) {
let str = '';
for(let i = 0; i < len; i++) {
const num = Math.floor(Math.random() * 62);
str += base62.encode(num);
}
return str;
}
console.log(generateRandomStr(6));随机生成 0-61 的数字,然后转成字符。
62 的 6 次方,范围有 568 亿,足够用了:
当然,随机数也是有碰撞的可能的,这个可以在生成之后查下表,看下是否有重复的,有的话就再重新生成。
不过每次生成都查表的话性能会不好,那有啥优化的方案呢?
可以提前生成一批压缩码,用的时候直接取!
可以用个定时任务来跑,每天凌晨 4 点生成一批。
这样,生成压缩码的方案就完美了。
小结下:
用递增 id + base62 作为压缩码,可以保证唯一,但是容易被人拿到其它短码,不安全。
用 url 做 hash 之后取一部分然后 base62 做为压缩码,有碰撞的可能,不唯一。
随机生成字符串再查表检测是否重复,可以保证唯一且不连续,但是性能不好。用提前批量生成的方式可以解决。
有的同学可能提到 uuid、雪花 id 之类的,那些都太长了,不适合用来做压缩码:
思路理清了,来写下代码。
创建个 nest 项目:
npm install -g @nestjs/cli
nest new short-url先进入项目,把它跑起来:
npm run start:dev浏览器看到 hello world,代表 nest 服务跑成功了:
然后用 docker 把 mysql 跑起来:
从 docker 官网下载 docker desktop,这个是 docker 的桌面端:
跑起来后,搜索 mysql 镜像(这步需要科学上网),点击 run:
输入容器名、端口映射、以及挂载的数据卷,还要指定一个环境变量:
端口映射就是把宿主机的 3306 端口映射到容器里的 3306 端口,这样就可以在宿主机访问了。
数据卷挂载就是把宿主机的某个目录映射到容器里的 /var/lib/mysql 目录,这样数据是保存在本地的,不会丢失。
而 MYSQL_ROOT_PASSWORD 的密码则是 mysql 连接时候的密码。
跑起来后,用 GUI 客户端连上,这里用的是 mysql workbench,这是 mysql 官方提供的免费客户端:
连接上之后,点击创建 database:
指定名字、字符集为 utf8mb4,然后点击右下角的 apply。
创建成功之后在左侧就可以看到这个 database 了:
当然,现在还没有表。
在 Nest 里用 TypeORM 连接 mysql。
安装用到的包:
npm install --save @nestjs/typeorm typeorm mysql2mysql2 是数据库驱动,typeorm 是用的 orm 框架,而 @nestjs/typeorm 是 nest 集成 typeorm 用的。
在 AppModule 里引入 TypeORM,指定数据库连接配置:
TypeOrmModule.forRoot({
type: "mysql",
host: "localhost",
port: 3306,
username: "root",
password: "guang",
database: "short-url",
synchronize: true,
logging: true,
entities: [],
poolSize: 10,
connectorPackage: 'mysql2',
extra: {
authPlugin: 'caching_sha2_password',
}
}),然后创建个 entity:
src/entities/UniqueCode.ts
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class UniqueCode {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 10,
comment: '压缩码'
})
code: string;
@Column({
comment: '状态, 0 未使用、1 已使用'
})
status: number;
}在 AppModule 引入:
保存之后,TypeORM会自动建表:
表创建好了,接下来插入一些数据:
nest g service unique-code --flat --no-spec生成 service 类,--flat 是不生成目录 --no-spec 是不生成测试代码:
然后创建 src/utils.ts 来放生成随机压缩码的代码:
import * as base62 from "base62/lib/ascii";
export function generateRandomStr(len: number) {
let str = '';
for(let i = 0; i < len; i++) {
const num = Math.floor(Math.random() * 62);
str += base62.encode(num);
}
return str;
}安装用到的包:
npm install base62然后在 UniqueCodeService 添加下插入压缩码的方法:
import { Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
import { generateRandomStr } from './utils';
import { UniqueCode } from './entities/UniqueCode';
@Injectable()
export class UniqueCodeService {
@InjectEntityManager()
private entityManager: EntityManager;
async generateCode() {
let str = generateRandomStr(6);
const uniqueCode = await this.entityManager.findOneBy(UniqueCode, {
code: str
});
if(!uniqueCode) {
const code = new UniqueCode();
code.code = str;
code.status = 0;
return await this.entityManager.insert(UniqueCode, code);
} else {
return this.generateCode();
}
}
}就是生成随机的长度为 6 的字符串,查下数据库,如果没查到,就插入数据,否则重新生成。
用定时任务的方式来跑:
安装用到的包:
npm install --save @nestjs/schedule在 AppModule 注册下:
然后在 service 方法上声明,每 5s 执行一次:
@Cron(CronExpression.EVERY_5_SECONDS)然后就可以看到一直在打印 insert 语句:
数据库中也可以看到插入的未使用的压缩码:
当然,一个个这么插入可太费劲了。
一般是在凌晨 4 点左右批量插入一堆,比如一次性插入 10000 个。
@Cron(CronExpression.EVERY_DAY_AT_4AM)
async batchGenerateCode() {
for(let i = 0; i< 10000; i++) {
this.generateCode();
}
}这里是每次 insert 一个,你也可以每次 insert 10 个 20 个这种。
批量插入性能会好,因为执行的 sql 语句少。这里就先不优化了。
压缩码有了,接下来生成 url 和压缩码的对应关系就好了。
同样需要创建 entity:
src/entities/ShortLongMap.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class ShortLongMap {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 10,
comment: '压缩码'
})
shortUrl: string;
@Column({
length: 200,
comment: '原始 url'
})
longUrl: string;
@CreateDateColumn()
createTime: Date;
}在 entities 引入:
同样会自动建表:
生成短链就是往这个表里添加记录。
加一个生成短链的 service:
nest g service short-long-map --flat --no-spec实现生成短链的方法:
import { UniqueCodeService } from './unique-code.service';
import { Inject, Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
import { ShortLongMap } from './entities/ShortLongMap';
import { UniqueCode } from './entities/UniqueCode';
@Injectable()
export class ShortLongMapService {
@InjectEntityManager()
private entityManager: EntityManager;
@Inject(UniqueCodeService)
private uniqueCodeService: UniqueCodeService;
async generate(longUrl: string) {
let uniqueCode = await this.entityManager.findOneBy(UniqueCode, {
status: 0
})
if(!uniqueCode) {
uniqueCode = await this.uniqueCodeService.generateCode();
}
const map = new ShortLongMap();
map.shortUrl = uniqueCode.code;
map.longUrl = longUrl;
await this.entityManager.insert(ShortLongMap, map);
await this.entityManager.update(UniqueCode, {
id: uniqueCode.id
}, {
status: 1
});
return uniqueCode.code;
}
}这里就是先从 unique-code 表里取一个压缩码来用,如果没有可用压缩码,那就生成一个。
然后在 short-long-map 表里插入这条新的短链映射,并且把用到的压缩码状态改为 1。
在 AppController 里添加一个接口:
import { ShortLongMapService } from './short-long-map.service';
import { Controller, Get, Inject, Query } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Inject(ShortLongMapService)
private shortLongMapService: ShortLongMapService;
@Get()
getHello(): string {
return this.appService.getHello();
}
@Get('short-url')
async generateShortUrl(@Query('url') longUrl) {
return this.shortLongMapService.generate(longUrl);
}
}在浏览器里测试下:
可以看到,打印了 4 条 sql:
首先 select 查出一个压缩码来,然后 insert 插入压缩码和 url 的映射,之后再把它 select 出来返回。
最后 update 更新压缩码状态。
看 sql 来说,是符合我们的预期的。
然后看下数据:
也是对的。
那剩下的事情就很简单了,只要加一个重定向就好:
首先在 service 里添加根据压缩码查询 longUrl 的方法:
async getLongUrl(code: string) {
const map = await this.entityManager.findOneBy(ShortLongMap, {
shortUrl: code
});
if(!map) {
return null;
}
return map.longUrl;
}然后在 AppController 里添加一个重定向的接口:
@Get(':code')
@Redirect()
async jump(@Param('code') code) {
const longUrl = await this.shortLongMapService.getLongUrl(code);
if(!longUrl) {
throw new BadRequestException('短链不存在');
}
return {
url: longUrl,
statusCode: 302
}
}测试下:
这样,我们的短链服务就完成了。
其他的非核心功能,比如记录每次访问记录,做一些分析:
这些比较简单,就不实现了。
经常用短链服务把长的 url 缩短,在短信里的链接一般都是这种。
用 Nest 自己实现了一个。
核心是压缩码的生成,分析了自增 id + base62,这样容易被人拿到其它短链,不安全。hash + base62 会有冲突的可能,所以最终用的是自己生成随机数 + base62 的方案。
当然,这个随机字符串最好是提前生成,比如用定时任务在低峰期批量生成一堆,之后直接用就好了。
短链的重定向使用 302 临时重定向,这样可以记录短链访问记录,做一些分析。
市面上的短链服务,基本都是这样实现的。
提到推送数据,大家可能会首先想到 WebSocket。
确实,WebSocket 能双向通信,自然也能做服务器到浏览器的消息推送。
但如果只是单向推送消息的话,HTTP 就有这种功能,它就是 Server Sent Event。
WebSocket 的通信过程是这样的:
首先通过 http 切换协议,服务端返回 101 的状态码后,就代表协议切换成功。
之后就是 WebSocket 格式数据的通信了,一方可以随时向另一方推送消息。
而 HTTP 的 Server Sent Event 是这样的:
服务端返回的 Content-Type 是 text/event-stream,这是一个流,可以多次返回内容。
Server Sent Event 就是通过这种消息来随时推送数据。
可能你是第一次听说 SSE,但你肯定用过基于它的应用。
比如你用的 CICD 平台,它的日志是实时打印的。
那它是如何实时传输构建日志的呢?
明显需要一段一段的传输,这种一般就是用 SSE 来推送数据。
再比如说 ChatGPT,它回答一个问题不是一次性给你全部的,而是一部分一部分的加载回答。
这也是基于 SSE。
或者这个 ai 聊天产品:
知道了什么是 SSE 以及它的应用,来自己实现一下吧:
创建 nest 项目:
nest new sse-test把它跑起来:
npm run start:dev访问 http://localhost:3000 可以看到 hello world,代表服务器跑成功了:
然后在 AppController 添加一个 stream 接口:
这里不是通过 @Get、@Post 等装饰器标识,而是通过 @Sse 标识这是一个 event stream 类型的接口。
@Sse('stream')
stream() {
return new Observable((observer) => {
observer.next({ data: { msg: 'aaa'} });
setTimeout(() => {
observer.next({ data: { msg: 'bbb'} });
}, 2000);
setTimeout(() => {
observer.next({ data: { msg: 'ccc'} });
}, 5000);
});
}返回的是一个 Observable 对象,然后内部用 observer.next 返回消息。
可以返回任意的 json 数据。
先返回了一个 aaa、过了 2s 返回了 bbb,过了 5s 返回了 ccc。
然后写个前端页面:
创建一个 react 项目:
npm create vite@latest sse-test-frontend -- --template react-ts在 App.tsx 里写如下代码:
import { useEffect } from 'react';
function App() {
useEffect(() => {
const eventSource = new EventSource('http://localhost:3000/stream');
eventSource.onmessage = ({ data }) => {
console.log('New message', JSON.parse(data));
};
}, []);
return (
<div>hello</div>
);
}
export default App;这个 EventSource 是浏览器原生 api,就是用来获取 sse 接口的响应的,它会把每次消息传入 onmessage 的回调函数。
在 nest 服务开启跨域支持:
然后把 react 项目 main.tsx 里这几行代码删掉,它会导致额外的渲染:
执行 npm run dev
vite 默认跑在 5173 端口:
浏览器访问下:
看到一段段的响应了没?
这就是 Server Sent Event。
在 devtools 里可以看到,响应的 Content-Type 是 text/event-stream:
然后在 EventStream 里可以看到每一次收到的消息:
这样,服务端就可以随时向网页推送消息了。
那它兼容性怎么样呢?
可以在 MDN 看到:
除了 ie、edge 外,其他浏览器都没任何兼容问题。
基本是可以放心用的。
那用在哪呢?
一些只需要服务端推送的场景就特别适合 Server Sent Event。
比如这个站内信:
这种推送用 WebSocket 就没必要了,可以用 SSE 来做。
那连接断了怎么办呢?
不用担心,浏览器会自动重连。
这点和 WebSocket 不同,WebSocket 如果断开之后是需要手动重连的,而 SSE 不用。
再比如说日志的实时推送。
来测试下:
tail -f 命令可以实时看到文件的最新内容:
通过 child_process 模块的 exec 来执行这个命令,然后监听它的 stdout 输出:
const { exec } = require("child_process");
const childProcess = exec('tail -f ./log');
childProcess.stdout.on('data', (msg) => {
console.log(msg);
});用 node 执行它:
然后添加一个 sse 的接口:
import { exec } from 'child_process';
@Sse('stream2')
stream2() {
const childProcess = exec('tail -f ./log');
return new Observable((observer) => {
childProcess.stdout.on('data', (msg) => {
observer.next({ data: { msg: msg.toString() }});
})
});
}监听到新的数据之后,把它返回给浏览器。
浏览器连接这个新接口:
测试下:
可以看到,浏览器收到了实时的日志。
很多构建日志都是通过 SSE 的方式实时推送的。
日志之类的只是文本,那如果是二进制数据呢?
二进制数据在 node 里是通过 Buffer 存储的。
const { readFileSync } = require("fs");
const buffer = readFileSync('./package.json');
console.log(buffer);而 Buffer 有个 toJSON 方法:
这样不就可以通过 sse 的接口返回了么?
试一下:
@Sse('stream3')
stream3() {
return new Observable((observer) => {
const json = readFileSync('./package.json').toJSON();
observer.next({ data: { msg: json }});
});
}确实可以。
也就是说,基于 sse,除了可以推送文本外,还可以推送任意二进制数据。
服务端实时推送数据,除了用 WebSocket 外,还可以用 HTTP 的 Server Sent Event。
只要 http 返回 Content-Type 为 text/event-stream 的 header,就可以通过 stream 的方式多次返回消息了。
它传输的是 json 格式的内容,可以用来传输文本或者二进制内容。
通过 Nest 实现了 sse 的接口,用 @Sse 装饰器标识方法,然后返回 Observable 对象就可以了。内部可以通过 observer.next 随时返回数据。
前端使用 EventSource 的 onmessage 来接收消息。
这个 api 的兼容性很好,除了 ie 外可以放心的用。
它的应用场景有很多,比如站内信、构建日志实时展示、chatgpt 的消息返回等。
再遇到需要消息推送的场景,不要直接 WebSocket 了,也许 Server Sent Event 更合适呢?
除了微信外,邮件也是常用的通讯方式。
那你平时都是怎么收发邮件的呢?
大多数人会回答,就用邮箱客户端啊,比如 qq 邮箱的:
但是这样体验并不好,比如写邮件的时候:
我有个漂亮的 html 页面,想直接把它作为邮件内容。
或者我想用 markdown 来写邮件。
但是它只支持富文本编辑器:
再比如收邮件的时候,我想把一些重要邮件的内容保存下来,附件啥的都下载到本地。
但是邮件多了的话,一个个手动搞太麻烦了。
有没有什么更好的方式呢?
当然是有的,作为一个专业的 Node 程序员,自然要用代码的方式来收发邮件了!
邮件有专门的协议:
发邮件用 SMTP 协议。
收邮件用 POP3 协议、或者 IMAP 协议。
并且在 node 里也有对应的包,发邮件用 nodemailer 包,收邮件用 imap 包。
来试试:
首先,要开启 smtp、imap 等服务,这里以 qq 邮箱举例(其他邮箱也类似):
在邮箱帮助中心 https://service.mail.qq.com/ 可以搜到如何开启 smtp、imap 等服务:
开启后可以在设置里看到:
然后在帮助中心页面搜索授权码:
按照指引生成一个授权码:
这个是 qq 邮箱特有的一个第三方登录密码:
然后就可以开始写代码了:
const nodemailer = require("nodemailer");
const transporter = nodemailer.createTransport({
host: "smtp.qq.com",
port: 587,
secure: false,
auth: {
user: 'xxxxx@qq.com',
pass: '你的授权码'
},
});
async function main() {
const info = await transporter.sendMail({
from: '"guang" <xxxx@qq.com>',
to: "xxxx@xx.com",
subject: "Hello 111",
text: "xxxxx"
});
console.log("邮件发送成功:", info.messageId);
}
main().catch(console.error);安装 nodemailer 包,然后执行上面的代码:
可以看到邮件发送成功了。
在邮箱里看看:
确实收到了这个邮件:
这样就用 node 发送了第一个邮件!
而且邮件是支持 html + css 的,比如把我之前写的一个 3 只小鸟的 button 的 html 拿过来:
放到一个文件里,然后发邮件的时候读取这个文件:
然后再跑下:
收到的邮件也渲染出了这个 html,并且 css 动画也是正常的:
那是不是可以加一些 js 呢?
想多了,邮件里可以包含任何 html+ css,但是不支持 js。
不过基于 html + css,就已经可以实现各种炫酷的邮件了。
就像前面说的 markdown 格式来写邮件,这个加一个 markdown 转 html 的包,然后作为邮件的 html 内容发送就好了。
也就是说,通过代码的方式,可以做出更炫酷的邮件来。
发邮件会了,那如何通过 node 来收邮件呢?
收邮件是用 pop3 或者 imap 协议,需要换一个包。
const Imap = require('imap');
const imap = new Imap({
user: 'xxx@qq.com',
password: '你的授权码',
host: 'imap.qq.com',
port: 993,
tls: true
});
imap.once('ready', () => {
imap.openBox('INBOX', true, (err) => {
imap.search([['SEEN'], ['SINCE', new Date('2023-07-10 19:00:00').toLocaleString()]], (err, results) => {
if (!err) {
console.log(results);
} else {
throw err;
}
});
});
});
imap.connect();安装 imap 的包,然后填入 qq 邮箱的 imap 服务器的域名、端口,填入用户名和授权码,就可以连接了。
这里的 imap 服务器的信息也是在帮助中心里搜索:
search 的参数写了两个:
['SEEN'] 是查询已读的邮件。
['SINCE', '某个日期'] 是查询从这个日期以来的邮件。
当然,还有更多的搜索条件,可以看 imap 包的文档。
跑下试试:
可以看到打印了搜索出的符合条件的邮件的 id,然后来处理下这些 id:
const { MailParser } =require('mailparser');
const fs = require('fs');
const path = require('path');
function handleResults(results) {
imap.fetch(results, {
bodies: '',
}).on('message', (msg) => {
const mailparser = new MailParser();
msg.on('body', (stream) => {
});
});
}
这里用 imap.fetch 来请求这些 id 的内容,bodies 为 '' 是查询 header + body 的意思:
然后处理下 body 的内容,把结果保存到 info 对象里。
这里解析邮件内容要使用 mailparser 这个包:
const { MailParser } =require('mailparser');
const fs = require('fs');
const path = require('path');
const Imap = require('imap');
function handleResults(results) {
imap.fetch(results, {
bodies: '',
}).on('message', (msg) => {
const mailparser = new MailParser();
msg.on('body', (stream) => {
const info = {};
stream.pipe(mailparser);
mailparser.on("headers", (headers) => {
info.theme = headers.get('subject');
info.from = headers.get('from').value[0].address;
info.mailName = headers.get('from').value[0].name;
info.to = headers.get('to').value[0].address;
info.datetime = headers.get('date').toLocaleString();
});
mailparser.on("data", (data) => {
if (data.type === 'text') {
info.html = data.html;
info.text = data.text;
console.log(info);
}
if (data.type === 'attachment') {
const filePath = path.join(__dirname, 'files', data.filename);
const ws = fs.createWriteStream(filePath);
data.content.pipe(ws);
}
});
});
});
}这部分还是容易看懂的,就是把 headers 的信息提取出来,把邮件 body 的信息提取出来,放到 info 对象里,打印。
如果有附件,就写到 files 目录下。
在本地创建个 files 目录,然后跑一下。
可以看到,前面发的那两个邮件都取到了。
日期也确实都是 7 月 10 日的。
我邮箱里有这样一个邮件:
可以看到,附件也下载到了 files 目录下:
把 html 的内容保存到本地文件里:
const filePath = path.join(__dirname, 'mails', info.theme + '.html');
fs.writeFileSync(filePath, info.html || info.text)以邮件主题为文件名。
当然,要现在本地创建 mails 这个目录,然后跑一下:
邮件内容和附件内容都保存了下来:
在邮箱里可以看到也是这些邮件:
打开这些 html 看看,起一个 http-server:
npx http-server .和在邮箱里看一模一样。
这样,就把邮件内容和附件都保存了下来。
你想保存一些重要邮件的时候,还需要手动一个个复制和下载附件么?
不需要,用 node 写代码保存不更方便么?
收邮件部分的代码如下:
const { MailParser } =require('mailparser');
const fs = require('fs');
const path = require('path');
const Imap = require('imap');
const imap = new Imap({
user: 'xx@qq.com',
password: '你的授权码',
host: 'imap.qq.com',
port: 993,
tls: true
});
imap.once('ready', () => {
imap.openBox('INBOX', true, (err) => {
imap.search([['SEEN'], ['SINCE', new Date('2023-07-10 19:00:00').toLocaleString()]], (err, results) => {
if (!err) {
handleResults(results);
} else {
throw err;
}
});
});
});
function handleResults(results) {
imap.fetch(results, {
bodies: '',
}).on('message', (msg) => {
const mailparser = new MailParser();
msg.on('body', (stream) => {
const info = {};
stream.pipe(mailparser);
mailparser.on("headers", (headers) => {
info.theme = headers.get('subject');
info.from = headers.get('from').value[0].address;
info.mailName = headers.get('from').value[0].name;
info.to = headers.get('to').value[0].address;
info.datetime = headers.get('date').toLocaleString();
});
mailparser.on("data", (data) => {
if (data.type === 'text') {
info.html = data.html;
info.text = data.text;
const filePath = path.join(__dirname, 'mails', info.theme + '.html');
fs.writeFileSync(filePath, info.html || info.text)
console.log(info);
}
if (data.type === 'attachment') {
const filePath = path.join(__dirname, 'files', data.filename);
const ws = fs.createWriteStream(filePath);
data.content.pipe(ws);
}
});
});
});
}
imap.connect();案例代码上传了。
邮件是常用的通讯方式,一般是通过邮箱客户端来收发邮件。
但是这样不够方便:
比如写邮件不能直接贴 html + css,不能写 markdown,收邮件不能按照规则自动下载附件、自动保存邮件内容。
这些需求都能通过代码来自己实现。
发邮件是基于 SMTP 协议,收邮件是基于 POP3 或 IMAP 协议。
node 分别有 nodemailer 包和 imap 包用来支持收发邮件的协议。
通过 nodemailer 发送了 html 的邮件,可以发送任何 html+css 的内容。
通过 imap 实现了邮件的搜索,然后用 mailparser来做了内容解析,然后把邮件内容和附件做了下载。
能够写代码来收发邮件之后,就可以做很多自动化的事情了:
比如定时自动发一些邮件,内容是从数据库查出来的,比如自动拉取邮件,根据一定的规则来保存邮件和附件内容等。
这就是 Node 里收发邮件的方式。