又是新造的一个轮子。

不过这个项目对我来说也是第一个从前端到数据库全部自主实现的全栈项目,所以前后弄了一两个星期。

数据库本来打算用MySQL,可是正好微软Azure给了250GB的Azure SQL免费额度,不用白不用。而且MySQL直接部署在服务器上占用的内存是node的几倍了,服务器可能也撑不住。

服务器部分

考虑到一个Node进程占用的内存也不可忽视,为了尽最大可能的节省服务器性能开销,我决定把后端直接整合到原先的Revincx API中,实际上这样做给我后续的开发增加了不少难度,两个域名共用一套后端要分开处理确实比较麻烦。

数据库对接部分

由于之前只接触过MySQL,对于微软的SQL Server还是比较陌生的。查了一部分资料才大致摸清楚,SQL Server与MySQL比较多了一个模式(Model),差不多相当于在数据库与表之间又套了一层?

Node.js 连接 SQL的模块是node-mssql,其实在上次迁移随机图片的API时已经在用SQL Server了,这次要处理的不多。

上次对于数据库的配置使用了一个js模块来导出,不过在了解了dotenv模块之后我也打算把数据库配置写到.env文件中。

在项目根目录新建.env文件:

1
2
3
DB_HOST=xxx.xxx.net
DB_USER=root
DB_PASSWORD=123456

然后在app.js中导入环境变量配置文件:

1
2
3
require('dotenv').config({
path: '../.env'
})

然后就可以在数据库模块中使用process.env.XXX来使用环境变量了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const dbConfig = {
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
server: process.env.DB_HOST,
port: 1433,
database: 'main',
pool: {
max: 10,
min: 0,
idleTimeoutMillis: 30000
},
options: {
encrypt: true,
enableArithAbort: true,
trustServerCertificate: false,
}
}

下面就是设计数据接口部分了。

插入记录

我在数据库主模块使用的函数都是异步函数,因此这里请求数据库连接的语句要用await,这里一开始我又犯了一个常识性错误,就是在await语句后面的函数后面不能直接取成员属性。比如let req = await request().query("xxx")这样就是不对的,必须拆成两句来写:

1
2
let req = await request();
let query = await req.query("xxx")

既然要插入短链接与长链接之间的记录,肯定要要在数据库存入短链接和长链接的映射关系。这里我使用的哈希算法,那是存字符串类型的哈希值还是存一个大整数呢?由于在查表的时候查询数字的速度比查询字符串要快,所以这里把短链接哈希值全部转成整数,用的时候再从后端转成字符串。下面是插入哈希值与长链接的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let addNewUrl = async (hash,url) => {
try {
let req = await request()
let result = await req
.query(`INSERT INTO [model].[renexUrl] (hash,url)
VALUES ( ${hash}, '${url}' )`)
return {
status: 'success',
rowsAffected: result.rowsAffected
}
}
catch (err)
{
console.log(err)
return {
status: 'error'
}
}
}

取出记录

从数据库去除记录也就是根据哈希值来查询长链接了。

函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let getUrl = async hash => {
try {
let req = await request()
let result = await req
.query(`SELECT url FROM [model].[renexUrl] WHERE hash = ${hash}`)
return {
status: "success",
url: result.recordset[0].url
}
}
catch (err)
{
console.log(err)
return {
status: "failed"
}
}
}

为了避免查询过程出错导致获取不到Url,我在返回的结果中加了一个status的字段表示是否查询成功。

后端部分

后端负责处理http请求并计算长链接的哈希值。

哈希计算部分

在选择短链接算法上,我本来是想着用MD5、SHA-1这种哈希算法。在查了一些资料后才知道,像这种强哈希算法可能会消耗不必要的性能,而当前的情况并不需要大量的数据操作,可以选用弱哈希算法。比如MurMurHash算法

至于用Node实现这个算法,我开始以为会有相关的npm包,然后发现这个算法比较简单,使用一个函数就可以实现了。我在Github上找到了用JavaScript实现的MurmurHash算法:https://github.com/garycourt/murmurhash-js

下面是它的核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
function murmurhash3_32_gc(key, seed) {
var remainder, bytes, h1, h1b, c1, c1b, c2, c2b, k1, i;

remainder = key.length & 3; // key.length % 4
bytes = key.length - remainder;
h1 = seed;
c1 = 0xcc9e2d51;
c2 = 0x1b873593;
i = 0;

while (i < bytes) {
k1 =
((key.charCodeAt(i) & 0xff)) |
((key.charCodeAt(++i) & 0xff) << 8) |
((key.charCodeAt(++i) & 0xff) << 16) |
((key.charCodeAt(++i) & 0xff) << 24);
++i;

k1 = ((((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16))) & 0xffffffff;
k1 = (k1 << 15) | (k1 >>> 17);
k1 = ((((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16))) & 0xffffffff;

h1 ^= k1;
h1 = (h1 << 13) | (h1 >>> 19);
h1b = ((((h1 & 0xffff) * 5) + ((((h1 >>> 16) * 5) & 0xffff) << 16))) & 0xffffffff;
h1 = (((h1b & 0xffff) + 0x6b64) + ((((h1b >>> 16) + 0xe654) & 0xffff) << 16));
}

k1 = 0;

switch (remainder) {
case 3: k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16;
case 2: k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8;
case 1: k1 ^= (key.charCodeAt(i) & 0xff);

k1 = (((k1 & 0xffff) * c1) + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff;
k1 = (k1 << 15) | (k1 >>> 17);
k1 = (((k1 & 0xffff) * c2) + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff;
h1 ^= k1;
}

h1 ^= key.length;

h1 ^= h1 >>> 16;
h1 = (((h1 & 0xffff) * 0x85ebca6b) + ((((h1 >>> 16) * 0x85ebca6b) & 0xffff) << 16)) & 0xffffffff;
h1 ^= h1 >>> 13;
h1 = ((((h1 & 0xffff) * 0xc2b2ae35) + ((((h1 >>> 16) * 0xc2b2ae35) & 0xffff) << 16))) & 0xffffffff;
h1 ^= h1 >>> 16;

return h1 >>> 0;
}

虽然看不懂,但是无所谓,这个算法的确能用。key参数就是要计算的值,seed则是加盐参数,可以使哈希值具有唯一性。

MurmurHash计算的结果是整数,而短链接要短,所以把这个整数转换为62进值来作为短链接的路径,下面是实现双向转换的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const string10to62 = number => {
let chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIGKLMNOPQRSTUVWXYZ'.split(''),
radix = chars.length,
quotient = +number,
arr = [];
do {
let mod = quotient % radix;
quotient = (quotient - mod) / radix;
arr.unshift(chars[mod]);
} while (quotient);
return arr.join('');
}

const string62to10 = number_code => {
let chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIGKLMNOPQRSTUVWXYZ',
radix = chars.length;
number_code = String(number_code);
let len = number_code.length,
i = 0,
origin_number = 0;
while (i < len) {
origin_number += Math.pow(radix, i++) * chars.indexOf(number_code.charAt(len - i) || 0);
}
return origin_number;
}

然后就可以封装一个计算哈希值的模块:

1
2
3
const text2Hash = text => {
return Math.floor(murmurhash(text, 1145141919810) / 1000)
}

这里我先是对text计算hash并加盐(这么臭的盐有加的必要嘛),然后为了进一步缩短生成结果的长度,我把它除了1000,经测试这样能把短链接的路径缩小的四位62进制字符。(但实际上在大型生产环境还是别这么做)

导出需要的函数供Http请求的模块使用:

1
2
3
4
5
module.exports = {
text2Hash,
string62to10,
string10to62
}

HTTP请求处理部分

这一部分就是把客户端的请求存到数据库里了。

实际上这一部分还要再拆分成两个部分,也就是请求添加短链接和获取短链接这两个部分。何况这两个部分还用到了不同的域名。

这里使用的JSON作为请求体的类型,所以对于Express来说,还要使用body-parser中间件来解析Json数据:

1
2
3
4
5
const bodyParser = require('body-parser')
...

let renexUrlRouter = new express.Router()
renexUrlRouter.use(bodyParser.json())

然后开始处理请求体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
renexUrlRouter.post('/',(async (req,res) => {
let reqBody = req.body
let resBody = {}
if(reqBody.action === "new" && reqBody.url != null)
{
let hash = text2Hash(reqBody.url)
let sqlRequest = await addNewUrl(hash,reqBody.url)
res.status(200)
if(sqlRequest.rowsAffected != null && sqlRequest.rowsAffected > 0)
{
resBody.code = 200
resBody.message = "Succeed"
resBody.urlPath = string10to62(hash)
}
else
{
resBody.code = 503
resBody.urlPath = string10to62(hash)
resBody.message = "Failed but returned path anyway"
}
}
else {
resBody.code = 503
resBody.message = "请求参数有误"
}

res.send(resBody);
}))

这里的reqBody有个action的字段,这么设计是因为我以后可能还会对这个接口设计其他的功能,比如自定义短链接等。不过目前这个字段暂定为只能用new,没用其他实际作用。

还有一个地方,就是对数据库查询失败的处理,这里我决定查询失败也返回正常的哈希值。为什么这么做呢,因为插入失败有可能是该链接已经被插入过了(因为我把hash设置为了链接表的主键)。虽然这种情况很少见,但也必须要考虑。另外,一个用户再前端点了两次提交也算这种情况。所以这里不应该在前端报错,而是仍然返回正确的哈希值。至于其他错误情况,目前我还没遇到过,等遇到了再说吧。

然后就是从短链接获取原链接的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const getUrlRouter = express.Router()

getUrlRouter.get('/:hashStr',async (req,res,next) => {
if(req.hostname !== 'renex.me')
{
next()
return
}
let hashStr = req.params.hashStr
let hash = string62to10(hashStr)
console.log("Request hash: " + hash);
let result = await getUrl(hash)
if(result.status === 'success')
{
res.redirect(result.url)
}
else
{
res.status(400)
res.send("Get url failed")
}
})

这里直接使用了路径参数作为短链接的形式,也就是请求/hashStr这个路径,其中的hashStr就是由哈希值生成的62进制字符串。

由于获取原链接采用的是另外一个域名,为了不与原API服务冲突,要先判断请求的域名是否是短链接域名,如果不是的话就要跳到下一层中间件。同时在Nginx里要添加Host这条Header。

同样这里也要做错误处理,如果客户端请求了错误的短链接,要返回一个400状态的http响应。

Nginx部分

这一部分直接对所有目标进行反代就行了:

1
2
3
4
5
location /
{
proxy_set_header Host renex.me;
proxy_pass http://127.0.0.1:8081/;
}

然后我还想对根目录的请求进行重定向到前端部分的域名:

1
2
3
4
location = /
{
return 301 https://url.renex.me;
}

location后面加等号的意思就是严格相等,在这里就是只匹配对根目录的请求。

前端部分

前端部分其实界面很简单,但因为我老是想把界面封装成Vue组件,所以碰到了不少坑。

再加上上次无意在b站刷到了TailwindCSS的介绍,就想着用一下试试,正好官网上给了针对于Vite + Vue 3.0的安装示例。

至于Vue组件库,本来是想着用Vue Material,结果发现它不支持Vue 3,最后决定直接用MDUI算了。

主界面

主界面设计的简陋无比,默认只有一个卡片,加上一定的边距就行了。

主要代码:

1
2
3
4
5
6
7
8
9
<template>
<div class="flex flex-col">
<div class="flex flex-col mdui-shadow-4 rounded mx-12 mt-16 p-4" @requestFinished="requestFinished">
<h4 class="self-start text-lg text-black">Renex 短链接</h4>
<url-input :loading = "loading" @requestUrl="requestUrlListener"></url-input>
</div>
<result ref="result" @requestFinished="requestFinished"></result>
</div>
</template>

这里的@requestUrl@requestFinished都是组件的时间监听器,既然封装了组件,就必然会有数据的传递。

输入框组件

输入框就是用户输入Url的地方了。

主要代码如下:

1
2
3
4
5
6
7
8
9
10
<template>
<div class="mdui-progress top-progress" v-if="loading">
<div class="mdui-progress-indeterminate"></div>
</div>
<div class="mdui-textfield mdui-textfield-floating-label">
<label class="mdui-textfield-label flex">请输入要缩短的链接...</label>
<input v-model="url" class="mdui-textfield-input" type="text"/>
</div>
<input @click="requestUrl" type="button" class="mdui-btn mdui-btn-raised bg-blue-400 mdui-color-theme-accent mdui-ripple self-end" value="提交">
</template>

这里的mdui-progress是MDUI提供的进度条组件,由于内容很少,我就没再单独封装了,直接用CSS把它固定再最上面就行:

1
2
3
4
5
6
7
.top-progress {
position: absolute!important;
top: 0;
width: 100%;
transform: translateX(-65px);
height: 2px!important;
}

提交逻辑部分

用户点击了提交按钮之后,需要把长链接从提交到服务器,最后返回到结果卡片。其中这两个组件没用直接的父子关系,所以要先经过App组件才能传递到Result组件。

UrlInput部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...

requestUrl: function () {
if(this.url.match(/^(?:(http|https|ftp):\/\/)?((?:[\w-]+\.)+[a-z0-9]+)((?:\/[^/?#]*)+)?(\?[^#]+)?(#.+)?$/i))
{
this.$emit("requestUrl",this.url)
}
else {
mdui.snackbar("请输入正确的网址",{
position: "right-bottom",
timeout: "1500"
})
}
}
...

App部分:

1
2
3
4
5
6
7
8
9
methods: {
requestFinished(args) {
this.loading = false
},
requestUrlListener(url) {
this.loading = true
this.$refs.result.requestNewUrl(url)
}
}

至于请求部分这里我直接使用了axios框架,比较用起来方便嘛

请求部分我放在了Result组件里,其实也可以放在UrlInput里,但我这个组件封装的有些不合理,导致我封装到哪最终都得传递。

Result部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
methods: {
requestNewUrl: function (url) {
axios.post("https://api.revincx.icu/renexUrl", {
action: "new",
url: url
},
{
timeout: 10000
})
.then(response => {
this.urlResult = `https://renex.me/${response.data.urlPath}`
this.finishRequest()
this.finished = true
})
.catch(err => {
mdui.snackbar("请求失败,请查看控制台日志", {
position: 'right-bottom',
timeout: 1500
})
this.finishRequest()
})
},
finishRequest() {
this.$emit("requestFinished", "test")
}
}

这里因为我把进度条的显示逻辑放在了UrlInput组件中,所以在请求完成后还要触发requestFinished来通知UrlInput组件数据已经请求完成,使其隐藏进度条。

出自之外,这里还用了MDUI的Snakbar组件作为提示框,来提示请求完成或失败等信息。

剪切板复制部分

本来是想着自己实现剪切板复制的逻辑,去查了一下资料发现好像还挺复杂,于是去找了一下现成的库,最终选择了clipboard.js这个库来实现。

逻辑很简单,只要在组件挂载之后注册一个复制的监听器就行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...

mounted() {
const btnCopy = new ClipboardJS('.copy-button');

btnCopy.on('success', function (e) {
mdui.snackbar("已复制到剪切板", {
position: "right-bottom",
timeout: 1500
})

e.clearSelection();
});
}
...

其中要在复制按钮上指定data属性:data-clipboard-target="#url-result"

在复制完成后调用了e.clearSelection()来清除对复制区域的选中。

结语

这篇文章大概是我写过最长的博客了,也是前后耗时最久的一篇。

这次开发学到了不少东西,也让我感觉到写一个项目有多难,果然,会和熟练还是有很大差距的

前端源码已经开源到renex-url仓库,说实话这个写的其实真的很烂。

后续有精力的话应该还会继续维护。