我是如何从一个idea发展到一个完整的前后端分离项目的
前言
本文旨在记录一下笔者是如何将自己的一个小想法一步一步发展成一个项目的。
笔者认为开源项目不一定需要有多前沿的技术,或者是有跨时代的突破,只要能解决一个痛点,或者实现一个功能,哪怕是简化一下流程,都是非常有价值的,而完成一个完整的开源项目真的很有成就感,因此笔者写下这篇文章,一方面是和大家分享笔者的开心,另一方面也是对自己的鼓励。
如果有不足的地方,还请各位读者斧正。
背景
笔者所在的小公司的测试环境是在本地局域网服务器中,平时都是通过修改 hosts 文件实现自定义域名的绑定的,例如192.168.36.2 example.project.local
,服务器再通过 nginx 来处理请求,而一旦开启一个新项目,就会设置一个新域名,比如一个新的后台项目,就要加一条192.168.36.2 admin.project.local
,对于我们开发人员来说这是小菜一碟,但是对于需要时不时查看我们进度的产品部来说(小公司工作流就这样,大家不用纠结),修改 hosts 文件就比较难弄了,于是笔者就萌生了一个想法,写一个小工具,让不是很会计算机的人也能轻松修改 hosts 。
预期的功能
在笔者的预期中,这个小工具应该具有以下的功能&特点:
-
跨平台,Windows、MacOS、Linux上都能用(虽然用 Linux 的大佬应该能自己操作 hosts )
-
能像 powertoys 中 hosts 编辑工具那样新建条目
-
能读取本机的 hosts 文件,选择其中的几项,做成分享链接,让其他人能轻松的应用到自己的电脑中
能解决什么问题?
在笔者的预期中,这个工具有如下使用场景:
-
需要让不是很懂电脑的人自己新增目录,比如笔者公司这种情况、
-
需要批量设置 hosts ,比如办公室一批新的电脑,如果每台都手动操作就太麻烦了
-
有服务器但是没有域名,需要小范围便捷的访问,比如买了一个帕鲁服务器,给朋友分享服务器地址的时候如果是IP地址就不太方便,记不住,而帕鲁是不能记住上次访问的服务器 ip 的,可以借助这个工具,让朋友和你一起绑定 hosts ,就可以通过简单的域名访问服务了
能否在线编辑 hosts ?
笔者的第一个想法就是做一个类似于 powertoys 中 hosts 编辑工具的网页,在线操作 hosts 文件:
![image.png](https://img.bald3r.wang/img/2024-05-08-202405081519962.png)
由于浏览器的种种限制,无法像本地的文本编辑器一样直接编辑并保存文件的,因此笔者决定曲线救国,通过浏览器读取 hosts 文件,然后保存的时候再覆盖原 hosts 文件,全部操作都在客户端完成,很完美~
读取文件
由于是笔者自己的开源项目,为了展示自己对新 api 的了解( 装B ),使用了File System API
,同时用 input
标签做兼容:
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
| if (needCompact) { const input = document.createElement("input"); input.type = "file"; input.onchange = async (e) => { const files = (e.target as HTMLInputElement).files; if (!files) return; const file = files[0]; const text = await file.text(); handleText(text); }; input.click(); return; } try { const [fileHandler] = await window.showOpenFilePicker(); const text = await (await fileHandler.getFile()).text(); handleText(text); } catch (e) { const { name, message: errMessage } = e as DOMException; switch (name) { case "NotReadableError": message.error("文件读取失败,请检查权限"); break;
case "AbortError": break;
default: message.error(errMessage); break; } }
|
处理文件内容
hosts 文件内容非常规则,一行是一个条目,#
开头的是注释,每一行中的每一项通过 空格 或者 tab 分割,因此非常好处理:
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
| const handleText = (str: string) => { rawStr.value = str;
const rows = str .split("\n") .map((row) => row.trim()) .filter((row) => row.length > 0) .filter((row) => !row.startsWith("#")) .map((row) => row.split(/\t|\s+/));
const rawData = mergeArray(rows);
rawData.forEach((row) => { if (row.length === 2) { const uuid = genUUID(); hostsData.value.push({ uuid, ip: row[0], domain: row[1] }); } else if (row.length > 2) { for (let i = 1; i < row.length; i++) { const uuid = genUUID(); hostsData.value.push({ uuid, ip: row[0], domain: row[i] }); } } });
return hostsData; };
|
将读取到的文件内容,直接转为下列数组格式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 127.0.0.1 test.local.me 192.168.0.1 demo.me.local
[ { "uuid":"f864775c-5c5d-45b3-acae-a4488f098113", "ip":"127.0.0.1", "domain":"test.local.me" }, { "uuid":"8c92894f-4076-4bb5-ac2d-fb428cef2359", "ip":"192.168.0.1", "domain":"demo.me.local" } ]
|
然后渲染在页面中,方便进行修改等操作:
![image.png](https://img.bald3r.wang/img/2024-05-08-202405081553725.png)
保存文件
通过 File System API
保存文件(用 a
标签做兼容):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| if (needCompact) { const blob = new Blob([file], { type: "application/zip" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "hosts"; a.click(); URL.revokeObjectURL(url); return; } try { const fileHandle = await window.showSaveFilePicker({ suggestedName: "hosts", }); const writable = await fileHandle.createWritable(); await writable.write(file); await writable.close(); } catch (error) { const { name, message: errMessage } = error as DOMException; if (name === "AbortError") return; message.error(errMessage); }
|
然后笔者发现,覆盖 hosts 之后,hosts 的内容会直接为空,笔者当时小脑就萎缩了,然后笔者尝试保存到桌面,发现内容被正常的保存了下来,因此笔者推断可能是权限问题导致的,hosts 文件无法被正确的替换掉,于是笔者决定曲曲线救国,用 shell 脚本来处理。(笔者是 MacOS)
用 shell 脚本就万事大吉了吗?
在笔者的想象中,shell 脚本作为本地运行的程序,并且可以请求管理员权限,那应该没啥问题。
于是笔者的目标现在很明确,将选择的条目转成脚本,用户直接下载一个 .sh
结尾的脚本文件,直接运行就可以了。
生成脚本的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const genScripts = (str: string) => { const string = ` #!/bin/bash
echo "请输入管理员密码以继续:" sudo cp /etc/hosts /etc/hosts.bak echo "hosts文件已备份为hosts.bak" echo "${str}" | sudo tee -a /etc/hosts echo "hosts文件已修改。" read -rsp $'请按任意键继续...\\n' -n1 key `; return string; };
|
内容非常简单,就是字符串,也能够成功保存,但是当笔者满怀期待的打开时,终端一闪而过,无事发生。😅😅😅
熟悉 Linux 的读者应该能猜到为什么,创建的文件默认是没有可执行权限的,需要手动添加权限:
但是显然在笔者的设想中,用户是不会使用终端的,难道要在浏览器中给文件赋权限吗?这显然是不现实的,一下子陷入了僵局。
如何给脚本授权?
笔者在网上搜索了半天,并没有发现能在前端页面中授权的方法,以笔者目前的姿势水平,只能想到一个方式,那就是用 Node
,但是很显然,纯前端是没办法使用 node
的,于是笔者只好把目光放在后端了。
使用 Node 后端
事已至此,笔者也不会轻言放弃,于是快速的用 node
+ express
搭了一个简单的后台,实现了一个路由 POST /hosts/gen
:
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
| router.post("/gen", async (req, res) => { let scripts = "\n"; const { data }: { data: Hosts[] } = req.body; for (const item of data) { scripts += item.ip + " " + item.domain + "\n"; } const str = genScripts(scripts);
const dirName = getFormattedDate(); const dirPath = path.join(process.cwd(), process.env.SCRIPT_DIR!, dirName); const FILENAME = getFormattedTime() + ".sh";
try { fs.accessSync(dirPath, fs.constants.F_OK); } catch (e) { fs.mkdirSync(dirPath, { recursive: true }); }
try { fs.writeFileSync(path.join(dirPath, FILENAME), str); fs.chmodSync(path.join(dirPath, FILENAME), "755"); } catch (e) { logger.error(e); res.send("error"); }
res.sendFile(path.join(dirPath, FILENAME), (err) => { if (err) { logger.error(err); res.send("error"); } }); });
|
非常的简单,从请求的 body
中取出数据,生成脚本后通过 fs
授权,然后传回给前端。
笔者又试了一下,令人绝望的事情发生了,下载下来的脚本依旧没有执行权限,笔者反复检查了后台生成的脚本,确认了是有执行权限的,但是下载下来之后就没有了,笔者当场脸都绿了🤢。
山穷水复疑无路
正在笔者抓耳挠腮之际,突然灵光乍现,想到之前下载过一个包,解压后里面的脚本是能直接运行的,于是笔者大胆猜测,一个文件的权限以及所属用户是存在文件的“元信息”中的,而文件在网络传输过程中是不传输“元信息”的,而压缩包在打包的过程中,会将“元信息”也一并保留,所以解压的文件的执行权限也保留了下来。(这段纯属猜测,笔者网上没搜到具体的说法,欢迎评论区交流)
既然有了思路,笔者就要大刀阔斧的干了。在 npm
上一番精挑细选后,笔者选择了 compressing
库来担此重任:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import { zip } from "compressing";
router.post("/gen", async (req, res) => {
...
zip.compressFile( path.join(dirPath, FILENAME), path.join(dirPath, `${FILENAME}.zip`) ).then(() => { res.sendFile(path.join(dirPath, `${FILENAME}.zip`), (err) => { if (err) { logger.error(err); res.send("error"); } }); fs.unlinkSync(path.join(dirPath, FILENAME)); }); });
|
通过压缩包来传递脚本,解压出来的文件成功的拥有了可执行权限,双击可以直接运行。至此笔者的idea已经基本实现了。
可以继续优化的部分
这个项目的整体功能已经实现了,但还是略显粗糙了,增加一些易用性相关的功能,例如添加一些引导等,后端接口防刷,hosts 的备份恢复等等,后续笔者还会不断完善这个项目。
![image.png](https://img.bald3r.wang/img/2024-05-09-202405091015970.png)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| script_dir=$(dirname "$0")
recover_sh_path="$script_dir/recover.sh"
sudo echo '#!/bin/bash sudo cp /etc/hosts.bak /etc/hosts if [ -f /etc/hosts.bak ]; then cp /etc/hosts.bak /etc/hosts echo "hosts 文件已恢复" else echo "未发现备份文件,无法恢复" fi read -rsp $"请按任意键继续..." -n1 key ' > "$recover_sh_path" sudo chmod +x "$recover_sh_path"
echo "已创建recover.sh,可用于恢复hosts文件。" sudo cp /etc/hosts /etc/hosts.bak echo "hosts文件已备份为hosts.bak"
|
后记
这个项目功能十分简单,解决了笔者日常工作生活中的一些小问题。笔者将自己的心路历程分享出来,目的是 求 star 告诉大家想写开源项目非常简单,关注自己平时产生的一个个 idea ,然后想办法实现就可以,不用怀疑自己的水平或者能力,毕竟开源就是一个抛砖引玉的过程,自己也会受益良多。比如笔者之前随手写的一个[微信小程序的 xr-frame 的 demo](baIder/weixin-xr-frame-demo (github.com)) (文章地址)),居然也有了 4 个 star,说明还是帮助到了别人的,所以大胆写吧少年!😉
笔者水平有限,如果不足之处,欢迎在评论区或者仓库指出!
预览地址
仓库地址:
前端仓库 | 后端仓库