一、前提
1. 需求
在微博字数有限制的情况下,当发表的博文需要插入文章链接或活动链接时,此时需要缩短链接,达到节省字数,给博主发布更多文字的空间的效果
在发送营销短信时,由于1条短信的字数有限,如果分享链接过长,想展示更多消息内容的话可能需要额外增加短信条数,费用也会随之增加,此时需要缩短链接,达到既美观又省钱的效果
在微信群或朋友圈分享购物网站商品链接、文章链接、邀请连接等,如果链接太长可能会霸占全屏,打开率极低,不利于传播和推广,此时需要缩短链接,达到简洁和方便传播的效果
综上,我们需要一款短链接服务,其作用主要有2点:
(1) 将长网址转换为短网址
(2) 点击访问短网址,能跳转到原来的长网址
2. 解决方案
(1) 使用第三方平台
目前市面上主流短链接平台有百度、腾讯、淘宝和新浪,他们的前缀域名分别是:http://dwz.cn、http://url.cn、http://tb.cn、http://t.cn
新浪短网址: 免费,市场兼容性最好的短链接,但官方停止了对外的api接口服务,需要找一些第三方工具生成
腾讯短链接: 免费,具有查询安全中心的权限,会出现直接封短链的情况
淘宝短链接: 免费,但是有权限限制,只服务于阿里系自家电商平台,其他网站链接无法使用
百度短网址: 收费,在腾讯系app中容易被封
(2) 自建短链接服务
由于市面上的第三方平台有些是收费,有些是有限制,不能满足自己的业务需求,因此我们可以选择自己搭建一套短链接服务,包括短链接的生成、存储、访问跳转等模块,以及自定义域名,域名校验拦截等,完全根据自己的需求定制
二、准备
1. 硬件准备
- 1台拥有公网 IP 的服务器(阿里云、腾讯云等)
- 公网IP的服务器已安装nginx, mysql, redis, nodejs服务
- 1个已经备案的域名
- 1台联网的个人电脑
- 个人电脑上安装了SSH工具(WinSCP, Xshell等)
2. 软件技能
本教程后台技术实现基于 nodejs + eggjs + mysql + redis 最终整体托管在Linux服务器,并使用nginx进行反向代理,因此在开始正式技术方案实施之前,您需要具备以下软件知识:
- 需掌握nodejs基本知识
- 需掌握mysql数据库操作知识
- 需掌握redis缓存服务器操作知识
- 需掌握nginx服务器基本知识
- 了解或熟悉eggjs框架整体知识
3. 知识汇总
- nodejs基础 http://nodejs.cn/api/
- Egg.js框架 https://eggjs.org/zh-cn/intro/quickstart.html
- mysql的ORM框架sequelize https://sequelize.org/v5/
- 短ID生成器 https://github.com/dylang/shortid
三、实现流程
1. 安装依赖库及添加数据库等配置
(1) Egg.js初始化项目并安装以下依赖库
package.json文件:
"dependencies": {
"egg": "^2.15.1",
"egg-cors": "^2.2.3",
"egg-mysql": "^3.0.0",
"egg-redis": "^2.4.0",
"egg-scripts": "^2.11.0",
"egg-sequelize": "^5.2.0",
"egg-validate": "^2.0.2",
"egg-view-assets": "^1.6.0",
"egg-view-nunjucks": "^2.2.0",
"mysql2": "^2.0.1",
"shortid": "^2.2.15"
},
(2) 配置redis和sequelize
/app/config/config.default.js文件
redis: {
client: {
port: 6379,
host: 'xx.xx.xx.xx',
password: 'xxxxxx',
db: 0,
},
},
sequelize: {
dialect: 'mysql',
database: 'xxx',
host: 'xx.xx.xx.xx',
port: '3306',
username: 'xxx',
password: 'xxxxxx',
timezone: '+08:00', // 由于orm用的UTC时间,这里必须加上东八区,否则取出来的时间相差8小时
define: { // model的全局配置
timestamps: true, // 添加create,update,delete时间戳
paranoid: false, // 添加软删除
freezeTableName: true, // 防止修改表名为复数
underscored: false, // 防止驼峰式字段被默认转为下划线
},
dialectOptions: {
charset: 'utf8mb4',
typeCast(field, next) {
// for reading from database
if (field.type === 'DATETIME') {
return field.string();
}
return next();
},
},
},
(3) 自定义短网址的redis缓存时间和前缀等参数
/app/config/config.default.js文件
shorturl: {
cache_maxAge: 3600 * 24 * 7,
cache_prefix: 'dwz',
table: 'url',
},
(4) 创建mysql中url表的迁移文件
/app/database/migrations/20191218034427-init-url.js文件
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
const { INTEGER, STRING, TEXT, DATE } = Sequelize;
await queryInterface.createTable('url', {
id: { type: INTEGER, primaryKey: true, autoIncrement: true },
url: { type: TEXT, allowNull: false, comment: '链接地址' },
hash: { type: STRING(512), comment: 'hash字符串' },
valid: { type: INTEGER(1), defaultValue: 1, comment: '是否有效' },
createdAt: DATE,
updatedAt: DATE,
});
},
down: async queryInterface => {
await queryInterface.dropTable('url');
},
};
(5) url表的数据库模型
/app/model/url.js
'use strict';
module.exports = app => {
const { INTEGER, STRING, TEXT } = app.Sequelize;
const Url = app.model.define('url', {
id: { type: INTEGER, primaryKey: true, autoIncrement: true },
url: { type: TEXT, allowNull: false, comment: '链接地址' },
hash: { type: STRING(512), comment: 'hash字符串' },
valid: { type: INTEGER(1), defaultValue: 1, comment: '是否有效' },
});
return Url;
};
2. Service层实现短链接的生成和还原
(1) 缩短链接
/app/extend/helper.js文件
'use strict';
/**
* 短ID生成器. 网址友好、不可预测的、集群兼容
* https://github.com/dylang/shortid
*/
const shortid = require('shortid');
module.exports = {
toInt(str) {
if (typeof str === 'number') return str;
if (!str) return str;
return parseInt(str, 10) || 0;
},
getHash() {
return shortid.generate();
},
};
/app/service/url.js文件
/**
* 获取hash
* @param {number} retryCount 重试次数
* @return {string} hash 短ID
*/
async getHash(retryCount) {
const { ctx } = this;
// 生成hash
const hash = ctx.helper.getHash();
// 检查是否已经存在hash
const existHash = await ctx.model.Url.findOne({ where: { hash } });
if (existHash) {
if (retryCount > 1) {
return this.getHash(retryCount - 1);
}
return null;
}
return hash;
}
/app/service/url.js文件
/**
* 缩短链接
* @param {String} url 原始url地址
* @return {Object} result 由原始url与hash组成的结果
*/
async short(url) {
const { ctx } = this;
const exist = await ctx.model.Url.findOne({ where: { url, valid: 1 } });
if (!exist) {
// 生成hash
const hash = await this.getHash(3);
if (!hash) {
const err = new Error('Invalid Hash');
err.status = 400;
throw err;
}
// 插入表
await ctx.model.Url.create({ url, hash });
return { url, hash };
}
return { url, hash: exist.hash };
}
(2) 还原短链接并用redis缓存
/app/service/url.js文件
/**
* 展开链接
* @param {String} hash 短链接hash
* @return {Object} result 数据库中该地址的详细信息
*/
async expand(hash) {
const { app } = this;
const { cache_prefix, cache_maxAge } = app.config.shorturl;
let result = await app.redis.get(`${cache_prefix}:${hash}`);
if (!result) {
result = await app.model.Url.findOne({ where: { hash } });
if (result) {
await app.redis.set(
`${cache_prefix}:${hash}`,
JSON.stringify(result),
'ex',
cache_maxAge
);
}
}
// 如果result是字符串,需要转换成json
result = typeof result === 'string' ? JSON.parse(result) : result;
return result;
}
3. 定义路由
/app/router.js文件
module.exports = app => {
const { router, controller } = app;
router.get('/:hash', controller.url.redirect); // 访问短网址
router.post(`/shorturl`, controller.url.short); // 生成短网址
};
4. Controller层处理路由
(1) 缩短链接
/**
* 缩短链接
*/
async short() {
const { ctx, service, app } = this;
ctx.validate({ url: 'url' }, ctx.request.body);
const url = ctx.request.body.url;
const res = await service.url.short(url);
res.shorturl = app.config.site.domain + res.hash;
ctx.body = { code: 1, msg: 'success', data: res };
}
(2) 展开链接
/**
* 展开链接
*/
async redirect() {
const { ctx, service } = this;
const hash = ctx.params.hash;
const record = await service.url.expand(hash);
if (!record) {
const err = new Error('no record found');
err.status = 404;
throw err;
}
ctx.status = 302;
ctx.redirect(record.url);
}
5. 其他配置
(1) 定义错误拦截中间件
/app/middleware/error_handler.js
'use strict';
module.exports = () => {
return async function errorHandler(ctx, next) {
try {
await next();
} catch (err) {
// 所有的异常都在 app 上触发一个 error 事件,框架会记录一条错误日志
ctx.app.emit('error', err, ctx);
const status = err.status;
if (status) {
let errMsg = err.message;
if (status === 422) {
// 参数校验异常
errMsg = err.errors;
const error = err.errors[0];
if (error.field === 'url') {
if (error.message === 'should not be empty') {
errMsg = '请填写链接URL';
} else if (error.message === 'should be a url') {
errMsg = '链接URL格式不正确';
}
} else {
errMsg = '参数异常';
}
} else if (status === 500 && ctx.app.config.env === 'prod') {
// 生产环境时 500 错误的详细错误内容不返回给客户端,因为可能包含敏感信息
errMsg = '服务器内部错误';
}
if (status === 404) {
ctx.status = status;
ctx.body = { error: errMsg };
} else {
ctx.status = 200;
ctx.body = { code: 0, msg: errMsg };
}
} else {
// 捕获service,controler内容错误
// ctx.body = { error: err.message };
ctx.status = 200;
ctx.body = { code: 0, msg: err.message };
}
}
};
};
(2) 将中间件添加至配置文件
config.middleware = [ 'errorHandler' ];
(3) 配置允许跨域和指定启动端口
config.cors = {
origin: '*',
allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS',
};
config.cluster = {
listen: {
port: 7002,
hostname: '127.0.0.1',
},
};
6. nginx配置
server {
listen 80;
server_name xxxxx.com;
rewrite ^(.*)$ https://$host$1 permanent;
location / {
proxy_pass http://127.0.0.1:7002;
}
}
server {
#启用 https
listen 443 ssl;
server_name xxxxx.com;
#告诉浏览器不要猜测mime类型
add_header X-Content-Type-Options nosniff;
ssl on;
ssl_certificate /usr/local/ssl/xxxxx.com.pem;
ssl_certificate_key /usr/local/ssl/xxxxx.com.key;
ssl_session_timeout 5m;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4; #使用此加密套件。
ssl_protocols TLSv1 TLSv1.1 TLSv1.2; #使用该协议进行配置。
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://127.0.0.1:7002;
}
}