该项目使用mysql+nodejs实现短网址服务;
支持自定义短网址;
完美解决了自定义短网址与自增id生产网址冲突问题。
以下所有文件都放在项目根目录下
init.txt
安装nodejs之后
在项目的根目录下执行下列命令
npm install mysql
npm install winston
npm install uuid
mysql模块,用于连接mysql数据库,执行curd操作。
winston模块,用于代替console.log打印日志。
uuid模块,用于支持配置winston模块的日志id。
db.js
封装了对数据库的curd操作,支持连接池,支持事务,解决了批量插入并且处理了占位符(?)的可读性问题。
封装的另一个目的,是将回调风格改为promise风格,调用者可以使用async-await将异步代码同步化。
const mysql = require('mysql');
const log = require('./log');
const logger = log.defaultLogger;//使用默认日志记录器
const db = {};
var pool = mysql.createPool({ //创建连接池
connectionLimit : 10,
host : 'localhost',
user : 'root',
password : '',
database : 'test',
charset : 'utf8'
});
/*
const conn = mysql.createConnection({
host:'localhost',
user:'root',
password:'',
database:'jyd'
});
conn.connect();
*/
//获取连接
db.conn = async function(){
return new Promise((resolve,reject)=>{
pool.getConnection(function(err, conn) {
if (err) {
reject(err);
}else{
resolve(conn);
}
});
});
};
//在事务中执行
db.doInTx = function(cb){
return new Promise((resolve,reject)=>{
db.conn().then(conn=>{
conn.beginTransaction(async(err)=>{
if(err){
conn.release();
await cb(err,null);
return;
}
try{
const res = await cb(null,conn);
conn.commit((err)=>{
logger.info('commit');
conn.release();
if(err){
logger.error(err);
reject(err);
return;
}
resolve(res);
});
}catch(e){
conn.rollback(err=>{
logger.info('rollback');
conn.release();
if(err){
logger.error(err);
reject(err);
return;
}
reject(e);
});
}
});
},async err=>{
await cb(err,null);
});
});
};
//批量插入。这里其实还可以优化一下,可以支持设置批量数,数据太多分多次批量操作插入。
db.insert = async function({conn,tablename,columns,values}){
let {sql,params} = genInsertSql({tablename,columns,values});
//logger.info(sql);
//logger.info(JSON.stringify(params,null,2));
try{
let rows = await query({conn,sql,params});
return rows;
}catch(e){
if(e.code == 'PARSER_JS_PRECISION_RANGE_EXCEEDED'){
logger.error(JSON.stringify(e));
}else{
throw e;
}
}
return;
};
//生成批量插入的sql
function genInsertSql({tablename,columns,values}){
const columnnames = columns.join(',');
const sql = `insert into ${tablename}(${columnnames})values `;
const params = [];
const placeholders = [];
values.forEach((item,index)=>{
let holder = [];
for(let i=0;i<columns.length;i++){
holder.push('?');
params.push(item[columns[i]]);
}
placeholders.push('('+holder.join(',')+')');
});
const ret = {
sql:sql+placeholders.join(',')+';',
params:params
};
//logger.info(ret);
return ret;
}
//查询操作
async function query({conn,sql,params}){
return new Promise((resolve,reject)=>{
conn.query(sql,params,(err,res)=>{
if(err){
reject(err);
return;
}
//logger.info(res);
//logger.info(JSON.stringify(res,null,2));
resolve(res);
});
});
}
db.query = query;
db.pool = pool;
module.exports = db;
//query方法和insert方法,其实还可以优化。可以去掉conn参数,每次获取连接的时候,生成一个标识,将连接保存到一个对象,然后查询和插入的时候,根据标识从中获取它,释放连接的时候,根据标识将其从对象中删除。
log.js
//npm install winston
//npm install uuid
const uuid = require('uuid');
const winston = require('winston');
const { createLogger, format, transports } = require('winston');
const { combine, timestamp, label, printf } = format;
const myFormat = printf(info => {
return `${info.timestamp} [${info.label}] ${info.level}: ${info.message}`;
});
function newLogger(loglabel = uuid.v4()){
const logger = createLogger({
format: combine(
label({ label: loglabel}),
timestamp(),
myFormat
),
transports: [new transports.Console()]
});
return logger;
}
const defaultLogger = newLogger();//提供一个默认的日志。该日志是全局唯一的,任何地方使用它,都是相同的日志id。
const log = {
newLogger,
defaultLogger
};
/*
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
//
// - Write to all logs with level `info` and below to `combined.log`
// - Write all logs error (and below) to `error.log`.
//
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
*/
//
// If we're not in production then log to the `console` with the format:
// `${info.level}: ${info.message} JSON.stringify({ ...rest }) `
//
/*
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.simple()
}));
}*/
module.exports = log;
tiny.js
短网址模块
一般短网址实现有两个思路
- tinyUrl=md5(longUrl).substr(0,16)
这种方法,需要解决tinyUrl重复的问题。 - 将自增id转为62进制对应的字符串。tinyUrl=genTinyUrl(id)
这种方法,自定义tinyUrl会使自增id突然跳到一个比较大的值,中间的id被浪费,而且不可预估浪费多少。
我们的实现结合上面的两个方案,通过记录自定义tinyUrl对自增id的占用关系,解决了tinyUrl重复的问题和id浪费的问题。
保存自定义tinyUrl和longUrl的关联的时候,我们也使用自增id,而不是设置id为parseId(tinyUrl)。
提供两个接口
- longUrl关联到自动生成的tinyUrl
如果longUrl已关联过tinyUrl,则直接返回。
否则获取下一个自增id,转成tinyUrl。
如果tinyUrl已被id’占用,假设tinyUrl’=genTinyUrl(id’),则将longUrl关联到tinyUrl’。
否则关联longUrl到tinyUrl。 - longUrl关联到自定义的tinyUrl
如果tinyUrl已关联到一个longUrl’,并且longUrl’!=longUrl则提示用户该tinyUrl已被使用。
如果tinyUrl已关联到一个longUrl’,并且longUrl’==longUrl则返回关联成功。
否则,获取下一个自增id,假设为id’,保存tinyUrl和longUrl的关联。并将tinyUrl对id’的占用,记录到另一张表。
假设
tinyUrl1=genTinyUrl(id1)
tinyUrl2=genTinyUrl(id2)
tinyUrl3=genTinyUrl(id3)
如果出现tinyUrl1占用id2,tinyUrl2占用id3的情况,则将tinyUrl1占用的id更新为id3,即现在tinyUrl1占用了id3,tinyUrl2一借一贷,逻辑上已经不占用别人的id,它对应的id也不被其他tinyUrl占用。
const db = require('./db');
const log = require('./log');
const logger = log.defaultLogger;
//作为短网址的字符(一共62个,我们的短网址最多6位,所以可以生成62的6次方个短网址)
const chars = `0123456789abcdegfhijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ`.split(new RegExp('','igm'));
//模拟业务
//给定长网址,生成短网址。这里还有工作没完成,只是生成了短网址的一部分,没有处理网址协议部分
async function generateTinyUrl({longUrl}){
if(!longUrl){
throw new Error(`invalid longUrl => ${longUrl}`);
}
return await db.doInTx(async (err,conn)=>{
if(err){
logger.error(err);
return;
}
//如果长网址已经存在,则直接返回
let req = {
conn,
sql:'select * from t_tiny_url where long_url = ?',
params:[longUrl]
};
let rows = await db.query(req);
if(rows.length > 0){
logger.info(`this longUrl is exsits => ` + JSON.stringify(rows));
return rows;
}
//获取下一个自增id
let tablename = 't_tiny_url';
let columns = ['long_url'];
let values = [{
long_url:longUrl
}];
rows = await db.insert({conn,tablename,columns,values});
//logger.info(rows);
let id = rows.insertId;
//计算短网址
//logger.info(id);
let tinyUrl = toTinyUrl(id);
//判断短网址是否存在
req.sql = 'select * from t_tiny_url where tiny_url = ?';
req.params = [tinyUrl];
rows = await db.query(req);
//如果短网址已经存在,就使用占用它id的 短网址对于的id 作为生成此短网址的数值。意思是谁占了我的坑,我就要用它的坑。如果它的坑被别人占了,我就把它们作为一个整体,当成它。这样只要坑被占了,就一定能找到一个未被占用的坑。
if(rows.length > 0){
//寻找占用了我的坑的家伙
req.sql = 'select * from t_tiny_id_provide where tiny_url = ?';
req.params = [tinyUrl];
rows = await db.query(req);
const _tinyUrl = tinyUrl;
const _id = rows[0].provide_id;
tinyUrl = toTinyUrl(_id);
logger.info(`id=>${id},tinyUrl=>${tinyUrl}`);
//req.sql = 'update t_tiny_id_provide set provide_id = ? where tiny_url = ?';
//req.params = [id,_tinyUrl];
//别人占了我的坑,我用了它(它们)的坑,扯平了,这些信息不需要了。
req.sql = 'delete from t_tiny_id_provide where provide_id = ?';
req.params = [_id];
rows = await db.query(req);
}
//logger.info(tinyUrl);
//logger.info(db);
//保存短网址
req.sql = 'update t_tiny_url set tiny_url = ? where id = ?';
req.params = [tinyUrl,id];
rows = await db.query(req);
return rows;
});
}
//自定义短码
async function customTinyUrl({tinyUrl,longUrl}){
if(!longUrl){
throw new Error(`invalid longUrl => ${longUrl}`);
}
if(!tinyUrl || !/[0-9a-zA-Z]{1,6}/.test(tinyUrl)){
throw new Error(`invalid tinyUrl => ${tinyUrl}`);
}
return await db.doInTx(async (err,conn)=>{
if(err){
logger.error(err);
return;
}
//如果短网址或者长网址已经存在,就直接返回
let req = {
conn,
sql:'select * from t_tiny_url where tiny_url = ? or long_url = ?',
params:[tinyUrl,longUrl]
};
let rows = await db.query(req);
if(rows.length > 0){
logger.info(`this tinyUrl/longUrl is exsits => ` + JSON.stringify(rows));
return rows;
}
let tablename = 't_tiny_url';
let columns = ['tiny_url','long_url'];
let values = [{
long_url:longUrl,tiny_url:tinyUrl
}];
//获取下一个自增id
rows = await db.insert({conn,tablename,columns,values});
//logger.info(rows);
let id = rows.insertId;
//logger.info(id);
//计算短网址对应的数值
let provideId = fromTinyUrl(tinyUrl);
//如果我的坑被别人占了,那就是声明别人占了你的坑。我不欠谁的坑,谁也不欠我的坑。
req.sql = 'update t_tiny_id_provide set provide_id = ? where provide_id = ?';
req.params = [id,provideId];
logger.info(req.params);
rows = await db.query(req);
logger.info('=>'+JSON.stringify(rows));
//如果我的坑没被别人占,那就声明一下,我占了某人的坑。
if(rows.affectedRows == 0){
tablename = 't_tiny_id_provide';
columns = ['tiny_url','provide_id'];
values = [{
tiny_url:tinyUrl,
provide_id:id
}];
//
rows = await db.insert({conn,tablename,columns,values});
}
return rows;
});
}
//根据数值计算短网址,当然这里并没有处理协议部分
function toTinyUrl(id){
let n = id;
let code = '';
let size = chars.length;
while(n > 0){
code += chars[n%size];
n = 0|(n/size);
}
return [...code].reverse().join('');
}
//根据短网址,计算数值
function fromTinyUrl(tinyUrl){
const chs = [...tinyUrl];
//logger.info(`chs=>${chs}`);
//logger.info(Array.isArray(chs));
let res = chs.map(ch=>chars.indexOf(ch));
logger.info(`res=>${res}`);
res = res.reduce((prev,curr)=>{
return prev*chars.length+curr;
},0);
logger.info(`res=>${res}`);
return res;
}
//测试
let test = async function(){
//await generateTinyUrl('http://www.test.com/4');
//await generateTinyUrl('http://www.test.com/1');
//await generateTinyUrl('http://www.test.com/2');
//await generateTinyUrl('http://www.test.com/x');
await customTinyUrl('4','http://www.test.com/1');
await customTinyUrl('1','http://www.test.com/2');
await customTinyUrl('2','http://www.test.com/3');
//await customTinyUrl('1','http://www.test.com/4');
await generateTinyUrl('http://www.test.com/4');
}
test = async function(){
await customTinyUrl('5','http://www.test.com/1');
await customTinyUrl('1','http://www.test.com/2');
await customTinyUrl('2','http://www.test.com/3');
//await customTinyUrl('1','http://www.test.com/4');
await generateTinyUrl('http://www.test.com/4');
await generateTinyUrl('http://www.test.com/5');
}
test = function(){
const id = fromTinyUrl('abc');
logger.info(id);
}
//test();
/*
test().then(data=>{
logger.info(data);
},err=>{
logger.error(err);
});*/
module.exports = {
generateTinyUrl,customTinyUrl
};
init.js
初始化模块
用于创建表
const db = require('./db');
const log = require('./log');
const logger = log.defaultLogger;
async function creatTables(err,conn){
if(err){
logger.error(err);
return;
}
//清除数据
let sqls = [
'drop table if exists t_tiny_url;',
'drop table if exists t_tiny_id_provide;',
`create table if not exists t_tiny_url(
id bigint primary key auto_increment,
tiny_url varchar(32) unique,
long_url varchar(767) unique,
key t_tiny_url_idx_tiny_url(tiny_url),
key t_tiny_url_idx_long_url(long_url)
);`,
`create table if not exists t_tiny_id_provide(
tiny_url varchar(32) unique,
provide_id bigint unique,
key t_tiny_id_provide_idx_tiny_url(tiny_url),
key t_tiny_id_provide_idx_provide_id(provide_id)
);`
];
//logger.info(sqls);
let params = [];
let defers = [];
sqls.forEach(sql=>{
defers.push(db.query({conn,sql,params}));
});
await Promise.all(defers);
}
let test = async function(){
await db.doInTx(creatTables);
}
test().then(data=>{
logger.info(data);
db.pool.end();
},err=>{
logger.error(err);
db.pool.end();
});
server.js
http服务器模块
const http = require('http');
const url = require('url');
const querystring = require('querystring');
const tiny = require('./tiny');
// 创建一个 HTTP 服务器
const server = http.createServer( async(req, res) => {
try{
const reqUrl = url.parse(req.url);
//这里没有用到框架,因为我们的需求非常简单
const handler = {
'/gen-tiny-url':'generateTinyUrl',
'/cust-tiny-url':'customTinyUrl'
};
const pathname = reqUrl.pathname;
//TODO 添加一个处理,访问短网址的时候,重定向到长网址
//TODO 添加一个处理,查询短网址对应的长网址
//TODO 添加一个处理,查询长网址对应的短网址,如果不存在,返回空
if(!handler[pathname]){
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404');
return;
};
const arg = querystring.parse(reqUrl.query);
console.info(arg);
res.writeHead(200, { 'Content-Type': 'text/plain' });
const rows = await tiny[handler[pathname]](arg);
//console.info(rows);
res.write(JSON.stringify(rows));
res.end();
}catch(e){
console.error(e);
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('500');
}
});
server.on('clientError', (err, socket) => {
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});
// 服务器正在运行
server.listen(1337);
package.json
{
"name": "tinyurl",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"mysql": "^2.16.0",
"uuid": "^3.3.2",
"winston": "^3.0.0"
}
}