nodejs开发短网址服务

一、前提

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. 知识汇总

三、实现流程

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;
	}
}