如何创建一张被浏览器绝对信任的 https 自签名证书?
在一些前端开发场景中,需要在本地创建 https 服务,Node.js 提供了 https 模块帮助开发者快速创建 https 的服务器,示例代码如下:
const https = require('https')
const fs = require('fs')
const options = {
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
}
const server = https.createServer(options, (req, res) => {
res.writeHead(200)
res.end('hello world\n')
})
server.listen(8080)
与创建 http 服务最大的区别在于:https 服务需要证书。因此需要在 options 选项中提供 key
和 cert
两个字段。大部分前端不知道如何创建 key
和 cert
,虽然网上能查到一些 openssl 命令,但也不知道是什么含义。所谓授人以鱼不如授人以渔,这里先从一些基本概念讲起,然后一步步教大家如何创建一个可以被浏览器绝对信任的自签名证书。
密码学知识
首先要知道加密学中非常重要的一个算法:公开密钥算法(Public Key Cryptography),也称为非对称加密算法(Asymmetrical Cryptography),算法的密钥是一对,分别是公钥(public key)和私钥(private key),一般私钥由密钥对的生成方(比如服务器端)持有,避免泄露,而公钥任何人都可以持有,也不怕泄露。
一句话总结:公钥加密、私钥解密。
公私钥出了用于加解密之外,还能用于数字签名。因为私钥只有密钥对的生成者持有,用私钥签署(注意不是加密)一条消息,然后发送给任意的接收方,接收方只要拥有私钥对应的公钥,就能成功反解被签署的消息。
一句话总结:私钥加签、公钥验证。
由于只有私钥持有者才能“签署”消息,如果不考虑密钥泄露的问题,就不能抵赖说不是自己干的。
数字签名和信任链
基于数字签名技术,假设 A 授权给 B,B 授权给 C,C 授权给 D,那么 D 就相当于拿到了 A 的授权,这就形成了一个完整的信任链。因此:
- 信任链建立了一条从根证书颁发机构(Root CA)到最终证书持有人的信任路径
- 每个证书都有一个签名,验证这个签名需要使用颁发该证书的机构的公钥
- 信任链的作用是确保接收方可以验证数字签名的有效性,并信任签名所代表的身份
以 https 证书在浏览器端被信任为例,整个流程如下:
可以看到,在这套基础设施中,涉及到很多参与方和新概念,例如:
- 服务器实体:需要申请证书的实体(如某个域名的拥有者)
- CA机构:签发证书的机构
- 证书仓库:CA 签发的证书全部保存到仓库中,证书可能过期或被吊销。
- 证书校验方:校验证书真实性的软件,例如浏览器、客户端等。
这些参与方、概念和流程的集合被称为公钥基础设施(Public Key Infrastructure)
X.509 标准
为了能够将这套基础设施跑通,需要遵循一些标准,最常用的标准是 X.509,其内容包括:
- 如何定义证书文件的结构(使用 ANS.1 来描述证书)
- 如何管理证书(申请证书的流程,审核身份的标准,签发证书的流程)
- 如何校验证书(证书签名校验,校验实体属性,比如的域名、证书有效期等)
- 如何对证书进行撤销(包括 CRL 和 OCSP 协议等概念)
X.509标准在网络安全中广泛使用,特别是在 TLS/SSL 协议中用于验证服务器和客户端的身份。在 Node.js 中,当 tls 通道创建之后,可以通过下面两个方法获取客户端和服务端 X.509 证书:
- 获取本地 X.509 证书:getX509Certificate
- 获取对方 X.509 证书:getPeerX509Certificate
生成证书
要想生成证书,首要有一个私钥(private key),私钥的生成方式为:
$ openssl genrsa -out key.pem 2048
会生成一个 key.pem 文件,内容如下:
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCB....
-----END PRIVATE KEY-----
然后,还需要一个与私钥相对应的公钥(public key),生成方式:
$ openssl req -new -sha256 -key key.pem -out csr.pem
按照提示操作即可,下面是示例输入(中国-浙江省-杭州市-西湖-苏堤-keliq):
You are about to be asked to enter information that will be incorporated
int0 your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:CN
State or Province Name (full name) [Some-State]:Zhejiang
Locality Name (eg, city) []:Hangzhou
Organization Name (eg, company) [Internet Widgits Pty Ltd]:West Lake
Organizational Unit Name (eg, section) []:Su Causeway
Common Name (e.g. server FQDN or YOUR name) []:keliq
Email Address []:email@example.com
Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:123456
An optional company name []:XiHu
生成的文件内容如下:
-----BEGIN CERTIFICATE REQUEST-----
MIIDATCCAekCAQAwgY8xCzAJBgNVBAYTAkNOMREw...
-----END CERTIFICATE REQUEST-----
公钥文件创建之后,接下来有两个选择:
- 将其发送给 CA 机构,让其进行签名
- 自签名
自签名
如果是本地开发,我们选择自签名的方式就行了,openssl 同样提供了命令:
$ openssl x509 -req -in csr.pem -signkey key.pem -out cert.pem
Certificate request self-signature ok
subject=C = CN, ST = Zhejiang, L = Hangzhou, O = West Lake, OU = Su Causeway, CN = keliq, emailAddress = email@example.com
最终得到了 cert.pem,内容如下:
-----BEGIN CERTIFICATE-----
MIIDpzCCAo8CFAf7LQmMUweTSW+ECkjc7g1uy3jCMA0...
-----END CERTIFICATE-----
到这里,所有环节都走完了,再来回顾一下,总共生成了三个文件,环环相扣:
- 首先生成私钥文件
key.pem
- 然后生成与私钥对应的公钥文件
csr.pem
- 最后用公私钥生成证书
cert.pem
实战——信任根证书
用上面自签名创建的证书来创建 https 服务:
const options = {
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
}
启动之后,如果你在浏览器中访问,会发现出错了:
命名是 https 服务,为什么浏览器说不是私密连接呢?因为自签名证书默认是不被浏览器信任的,只需要将 cert.pem 拖到钥匙里面即可,然后修改为「始终信任」,过程中需要验证指纹或者输入密码:
如果你觉得上述流程比较繁琐,可以用下面的命令行来完成:
$ sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain cert.pem
实战——指定主题备用名称
然而,即使添加了钥匙串信任,问题似乎并没有解决,报错还是依旧:
仔细看,其实报错信息发生了变化,从原来的 NET::ERR_CERT_AUTHORITY_INVALID
变成了 NET::ERR_CERT_COMMON_NAME_INVALID
,这又是怎么回事呢?我们点开高级按钮看一下详细报错:
这段话的意思是:当前网站的 SSL 证书中的通用名称(Common Name)与实际访问的域名不匹配。
证书中会包含了一个通用名称字段,用于指定证书的使用范围。如果证书中的通用名称与您访问的域名不匹配,浏览器会出现NET::ERR_CERT_COMMON_NAME_INVALID错误。
一句话描述,证书缺少了主题备用名称(subjectAltName),而浏览器校验证书需要此字段。为了更好的理解这一点,我们可以用下面的命令查看证书的完整信息:
$ openssl x509 -in cert.pem -text -noout
输出结果如下:
Certificate:
Data:
Version: 1 (0x0)
Serial Number:
07:fb:2d:09:8c:53:07:93:49:6f:84:0a:48:dc:ee:0d:6e:cb:78:c2
Signature Algorithm: sha256WithRSAEncryption
Issuer: C = CN, ST = Zhejiang, L = Hangzhou, O = West Lake, OU = Su Causeway, CN = keliq, emailAddress = email@example.com
Validity
Not Before: Nov 15 06:29:36 2023 GMT
Not After : Dec 15 06:29:36 2023 GMT
Subject: C = CN, ST = Zhejiang, L = Hangzhou, O = West Lake, OU = Su Causeway, CN = keliq, emailAddress = email@example.com
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
Modulus:
00:ac:63:b1:f1:7a:69:aa:84:ef:9d:0e:be:c1:f7:
80:3f:6f:59:e1:7d:c5:c6:db:ff:2c:f3:99:12:7f:
...
Exponent: 65537 (0x10001)
Signature Algorithm: sha256WithRSAEncryption
Signature Value:
70:d9:59:10:46:dc:7b:b3:19:c8:bd:4b:c5:70:4f:89:b6:6a:
53:1c:f2:35:27:c8:0a:ed:a8:0a:13:1f:46:3e:e7:a7:ff:1f:
...
我们发现,这个证书并没有 Subject Alternative Name 这个字段,那如何增加这个字段呢?有两种方式:
指定 extfile 选项
$ openssl x509 -req \
-in csr.pem \
-signkey key.pem \
-extfile <(printf "subjectAltName=DNS:localhost") \
-out cert.pem
再次用命令查看证书详情,可以发现 Subject Alternative Name 字段已经有了:
...
X509v3 extensions:
X509v3 Subject Alternative Name:
DNS:localhost
X509v3 Subject Key Identifier:
21:65:8F:93:49:BC:DF:8C:17:1B:6C:43:AC:31:3C:A9:34:3C:CB:77
...
用新生成的 cert.pem 启动 https 服务,再次访问就正常了,可以点击小锁查看证书详细信息:
但是如果把 localhost 换成 127.0.0.1 的话,访问依然被拒绝,因为 subjectAltName 只添加了 localhost 这一个域名,所以非 localhost 域名使用此证书的时候,浏览器就会拒绝。
新建 .cnf 文件
这次我们新建一个 ssl.cnf 文件,并在 alt_names 里面多指定几个域名:
[req]
prompt = no
default_bits = 4096
default_md = sha512
distinguished_name = dn
x509_extensions = v3_req
[dn]
C=CN
ST=Zhejiang
L=Hangzhou
O=West Lake
OU=Su Causeway
CN=keliq
emailAddress=keliq@example.com
[v3_req]
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName=@alt_names
[alt_names]
DNS.1 = localhost
IP.2 = 127.0.0.1
IP.3 = 0.0.0.0
然后一条命令直接生成密钥和证书文件:
$ openssl req -newkey rsa:2048 -new -nodes -x509 -days 3650 \
-config openssl.cnf \
-keyout key.pem \
-out cert.pem
再次查看证书详情,观察 Subject Alternative Name 字段:
...
X509v3 extensions:
X509v3 Key Usage:
Digital Signature, Non Repudiation, Key Encipherment
X509v3 Subject Alternative Name:
DNS:localhost, IP Address:127.0.0.1, IP Address:0.0.0.0
X509v3 Subject Key Identifier:
B6:FC:1E:68:CD:8B:97:D0:80:0E:F1:18:D3:39:86:29:90:0B:9D:1F
...
这样无论是访问 localhost 还是 127.0.0.1 或者 0.0.0.0,浏览器都能够信任。
来源:juejin.cn/post/7301574056720744483
云音乐贵州机房迁移总体方案回顾
一、背景
2023年确定要将云音乐整体服务搬迁至贵州机房,项目需要在各种限制条件下,保障2000+应用、100w+QPS的服务稳定迁移,是云音乐历史上规模最大、人员最多、难度最高的技术项目。在此过程中,解决了大量历史技术债务,同时化解了大量新增系统性风险。以下为总体方案回顾。
二、项目难点
- 迁移规模大
- 此次需要云音乐以及旗下独立App的服务均整体迁移至贵州。涉及2000+应用、100w+QPS的稳定迁移,同时涉及中间件、存储、机房、三方依赖服务等整体的搬迁,搬迁规模大。
- 业务复杂度高
- 场景复杂。迁移规模大,带来更广的业务场景覆盖。而不同的场景对数据一致性要求不同、延迟敏感度不同。迁移方案需要考虑各种场景带来的问题,并提供标准化的解决方案。
- 服务间依赖复杂。此次带来约2000+应用的搬迁,各服务间的调用和依赖情况复杂,在分批迁移方案中需要协调,以及解决迁移期间跨机房30msRT上升带来的问题。
- 历史积弊多
- 贵州迁移前,存在诸多历史技术积弊,影响着全站整体的稳定性。
- 新增风险大
- 贵州迁移带来诸多新增风险,且风险大、解决难度高。
- 部分场景无法做到真实环境全流程预演。
- 在基础技术建设上,也有一些不足的情况,影响整体搬迁执行效率、迁移准确性。
- 限制条件严苛
- 云音乐有着大量的用户基数,此次搬迁要求:不停机迁移、不产生P2及以上事故。除此之外还有机器、网络带宽、网络稳定性、网络RT、迁移方案等限制条件。
- 事项推进&协调难度大
- 此次搬迁规模大,同样,参与人员规模大,整体协调难度大
- 此外带来较多的人因风险。可能因极小的细节未执行到位,就会造成全局事故。
三、重点限制&要求
- 尽可能少采购或不采购额外的机器,贵州和杭州无法完全对等部署。
- 杭州与贵州的长传带宽控制在200Gbps以内,且存在闪断的可能性,各迁移方案需要重点考虑闪断带来的影响。
- 贵州机房与杭州机房之间网络延迟约30ms,各方迁移方案需重点考虑机房延迟带来的影响。
- 业务可用性要求:不影响核心重点业务场景的可用性,不出现P2及以上事故。
- 控制迁移方案对业务代码的侵入。
四、分批方案
1. 分批的原则
1.1 团队/领域间解耦
大团队/领域之间的迁移方案尽可能解耦,分不同批次搬迁。好处:
- 可以将问题拆分、领域清晰。
- 大数据、算法、云音乐技术中心串行搬迁,可以实现机器资源池共享,降低机器采购成本。
- 降低单一团队/领域切流时问题处理复杂度。
1.2 服务端流量自闭环
云音乐服务端需要将流量闭环在同一个机房,避免产生跨区域调用。
云音乐经过微服务之后,目前存在千+服务,各服务间依赖复杂。在贵州机房与杭州机房之间网络延迟约30ms的背景下,每产生一次跨区域调用,则RT上升30ms。
1.3 C端优先
优先迁移C端相关的应用及其资源,其次B端。
关于此处,会有同学认为优先B端可能会更稳,但优先采用B端优先,会有如下问题:
- B端服务搬迁后,腾挪的机器有限。
- B端服务与C端服务相差较大,即使B端服务先行搬迁无问题,也不足以证明C端服务就一定没问题。
对于如何保障C端服务搬迁的稳定性,在文章后续章节展开。
1.4 在可用资源范围内
迁移期间,需要在贵州准备与杭州同等规模的机器资源,因此批次不可能不受到资源的限制。其主要受限制资源为:
- 机器资源
- 贵州&杭州的长传带宽资源
因此,按照以上原则进行分批后,若资源仍不足,再根据团队/领域拆分出第二批
2. 最终分批方案
基于以上原则,最终分批方案如下所示
- 大数据、算法、技术中心串行搬迁。
- 心遇因强依赖云信IM服务,与云信服务独立搬迁
- 技术中心应用基本一批次全部搬迁完成。
- 技术中心的转码、公技侧后台、质量侧系统在第二批次搬迁完成。
五、切流方案
1. 切流的原则
1.1 可灰度
能够按照用户ID、设备ID、IP、流量标几个维度逐步灰度切流。
- 利于预热。在服务启动后,缓存、连接池需要随请求逐步预热,若流量直接全部打过来,可能会将服务打垮。
- 利于测试。能够灰度测试整体功能,避免大面积异常。
1.2 可回滚
尽管做了各种稳定性保障来避免回滚,但是如遇到极端情况,仍有整体回滚的可能性。因此搬迁方案必须可回滚。
1.3 控制长传带宽
在切流过程中,杭州和贵州之间会有大量的服务访问、数据传输,从而可能突破长传带宽200Gbps的限制。因此切流方案中必须减少不必要的跨区域流量。
2. 切流方案
2.1 切流点选择
服务端整体通用架构简化后,如上图所示,因此有如下几个切入点:
- 客户端切流。客户端通过动态切换域名配置,可实现流量的切换。切流算法可以与网关使用保持一致,我们在贵州迁移中就采用了此方案,从而大幅降低贵州与杭州的长传带宽。
- DNS切换。因DNS存在缓存过期,不适合作为流量控制的主要手段。在贵州迁移中,我们主要用其作为长尾流量的切换的手段。
- 四层LB切流、Nginx切流。主要由SA侧负责,因自动化和操作复杂度等因素,在贵州迁移中,四层LB切流只用于辅助切流手段,Nginx因过高的人工操作复杂度,不用于切流。
- 网关切流。网关作为服务端广泛接触的首要流量入口,其系统建设相对完善、自动化程度较高,因此作为主要切流手段。在此次迁移中,网关支持按用户ID、设备ID、IP进行按比例切流。
- 定时任务、MQ切换。主要用于定时任务、MQ的流量切换。
- RPC流量控制。RPC流量路由策略与网关保持一致,依据切流比例,进行RPC流量调用。从而避免跨机房RT的不可控。
- 存储层切换。主要负责存储的切换。
2.2 存储层迁移策略
云音乐业务场景较多,不同场景下对数据一致性的要求也不一样,例如:营收下的订单类场景需要数据强一致性,而点赞需要数据最终一致性即可。
在涉及不同的存储时,也有着多种多样的迁移策略。对此,中间件以及各存储层支持了不同的迁移策略选择,各个业务基于不同的场景,选择正确的策略。迁移策略主要如下:
类型 | 迁移策略 |
---|---|
DB | 读本地写远程、读远程写远程、读本地写本地、禁写 |
Redis | 读写远程+需要禁写、读本地写远程+需要禁写、读写本地 |
Memcached | 异步双写、同步双写、不同步 |
2.3 切流步骤
对以上切入点再次进行分类,可再次简化为流量层切流、存储层切换。在正式切流时,我们按照如下步骤进行切流。
3. 回滚方案
先存储层按序切换,然后流量层按序切换。
六、稳定性保障&治理
1. 全域的稳定性风险
- 全域的稳定性风险。我们在做一般的活动稳定性保障时,一般从活动的主链路出发,再梳理相关依赖,从而整理出稳定性保障&治理的重点。而这种方法确不适用于贵州机房迁移,从前面的分批概览图可得知:此次贵州机房迁移带来全域的稳定性风险。
- 墨菲定律:"如果一件事情有出错的可能性,那么它最终一定会出错。"
- 业界没有类似的经验可参考
因此整个项目组也在摸着石头过河,在此过程中,既有大的方案的设计,也有细枝末节的问题发现和推进处理。总结起来,我们总共从以下几个方面着手进行稳定性保障:
- 信息梳理&摸查
- 新增风险发现&处理
- 历史技术债务处理
- 标准化接入
- 监控告警增强
- 应急预案保障
- 业务侧技术方案保障
- 杭州集群下线保障
2. 信息梳理&摸查
盘点梳理机器资源情况、网络带宽、迁移期间服务可用性要求等全局限制条件,从而确定分批方案、迁移思路。
2.1 机器资源盘点
主要盘点核数、内存。在此过程中,也推进了资源利用率优化、废弃服务下线等事宜。 通过如下公式计算机器资源缺口:搬迁机器缺口 = 搬迁所需数量 -(可用数量+可优化数量)
2.2 长传带宽盘点
需要控制云音乐的长传带宽总量 <= 相对安全的带宽量 相对安全的带宽量 = (长传带宽总量 / 2 x 0.8) - 已被占用带宽量
2.3 迁移期间服务可用性要求
若业务允许全站停服迁移、或仅保障少量核心服务不挂,那么整体迁移方案会简单很多。因此业务对迁移期间的可用性要求,关乎着搬迁方案如何设计。 最终讨论后确定,需要:迁移不产生P2及以上事故
2.4 服务间跨区域调用RT摸查
基于Trace链路,预测分批情况下RT增长情况。
3. 新增系统性风险
此次贵州迁移主要带来的新增系统性风险是:
- 因公网质量问题,带来迁移后用户体验差的风险。
- 因跨机房延迟30ms ,带来的业务侧应用雪崩风险。
- 因跨机房传输网络不稳定,带来的整体系统性风险。
- 因杭州和贵州机房同时部署,带来的服务节点数量、API数量、RPC数量翻倍风险
- 因大规模数据变更,带来的系统性能风险。
- 因新机房建设、搬迁,带来的底层基础设施风险。
- 因全域团队协作、大范围配置变更&发布,带来的人因操作、协作风险。
3.1 因公网质量问题,带来迁移后用户体验差的风险
贵州公网质量如何?迁移至贵州之后是否会因公网质量问题,导致用户体验差?由于云音乐用户基数大,且注重用户体验,这个是必须提前摸清的问题。若公网质量真的存在较大问题,云音乐可能会停止贵州迁移项目。
对此,我们通过如下方式进行了公网质量验证和保障:
- 通过客户端预埋逻辑,抽样检测同时请求杭州和贵州机房的RT差异。
- 通过RT的差异,再下钻分析杭州和贵州机房的差异点。
- 解决或排除机房、客户端、域名配置等差异,最终得出公网质量的差异。
- 在正式切流前,解决完成客户端、机房等差异,保障整体网络请求质量。
- 通过QA侧的整体测试。
3.2 因跨机房延迟30ms ,带来的业务侧应用雪崩风险
云音乐C端服务当前的RT普遍在5~70ms之间,若增加30ms,可能会导致请求堆积、线程池打爆等风险。为避免此风险,我们从如下几个方面入手:
- 尽可能同一批次搬迁,避免长期跨机房调用。
- 同一批次应用,基于用户ID、设备ID、IP进行Hash,实现同机房调用优先。
- 无法同一批次搬迁的应用。
- 确保会只跨一次,避免因循环调用等原因导致的多次跨机房。
- 需提供降级方案,对服务弱依赖。
- 服务需通过QA侧的测试。
3.3 因跨机房传输网络不稳定,带来的整体系统性风险
跨机房网络的现状和参考数据:
- 共计2条线,单条带宽为:100Gbps,但建议保持单条利用率在80%及以下。
- 参考网易北京与杭州的长传带宽质量。
- 可能会出现单条中断的情况,在网络侧的表现为网络抖动。若单条线中断,那么发生故障的请求会重连至另一条线。
- 极低概率出现2条线全部中断的情况。
基于以上现状,需要重点考虑并解决:
- 各中间件、存储在切流期间,长传网络出现问题时的表现、应对和兜底措施。例如ZK重连、重连失败后的重连风暴问题。
- 各服务在切流完成后,若仍长期使用长传网络,若长传网络出现问题的表现、应对和兜底措施。
在贵州迁移项目中,我们对以上重点问题进行了梳理和解决,并制定了各种应急预案和极端情况下的回滚方案。
3.4 因杭州和贵州机房同时部署,带来的服务节点数量、API数量、RPC数量翻倍风险
因杭州和贵州机房同时部署,带来的服务节点数量、API数量、RPC数量翻倍风险
在服务节点数量、API数量、RPC数量翻倍后,主要对底层依赖带来连接、重连上的冲击,以及原有连接数上限的冲击。
在我们实际搬迁中,也因遗漏了这一点,导致线上ZK出现瓶颈,进而ZK挂掉的问题。其主要表现为在网关场景下存在数据推送瓶颈。最终通过网关侧的ZK拆分解决该问题。
除此之外,DB、Memcached、Redis、MQ等资源的连接数也可能会超过原先设定的上限,需要评估后进行调整。
3.5 因大规模数据变更,带来的系统性能风险
大规模数据变更的场景包含但不限于:
- 批量调整配置中心值,因达到配置中心的性能瓶颈,导致配置变更时间过长,或服务挂掉。
- 批量的服务部署、重启,因达到K8S、构建机的性能瓶颈,导致部署、重启时间过长,或服务挂掉。
- 对迁移当晚核心路径上的服务进行集中访问、操作,因达到服务的性能瓶颈,导致访问超时、白屏、数据延迟、或服务挂掉的问题。
针对以上风险,我们重点对配置中心、K8S、贵州迁移管控平台等系统进行了性能优化,以支撑整体迁移。
3.6 因新机房建设、搬迁带来的底层基础设施风险。
因新机房建设、搬迁带来的底层基础设施风险包含但不限于:
- 同城双活能力的缺失。为应对此风险,我们在逻辑上继续保留同城双活的能力,并暂时通过机房不同楼层的部署架构,来尽可能弥补同城双活能力的缺失。
- 机器上架、环境搭建、网络传输等需确保达到验收标准。为应对此风险,运维侧提供相关方案保障整体环境,并最终通过业务侧QA验收。
3.7 因全域团队协作、大范围变更&发布,带来的人因操作、协作风险
在贵州迁移前,已经有多次发生因配置变更错误带来的事故。而此项目带来从未有过的全域迁移,全域协作,大范围变更&发布,风险不可谓不高。在此过程中,通过了许多方式来保障事项的落地,其中比较关键的点,也是项目成功的关键点包括:
- 各部门领导与同事的支持。
- 分工明确。在战略、战术、细节、事项推进等多个点均有相关人员把控,各司其职。
- 各项信息的细化梳理&定位。
- 定期的沟通协作会议,通过敏捷式项目管理,进行滚动式问题发现。
- 问题发现、治理、验证必须闭环。
- 尽可能中心系统化、自动化处理。无法自动化的,则提供标准化实施手册。
- 重点问题,case by case,one by one。
4. 历史技术债务处理
在贵州迁移项目中,比较突出的历史债务处理有:
- ZK强依赖问题
- 在线业务Kafka迁移Nydus。
- 配置硬编码
- 服务间依赖改造
- 资源优化&控制
- 心遇依赖拆分
- 元信息不准确
- 组件版本过于陈旧问题
- 测试环境自动化部署成功率低
- 租户多集群拆分为多应用
4.1 ZK强依赖问题
ZK的不稳定已导致云音乐最高出现P1级事故,在贵州迁移项目中,因网络环境、机房环境、迁移复杂度等因素,ZK服务挂掉的概率极大,因此必须不能对其强依赖。
最终中间件侧对其改造,支持ZK发生故障时,其注册信息降级到本地内存读取。并推进相关依赖方进行升级改造。
4.2 在线业务Kafka迁移Nydus。
Nydus作为云音乐主力MQ产品,相较开源Kafka有更好的监控、运维等能力,Kafka在云音乐在线业务中已不再推荐使用。在贵州迁移中,MQ也需要进行两地切换/切流。
主要收益:
- 在线业务稳定性
- Kafka机器资源回收
- MQ切流特性&历史债务收敛
在推进层面:
- 第一里程碑:生产者完成双写
- 第二里程碑:消费者完成双消费
- 第三里程碑:完成废弃TOPIC下线、代码下线等收尾工作
4.3 配置硬编码
在贵州迁移项目中,需要做大量的配置迁移、变更。其主要为:机房名、集群名、机器IP、机器Ingress域名的变化。而这些在配置中心、代码、自动化脚本、JVM参数中均有存在,此外,IP黑白名单还可能涉及到外部厂商的改造变更。
在具体推进上,采用自动化扫描+人工梳理结合,并辅以标准化改造指引文档。
- 自动化扫描:通过代码扫描、配置中心扫描、JVM参数扫描、连接扫描等方式进行问题发现。
- 人工梳理:外部厂商、不受Git管控的脚本、以及运维侧的配置(例如:存储层访问权限的黑白名单等)、以及自动化扫描可能的遗漏,由各研发、运维人员再次自行梳理。
4.4 服务间依赖改造
核心应对杭州与贵州跨机房30ms RT和长传网络不稳定的风险。对循环调用、不合理依赖、强依赖进行改造。
- 减少不必要依赖。
- 必须不能出现服务跨机房强依赖。
- 不能因循环调用导致跨机房RT飙升。
4.5 资源优化&控制
因贵州需要与杭州同等容量部署,可能存在资源不足的情况。对此需要:
- 统一服务的资源利用率标准,推进资源利用率改造
- 对部分服务进行合并、下线、缩容处理。
4.6 心遇依赖拆分
因心遇强依赖云信,且云信IM为心遇核心业务功能,最终确定心遇为独立批次搬迁。因此心遇依赖的中台服务、存储、算法&大数据相关任务,均需拆分出来,不能与云音乐耦合,否则会产生跨机房调用,影响服务稳定性。
4.7 元信息不准确
在此次迁移中,存在较多的元信息不准确的问题,例如:
不足项 | 解释 |
---|---|
应用的元信息需要补充、更新 | 1. 应用归属的团队信息不准确 2. 应用的废弃、待废弃状态未知 3. 测试应用、非业务应用信息偏杂乱 |
应用团队归属信息多处维护,未统一 | 应用在多个平台均有维护,且均存在维护不准确的问题 |
应用的各项依赖信息不全 | 应用依赖的db、redis、memcached资源,以及在配置中心的key无法全面准确拉取 |
应用的各项依赖信息可视化、系统化建设不足 | 1. 应用依赖的组件版本、依赖的存储资源等,缺乏友好的可视化查询能力。 2. 各项信息之间的关联性建设不足 |
底层中间件、存储元信息不全 | 1. 不同的ZK集群的用处缺乏统一维护。 2. 各项元信息反查调用源IP、集群、应用、团队、负责人的能力不足 |
以上问题在迁移中,通过脚本、1对1沟通确认、手动梳理等多种方式进行了临时处理,在贵州迁移后,仍需再全面的系统性规划。
4.8 组件版本过于陈旧问题
有较多的应用长期不升级,与最新版本跨度较大,存在较多的兼容性问题,需要人工进行升级处理。升级流程大致如下:
在迁移中期,我们进行了自动升级平台建设,基本支持以上升级流程自动化。
4.9 测试环境自动部署成功率低
因此次迁移涉及全部的应用在不同环境的部署,全部人工操作的效率过低,因此我们在非线上环境均由脚本自动化部署,而测试环境由于维护不足,部署成功率较低。
4.10 租户多集群拆分为多应用
当前贵州迁移时整体会按照应用维度进行迁移、切流到贵州。因此对于中台租户型应用、多地域注册类型的应用需要拆分。
5. 标准化接入
除了以上提到的历史技术债务处理和新增系统性风险,公共技术侧大都提供了标准化的接入、改造治理方式。例如:
- 贵州迁移中间件方案汇总。涵盖所有涉及中间件的迁移、切流、迁移策略、接入等指导方案。
- 贵州迁移升级指导。涵盖自动升级与手动升级、脚手架应用与非脚手架应用的升级方案。
- 贵州迁移线上部署指导。涵盖贵州线上部署前的各项必要准备事项,以及特殊应用的注意事项。
- 贵州迁移监控大盘观测指导。涵盖各类迁移监控的观测指导。
- 中台、多地域注册拆分指导。涵盖中台租户、多地域注册类型应用的拆分指导方案,以及整体的拆分流程、验证要点等。
- ddb、redis、memcached、KSchedule等非标治理。涵盖各中间件、存储的非标风险列表、处理办法等。
- 杭州集群下线指导。涵盖杭州集群如何观察、缩容、下线、机器回收的指导方案。
6. 监控告警
在监控告警层面,主要提供了:
- 贵州迁移整体大盘监控。提供了迁移相关全局比例,异常流量,异常比例,能够区分是迁移导致的还是本身杭州服务就有问题导致。同时集成资源层相关指标,判断是单个资源有问题还是全部资源有问题。
- 贵州迁移应用监控。提供了单个应用的贵州迁移监控,应用贵州杭州流量比例,异常流量,异常比例,能够区分是贵州还是杭州的问题。同时有资源相关的指标。
- 杭州集群与贵州集群的哨兵监控对比分析。提供指定应用的杭州和贵州集群在CPU利用率、线程池满、异常比例、RT超时等维度的对比。
- 全局/应用的SLO监控。提供核心指标受损监控。
- 应用层面的系统监控。研发可通过哨兵、APM来查看定位具体的问题。
7. 应急预案
在贵州迁移期间,基于以上风险,主要准备如下应急预案:
- 客户端截流。在开启后,客户端将访问本地或CDN缓存,不再向服务端发送请求。
- 全站服务QPS限流至安全阈值。在开启后,全站的后端服务将限流调整至较低的安全阈值上,在极端情况下,避免因跨机房RT、跨机房传输、跨机房访问等因素的性能瓶颈引起服务端雪崩。
- 长传带宽监控&限流。在开启后,部分离线数据传输任务将会被限流。保障在线业务的带宽在安全水位下。
- 回滚方案。当出现重大问题,且无法快速解决时,逐步将存储、流量切回杭州。
- 外网逃生通道。当出现长传网络完全中断,需要回滚至杭州。通过外网逃生通道实现配置、核心数据的回滚。
- 业务领域内的应急预案。各业务领域内,需要考虑切流前的主动降级预案、切流中的应急预案。
- 批量重启。当出现局部服务必须通过重启才能解决的问题时,将会启用批量重启脚本实现快速重启。当出现全局服务必须通过重启才能解决问题时,需要当场评估问题从而选择全量重启或全量回滚至杭州。
8. 业务技术侧方案
业务技术侧方案重点包含但不限于:
- 应用搬迁范围、搬迁批次梳理明确。当上下游依赖的应用处于不同批次时,需要跨团队沟通协调。
- 明确业务影响,从而确定各应用的中间件、存储迁移策略。
- 历史技术债务处理
- 标准化接入
- 核心场景稳定性保障方案
- 核心指标监控建设完善。
- 切流SOP。包括切流前(前2天、前1天、前5分钟)、切流中、切流后各阶段的执行事项。
- 切流降级方案、应急预案
- 切流停止标准
9. 杭州集群下线
在服务迁移至贵州后,若杭州仍有流量调用,需排查流量来源,并推进流量下线或转移至贵州。先缩容观察,无正常流量、CDN回源等之后,再做集群下线。
七、测试&演练
此次贵州迁移,在各应用标准化治理之后,通过系统批量工具完成贵州各项环境的搭建、测试环境的批量部署。
1. 测试环境演练
1.1 准备事项
在测试演练开始前,我们重点做了如下准备:
- 贵州测试环境批量创建。通过迁移工具,实现贵州测试集群的批量创建、配置批量迁移等。
- 应用自动化升级。通过自动升级平台,实现大规模应用的批量升级,支持了各组件、各应用的多次快速验证、快速升级。
- 测试环境自动化部署。通过自动化部署脚本,为支持测试环境能够多次、高效演练。
- SOP梳理&平台建设。通过SOP平台,将SOP文档沉淀为系统能力,实现各SOP能力的系统化。
- 迁移监控大盘建设。通过细化梳理监控指标,构建监控大盘,掌握各应用、各组件在切流期间的表现。
1.2 执行步骤
在测试环境演练,总体思路是逐步扩大验证范围,最终达到全局基本功能基本验证通过。以下为主要演练顺序,每一步视执行结果,再选择是否重复执行。
顺序 | 验证事项 |
---|---|
1 | 验证中间件内部逻辑是否正确: 1. 网关、RPC、存储层路由策略是否正确。 2.验证监控大盘是否正确 3.验证SOP平台是否正确 4.... |
2 | 验证存储层切换是否正确 |
3 | 逐一对各业务团队进行演练: 1.加深各团队对切流能力的感知。 2.验证收集中间件、存储在各领域的表现。 3.验证各团队、各领域迁移策略的合理性 |
4 | 对BFF、FaaS等特殊应用类型进行演练 |
2. 线上环境演练
因测试环境和线上环境仍存在较大的差异,需要摸清线上真实情况,在演练原则和演练目标上均较测试环境演练有更严格、细致的要求。
2.1 演练原则
- 不对线上数据产生污染;
- 不产生线上 P2 以上事故。
2.2 演练目标
分类 | 目标内容 |
---|---|
公技演练目标 | 1. 切流验证,网关,rpc,贵州迁移大盘监控 2.网关切流比例、快慢,数据库 ddb 贵州跨机房建连对业务影响 3.端上切流,网关切流验证 |
业务演练目标 | 1.流量切换,贵州跨机房对业务影响 2.业务指标和SLO 3.业务预案有效性验证 4.RT变化情况 |
存储演练目标 | 1.ddb 复制延迟,连接数(由于跨机房创建DDB连接非常慢, 主要观察流量到贵州后新建连接对应用和数据库影响及恢复情况) 2.redis数据同步、整体表现 |
网络演练目标 | 1.跨机房延迟情况 2.跨机房带宽实际占用 3.网络带宽占用监控 |
2.3 演练终止条件
- P0、P1 核心场景 SLO 95%以下;
- 用户舆情增长波动明显;
- 跨机房网络大规模异常;
- 大量业务指标或者数据异常;
- 贵州流量达到预定 90%。
3. 独立App迁移验证
在云音乐主站正式切流前,先对云音乐旗下独立App进行了线上搬迁验证,保障云音乐迁移时的稳定性。
八、系统沉淀
1. SOP平台
SOP即标准作业程序(Standard Operating Procedure),源自传统工业领域,强调将某项操作以标准化、流程化的方式固化下来。
SOP平台将标准化、流程化的操作进行系统化呈现,并对接各中间件平台,实现操作效率的提升。在贵州迁移过程中,能够实现多部门信息同步、信息检查,并显著降低批量操作的出错概率、执行效率,降低人因风险。同时也可为后续其他大型项目提供基础支撑。
2. 自动升级平台
自动升级平台串联代码升级变更、测试部署、测试验证、线上发布、线上检测,实现升级生命周期重要节点的自动化。在贵州迁移过程中,显著提升整体升级、验证、部署效率。同时可为后续的大规模组件升级、组件风险治理、组件兼容性摸查、Sidecar式升级提供基础支撑。
九、不足反思
1. 元信息建设仍然不足
精准筛选出每项事宜涉及的范围,是顺利进行各项风险治理的前提条件。在此次贵州机房迁移中也暴露出元信息建设不足的问题。
不足项 | 解释 |
---|---|
应用的元信息需要补充、更新 | 1. 应用归属的团队信息不准确 2. 应用的废弃、待废弃状态未知 3. 测试应用、非业务应用信息偏杂乱 |
应用团队归属信息多处维护,未统一 | 应用在多个平台均有维护,且均存在维护不准确的问题 |
应用的各项依赖信息不全 | 应用依赖的db、redis、memcached资源,以及在配置中心的key无法全面准确拉取 |
应用的各项依赖信息可视化、系统化建设不足 | 1. 应用依赖的组件版本、依赖的存储资源等,缺乏友好的可视化查询能力。 2. 各项信息之间的关联性建设不足 |
底层中间件、存储元信息不全 | 1. 不同的ZK集群的用处缺乏统一维护。 2. 各项元信息反查调用源IP、集群、应用、团队、负责人的能力不足 |
2. 各项元信息的创建、更新、销毁标准化、系统化
在贵州迁移过程中,做了历史技术债务处理、标准化接入方式,后续可针对各项元信息的创建、更新、销毁进行标准化、系统化建设。例如:
- 应用、集群的创建和销毁需要前置校验、审批。以及后期的架构治理扫描。
- 借助组件升级平台,实现组件发布、升级的标准化、系统化。
- DB、Redis、Memcached、ZK的申请、使用、接入等标准化、防劣化。
3. 应用配置标准化
目前应用可做配置的入口有:配置中心、properties文件、props文件、JVM参数、硬编码。不同的中间件提供出的配置方式也各有不同,所以各应用的配置比较五花八门。因此可做如下改进:
- 明确各种配置入口的使用标准。比如:什么时候建议用配置中心?什么时候建议用JVM参数?
- 在组件提供侧、应用研发侧均有一定的宣贯、提示。避免配置方式过于杂乱。
- 提供配置统一上报的能力。助力元信息的建设。
4. 批处理能力需再进一步增强
在贵州机房迁移中,除了SOP平台和自动升级平台的系统沉淀外,业务中间件、Horizon部署平台都提供了一定的工具支撑,从而在一定程度上提升了整体迁移的效率。在之后,随着对效率、系统间融合的要求的提高。需要继续在功能、性能、稳定性等多个层面,继续对批处理、系统间融合进行系统化建设。例如:
- 批量拉取、筛选指定条件的应用以及相关依赖信息。
- 基于指定的环境、团队、应用、集群等维度,进行服务的批量重启、部署。此处需要进一步提升测试环境部署成功率
- 基于指定的应用、集群等维度,进行批量的服务复制、配置复制。
5. ZK稳定性、可维护性优化
在贵州迁移中,ZK的问题相对突出,对此也投入了比较多的人力去排查、解决以及推进风险治理。后续仍需要在ZK的稳定性、可维护性上探讨进一步优化的可能性:
- ZK元信息的维护和使用标准。明确各ZK集群的用处、各ZK Path的用处,ZK集群间隔离、复用的标准,并推进相关标准化治理。
- ZK故障时,因开启降级至内存,业务无法重启服务。若故障期间叠加其他事故,则会导致其他事故被放大。
- 其他稳定性、可维护性梳理
6. 公技侧稳定性保障长效机制和系统化建设
尽管在贵州机房迁移中,做了大量的稳定性保障措施,但依赖每个研发对各自负责领域的理解、运维能力。是否能在团队管理、设施管理、服务管理、稳定性管理、架构设计等多方面,探索出一套可持续的长效保障机制?并进行一定的稳定性系统化建设?从而避免点状问题随机发生。
7. 组件生产、发布、治理能力增强
贵州迁移中涉及大量的组件变更与发布,以及业务侧组件升级与治理。组件可以从生产侧和使用侧进行分析,而组件生命周期主要由2条主线贯穿:
- 组件生产发布线:组件的生产、测试验证、发布。
- 组件风险治理线:风险定义、风险发现、升级推进、升级验证 依据此分类,服务端的组件管理仍有较多可提升空间。
来源:juejin.cn/post/7389952004791894016
JavaScript实现访问本地文件夹
这个功能放在之前是不可能实现的,因为考虑到用户的隐私,但是最近有一个新的api可以做到这一点。下面来进行一个简单的功能实现。
如何选择文件夹
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<button>打开文件夹</button>
<script>
var btn = document.querySelector('button');
btn.onclick=function() {
showDirectoryPicker()
}
</script>
</body>
</html>
我们调用showDirectoryPicker这个函数就可以实现一个选择文件夹的功能。
showDirectoryPicker()
options
可选
选项对象,包含以下属性:
id
通过指定 ID,浏览器能够记住不同 ID 所对应的目录。当使用相同的 ID 打开另一个目录选择器时,选择器会打开相同的目录。mode
字符串,默认为"read"
,可对目录进行只读访问。设为"readwrite"
可对目录进行读写访问。startIn
一个FileSystemHandle
对象或者代表某个众所周知的目录的字符串(如:"desktop"
、"documents"
、"downloads"
、"music"
、"pictures"
、"videos"
)。用于指定选择器的起始目录。
返回值
一个 Promise
对象,会兑现一个 FileSystemDirectoryHandle
(en-US) 对象。
异常
AbortError
当用户直接关闭了目录选择器或选择的目录是敏感目录时将会抛出 AbortError。
如何得到文件夹中的文件/子文件夹
首先对于上面所写的东西,我们进行try catch的优化
try {
// 获得文件夹的句柄
const handle = await showDirectoryPicker();
}
catch {
//用户拒绝查看文件
alert('访问失败')
}
之后我们来看一下这个headler打印出来是什么
句柄的简单解释
对于“句柄”,在下一直停留在一知半解的认识层面,近日在下学习Windows编程,决定趁此机会将句柄彻底搞清楚。查阅了一些网络上的资料,发现网络上的讲解大概可以分为两类:一种是以比喻、类比的方式说明,这种方法虽然形象易懂,但并没有从原理上、本质上加以揭示,让人仍然想问“为什么?”、“怎么实现?”。另一种是给出源代码,无可厚非,这当然是最本质的说明了,但这样一来,又显得不够直观,初学者理解起来有一定的难度。鉴于此,在下尽微末之能,结合自己的愚见,在两者之间折中,用图解的方式来将原理呈现出来,做到一目了然。
这里需要说明:
1.这里将句柄所能标识的所有东西(如窗口、文件、画笔等)统称为“对象”。
2.图中一个小横框表示一定大小的内存区域,并不代表一个字节,如标有0X00000AC6的横框表示4个字节。
3.图解的目的是为了直观易懂,所以不一定与源码完全对应,会有一定的简化。
让我们先看图,再解释。
其中,图1是程序运行到某时刻时的内存快照,图2是程序往后运行到另一时刻时的内存快照。红色部分标出了两次的变化。
简单解释:
Windows是一个以虚拟内存为基础的操作系统,很多时候,进程的代码和数据并不全部装入内存,进程的某一段装入内存后,还可能被换出到外存,当再次需要时,再装入内存。两次装入的地址绝大多数情况下是不一样的。也就是说,同一对象在内存中的地址会变化。(对于虚拟内存不是很了解的读者,可以参考有关操作系统方面的书籍)那么,程序怎么才能准确地访问到对象呢?为了解决这个问题,Windows引入了句柄。
系统为每个进程在内存中分配一定的区域,用来存放各个句柄,即一个个32位无符号整型值(32位操作系统中)。每个32位无符号整型值相当于一个指针,指向内存中的另一个区域(我们不妨称之为区域A)。而区域A中存放的正是对象在内存中的地址。当对象在内存中的位置发生变化时,区域A的值被更新,变为当前时刻对象在内存中的地址,而在这个过程中,区域A的位置以及对应句柄的值是不发生变化的。这种机制,用一种形象的说法可以表述为:有一个固定的地址(句柄),指向一个固定的位置(区域A),而区域A中的值可以动态地变化,它时刻记录着当前时刻对象在内存中的地址。这样,无论对象的位置在内存中如何变化,只要我们掌握了句柄的值,就可以找到区域A,进而找到该对象。而句柄的值在程序本次运行期间是绝对不变的,我们(即系统)当然可以掌握它。这就是以不变应万变,按图索骥,顺藤摸瓜。
**所以,我们可以这样理解Windows **句柄:
数值上,是一个32位无符号整型值(32位系统下);逻辑上,相当于指针的指针;形象理解上,是Windows中各个对象的一个唯一的、固定不变的ID;作用上,Windows使用句柄来标识诸如窗口、位图、画笔等对象,并通过句柄找到这些对象。
下面,关于句柄,再交代一些关键性细节:
1.所谓“唯一”、“不变”是指在程序的一次运行中。如果本次运行完,关闭程序,再次启动程序运行,那么这次运行中,同一对象的句柄的值和上次运行时比较,一般是不一样的。
其实这理解起来也很自然,所谓“一把归一把,这把是这把,那把是那把,两者不相干”(“把”是形象的说法,就像打牌一样,这里指程序的一次运行)。
2.句柄是对象生成时系统指定的,属性是只读的,程序员不能修改句柄。
3.不同的系统中,句柄的大小(字节数)是不同的,可以使用sizeof()来计算句柄的大小。
4.通过句柄,程序员只能调用系统提供的服务(即API调用),不能像使用指针那样,做其它的事。
再回归正题。
处理句柄函数
async function processHandler(handle) {
if (handle.kind==='file'){
return handle
}
handle.children=[]
const iter = await handle.entries();//获得文件夹中的所有内容
//iter:异步迭代器
for await (const info of iter){
var subHandle = await processHandler(info[1]);
handle.children.push(subHandle)
}
return handle
}
如何得到文件内容
const root = await processHandler(handle);
// 获得文件内容
const file = await root.children[1].getFile();
const reader = new FileReader();
reader.onload=e=>{
// 读取结果
console.log(e.target.result)
}
reader.readAsText(file,'utf-8')
这里用到的就是一个很简单的文件读了。
下面是完整的代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<button>打开文件夹</button>
<script>
var btn = document.querySelector('button');
btn.onclick=async function() {
try {
// 获得文件夹的句柄
const handle = await showDirectoryPicker();
const root = await processHandler(handle);
// 获得文件内容
const file = await root.children[1].getFile();
const reader = new FileReader();
reader.onload=e=>{
// 读取结果
console.log(e.target.result)
}
reader.readAsText(file,'utf-8')
}
catch {
//用户拒绝查看文件
alert('访问失败')
}
}
async function processHandler(handle) {
if (handle.kind==='file'){
return handle
}
handle.children=[]
const iter = await handle.entries();//获得文件夹中的所有内容
//iter:异步迭代器
for await (const info of iter){
var subHandle = await processHandler(info[1]);
handle.children.push(subHandle)
}
return handle
}
</script>
</body>
</html>
来源:juejin.cn/post/7268011328940769315
AI真的可以写出可商用的代码吗?
背景
在MQTT中,代理(Broker)将发布的消息(publish messages)根据它们的主题(topic)匹配到订阅了这些主题的客户端。这个过程对于客户端来说是透明的,因为客户端只需要指定它们感兴趣的主题,而MQTT代理负责将消息分发给所有订阅了匹配主题的客户端。 如果想通过topic作为解耦key,实现一个客户端多个Listener,每个Listener都具备自己的topic及业务处理逻辑(类似RocketMQ消费者模式)。所以MQTT客户端具备把消息根据topic路由到相应的Listener中。 回归主题我希望通过AI帮实现topic路由。(实现matchTopic方法)
private boolean matchTopic(String subscribedTopic, String publishedTopic)
AI写代码
AI选择:通义千问
第一次
指令
通过java实现mqtt的topic路由匹配的方法matchTopic(String subscribedTopic,String publishedTopic)
AI输出(只粘贴代码部分)
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MqttTopicMatcher {
/**
* 模拟MQTT的topic匹配。
*
* 注意:这个实现是简化的,它不支持通配符在主题字符串中的非末尾位置,
* 并且没有处理多层加号(`+`)和井号(`#`)的复杂情况。
* 它主要用于理解和教学目的。
*
* @param subscribedTopic 订阅的主题,可能包含`+`或`#`通配符
* @param publishedTopic 发布的主题
* @return 如果发布的主题与订阅的主题匹配,则返回true;否则返回false
*/
public static boolean matchTopic(String subscribedTopic, String publishedTopic) {
// 将加号(`+`)替换为正则表达式中的任意字符(`.`)
String regexTopic = subscribedTopic.replace("+", ".");
// 处理井号(`#`):如果订阅的主题以`#`结尾,则替换为任意数量的任意字符(`.*`)
// 注意:这里我们假设`#`只出现在字符串的末尾,且前面没有其他通配符
if (regexTopic.endsWith("#")) {
regexTopic = regexTopic.substring(0, regexTopic.length() - 1) + ".*";
}
// 编译正则表达式
Pattern pattern = Pattern.compile("^" + regexTopic + "$");
// 匹配发布的主题
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches();
}
public static void main(String[] args) {
// 示例
String subscribedTopic1 = "sensors/+/level";
String publishedTopic1 = "sensors/temperature/level";
System.out.println(matchTopic(subscribedTopic1, publishedTopic1)); // 应输出 true
String subscribedTopic2 = "sensors/#";
String publishedTopic2 = "sensors/temperature/room1/level";
System.out.println(matchTopic(subscribedTopic2, publishedTopic2)); // 应输出 true
String subscribedTopic3 = "sensors/+";
String publishedTopic3 = "sensors/temperature/level";
System.out.println(matchTopic(subscribedTopic3, publishedTopic3)); // 应输出 false,因为`+`只匹配单个层级
// 注意:这个实现是简化的,不涵盖所有MQTT topic匹配的场景
}
}
验证
public static void main(String[] args) {
boolean a = matchTopic("$SYS/brokers/+/clients/#", "$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected");
System.out.println("msg: " + a); // 应该输出 true
boolean b = matchTopic("DEVICE/+/msg", "DEVICE/0000000091238112/msg");
System.out.println("msg: " + b); // 应该输出 true
}
public static boolean matchTopic(String subscribedTopic, String publishedTopic) {
// 将加号(`+`)替换为正则表达式中的任意字符(`.`)
String regexTopic = subscribedTopic.replace("+", ".");
// 处理井号(`#`):如果订阅的主题以`#`结尾,则替换为任意数量的任意字符(`.*`)
// 注意:这里我们假设`#`只出现在字符串的末尾,且前面没有其他通配符
if (regexTopic.endsWith("#")) {
regexTopic = regexTopic.substring(0, regexTopic.length() - 1) + ".*";
}
// 编译正则表达式
Pattern pattern = Pattern.compile("^" + regexTopic + "$");
// 匹配发布的主题
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches();
}
实质输出结果:
msg: false
msg: false
结论:
不符合
第二次
指令
public static void main(String[] args) {
boolean a = matchTopic("$SYS/brokers/+/clients/#", "$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected");
System.out.println("msg: " + a); // 应该输出 true
boolean b = matchTopic("DEVICE/+/msg", "DEVICE/0000000091238112/msg");
System.out.println("msg: " + b); // 应该输出 true
}
public static boolean matchTopic(String subscribedTopic, String publishedTopic) {
// 将加号(`+`)替换为正则表达式中的任意字符(`.`)
String regexTopic = subscribedTopic.replace("+", ".");
// 处理井号(`#`):如果订阅的主题以`#`结尾,则替换为任意数量的任意字符(`.*`)
// 注意:这里我们假设`#`只出现在字符串的末尾,且前面没有其他通配符
if (regexTopic.endsWith("#")) {
regexTopic = regexTopic.substring(0, regexTopic.length() - 1) + ".*";
}
// 编译正则表达式
Pattern pattern = Pattern.compile("^" + regexTopic + "$");
// 匹配发布的主题
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches();
}
实质输出结果:
msg: false
msg: false
AI输出(只粘贴代码部分)
您提供的代码中,matchTopic
方法用于将MQTT主题过滤器subscribedTopic
转换为正则表达式,并检查发布的主题publishedTopic
是否与该正则表达式匹配。但是,这里有几个需要注意的点:
正则表达式边界:
在 MQTT 主题匹配中,通常期望整个主题字符串与过滤器完全匹配(或至少从开头开始匹配,直到过滤器的末尾)。然而,find()
方法在Matcher
类中用于查找与正则表达式匹配的任何子序列,而不是整个字符串。因此,如果publishedTopic
包含比subscribedTopic
更长的前缀,find()
仍然会返回true,即使后续部分不匹配。 完整的字符串匹配:
为了确保整个publishedTopic
字符串与subscribedTopic
匹配(或匹配到subscribedTopic
指定的模式),您应该在正则表达式的前后添加^
和$
锚点。 处理$
前缀:
在MQTT中,$SYS
是一个保留前缀,用于系统主题。在正则表达式中,$
是一个特殊字符,表示字符串的末尾。但是,由于MQTT主题中的$
仅仅是文本的一部分,您不需要在正则表达式中对它进行特殊处理(除非您打算在正则表达式中直接使用$
的特殊含义)。
下面是修改后的matchTopic
方法,它使用matches()
,并确保整个字符串匹配:
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public static boolean matchTopic(String subscribedTopic, String publishedTopic) {
// 将 + 替换为 [^/]+,将 # 替换为 .*,并在字符串前后添加 ^ 和 $ 以确保整个字符串匹配
String regexTopic = "^" + subscribedTopic.replace("+", "[^/]+").replace("#", ".*") + "$";
Pattern pattern = Pattern.compile(regexTopic);
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches(); // 使用 matches() 来确保整个字符串匹配
}
public static void main(String[] args) {
boolean a = matchTopic("$SYS/brokers/+/clients/#", "$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected");
System.out.println("msg: " + a); // 应该输出 true
boolean b = matchTopic("DEVICE/+/msg", "DEVICE/0000000091238112/msg");
System.out.println("msg: " + b); // 应该输出 true
}
验证
实质输出结果:
msg: false msg: true
结论:
不符合
第三次
指令
结果还是msg: false
AI输出(只粘贴代码部分)
在从你提供的代码来看,问题可能在于正则表达式的构建方式。+
和#
通配符需要正确地转换为正则表达式来匹配MQTT主题。
+
代表单个层级中的任意非斜杠字符。
#
代表从层级的某一点开始到主题结尾的所有层级。
你的代码中已经正确地处理了+
和#
,但可能存在一些细微的问题导致输出结果为false
。
让我们稍微调整一下你的代码,以确保它能够正确地处理MQTT主题的匹配:
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public static boolean matchTopic(String subscribedTopic, String publishedTopic) {
// 将 MQTT 通配符转换为正则表达式
String regexTopic = "^" + subscribedTopic.replace("+", "[^/]+").replace("#", "([^/]+)/.*") + "$";
// 编译正则表达式
Pattern pattern = Pattern.compile(regexTopic);
// 创建匹配器并检查是否匹配
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches(); // 使用 matches() 来确保整个字符串匹配
}
public static void main(String[] args) {
boolean a = matchTopic("$SYS/brokers/+/clients/#", "$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected");
System.out.println("msg: " + a); // 应该输出 true
boolean b = matchTopic("DEVICE/+/msg", "DEVICE/0000000091238112/msg");
System.out.println("msg: " + b); // 应该输出 true
}
在上述代码中,我将#
的替换部分稍作修改。原来的".*"
可能会匹配任何字符(包括斜杠),这可能会导致意外匹配。通过使用"([^/]+)/.*"
,我们确保了 # 之前的部分必须是一个不包含斜杠的字符串,并且之后可以跟任何东西(包括零个或多个层级)。
让我们尝试运行这段修改后的代码。如果你仍然遇到问题,请告诉我,我们可以进一步调试。
验证
实质输出结果:
msg: false msg: true
结论:
不符合
第四次
指令
运行结果还是不对,$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected
,输出结果还是msg: false
AI输出(只粘贴代码部分)
在既然正则表达式看起来是正确的,但仍然没有匹配成功,我们需要进一步检查正则表达式的实现细节。问题可能在于正则表达式如何处理#
之后的内容。 在MQTT中,#
代表从层级的某一点开始到主题结尾的所有层级。这意味着#
之前的部分必须是一个完整的层级,而#
之后的部分可以是零个或多个层级。 让我们再次修改正则表达式以确保这一点得到正确的处理:
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public static boolean matchTopic(String subscribedTopic, String publishedTopic) {
// 将 MQTT 通配符转换为正则表达式
String regexTopic = "^" + subscribedTopic
.replace("+", "[^/]+")
.replace("#", "([^/]+)(/.*)?")
+ "$";
System.out.println("Regex topic: " + regexTopic); // 添加这一行以查看正则表达式
// 编译正则表达式
Pattern pattern = Pattern.compile(regexTopic);
// 创建匹配器并检查是否匹配
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches(); // 使用 matches() 来确保整个字符串匹配
}
public static void main(String[] args) {
boolean a = matchTopic("$SYS/brokers/+/clients/#", "$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected");
System.out.println("msg: " + a); // 应该输出 true
boolean b = matchTopic("DEVICE/+/msg", "DEVICE/0000000091238112/msg");
System.out.println("msg: " + b); // 应该输出 true
}
这里的关键变化是在#
的替换部分:
"([^/]+)(/.*)?"
:这部分正则表达式意味着#
前面的部分必须是一个不包含斜杠的字符串,而#
后面的部分可以是零个或多个层级。"(.*)" / "(/.*)"
都用来匹配剩余的路径,但(/.*)?
表示这个部分是可选的,即#
后面可以没有更多的层级。
现在,让我们再次运行这段代码。你应该能看到输出的正则表达式,以及最终的匹配结果。如果还是有问题,请提供输出结果以便进一步分析。
验证
实质输出结果:
msg: false msg: true
结论:
不符合
第五次
指令
运行结果还是不对,$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected
,怀疑$SYS
影响了正则匹配,输出结果还是msg: false
AI输出(只粘贴代码部分)
你提到$SYS
可能影响了正则表达式的匹配。确实,在正则表达式中,某些特殊字符会被解释为具有特殊的含义,而非直接的文本匹配。在这种情况下,$
是一个特殊字符,在正则表达式中通常用于表示字符串的结尾。因此,我们需要对$SYS
进行转义,以确保它被视为普通文本而不是特殊符号。
让我们修改代码以转义$SYS
字符串中的$
,确保它被视为普通的字符。
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public static boolean matchTopic(String subscribedTopic, String publishedTopic) {
// 将 MQTT 通配符转换为正则表达式
String regexTopic = "^" + subscribedTopic
.replace("+", "[^/]+")
.replace("#", "([^/]+)/.*")
.replace("$", "\$") // 转义 $ 字符
+ "$";
// 编译正则表达式
Pattern pattern = Pattern.compile(regexTopic);
// 创建匹配器并检查是否匹配
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches(); // 使用 matches() 来确保整个字符串匹配
}
public static void main(String[] args) {
boolean a = matchTopic("$SYS/brokers/+/clients/#", "$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected");
System.out.println("msg: " + a); // 应该输出 true
boolean b = matchTopic("DEVICE/+/msg", "DEVICE/0000000091238112/msg");
System.out.println("msg: " + b); // 应该输出 true
}
在这个版本中,我们添加了一个.replace("$", "\$")
来转义$SYS
中的$
字符。这将确保$SYS
在正则表达式中被视为普通文本。
现在,让我们再次运行这段代码。你应该能看到输出的正则表达式,以及最终的匹配结果。如果还是有问题,请提供输出结果以便进一步分析。
验证
实质输出结果:
msg: true msg: true
结论:
符合
代码最终结果:
对代码今天细微调整,运营到生产环境代码。
private boolean matchTopic(String subscribedTopic, String publishedTopic) {
String regexTopic = "^" + subscribedTopic.replace("+", "[^/]+").replace("#", "([^/]+)/.*").replace("$", "\$") + "$";
// 编译正则表达式
Pattern pattern = Pattern.compile(regexTopic);
// 创建匹配器并检查是否匹配
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches(); // 使用 matches() 来确保整个字符串匹配
}
总结
在使用AI工具进行代码生成时,用户可以通过简单的自然语言描述或问题,AI便能快速理解并生成相应的代码片段。这种交互方式非常直观,用户只需提供关键信息和问题,AI便能迅速响应并生成匹配的代码。 AI写代码的体验还体现在其高效性和便捷性上。通过AI的帮助,开发者可以更加专注于项目的核心逻辑和功能实现,而无需在编写基础代码上花费过多时间。这不仅能够提高开发效率,还能在一定程度上减轻开发者的负担,特别是在面对复杂或重复性的编程任务时,AI工具能够显著提升工作效率。 尽管AI工具在代码生成方面展现出了强大的能力,但开发者在使用时仍需保持警惕,确保生成的代码符合项目的实际需求和标准。此外,对于特定领域的复杂应用或特定需求的实现,人类开发者的专业知识和经验仍然是不可或缺的。因此,AI工具与人类开发者的结合将是未来软件开发的一个重要趋势,共同推动软件开发的进步和创新。
来源:juejin.cn/post/7397668641645396022
网站刚线上就被攻击了,随后我一顿操作。。。
大家好,我是冰河~~
自己搭建的网站刚上线,短信接口就被一直攻击,并且攻击者不停变换IP,导致阿里云短信平台上的短信被恶意刷取了几千条,加上最近工作比较忙,就直接在OpenResty上对短信接口做了一些限制,采用OpenResty+Lua的方案成功动态封禁了频繁刷短信接口的IP。
一、临时解决方案
由于事情比较紧急,所以,当发现这个问题时,就先采用快速的临时方案解决。
(1)查看Nginx日志发现被攻击的IP 和接口
[root@binghe ~]# tail -f /var/log/nginx/access.log
发现攻击者一直在用POST请求 /fhtowers/user/getVerificationCode这个接口
(2)用awk和grep脚本过滤nginx日志,提取攻击短信接口的ip(一般这个接口是用来发注册验证码的,一分钟如果大于10次请求的话就不是正常的访问请求了,大家根据自己的实际情况更改脚本)并放到一个txt文件中去,然后重启nginx
[root@binghe ~]# cat denyip.sh
#!/bin/bash
nginx_home=/usr/local/openresty/nginx
log_path=/var/log/nginx/access.log
tail -n5000 $log_path | grep getVerification | awk '{print $1}' |sort | uniq -c | sort -nr -k1 | head -n 100 |awk '{if($1>10)print ""$2""}' >$nginx_home/denyip/blocksip.txt
/usr/bin/nginx -s reload
(3)设置Nginx去读取用脚本过滤出来的blocksip.txt(注意一下,我这里的Nginx是用的openresty,自带识别lua语法的,下面会有讲openresty的用法)
location = /fhtowers/user/getVerificationCode { #短信接口
access_by_lua '
local f = io.open("/usr/local/openresty/nginx/denyip/blocksip.txt") #黑名单列表
for line in f:lines() do
if ngx.var.http_x_forwarded_for == line then #如果ip在黑名单列表里直接返回403
ngx.exit(ngx.HTTP_FORBIDDEN)
end
end
';
proxy_pass http://appservers; #不在名单里就转发给后台的tomcat服务器
}
(4)把过滤脚本放进crontab任务里,一分钟执行一次
[root@binghe ~]# crontab -e
*/1 * * * * sh /root/denyip.sh
(5)查看一下效果,发现攻击者的请求都被返回403并拒绝了
二、OpenResty+Lua方案
临时方案有效果后,再将其调整成使用OpenResty+Lua脚本的方案,来一张草图。
接下来,就是基于OpenResty和Redis实现自动封禁访问频率过高的IP。
2.1 安装OpenResty
安装使用 OpenResty,这是一个集成了各种 Lua 模块的 Nginx 服务器,是一个以Nginx为核心同时包含很多第三方模块的Web应用服务器,使用Nginx的同时又能使用lua等模块实现复杂的控制。
(1)安装编译工具、依赖库
[root@test1 ~]# yum -y install readline-devel pcre-devel openssl-devel gcc
(2)下载openresty-1.13.6.1.tar.gz 源码包,并解压;下载ngx_cache_purge模块,该模块用于清理nginx缓存;下载nginx_upstream_check_module模块,该模块用于ustream健康检查。
[root@test1 ~]# cd /usr/local/
[root@test1 local]# wget https://openresty.org/download/openresty-1.13.6.1.tar.gz
[root@test1 local]# tar -zxvf openresty-1.13.6.1.tar.gz
[root@test1 local]# cd openresty-1.13.6.1/bundle
[root@test1 local]# wget http://labs.frickle.com/files/ngx_cache_purge-2.3.tar.gz
[root@test1 local]# tar -zxvf ngx_cache_purge-2.3.tar.gz
[root@test1 local]# wget https://github.com/yaoweibin/nginx_upstream_check_module/archive/v0.3.0.tar.gz
[root@test1 local]# tar -zxvf v0.3.0.tar.gz
(3)配置需安装的模块
# ./configure --help可查询需要安装的模块并编译安装
[root@test1 openresty-1.13.6.1]# ./configure --prefix=/usr/local/openresty --with-luajit --with-http_ssl_module --user=root --group=root --with-http_realip_module --add-module=./bundle/ngx_cache_purge-2.3/ --add-module=./bundle/nginx_upstream_check_module-0.3.0/ --with-http_stub_status_module
[root@test1 openresty-1.13.6.1]# make && make install
(4)创建一个软链接方便启动停止
[root@test1 ~]# ln -s /usr/local/openresty/nginx/sbin/nginx /bin/nginx
(5)启动nginx
[root@test1 ~]# nginx #启动
[root@test1 ~]# nginx -s reload #reload配置
如果启动时候报错找不到PID的话就用以下命令解决(如果没有更改过目录的话,让它去读nginx的配置文件就好了)
[root@test1 ~]# /usr/local/openresty/nginx/sbin/nginx -c /usr/local/openresty/nginx/conf/nginx.conf
随后,打开浏览器访问页面。
(6)在Nginx上测试一下能否使用Lua脚本
[root@test1 ~]# vim /usr/local/openresty/nginx/conf/nginx.conf
在server里面加一个
location /lua {
default_type text/plain;
content_by_lua ‘ngx.say(“hello,lua!”)’;
}
加完后重新reload配置。
[root@test1 ~]# nginx -s reload
在浏览器里输入 ip地址/lua,出现下面的字就表示Nginx能够成功使用lua了
2.2 安装Redis
(1)下载、解压、编译安装
[root@test1 ~]# cd /usr/local/
[root@test1 local]# wget http://download.redis.io/releases/redis-6.0.1.tar.gz
[root@test1 local]# tar -zxvf redis-6.0.1.tar.gz
[root@test1 local]# cd redis-6.0.1
[root@test1 redis-6.0.1]# make
[root@test1 redis-6.0.1]# make install
(2)查看是否安装成功
[root@test1 redis-6.0.1]# ls -lh /usr/local/bin/
[root@test1 redis-6.0.1]# redis-server -v
Redis server v=3.2.5 sha=00000000:0 malloc=jemalloc-4.0.3 bits=64 build=dae2abf3793b309d
(3)配置redis 创建dump file、进程pid、log目录
[root@test1 redis-6.0.1]# cd /etc/
[root@test1 etc]# mkdir redis
[root@test1 etc]# cd /var/
[root@test1 var]# mkdir redis
[root@test1 var]# cd redis/
[root@test1 redis]# mkdir data log run
(4)修改配置文件
[root@test1 redis]# cd /usr/local/redis-6.0.1/
[root@test1 redis-6.0.1]# cp redis.conf /etc/redis/6379.conf
[root@test1 redis-6.0.1]# vim /etc/redis/6379.conf
#绑定的主机地址
bind 192.168.1.222
#端口
port 6379
#认证密码(方便测试不设密码,注释掉)
#requirepass
#pid目录
pidfile /var/redis/run/redis_6379.pid
#log存储目录
logfile /var/redis/log/redis.log
#dump目录
dir /var/redis/data
#Redis默认不是以守护进程的方式运行,可以通过该配置项修改,使用yes启用守护进程
daemonize yes
(5)设置启动方式
[root@test1 redis-6.0.1]# cd /usr/local/redis-6.0.1/utils/
[root@test1 utils]# cp redis_init_script /etc/init.d/redis
[root@test1 utils]# vim /etc/init.d/redis #根据自己实际情况修改
/etc/init.d/redis文件的内容如下。
#!/bin/sh
#
# Simple Redis init.d script conceived to work on Linux systems
# as it does use of the /proc filesystem.
REDISPORT=6379
EXEC=/usr/local/bin/redis-server
CLIEXEC=/usr/local/bin/redis-cli
PIDFILE=/var/run/redis_${REDISPORT}.pid
CONF="/etc/redis/${REDISPORT}.conf"
case "$1" in
start)
if [ -f $PIDFILE ]
then
echo "$PIDFILE exists, process is already running or crashed"
else
echo "Starting Redis server..."
$EXEC $CONF
fi
;;
stop)
if [ ! -f $PIDFILE ]
then
echo "$PIDFILE does not exist, process is not running"
else
PID=$(cat $PIDFILE)
echo "Stopping ..."
$CLIEXEC -p $REDISPORT shutdown
while [ -x /proc/${PID} ]
do
echo "Waiting for Redis to shutdown ..."
sleep 1
done
echo "Redis stopped"
fi
;;
*)
echo "Please use start or stop as first argument"
;;
esac
增加执行权限,并启动Redis。
[root@test1 utils]# chmod a+x /etc/init.d/redis #增加执行权限
[root@test1 utils]# service redis start #启动redis
(6)查看redis是否启动
2.3 Lua访问Redis
(1)连接redis,然后添加一些测试参数
[root@test1 utils]# redis-cli -h 192.168.1.222 -p 6379
192.168.1.222:6379> set "123" "456"
OK
(2)编写连接Redis的Lua脚本
[root@test1 utils]# vim /usr/local/openresty/nginx/conf/lua/redis.lua
local redis = require "resty.redis"
local conn = redis.new()
conn.connect(conn, '192.168.1.222', '6379') #根据自己情况写ip和端口号
local res = conn:get("123")
if res==ngx.null then
ngx.say("redis集群中不存在KEY——'123'")
return
end
ngx.say(res)
(3)在nginx.conf配置文件中的server下添加以下location
[root@test1 utils]# vim /usr/local/openresty/nginx/conf/nginx.conf
location /lua_redis {
default_type text/plain;
content_by_lua_file /usr/local/openresty/nginx/conf/lua/redis.lua;
}
随后重新reload配置。
[root@test1 utils]# nginx -s reload #重启一下Nginx
(4)验证Lua访问Redis的正确性
在浏览器输入ip/lua_redis, 如果能看到下图的内容表示Lua可以访问Redis。
准备工作已经完成,现在要实现OpenResty+Lua+Redis自动封禁并解封IP了。3.4
2.4 OpenResty+Lua实现
(1)添加访问控制的Lua脚本(只需要修改Lua脚本中连接Redis的IP和端口即可)
ok, err = conn:connect(“192.168.1.222”, 6379)
注意:如果在Nginx或者OpenResty的上层有用到阿里云的SLB负载均衡的话,需要修改一下脚本里的所有…ngx.var.remote_addr,把remote_addr替换成从SLB获取真实IP的字段即可,不然获取到的IP全都是阿里云SLB发过来的并且是处理过的IP,同时,这些IP全都是一个网段的,根本没有办法起到封禁的效果)。
完整的Lua脚本如下所示。
[root@test1 lua]# vim /usr/local/openresty/nginx/conf/lua/access.lua
local ip_block_time=300 --封禁IP时间(秒)
local ip_time_out=30 --指定ip访问频率时间段(秒)
local ip_max_count=20 --指定ip访问频率计数最大值(秒)
local BUSINESS = ngx.var.business --nginx的location中定义的业务标识符,也可以不加,不过加了后方便区分
--连接redis
local redis = require "resty.redis"
local conn = redis:new()
ok, err = conn:connect("192.168.1.222", 6379)
conn:set_timeout(2000) --超时时间2秒
--如果连接失败,跳转到脚本结尾
if not ok then
goto FLAG
end
--查询ip是否被禁止访问,如果存在则返回403错误代码
is_block, err = conn:get(BUSINESS.."-"..ngx.var.remote_addr)
if is_block == '1' then
ngx.exit(403)
goto FLAG
end
--查询redis中保存的ip的计数器
ip_count, err = conn:get(BUSINESS.."-"..ngx.var.remote_addr)
if ip_count == ngx.null then --如果不存在,则将该IP存入redis,并将计数器设置为1、该KEY的超时时间为ip_time_out
res, err = conn:set(BUSINESS.."-"..ngx.var.remote_addr, 1)
res, err = conn:expire(BUSINESS.."-"..ngx.var.remote_addr, ip_time_out)
else
ip_count = ip_count + 1 --存在则将单位时间内的访问次数加1
if ip_count >= ip_max_count then --如果超过单位时间限制的访问次数,则添加限制访问标识,限制时间为ip_block_time
res, err = conn:set(BUSINESS.."-"..ngx.var.remote_addr, 1)
res, err = conn:expire(BUSINESS.."-"..ngx.var.remote_addr, ip_block_time)
else
res, err = conn:set(BUSINESS.."-"..ngx.var.remote_addr,ip_count)
res, err = conn:expire(BUSINESS.."-"..ngx.var.remote_addr, ip_time_out)
end
end
-- 结束标记
::FLAG::
local ok, err = conn:close()
(2)在需要做访问限制的location里加两段代码即可,这里用刚才的/lua做演示
[root@test1 lua]# vim /usr/local/openresty/nginx/conf/nginx.conf
主要是添加如下配置。
access_by_lua_file /usr/local/openresty/nginx/conf/lua/access.lua;
其中,set $business “lua”
是为了把IP放进Redis的时候标明是哪个location的,可以不加这个配置。
随后,重新reload配置。
[root@test1 lua]# nginx -s reload #修改完后重启nginx
(3)打开浏览器访问192.168.1.222/lua 并一直按F5刷新。
随后,连接Redis,查看IP的访问计数。
[root@test1 ~]# redis-cli -h 192.168.1.222 -p 6379
发现redis已经在统计访问lua这个网页ip的访问次数了
这个key的过期时间是30秒,如果30秒没有重复访问20次这个key就会消失,所以说正常用户一般不会触发这个封禁的脚本。
当30秒内访问超过了20次,发现触发脚本了,变成了403
再次查看Redis的key,发现多了一个lua-block-192.168.1.158,过期时间是300秒,就是说在300秒内这个ip无法继续访问192.168.1.222/lua这个页面了。
过五分钟后再去访问这个页面,又可以访问了。
这个脚本的目的很简单:一个IP如果在30秒内其访问次数达到20次则表明该IP访问频率太快了,因此将该IP封禁5分钟。同时由于计数的KEY在Redis中的超时时间设置成了30秒,所以如果两次访问间隔时间大于30秒将会重新开始计数。
大家也可以将这个脚本优化成,第一次封禁5分钟,第二次封禁半小时,第三次封禁半天,第四次封禁三天,第五次永久封禁等等。
好了,今天就到这儿吧,我是冰河,我们下期见~~
来源:juejin.cn/post/7399109720457543721
三大微前端框架,谁是你的理想型?
1. 分享目标:
2. 什么是微前端?
故事开始于三年前…
小明为公司重构了一版新的管理后台,采用了市面上最流行的SPA渲染模式,具体技术栈使用的是 react + react-router。
项目第一版很快就顺利上线了,但在后续的迭代中,遇到一个棘手的问题:产品经理希望快速复用之前项目的某些页面。这让小明犯了难,因为老项目是用“上古神器” jQuery 写的,完全重构成 react,成本非常高。这时后端老哥丢过来一句:“你们前端用 iframe 嵌进来就可以了吧? ” 小明心里很清楚 iframe 有许多小毛病,但在当时,也确实没有比它更好的选择了。
上线后,随着时间的推移,用户产生了困惑:
- 为什么这个页面的弹框不居中了?
- 为什么这个页面的跳转记录无法保存? ...
小明心里其实非常清楚,这一切都是 iframe 带来的弊端。
时间来到三年后的今天,小明听说微前端能够解决 iframe 的各种疑难杂症,于是展开了调研。
市面上对微前端的定义让人眼花缭乱,比如微前端是:
这里给出我对微前端最接地气的定义:
故事开始于三年前…
小明为公司重构了一版新的管理后台,采用了市面上最流行的SPA渲染模式,具体技术栈使用的是 react + react-router。
项目第一版很快就顺利上线了,但在后续的迭代中,遇到一个棘手的问题:产品经理希望快速复用之前项目的某些页面。这让小明犯了难,因为老项目是用“上古神器” jQuery 写的,完全重构成 react,成本非常高。这时后端老哥丢过来一句:“你们前端用 iframe 嵌进来就可以了吧? ” 小明心里很清楚 iframe 有许多小毛病,但在当时,也确实没有比它更好的选择了。
上线后,随着时间的推移,用户产生了困惑:
- 为什么这个页面的弹框不居中了?
- 为什么这个页面的跳转记录无法保存? ...
小明心里其实非常清楚,这一切都是 iframe 带来的弊端。
时间来到三年后的今天,小明听说微前端能够解决 iframe 的各种疑难杂症,于是展开了调研。
市面上对微前端的定义让人眼花缭乱,比如微前端是:
这里给出我对微前端最接地气的定义:
“类似于iframe的效果,但没有它带来的各种问题”——小明。
3. 主流技术方向分类
首先,“微前端”作为近几年国内前端界最火的技术之一,目前存在多个技术流派。我按照它们对 iframe 看法的不同,将主流微前端方案分为了三大派系:革新派、改良派、中间派。
首先,“微前端”作为近几年国内前端界最火的技术之一,目前存在多个技术流派。我按照它们对 iframe 看法的不同,将主流微前端方案分为了三大派系:革新派、改良派、中间派。
3.1. 革新派 qiankun
以 qiankun 为主的革新派认为: iframe 问题很多,应避免使用它。 完全可以利用现有的前端技术自建一套应用隔离渲染方案。
以 qiankun 为主的革新派认为: iframe 问题很多,应避免使用它。 完全可以利用现有的前端技术自建一套应用隔离渲染方案。
3.1.1. 原理:
3.1.1.1. 基于 single-spa
将路由切换与子应用加载、卸载等生命周期结合起来是微前端的一项核心能力。这一步 qiankun 是基于 single-spa 实现的,不同的是它支持以 html 作为加载子应用的入口,不必像 single-spa 那样需要手动梳理资源链接,内部插件 import-html-entry 会自动分析 html 以获取 js 和 css。
将路由切换与子应用加载、卸载等生命周期结合起来是微前端的一项核心能力。这一步 qiankun 是基于 single-spa 实现的,不同的是它支持以 html 作为加载子应用的入口,不必像 single-spa 那样需要手动梳理资源链接,内部插件 import-html-entry 会自动分析 html 以获取 js 和 css。
3.1.1.2. 样式隔离
为了确保子应用之间样式互不影响,qiankun 内置了三种样式隔离模式:
- 默认模式。
原理是加载下一个子应用时,将上一个子应用的 、
等样式相关标签通通删除与替换,来实现样式隔离。缺点是仅支持单例模式(同一时间只能渲染单个子应用),且没法做到主子应用及多个子应用之间的样式隔离。
- 严格模式。
为了确保子应用之间样式互不影响,qiankun 内置了三种样式隔离模式:
- 默认模式。
原理是加载下一个子应用时,将上一个子应用的 、
等样式相关标签通通删除与替换,来实现样式隔离。缺点是仅支持单例模式(同一时间只能渲染单个子应用),且没法做到主子应用及多个子应用之间的样式隔离。
- 严格模式。
可通过 strictStyleIsolation:true
开启。原理是利用 webComponent 的 shadowDOM 实现。但它的问题在于隔离效果太好了,在目前的前端生态中有点水土不服,这里举两个例子。
- 可能会影响 React 事件。比如这个issue 当 Shadow Dom 遇上 React 事件 ,大致原因是在 React 中事件是“合成事件”,在React 17 版本之前,所有用户事件都需要冒泡到 document 上,由 React 做统一分发与处理,如果冒泡的过程中碰到 shadowRoot 节点,就会将事件拦截在 shadowRoot 范围内,此时
event.target
强制指向 shadowRoot,导致在 react 中事件无响应。React 17 之后事件监听位置由 document 改为了挂载 App 组件的 root 节点,就不存在此问题了。
- 弹框样式丢失。 原因是主流UI框架比如 antd 为了避免上层元素的样式影响,通常会把弹框相关的 DOM 通过
document.body.appendChild
插入到顶层 body 的下边。此时子应用中 antd 的样式规则,由于开启了 shadowDom ,只对其下层的元素产生影响,自然就对全局 body 下的弹框不起作用了,造成了样式丢失的问题。
解决方案:调整 antd 入参,让其在当前位置渲染。
- 实验模式。
可通过 experimentalStyleIsolation:true
开启。 原理类似于 vue 的 scope-css,给子应用的所有样式规则增加一个特殊的属性选择器,限定其影响范围,达到样式隔离的目的。但由于需要在运行时替换子应用中所有的样式规则,所以目前性能较差,处于实验阶段。
3.1.1.3. JS 沙箱
确保子应用之间的“全局变量”不会产生冲突。
- 快照沙箱( snapshotSandbox )
- 激活子应用时,对着当前
window
对象照一张相(所有属性 copy 到一个新对象windowSnapshot
中保存起来)。 - 离开子应用时,再对着
window
照一张相,对比离开时的window
与激活时的 window (也就是windowSnapshot
)之间的差异。- 记录变更。Diff 出在这期间更改了哪些属性,记录在
modifyPropsMap
对象中。 - 恢复环境。依靠
windowSnapshot
恢复之前的window
环境。
- 记录变更。Diff 出在这期间更改了哪些属性,记录在
- 下次激活子应用时,从
modifyPropsMap
对象中恢复上一次的变更。
- 单例的代理沙箱 ( LegacySanbox )
与快照沙箱思路很相似,但它不用通过 Diff 前后 window 的方式去记录变更,而是通过 ES6的 Proxy 代理 window 属性的 set 操作来记录变更。由于不用反复遍历 window,所以性能要比快照沙箱好。
- 支持多例的代理沙箱( ProxySandbox )
以上两种沙箱机制,都只支持单例模式(同一页面只支持渲染单个子应用)。
原因是:它们都直接操作的是全局唯一的 window。此时机智的你肯定想到了,假如为每个子应用都分配一个独立的“虚拟window”,当子应用操作 window 时,其实是在各自的“虚拟 window”上操作,不就可以实现多实例共存了?事实上,qiankun 确实也是这样做的。
既然是“代理”沙箱,那“代理”在这的作用是什么呢?
主要是为了实现对全局对象属性 get、set 的两级查找,优先使用fakeWindow,特殊情况(set命中白名单或者get到原生属性)才会改变全局真实window。
如此,qiankun 就对子应用中全局变量的 get 、 set 都实现了管控与隔离。
3.1.2. 优势:
3.1.2.1. 具有先发优势
2019年开源,是国内最早流行起来的微前端框架,在蚂蚁内外都有丰富的应用,后期维护性是可预测的。
3.1.2.2. 开箱即用
虽然是基于国外的 single-spa 二次封装,但提供了更加开箱即用的 API,比如支持直接以 HTML 地址作为加载子应用的入口。
3.1.2.3. 对 umi 用户更加友好
有现成的插件 @umijs/plugin-qiankun 帮助降低子应用接入成本。
3.1.3. 劣势:
3.1.3.1. vite 支持性差
由上可知,代理沙箱实现的关键是需要将子应用的 window “替换”为 fakeWindow,在这一步 qiankun 是通过函数 window 同名参数 + with 作用域绑定的方式,更改子应用 window 指向为 fakeWindow,最终使用 eval(...) 解析运行子应用的代码。
const jsCode = `
(function(window, self, globalThis){
with(this){
// your code
window.a = 1;
b = 2
...
}
}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);
`
eval(jsCode)
问题就出在这个 eval 上, vite 的构建产物如果不做特殊降级,默认打包出的就是 ESModule 语法的代码,使用 eval 解析运行会报下图这个错误。
报错的大意是, import 语法的代码必须放在 中执行。
官方目前推荐的解决方法是关闭沙箱... 但其实还有另一种比较取巧的方案:vite 生态里有一款专门兼容此问题的vite-plugin-qiankun 插件,它的原理是: eval 虽然没办法执行静态 import 语法,但它可以执行动态 import(...) 语法。
所以这款插件的解决方案就是替换子应用代码中的静态 import 为动态 import(),以绕过上述限制。
3.1.3.2. 子应用接入成本较高,详细步骤参考子应用接入文档
umi 用户可忽略这点,尤其是 @umi/max 用户,相比 webpack 接入成本要低很多。
3.1.3.3. JS 沙箱存在性能问题,且并不完善。
大致原因是 with + proxy 带来的性能损耗,详见 JS沙箱的困境 。当然 qiankun 官方也在针对性的进行优化,进展在这篇《改了 3 个字符,10倍的沙箱性能提升?!!》文章中可见一斑 。
3.2. 改良派 wujie
3.2.1. 原理:
wujie 是腾讯出品的一款微前端框架。作为改良派的代表,它认为: iframe 虽然问题很多,但仅把它作为一个 js 沙箱去用,表现还是很稳定的,毕竟是浏览器原生实现的,比自己实现 js 沙箱靠谱多了。至于 iframe 的弊端,可以针对性的去优化:
- DOM 渲染无法突破 iframe 边界?(弹框不居中问题)
那 DOM
就不放 iframe
里渲染了,而是单独提取到一个 webComponent
里渲染,顺便用 shadowDOM
解决样式隔离的问题。
简单说,无界的方案就是:JS 放 iframe 里运行,DOM 放 webComponent 渲染。
那么问题来了: 用 JS 操作 DOM 时,两者如何联系起来呢?毕竟 JS 默认操作的总是全局的 DOM。无界在此处用了一种比较 hack 的方式:代理子应用中所有的 DOM 操作,比如将 document
下的 getElementById、querySelector、querySelectorAll、head、body
等查询类 api 全部代理到 webComponent
。
下图是子应用真实运行时的例子:
至于多实例模式,就更容易理解了。给每个子应用都分配一套 iframe
+ webComponent
的组合,就可以实现相互之间的隔离了!
- 刷新页面会导致子应用路由状态丢失?
通过重写 iframe
实例的history.pushState
和 history.replaceState
,将子应用的 path
记录到主应用地址栏的 query
参数上,当刷新浏览器初始化 iframe
时,从地址栏读到子应用的 path
并使用 iframe
的 history.replaceState
进行同步。
简单理解就是:将子应用路径记录在地址栏参数中。
3.2.2. 优势:
3.2.2.1. 相比 qiankun 接入成本更低。
- 父应用:
- 与 iframe 的接入方式很类似,只需引入一个 React 组件渲染子应用即可。
- 与 iframe 的接入方式很类似,只需引入一个 React 组件渲染子应用即可。
- 子应用理论上不需要做任何改造
3.2.2.2. vite 兼容性好
直接将完整的 ESM 标签块 插入 iframe 中,避免了 qiankun 使用 eval 执行 ESM 代码导致的报错问题。
3.2.2.3. iframe 沙箱隔离性好
3.2.3. 劣势:
3.2.3.1. 坑比较多
- 明坑: 用于 JS 沙箱的 iframe 的 src 必须指向一个同域地址导致的问题。
具体问题描述见下图:
具体问题描述见下图:
此 [issue]() 至今无法在框架层面得到解决,属于 iframe 的原生限制。
手动的解决方案:
- 主应用提供一个路径比如说 https://host/empty ,这个路径不需要返回任何内容,子应用设置 attr 为 {src:'https://host/empty'},这样 iframe 的 src 就是 https://host/empty。
- 在主应用 template 的 head 插入
这样的代码可以避免主应用代码污染。
- 暗坑: 复杂的 iframe 到 webComponent 的代理机制,导致市面上大部分富文本编辑器都无法在无界中完好运行。所以有富文本的项目,尽量别用无界,除非你对富文本库的源码了如指掌。issues 在这里。
3.2.3.2. 长期维护性一般。
3.2.3.3. 内存开销较大
用于 js 沙箱的 iframe 是隐藏在主应用的 body 下面的,相当于是常驻内存,这可能会带来额外的内存开销。
3.3. 中间派 micro-app
3.3.1. 原理:
京东的大前端团队出品。
样式隔离方案与 qiankun 的实验方案类似,也是在运行时给子应用中所有的样式规则增加一个特殊标识来限定 css 作用范围。
子应用路由同步方案与 wujie 类似,也是通过劫持路由跳转方法,同步记录到 url 的 query 中,刷新时读取并恢复。
组件化的使用方式与 wujie 方案类似,这也是 micro-app 主打的宣传点。
最有意思的是它的沙箱方案,居然内置了两种沙箱:
- 类 qiankun 的 with 代理沙箱,据说相比 qiankun 性能高点,但目前微前端框架界并没有一个权威的基准性能测试依据,所以并无有效依据支撑。
- 类 wujie 的 iframe 沙箱,用于兼容 vite 场景。
开发者可以根据自身的实际情况自由选择。
整体感觉 micro-app 是一种偏“现实主义”的框架,它的特点就是取各家所长,最终成为了功能最丰富的微前端框架。
3.3.2. 优势:
3.3.2.1. 支持的功能最丰富。
3.3.2.2. 接入成本低。
3.3.2.3. 文档完善。
micro-zoe.github.io/micro-app/d…
3.3.3. 劣势:
3.3.3.1. 功能丰富导致配置项与 api 太多。
3.3.3.2. 静态资源补全问题。
静态资源补全是基于父应用的,而非子应用这需要开发者自己手动解决。
4. 选型建议
统计时间2023.12.3 | npm周下载量 | star数 | issue数 | 最近更新时间 | 接入成本 | 沙箱支持vite |
---|---|---|---|---|---|---|
qiankun | 22k | 15k | 362/1551 | 12天前 | 高 | ❌ |
wujie | 1.3k | 3.4k | 280/271 | 24天前 | 低 | ✅ |
micro-app | 1.1k | 4.9k | 57/748 | 1个月前 | 中 | ✅ |
- 刚性建议。
- vite 项目且对 js 沙箱有刚需,选 wujie 或者 micro-app。
- 项目存在复杂的交互场景,比如有用到富文本编辑器库,选 wujie 前请做好充分的测试。
- 如果你的团队对主、子应用的开发完全受控,即使有隔离性问题也可以通过治理来解决,那么可以试试更轻量的 single-SPA 方案。
- 如果特别重视稳定性,那无疑是 iframe 最佳... 因为 iframe 存在的问题都是摆在明面的,市面上现有的微前端框架多多少少都有一些隐性问题。
- 综合推荐。
主要从接入成本、功能稳定性、长期维护性三方面来衡量:
- 接入成本: wujie > microApp > qiankun (由低到高)
- 功能稳定性:qiankun > microApp > wujie
- 长期维护性:qiankun > microApp > wujie
看你的团队最看重哪一点,针对性去选择就好了,没有十全十美微前端框架,只有适合自己的。
最后
以上内容,确实会有我强烈的个人理解与观点,这也是我写文章一贯的风格。我并不喜欢那种客观且枯燥无味的文章,读完之后感觉像流水账,给不了读者任何的指导。我认为文章就是要有观点输出,技术文章也不例外,如果非常看重准确无误的表达,可以直接去看说明文档or源码,那应该是最权威的知识。如有错误或者误解,可以评论区或者私信指出,我积极改正。
来源:juejin.cn/post/7309477710523269174
既然有了Kubernetes,为什么还需要 Istio?
如果您听说过Service Mesh并尝试过Istio,您可能会有以下问题:
- 为什么 Istio 运行在 Kubernetes 上?
- Kubernetes 和服务网格在云原生应用架构中分别扮演什么角色?
- Istio 在哪些方面对Kubernetes进行了扩展?它解决了什么问题?
- Kubernetes、Envoy 和 Istio 之间是什么关系?
本文将带您了解 Kubernetes 和 Istio 的内部工作原理。另外,我还会介绍 Kubernetes 中的负载均衡方法,并解释为什么有了 Kubernetes 还需要 Istio。
Kubernetes 本质上是通过声明性配置进行应用程序生命周期管理,而服务网格本质上是提供应用程序间流量、安全管理和可观察性。如果你已经使用 Kubernetes 搭建了一个稳定的应用平台,那么如何为服务之间的调用设置负载均衡和流量控制呢?这就是服务网格发挥作用的地方。
Envoy 引入了 xDS 协议
,该协议受到各种开源软件的支持,例如Istio、MOSN等。Envoy 将 xDS 贡献给服务网格或云原生基础设施。Envoy 本质上是一个现代版本的代理,可以通过 API 进行配置,并基于它衍生出许多不同的使用场景——例如 API 网关、服务网格中的 sidecar 代理和边缘代理。
本文包含以下内容:
- kube-proxy 作用的描述。
- Kubernetes 对于微服务管理的局限性。
- 介绍 Istio 服务网格的功能。
- Kubernetes、Envoy 和 Istio 服务网格中一些概念的比较。
Kubernetes 与服务网格
下图展示了 Kubernetes 和 Service Mesh(每个 pod 一个 sidecar 模型)中的服务访问关系。
流量转发
Kubernetes 集群中的每个节点都会部署一个 kube-proxy 组件,该组件与 Kubernetes API Server 通信,获取集群中服务的信息,然后设置 iptables 规则,将服务请求直接发送到对应的 Endpoint(属于该集群的 pod)。同一组服务)。
服务发现
Istio 可以跟随 Kubernetes 中的服务注册,还可以通过控制平面中的平台适配器与其他服务发现系统对接;然后使用数据平面的透明代理生成数据平面配置(使用 CRD,存储在 etcd 中)。数据平面的透明代理作为sidecar容器部署在每个应用服务的pod中,所有这些代理都需要请求控制平面同步代理配置。代理是“透明的”,因为应用程序容器完全不知道代理的存在。该进程中的 kube-proxy 组件也需要拦截流量,只不过 kube-proxy 拦截进出 Kubernetes 节点的流量,而 sidecar 代理拦截进出 pod 的流量。
服务网格的缺点
由于 Kubernetes 每个节点上运行有很多 pod,将原有的 kube-proxy 路由转发功能放在每个 pod 中会增加响应延迟(由于 sidecar 拦截流量时的跳数更多)并消耗更多资源。为了以细粒度的方式管理流量,将添加一系列新的抽象。这会进一步增加用户的学习成本,但随着技术的普及这种情况会慢慢得到缓解。
服务网格的优点
kube-proxy 设置是全局的,无法对每个服务进行精细控制,而服务网格通过 sidecar 代理将流量控制从 Kubernetes 的服务层中取出,从而实现更大的弹性。
Kube-Proxy 的缺点
首先,如果转发的 Pod 无法正常服务,它不会自动尝试另一个 Pod。每个 pod 都有健康检查机制,当 pod 出现健康问题时,kubelet 会重启 pod,kube-proxy 会删除相应的转发规则。此外,nodePort 类型的服务无法添加 TLS 或更复杂的消息路由机制。
Kube-proxy 实现了 Kubernetes 服务的多个 pod 实例之间的流量负载均衡,但是如何对这些服务之间的流量进行细粒度控制——例如将流量按百分比划分到不同的应用程序版本(这些版本都是同一个应用程序的一部分)服务但在不同的部署上),或者进行灰度发布和蓝绿发布?
Kubernetes 社区提供了一种使用 Deployment 进行灰度发布方法,这本质上是一种通过修改 pod 标签将不同 pod 分配给部署服务的方法
。
Kubernetes Ingress 与 Istio 网关
如上所述,kube-proxy 只能在 Kubernetes 集群内路由流量。Kubernetes 集群的 Pod 位于 CNI 创建的网络中。入口(在 Kubernetes 中创建的资源对象)是为了集群外部的通信而创建的。它由位于 Kubernetes 边缘节点上的入口控制器驱动,负责管理南北流量。Ingress 必须对接各种 Ingress Controller,例如nginx ingress 控制器。Ingress仅适用于HTTP流量,使用简单。它只能通过匹配有限数量的字段(例如服务、端口、HTTP 路径等)来路由流量。这使得无法路由 MySQL、Redis 和各种 RPC 等 TCP 流量。这就是为什么你会看到人们在入口资源注释中编写 nginx 配置语言。直接路由南北流量的唯一方法是使用服务的 LoadBalancer 或 NodePort,前者需要云供应商支持,后者需要额外的端口管理。
Istio Gateway 的功能与 Kubernetes Ingress 类似,负责进出集群的南北向流量
。Istio Gateway 描述了一种负载均衡器,用于承载进出网格边缘的连接。该规范描述了一组开放端口以及这些端口使用的协议、用于负载均衡的 SNI 配置等。 Gateway 是一个 CRD 扩展,它也重用了 sidecar 代理的功能;详细配置请参见Istio 网站
。
Envoy
Envoy 是 Istio 中默认的 sidecar 代理。Istio 基于 Enovy 的 xDS 协议扩展了其控制平面。在谈论 Envoy 的 xDS 协议之前,我们需要先熟悉一下 Envoy 的基本术语。以下是 Envoy 中的基本术语及其数据结构列表;请参阅Envoy 文档
了解更多详细信息。
基本术语
以下是您应该了解的 Enovy 基本术语。
- Downstream:下游主机连接 Envoy,发送请求,接收响应;
即发送请求的主机
。 - Upstream:上游主机接收来自 Envoy 的连接和请求并返回响应;
即接收请求的主机
。 - Listener:Listener 是一个命名的网络地址(例如端口、UNIX 域套接字等);下游客户端可以连接到这些侦听器。Envoy 向下游主机公开一个或多个侦听器以进行连接。
- Cluster:集群是 Envoy 连接的一组逻辑上相同的上游主机。Envoy 通过服务发现来发现集群的成员。或者,可以通过主动健康检查来确定集群成员的健康状态。Envoy 通过负载均衡策略决定集群中的哪个成员来路由请求。
Envoy 中可以设置多个监听器,每个监听器可以设置一个过滤器链(过滤器链表),并且过滤器是可扩展的,以便我们可以更轻松地操纵流量的行为——例如设置加密、私有 RPC 等。
xDS 协议由 Envoy 提出,是 Istio 中默认的 sidecar 代理,但只要实现了 xDS 协议,理论上就可以在 Istio 中用作 sidecar 代理——比如蚂蚁集团开源的MOSN。
Istio 是一个功能非常丰富的服务网格,包括以下功能。
- 流量管理:这是Istio最基本的功能。
- 策略控制:启用访问控制系统、遥测捕获、配额管理、计费等。
- 可观察性:在 sidecar 代理中实现。
- 安全身份验证:Citadel 组件执行密钥和证书管理。
Istio 中的流量管理
Istio 中定义了以下 CRD 来帮助用户进行流量管理。
- 网关:网关描述了运行在网络边缘的负载均衡器,用于接收传入或传出的 HTTP/TCP 连接。
- VirtualService:VirtualService 实际上将 Kubernetes 服务连接到 Istio 网关。它还可以执行其他操作,例如定义一组在寻址主机时应用的流量路由规则。
- DestinationRule:DestinationRule 定义的策略决定流量经过路由后的访问策略。简而言之,它定义了流量的路由方式。其中,这些策略可以定义为负载均衡配置、连接池大小和外部检测(用于识别并驱逐负载均衡池中不健康的主机)配置。
- EnvoyFilter:EnvoyFilter 对象描述代理服务的过滤器,可以自定义 Istio Pilot 生成的代理配置。这种配置一般初级用户很少使用。
- ServiceEntry:默认情况下,Istio 服务网格中的服务无法发现网格之外的服务。ServiceEntry 允许将其他条目添加到 Istio 内的服务注册表中,从而允许网格中自动发现的服务访问并路由到这些手动添加的服务。
Kubernetes、xDS、Istio
回顾了 Kubernetes 的 kube-proxy 组件、xDS 和 Istio 中流量管理的抽象之后,现在让我们仅在流量管理方面对这三个组件/协议进行比较(请注意,这三个组件并不完全相同)。
要点
- Kubernetes 的本质是应用程序生命周期管理,特别是部署和管理(伸缩、自动恢复、发布)。
- Kubernetes 为微服务提供了可扩展且高弹性的部署和管理平台。
- 服务网格基于透明代理,通过 sidecar 代理拦截服务之间的流量,然后通过控制平面配置管理它们的行为。
- 服务网格将流量管理与 Kubernetes 解耦,无需 kube-proxy 组件来支持服务网格内的流量;通过提供更接近微服务应用程序层的抽象来管理服务间流量、安全性和可观察性。
- xDS 是服务网格配置的协议标准之一。
- 服务网格是 Kubernetes 中服务的更高级别抽象。
概括
如果说 Kubernetes 管理的对象是 Pod,那么 Service Mesh 管理的对象就是服务
,所以只要用 Kubernetes 来管理微服务,然后应用 Service Mesh 就可以了。如果您甚至不想管理服务,那么可以使用像Knative
这样的无服务器平台z。
来源:juejin.cn/post/7310878133720301604
2024我给公司亏钱了,数据一致性问题真的马虎不得
最近五阳遇到了线上资损问题,我开始重视分布式事务的数据一致性问题,拿我擅长的场景分析下。
举个🌰例子
在付费会员场景,用户购买会员后享受会员权益。在会员售后场景,用户提交售后,系统需要冻结权益并且原路赔付退款。
系统如何保证冻结权益和订单退款的数据一致性呢?当无法保证数据一致时,会导致什么问题呢?
标题 | 业务结果 |
---|---|
订单退款,但未冻结权益 | 平台资金损失 |
订单未退款,但权益冻结 | 用户资金损失 |
订单退款,权益冻结 | 正常 |
通过这个例子可以看到电商场景中,数据不一致可能会导致资金损失。这是电商场景对数据一致性要求高的原因,很多资损(资金损失)问题都是源于数据不一致。
如何理解数据一致性,一致性体现在哪里?
狭义上的数据一致是指:数据完全相同,在数据库主从延迟场景,主从数据一致是指:主数据副本和从数据副本,数据完全相同,客户端查询主库和查询从库得到的结果是相同的,也就是一致的。
除数据多副本场景使用数据一致性的概念之外,扩展后其他场景也使用这个概念。例如分布式事务中,多个事务参与者各自维护一种数据,当多种数据均处于合法状态且符合业务逻辑的情况下,那就可以说整体处于数据一致了。(并不像副本场景要求数据完全相同)
例如会员订单有支付状态和退款状态,会员优惠券有未使用状态和冻结状态。 在一次分布式事务执行前后,订单和优惠券的状态是一致的,即会员订单退款、会员券冻结;会员订单未退款,会员券状态为可使用;
此外还有异构数据一致性,超时一致。异构数据一致性是指同一种数据被异构到多种存储中间件。例如本地缓存、Redis缓存和数据库,即三级缓存的数据一致性。还有搜索场景,需要保证数据库数据和 ElasticSearch数据一致性,这也是分布式事务问题。
一致性和原子性的区别
原子性 指的是事务是一个不可分割的最小工作单元,事务中的操作要么全部成功,要么全部失败。
一致性 指的是事务执行前后,所有数据均处于一致性状态,一致性需要原子性的支持。如果没有实现原子性,一致性也无法实现。一致性在原子性的基础上,还要求实现数据的正确性。例如在同一个事务中实现多商品库存扣减,多个SQL除了保证同时成功同时失败外,还需要保证操作的正确性。如果所有SQL都返回成功了,但是数据是错误的,这无法接受。这就是一致性的要求。
由此可见,数据一致性本身就要求了数据是正确的。
隔离性是指:其他事务并发访问同一份数据时,多个事务之间应该保持隔离性。隔离性级别:如读未提交、读已提交、可重复读和串行化。
隔离性强调的是多个事务之间互不影响的程度,一致性强调的是一个事务前后,数据均处于一致状态。
什么是强一致性
在分布式事务场景,强一致性是指:任何一个时刻,看到各个事务参与者的数据都是一致的。系统不存在不一致的情况。
值得一提的是,CAP理论指出,数据存在多副本情况下,要保证强一致性(在一个绝对时刻,两份数据是完全一致的)需要牺牲可用性。
也就是说系统发现自身处于不一致状态时,将向用户返回失败状态。直至数据一致后,才能返回最新数据,这将牺牲可用性。
为了保证系统是可用的,可以返回旧的数据,但是无法保证强一致性。
会员售后能保证强一致性吗?
会员售后关键的两个动作:权益冻结和订单退款。 两者能保证强一致性吗?答案是不能。假设权益冻结是一个本地事务性操作,但是订单退款包括订单状态流程、支付系统资金流转等等。
如此复杂的流程难以保证任意一个绝对时刻,用户看到权益冻结后,资金一定到账了;这是售后场景 无法达到强一致性的根本原因。
最终一致性和强一致性
最终一致性不要求系统在任意一个时刻,各参与方数据都是一致的,它要求各参与方数据在一定时间后处于一致状态。
最终一致性没有明确这个时间是多长,所以有人说最终一致性就是没有一致性,谁知道多久一定能保证一致呢。
保证最终一致性的手段有哪些?
TCC
TCC 包含 Try、Confirm 和 Cancel 三个操作。
- 确保每个参与者(服务)都实现了 Try、Confirm 和 Cancel 操作。
- 确保在业务逻辑中,如果 Try 操作成功,后续必须执行 Confirm 操作以完成事务;如果 Try 失败或者 Cancel 被调用,则执行 Cancel 操作撤销之前的操作。
需要说明,如果Confirm执行失败,需要不停不重试Confirm,不得执行Cancel。 按照TCC的语义 Try操作已经锁定了、预占了资源。 Confirm在业务上一定是可以成功的。
TCC的问题在于 分布式事务的任意一个操作都应该提供三个接口,每个参与者都需要提供三个接口,整体交互协议复杂,开发成本高。当发生嵌套的分布式事务时,很难保证所有参与者都实现TCC规范。
为什么TCC 方案包含Try
如果没有Try阶段,只有Confirm和 Cancel阶段,如果Confirm失败了,则调用Cancel回滚。为什么Tcc不这样设计呢?
引入Try阶段,为保证不发生状态回跳的情况。
Try阶段是预占资源阶段,还未实际修改资源。设想资金转账场景, A账户向B账户转账100元。 在Try阶段 A账户记录了预扣100元,B 账户记录了预收100元。 如果A账户不足100元,那么Try阶段失败,调用Cancel回滚,这种情况,在任意时刻,A、B用户视角转账是失败的。
Try阶段成功,则调用Confirm接口,最终A账户扣100元,B收到100元。虽然这无法保证在某个时刻,A、B账户资金绝对一致。但是如果没有Try阶段,那么将发生 状态回跳的情况;
状态回跳:即A账户操作成功了,但是B账户操作失败,A账户资金又被回滚了。那么用户A 看到自己的账户状态就是 钱被扣了,但是过一会钱又回来了。
同理B账户也可能遇到收到钱了,但是过一会钱又没了。在转账场景,这种回跳情况几乎是不能接受的。
引入了Try阶段,就能保证不发生状态回跳的情况。
最大努力通知
最大努力通知是指通知方通过一定的机制最大努力将业务处理结果通知到接收方。一般用于最终一致性时间敏感度低的场景,并且接收方的结果不会影响到发起方的结果。即接收方处理失败时,发送方不会跟随回滚。
在电商场景,很多场景使用最大努力通知型作为数据一致性方案。
会员售后如何保证最终一致性?
回到开头的问题,以下是数据不一致的两种情况。
标题 | 业务结果 |
---|---|
订单退款,但未冻结权益 | 平台资金损失 |
订单未退款,但权益冻结 | 用户资金损失 |
订单退款,权益冻结 | 正常 |
平台资损难以追回,当发生容易复现的平台资损时,会引来更多的用户“薅羊毛”,资损问题将进一步放大,所以平台资损一定要避免。
当发生用户资损时,用户可以通过客服向平台追诉,通过人工兜底的方式,可以保证“最终”数据是一致的。
所以基于这个大的原则,大部分系统都是优先冻结会员权益,待用户权益明确冻结后,才根据实际冻结情况,向用户退款。这最大程度上保证了售后系统的资金安全。
会员售后整体上是最大努力通知一致性方案。当权益冻结后,系统通过可重试的消息触发订单退款,且务必保证退款成功(即便是人工介入情况下)。虽说是最大努力通知型,但不代表一致性弱,事实上支付系统的稳定性要求是最高级别的,订单退款成功的可靠性是能得到保证的。
如果权益冻结是分布式事务,如何保证一致性
一开始我们假设权益冻结是一个本地事务,能保证强一致性,这通常与实际不符。权益包含很多玩法,例如优惠券、优惠资格、会员日等等,权益冻结并非是本地事务,而是分布式事务,如何保证一致性呢?
方案1 TCC方案
假设 权益系统包含三个下游系统,身份系统(记录某个时间段是会员)、优惠券系统、优惠资格系统。 TCC方案将要求三方系统均实现 Try、Confirm、Cancel接口。开发成本和上下游交付协议比较复杂。
方案2 无Try 的 TCC方案
假设无Try阶段,直接Confirm修改资源,修改失败则调用Cancel,那么就会出现状态跳回情况,即优惠券被冻结了,但是后面又解冻了。
这种情况下,系统只需要实现扣减资源和回滚资源 两种接口。 系统设计大大简化
方案3 Prepare + Confirm
Prepare: 检查接口,即检查资源是否可以被修改,但是不会锁定资源。
Confirm: 修改资源接口,实际修改资源状态。
如果Prepare失败,则返回执行失败,由于未预占用资源,所以无需回滚资源。在Prepare成功后,则立即调用Confirm。如果Confirm执行失败,则人工介入。
一定需要回滚吗?
在一致性要求高的场景,需要资源回滚能力,保证系统在一定时间后处于一致状态。如果没有回滚,势必导致在某些异常情况下,系统处于不一致状态,且无法自动恢复。
会员售后场景虽然对资金较为敏感,但不需要资源回滚。理由如下
- 将订单退款置为 权益成功冻结之后,可以保证系统不出现平台资损。即权益未完全冻结,订单是不是退款的。
- 用户资损情况可以通过人工客服兜底解决。
在上述方案3中,先通过Prepare阶段验证了参与方都是可以冻结的,在实际Confirm阶段这个状态很难发生改变。所以大概率Confirm不会失败的。
只有极低的概率发生Confirm失败的情况,即用户在权益冻结的一瞬间,使用优惠券,这将导致资源状态发生改变。
解决此类问题,可以在权益冻结后,评估冻结结果,根据实际的冻结结果,决定如何赔付用户,赔付用户多少钱。所以用户并发用券,也不会影响资金安全。
人工兜底与数据一致性
程序员应该在业务收益、开发成本、数据不一致风险等多个角度评估系统设计的合理性。
会员售后场景,看似数据一致性要求高,仿佛数据不一致,就会产生严重的资损问题。但实际分析后,系统并非需要严格的一致性。
越是复杂的系统设计,系统稳定性越差。越是简洁的系统设计,系统稳定性越高。当选择了复杂的系统设计提高数据一致性,必然需要付出更高的开发成本和维护成本。往往适得其反。
当遇到数据一致性挑战时,不妨跳出技术视角,尝试站在产品视角,思考能否适当调整一下产品逻辑,容忍系统在极端情况下,有短暂时间数据不一致。人工兜底处理极端情况。
大多数情况下,产品经理会同意。
来源:juejin.cn/post/7397013935105769523
Docker容器日志过大?有没有比较简单的方式解决?
Docker容器日志过大?有没有比较简单的方式解决?
1. 问题描述
当我们尝试查看特定 Docker 容器的日志时,通常会使用 docker logs <容器名称>
命令。然而,有时候会发现控制台持续输出日志信息,持续时间可能相当长,直到最终打印完成。这种现象往往源自对 Docker 容器日志长时间未进行处理,导致日志积累过多,占用了系统磁盘空间。因此,为了释放磁盘空间并优化系统性能,我们可以采取一些简单而有效的方法来处理这些庞大的日志文件。
2. docker日志处理机制
需要处理问题,那我们肯定要先了解docker的日志处理机制,了解了基本的机制,能够帮助我们更好的理解问题并解决问题。
2.1 日志查看
docker logs <容器名称>
可以查看docker容器的输出日志,但是这里的日志主要包含标准输出和标准错误输出,一些容器可能会把日志输出到某个日志文件中,比如tomcat,这样使用docker logs <容器名称>
命令是无法查看的。
注意docker logs
命令查看的是容器的全部日志,当日志量很大时会对容器的运行造成影响,可以通过docker logs --tail N container name
查看最新N行的数据,N是一个整数。
2.2 处理机制
当我们启动一个docker容器时,实际上时作为docker daemon的一个子进程运行的,docker daemon可以拿到容器里进程的标准输出与标准错误输出,并通过docker的log driver模块来处理,大致图示如下:
上面图中所列举的就是所支持的Log Driver:
- none:容器没有日志,
docker logs
不输出任何内容 - local:日志以自定义格式存储
- json-file:日志以json格式存储,默认的Log Driver
- syslog:将日志写入syslog。syslog守护程序必须在主机上运行
- journald:将日志写入journald。journald守护程序必须在主机上运行
- gelf:将日志写入Graylog Extended Log Format端点,如Graylog或Logstash
- fluentd:将日志写入fluentd。fluentd守护程序必须在主机上运行
- awslogs:将日志写入Amazon CloudWatch Logs
- splunk:通过HTTP Event Collector将日志写入splunk
- etwlogs:将日志作为ETW(Event Tracing for Windows)事件写入。只在Windows平台可用
- gcplogs:将日志写入Google Cloud Platform Logging
- logentries:将日志写入Rapid7 Logentries
可以使用命令docker info | grep "Logging Driver"
2.3 默认的json-file
json-file Log Driver是Docker默认启用的Driver,将容器的STDOUT/STDERR输出以json的格式写到宿主机的磁盘,日志文件路径为 /var/lib/docker/containers/{container_id}/{container_id}-json.log
格式是这样的:
json-file将每一行日志封装到一个json字符串中。
json-file支持如下配置:
- max-size:单个日志文件的最大大小,单位可以为k、m、g,默认是-1,表示日志文件可以无限大。
- max-file:最多可以存多少个日志文件,默认数量是1,当默认数量大于1时,每个日志文件达到最大存储大小,且数量达到设置数量,产生新日志时会删除掉最旧的一个日志文件。
- labels:指定日志所使用到的标签,使用逗号分割。比如traceId,message两个标签。
- env:指定与日志相关的环境变量,使用逗号分割
- env-rejex:一个正则表达式来匹配与日志相关的环境变量
- compress:是否压缩日志文件
3. 如何解决?
3.1 查看日志大小
我们可以通过如下脚本获取当前所有容器的日志大小,这里时使用docker默认的json-file的形式:
#!/bin/sh
echo "======== docker containers logs file size ========"
logs=$(find /var/lib/docker/containers/ -name *-json.log)
for log in $logs
do
ls -lh $log
done
执行脚本:
json-file的命令开头的一小串字符时容器的id。
例如我有一个docker容器id是2de6f164ee11,我们可以适当修改shell脚本,查看某一个容器的日志大小。
logs=$(find /var/lib/docker/containers/ -name *-json.log | grep "2de6f164ee11")
3.2 删除日志
如果docker容器正在运行,使用rm -rf的方式删除日志后,磁盘空间并没有释放。原因是在Linux或者Unix系统中,通过rm -rf或者文件管理器删除文件,将会从文件系统的目录结构上解除链接(unlink)。如果文件是被打开的(有一个进程正在使用),那么进程将仍然可以读取该文件,磁盘空间也一直被占用。
正确的方式是直接使用命令改写日志文件。
cat /dev/null > *-json.log
cat
: 是一个命令,用于连接文件并打印它们的内容到标准输出(通常是终端)。/dev/null
: 是一个特殊的设备文件,向它写入的内容会被丢弃,读取它将会立即返回结束符。>
: 是重定向操作符,将命令的输出重定向到文件。*-json.log
: 是通配符,用于匹配当前目录中所有以-json.log
结尾的文件。
可以使用如下脚本,直接处理所有的日志文件:
#!/bin/sh
echo "======== start clean docker containers logs ========"
logs=$(find /var/lib/docker/containers/ -name *-json.log)
for log in $logs
do
echo "clean logs : $log"
cat /dev/null > $log
done
echo "======== end clean docker containers logs ========"
注意,虽然使用这种方式可以删除日志,释放磁盘,但是过一段时间后,日志又会涨回来,所以要从根本上解决问题,只需要添加两个参数。没错!就是上面所讲到的max-size和max-file。
3.3 治本操作
在运行docker容器时,添加上max-size和max-file可以解决日志一直增长的问题。
docker run -it --log-opt max-size=10m --log-opt max-file=3 alpine ash
这段启动命令表示总共有三个日志文件,每个文件的最大大小时10m,这样就能将该容器的日志大小控制在最大30m。
4. 总结
在运行容器时,我们就应该优先考虑如何处理日志的问题,后面不必为容器运行后所产生的巨大日志而手足无措。
当然需要删除无用日志可以通过3.1,3.2的操作完成,建议在运行容器的时候加上max-size和max-file参数或者至少加上max-size参数。
来源:juejin.cn/post/7343178660069179432
从组件库中学习颜色主题配置
前言
对于一般前端来说,在颜色选择配置上可能没有设计师那么专业,特别在某些项目中的一些场景颜色配置上可能都是用相近的颜色或者透明度来匹配,没有一个专门的颜色对比输出。
所以本文想给大家讲一下主题色的配置应用,其中antd组件库
给我们提供了十二种自然主题色板,在美感和视觉上感觉非常的舒适自然,可以参考来使用。
我们以antd组件库
的色彩体系中的火山主题为例来讲解下面的内容。(本文会涉及到 sass
的语法)
其中提供的主题色每一种都有从浅至深有 10
个颜色,一般以第 6
种为主题的主色,其中的一些场景也给出了我们对应的颜色级别。
以图为例,告诉我们常用的场景对应的颜色深浅级别
- selected 选中:颜色值为
1
- hover 悬浮:颜色值为
5
- click 点击:颜色值为
7
主题色场景
以上述场景为例我们来实践一下,先列出10种颜色,然后写入在 css
变量中
$color-valcano: (
'valcano-1': #fff2e8,
'valcano-2': #ffd8bf,
'valcano-3': #ffbb96,
'valcano-4': #ff9c6e,
'valcano-5': #ff7a45,
'valcano-6': #fa541c,
'valcano-7': #d4380d,
'valcano-8': #ad2102,
'valcano-9': #871400,
'valcano-10': #610b00
);
:root{
@each $attribute, $value in $color-valcano {
#{'--color-#{$attribute}'}: $value
}
}
对于以上的场景,我们只需要应用对应的 css
变量即可
最终的变量如下
:root {
--color-valcano-1: #fff2e8;
--color-valcano-2: #ffd8bf;
--color-valcano-3: #ffbb96;
--color-valcano-4: #ff9c6e;
--color-valcano-5: #ff7a45;
--color-valcano-6: #fa541c;
--color-valcano-7: #d4380d;
--color-valcano-8: #ad2102;
--color-valcano-9: #871400;
--color-valcano-10: #610b00;
}
示例如下
通过变量后缀带数字这种非常难记住对应的场景值,不利于开发,我们可以再优化一下,把对应的场景细化出来,存储对应的颜色级别。
$scence-color-level: (
'primary': 6,
'selected': 1,
'hover': 5,
'border': 5,
'click': 7
);
:root{
@each $attribute, $value in $scence-color-level {
#{'--color-#{$attribute}'}: map-get($color-valcano, #{'valcano-#{$value}'})
}
}
我们来看看转换之后的变量
:root {
--color-primary: #fa541c;
--color-selected: #fff2e8;
--color-hover: #ff7a45;
--color-border: #ff7a45;
--color-click: #d4380d;
}
这样遇到对应的变量我们就可以不用关心颜色的深浅级别,只需要找对应场景,例如 selected
场景只需要使用变量 var(--color-selected)
就可以了。
当我们想切换其他主题的时候,难道要全部重写一遍,手动变更吗?
我们再来优化一下,将主题的变量变成动态的
$theme: 'valcano';
$theme-color: (
'valcano': $color-valcano,
'lime': $color-lime,
'cyan': $color-cyan,
'purple': $color-purple
);
:root{
@each $attribute, $value in $scence-color-level {
#{'--color-#{$attribute}'}: map-get(map-get($theme-color,$theme), #{'#{$theme}-#{$value}'})
}
}
以代码为例 引入了四种主题valcano
lime
cyan
purple
,若要切换主题,只需要更改变量$theme
即可
可以在代码片段中的 style
中手动更改$theme
变量值,然后运行查看效果
element组件库主题切换
了解完原理并实践之后,我们来看看 element组件库 的切换主题的原理是怎样的?
这是element的主题变量
$colors: () !default;
$colors: map.deep-merge(
(
'white': #ffffff,
'black': #000000,
'primary': (
'base': #409eff,
),
'success': (
'base': #67c23a,
),
'warning': (
'base': #e6a23c,
),
'danger': (
'base': #f56c6c,
),
'error': (
'base': #f56c6c,
),
'info': (
'base': #909399,
),
),
$colors
);
官网提供的覆盖方法
// styles/element/index.scss /* 只需要重写你需要的即可 */
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
$colors: ( 'primary': ( 'base': green, ), ),
);
// 如果只是按需导入,则可以忽略以下内容。
// 如果你想导入所有样式:
// @use "element-plus/theme-chalk/src/index.scss" as *;
官网定义的主题变量是通过 map.deep-merge
来实现主题映射合并。
map.deep-merge
的作用是:用于深度合并两个或多个映射(maps)。它可以在不丢失嵌套映射的情况下合并映射,这对于处理复杂的配置数据结构非常有用。
所以其实就是通过新的配置去合并覆盖它,有点类似 Object.assign()
这种变量对象覆盖的感觉。
其中element组件库也是通过sass函数自动生成需要用到的 css 变量来重构整一个样式系统。
为什么最后都转变成css变量?
- 兼容性:因为CSS 变量是一个非常有用的功能,几乎所有浏览器都支持。
- 动态性:每个组件都是有对应的css变量,想要改变颜色,只需要动态地改变组件内的个别变量即可。
- 多样性:也可以通过js来控制css变量
来源:juejin.cn/post/7398340132161994793
好烦啊,1个SQL干崩核心系统长达12小时!
前言
1个SQL干崩核心系统长达12小时!分享一下这次的故障排查过程
1.故障现象
大周末的接到项目组的电话,反馈应用从凌晨4点开始持续卡顿,起初并未关注,到下午2点左右,核心系统是彻底干绷了,远程接入后发现,数据库后台有大量的异常等待事件
enq:TX -index contention
cursor: pin S wait on X
direct path read
通过监控发现服务器IO和CPU使用率已经高达90%
整个数据库算是夯住了!
根据经验判断应该是性能的问题
2.排查过程
2.1 AWR分析
对于这种性能的问题,首先采集到AWR报告并结合ASH报告分析一下
Direct path read事件尽然排到了第一位!占DB time高达63%,这个等待事件是让一些不常使用的大表数据(冷数据),在全表扫描时,每次都从磁盘读到用户的私有内存(PGA),而不要去挤占有限的、宝贵的、频繁使用的数据(热数据)所在的共享内存(SGA-buffer cache)。
2.2 定位异常SQL
对该TOP SQL分析发现,sql执行频繁,怀疑是执行计划发生变化,如果不把导致问题的根本原因找到,那么很有可能下次还会再发生!
2.3 分析执行计划
通过定位SQL Id,我们去看内存中的执行计划,明显看到了执行计划发生了变化,全表扫占用大量的IO,这里查看执行计划的方法很多。
--该方法是从共享池得到
如果SQL已被age out出share pool,则查找不到
select * from table
(dbms_xplan.display_cursor('&sql_id',null,'typical'));
--该方法是通过awr中得到
select * from table(dbms_xplan.display_awr('&sql_id'));
此时再追踪历史的执行计划发现,从凌晨故障发生开始,执行计划就发生了变化,SQL执行耗费到CPU的平均时间高达上百秒,历史执行计划再次验证了我的判断!
2.4 故障定位
跟业务确认得知,在凌晨业务人员发现,存储空间不够,删除了分区的来释放空间,此处相当于对表结构做了修改,执行计划发生了变化,再加上故障SQL的对应分区,统计信息一直未收集导致这次执行计划发生改变!
3.处理过程
1.定位到SQL的内存地址,从内存中刷出执行计划
select address,hash_value,
executions,parse_calls
from v$sqlarea where
sql_id='4ca86dg34xg62';
--刷出内存
exec sys.dbms_shared_pool.purge('C000000A4C502F40,4103674309','C');
2.收集分区统计信息
BEGIN
-- 为整个表加上统计信息(包括所有分区)
DBMS_STATS.GATHER_TABLE_STATS(
ownname => 'YOUR_SCHEMA', -- 替换为你的模式名
tabname => 'YOUR_PARTITIONED_TABLE', -- 替换为你的分区表名
cascade => TRUE, -- 收集所有分区的统计信息
estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE, -- 自动估算采样百分比
method_opt => 'FOR ALL COLUMNS SIZE AUTO', -- 为所有列自动决定采样大小
degree => DBMS_STATS.DEFAULT_DEGREE -- 使用默认并行度
);
END;
/
此时我们再次查看执行计划,正确了!
4.技能拓扑
分区索引的失效,会引起执行计划的改变
1.TRUNCATE、DROP 操作可以导致该分区表的全局索引失效,
而分区索引依然有效,如果操作的分区没有数据,
那么不会影响索引的状态。
需要注意的是,
对分区表的 ADD 操作对分区索引和全局索引没有影响。
2.如果执行 SPLIT 的目标分区含有数据,
那么在执行 SPLIT 操作后,全局索引和分区索引都会
被被置为 UNUSABLE。
如果执行 SPLIT 的目标分区没有数据,
那么不会影响索引的状态。
3.对分区表执行 MOVE 操作后,
全局索引和分区索引都会被置于无效状态。
4.对于分区表而言,除了 ADD 操作之外,
TRUNCATE、DROP、EXCHANGE 和 SPLIT
操作均会导致全局索引失效,
但是可以加上 UPDATE GLOBAL INDEXES 子句让全局索引不失效。
在 12C 之前的版本,对分区表进行删除分区或者 TRUNCATE 分区,合并或者分裂分区MOVE 分区等 DDL 操作时,分区表上的全局索引会失效,通常要加上 UPDATE GLOBAIINDEXES 或者 ONLINE 关键字,可是加上这些关键字之后,本来很快的 DDL 操作可能就要花费很长的时间,而且还要面临锁的问题。“
Oracle 12C推出了分区表全局索引异步维护特性这个特性有效的解决了这个问题,在对分区表进行上述 DDL 操作时,既能快速完成操作,也能保证全局索引有效,然后通过调度JOB 在固定的时候对全局索引进行维护。“
5.总结
警惕Oracle数据库性能“隐形杀手”——Direct Path Read, 如果不把导致问题的根本原因找到,那么很有可能下次还会再发生!
来源:juejin.cn/post/7387610960159473676
内网开发提效指南
❝
工欲善其事必先利其器,使用过内网开发的小伙伴都知道,CV大法在内网基本就废了,查资料也是非常的不便。对于一名程序员来说,如果把搜索引擎和CV键给他ban了,遇到问题后那基本是寸步难行。今天给大家介绍几种帮助内网开发提效的方法,希望能够帮助到大家。
一、文档站点内网部署
可以把项目中所用技术和框架的文档部署到公司内网中。
以elementPlus为例:
1、访问gh-pages分支https://github.com/element-plus/element-plus/tree/gh-pages
,下载文档站源码。
2、将文档站部署到内网服务器(以nginx为例)。
server {
listen 9800;
server_name localhost;
location / {
root html/element-plus-gh-pages;
index index.html index.htm;
try_files $uri $uri/ /element-plus-gh-pages/index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
部署后的访问速度也是非常快的
使用这种方式,随着部署的站点增多,后续框架、文档更新的时候,维护起来相对是比较麻烦的。且只能查看文档,遇到问题需要求助度娘还是不太方便。
下面介绍两种物理外挂,可以直接访问外网。
二、USB跨屏穿越器数据线
个人感觉此方案的体验是最好的,缺点是需要两台电脑,并且需要花钱买一根线,价格在80-200之间。
购买
某宝、某鱼都有销售,我是在某宝85块买的。
使用
连接两台电脑的USB端口即可,会自动安装驱动,那根线实际上就相当于是一个文件中转器,可以实现剪切板、文件的互传。使用体验就跟一台电脑连接了两台显示器一样。如下图所示:
三、手机投屏
本文重点介绍此方案,因为可以白嫖且不需要第二台电脑。一部安卓手机+数据线即可,缺点是文件传输不太方便。它就是一个开源投屏项目scrcpy
。可以看到,此项目在github上拥有高达102k的star数量。
✨亮点
- 亮度 (原生,仅显示设备屏幕)
- 表演 (30~60fps)
- 质量 (1920×1080或以上)
- 低延迟 (70~100ms)
- 启动时间短 (显示第一张图像约1秒)
- 非侵入性 (设备上没有安装任何东西)
- 不需要 ROOT
- 有线无线都可连接
- 可以随便调整界面和码率
- 画面随意裁剪,自带录屏(手游直播利器)
- 支持多设备同时投屏
- 利用电脑的键盘和鼠标可以控制手机
- 把 APK 文件拖拽到电脑窗口即可安装应用到手机,把普通文件拖拽到窗口即可复制到手机
- 手机电脑共享剪贴板
- 自动检测USB连接的设备
- 可直接添加设备的局域网IP,达到无线控制的效果
- 将自动保存连接过的IP地址,下次输入时,自动提醒
- 支持设备别名
- 支持中英两种语言
- Tray menu
- 等等等...
安装
根据不同系统直接去release页面下载对应版本即可:github.com/Genymobile/…
使用
下载解压完,进入软件目录,点击下图按钮打开命令行界面,输入启动命令即可。
命令行输入scrcpy,按回车, 猿神,起洞!
启动之后,即可使用鼠标操作手机,非常的丝滑
1、手机复制文本到电脑
2、电脑复制文本到手机
可以看到,使用投屏的方式,也可以实现CV大法。并且可以使用手机端的外网搜索资料、解决问题等。以下是该项目的快捷键,熟练使用,即可达到人机合一的地步。
快捷键
操作 | 快捷键 | 快捷键 (macOS) | ||
---|---|---|---|---|
切换全屏模式 | Ctrl +f | Cmd +f | ||
将窗口调整为 1:1 | Ctrl +g | Cmd +g | ||
调整窗口大小以删除黑色边框 | Ctrl +x | 双击黑色背景 | Cmd +x | 双击黑色背景 |
设备HOME 键 | Ctrl +h | 鼠标中键 | Ctrl +h | 鼠标中键 |
设备BACK 键 | Ctrl +b | 鼠标右键 | Cmd +b | 鼠标右键 |
设备任务管理 键 | Ctrl +s | Cmd +s | ||
设备菜单 键 | Ctrl +m | Ctrl +m | ||
设备音量+ 键 | Ctrl +↑ | Cmd +↑ | ||
设备音量- 键 | Ctrl +↓ | Cmd +↓ | ||
设备电源 键 | Ctrl +p | Cmd +p | ||
点亮手机屏幕 | 鼠标右键 | 鼠标右键 | ||
关闭设备屏幕(保持镜像) | Ctrl +o | Cmd +o | ||
展开通知面板 | Ctrl +n | Cmd +n | ||
折叠通知面板 | Ctrl +Shift +n | Cmd +Shift +n | ||
将设备剪贴板中的内容复制到计算机 | Ctrl +c | Cmd +c | ||
将计算机剪贴板中的内容粘贴到设备 | Ctrl +v | Cmd +v | ||
将计算机剪贴板中的内容复制到设备 | Ctrl +Shift +v | Cmd +Shift +v | ||
安装APK | 将APK 文件拖入投屏 | 将APK 文件拖入投屏 | ||
传输文件到设备 | 将文件拖入投屏 | 将文件拖入投屏 | ||
启用/禁用FPS计数器(stdout) | Ctrl +i | Cmd +i |
使用小技巧
经过笔者几天的使用,总结出几个小技巧。
1、电脑键盘控制手机进行中文输入,必须使用正确的输入法组合。
手机端:讯飞输入法(搜狗输入法不支持)
电脑端:ENG(使用英文键盘)
2、手机熄屏状态下投屏。 在scrcpy命令后加上熄屏参数即可:scrcpy --turn-screen-off
这样就可以在手机熄屏的状态下,仍可以被电脑操作,达到节省电量和减轻发热的目的。
诸如此类的命令参数还有很多,执行scrcpy --help
就可查看详细的帮助文档。
衍生项目
因为开源的特性,scrcpy也衍生了一些相关项目,列举其中一些:
- QtScrcpy 使用qt重新实现的桌面端,并加强了对游戏的支持。
- scrcpy-gui 为scrcpy的命令行提供了gui界面。
- guiscrcpy 另一个scrcpy的gui界面。
- scrcpy-docker docker版本的scrcpy。
- scrcpy-go go语言版本的scrcpy,增强对游戏的支持。
总结
第二第三种方法虽然建立了内网开发电脑和外网设备的联系,但是是不会被公司的安全系统检测到一机双网的,因为其本质就类似于设计模式中的发布订阅模式,用数据线充当了中间人,两台设备之间方便传输数据了而已,不会涉及到联网。
内网开发的痛点,无非就是复制粘贴、文件传输不便,只要打通这个链路,就能解决此问题。以上三个方法,笔者在实际工作中都用到了,确实极大的提高了工作效率。如果你也在饱受内网开发的折磨,不妨试试这几个方法。
来源:juejin.cn/post/7362464700879716403
独立开发最重要的还是产品要打出差异化
独立开发者解放思维,开放眼界真的很有必要。就算自己一时没有好的 idea,也可以多观察学习一些(成功的)非主流的独立产品。我之前写文说过独立开发有死亡加速三件套:笔记、记账、todo。但是不得不说我还是保守了,俗话说的好:勇敢的人先享受世界。既然我单个产品都没优势,那我 all in one,把笔记、记账、待办都做到一个 app 里行不行?
直觉上我感觉这样不行吧?但是,啊?
原来真有人吃粉会点全家福啊。于是我想你做是能做但是用户就会喜欢吗?
我直接一个好家伙,两千多评价,4.7分!
嫉妒使我面目全非了。看了一下会员价格,买断28。这个 app 在没有苹果推荐的情况下,做到了两千多评价,我估计用户数大于 5 万。我看了一下上线时间,这个 app 前后开发持续了一年多的时间。如果我按照付费转化10%算,那么这个 app 扣掉 15% 苹果渠道费后收入大概有 12 万(50000 * 10% * 28 * 0.85)。但是这个 app 如果运营得当,后面还是会有新用户进来。因为app是极简(没有设计师的委婉说法),设计成本几乎没有。以当下的数据看,算是回本了。后面再来的增长都是利润了。
定位分析:笔记、待办、记账是很主流的用户需求,但是也有很多入门用户都只有很轻度的需求,垂直的 app 都太专业了,如果我想要这些数据在一起,很轻量,没有广告,设计简洁,又一次买断,那么市场上就没有对应的产品。
完成这个app 有两个难点:首先你要判断市场有这么一块空白,第二个难点,因为任何一个单点都没有优势,怎样触达目标用户。
我看了作者的小红书,他选择了一个很新颖的切入点:学生群体。他有一个宣传口号是“专注学生自律养成的工具 app”。学生群体不会有特别重度的需求,又集中,因为付费转化有限所以垂类专业app都不太考虑学生群体,在这个用户定位这个app又找到了独特的切入点。
这个定位有多重要呢,这个帖子带来的用户下载量就能达到 10000。
总的来说,这个 app 给我带来了很大的震撼。希望也能给大家带来启发。
来源:juejin.cn/post/7267409589066563642
一个高并发项目到落地的心酸路
前言
最近闲来没事,一直在掘金上摸鱼,看了不少高并发相关的文章,突然有感而发,想到了几年前做的一个项目,也多少和高并发有点关系。
这里我一边回忆落地细节一边和大家分享下,兴许能给大家带来点灵感。
正文
需求及背景
先来介绍下需求,首先项目是一个志愿填报系统。
核心功能是两块,一是给考试填报志愿,二是给老师维护考生数据。
本来这个项目不是我们负责,奈何去年公司负责这个项目的组遭到了甲方严重的投诉,说很多考生用起来卡顿,甚至把没填上志愿的责任归到系统上。
甲方明确要求,如果这年再出现这种情况,公司在该省的所有项目将面临被替换的风险。
讨论来讨论去,最后公司将任务落到我们头上时,已经是几个月后的事了,到临危受命阶段,剩下不到半年时间。
虽然直属领导让我们不要有心理负担,做好了表扬,做不好锅也不是我们的,但明显感觉到得到他的压力,毕竟一个不小心就能上新闻。
分析
既然开始做了,再说那些有的没的就没用了,直接开始分析需求。
首先,业务逻辑并不算复杂,难点是在并发和数据准确性上。与客户沟通后,大致了解了并发要求后,于是梳理了下。
- 考生端登录接口、考生志愿信息查询接口需要4W QPS
- 考生保存志愿接口,需要2W TPS
- 报考信息查询4W QPS
- 老师端需要4k QPS
- 导入等接口没限制,可以异步处理,只要保证将全部信息更新一遍在20分钟以内即可,同时故障恢复的时间必须在20分钟以内(硬性要求)
- 考生端数据要求绝对精准,不能出现遗漏、错误等和考生操作不一致的数据
- 数据脱敏,防伪
- 资源是有限的,提供几台物理机
大的要求就这么多,主要是在有限资源下需要达到如此高的并发确实需要思考思考,一般的crud根本达不到要求。
方案研讨
接下来我会从当时我们切入问题的点开始,从前期设计到项目落地整个过程的问题及思考,一步步去展示这个项目如何实现的
首先,我们没有去设计表,没有去设计接口,而是先去测试。测试什么?测试我们需要用到或可能用到的中间件是否满足需求
MySQL
首先是MySQL,单节点MySQL测试它的读和取性能,新建一张user表。
向里面并发插入数据和查询数据,得到的TPS大概在5k,QPS大概在1.2W。
查询的时候是带id查询,索引列的查询不及id查询,差距大概在1k。
insert和update存在细微并发差距,但基本可以忽略,影响更新性能目前最大的问题是索引。
如果表中带索引,将降低1k-1.5k的TPS。
目前结论是,mysql不能达到要求,能不能考虑其他架构,比如mysql主从复制,写和读分开。
测试后,还是放弃,主从复制结构会影响更新,大概下降几百,而且单写的TPS也不能达到要求。
至此结论是,mysql直接上的方案肯定是不可行的
Redis
既然MySQL直接查询和写入不满足要求,自然而然想到加入redis缓存。于是开始测试缓存,也从单节点redis开始测试。
get指令QPS达到了惊人的10w,set指令TPS也有8W,意料之中也惊喜了下,仿佛看到了曙光。
但是,redis容易丢失数据,需要考虑高可用方案
实现方案
既然redis满足要求,那么数据全从redis取,持久化仍然交给mysql,写库的时候先发消息,再异步写入数据库。
最后大体就是redis + rocketMQ + mysql的方案。看上去似乎挺简单,当时我们也这样以为 ,但是实际情况却是,我们过于天真了。
这里主要以最重要也是要求最高的保存志愿信息接口开始攻略
故障恢复
第一个想到的是,这些个节点挂了怎么办?
mysql挂了比较简单,他自己的机制就决定了他即使挂掉,重启后仍能恢复数据,这个可以不考虑。
rocketMQ一般情况下挂掉了可能会丢失数据,经过测试发现,在高并发下,确实存在丢消息的现象。
原因是它为了更加高效,默认采用的是异步落盘的模式,这里为了保证消息的绝对不丢失,修改成同步落盘模式。
然后是最关键的redis,不管哪种模式,redis在高并发下挂掉,都会存在丢失数据的风险。
数据丢失对于这个项目格外致命,优先级甚至高于并发的要求。
于是,问题难点来到了如何保证redis数据正确,讨论过后,决定开启redis事务。
保存接口的流程就变成了以下步骤:
1.redis 开启事务,更新redis数据
2.rocketMQ同步落盘
3.redis 提交事务
4.mysql异步入库
我们来看下这个接口可能存在的问题。
第一步,如果redis开始事务或更新redis数据失败,页面报错,对于数据正确性没有影响
第二步,如果rocketMQ落盘报错,那么就会有两种情况。
情况一,落盘失败,消息发送失败,好像没什么影响,直接报错就可。
情况二,如果发送消息成功,但提示发送失败(无论什么原因),这时候将导致mysql和redis数据的最终不一致。
如何处理?怎么知道是redis的有问题还是mysql的有问题?出现这种情况时,如果考生不继续操作,那么这条错误的数据必定无法被更新正确。
考虑到这个问题,我们决定引入一个时间戳字段,同时启动一个定时任务,比较mysql和redis不一致的情况,并自主修复数据。
首先,redis中记录时间戳,同时在消息中也带上这个时间戳并在入库时记录到表中。
然后,定时任务30分钟执行一次,比较redis中的时间戳是否小于mysql,如果小于,便更新redis中数据。如果大于,则不做处理。
同时,这里再做一层优化,凌晨的时候执行一个定时任务,比较redis中时间戳大于mysql中的时间戳,连续两天这条数据都存在且没有更新操作,将提示给我们手动运维。
然后是第三步,消息提交成功但是redis事务提交失败,和第二步处理结果一致,将被第二个定时任务处理。
这样看下来,即使redis崩掉,也不会丢失数据。
第一轮压测
接口实现后,当时怀着期待,信息满满的去做了压测,结果也是当头棒喝。
首先,数据准确性确实没有问题,不管突然kill掉哪个环节,都能保证数据最终一致性。
但是,TPS却只有4k不到的样子,难道是节点少了?
于是多加了几个节点,但是仍然没有什么起色。问题还是想简单了。
重新分析
经过这次压测,之后一个关键的问题被提了出来,影响接口TPS的到底是什么???
一番讨论过后,第一个结论是:一个接口的响应时间,取决于它最慢的响应时间累加,我们需要知道,这个接口到底慢在哪一步或哪几步?
于是用arthas看了看到底慢在哪里?
结果却是,最慢的竟然是redis修改数据这一步!这和测试的时候完全不一样。于是针对这一步,我们又继续深入探讨。
结论是:
redis本身是一个很优秀的中间件,并发也确实可以,选型时的测试没有问题。
问题出在IO上,我们是将考生的信息用json字符串存储到redis中的(为什么不保存成其他数据结构,因为我们提前测试过几种可用的数据结构,发现redis保存json字符串这种性能是最高的),
而考生数据虽然单条大小不算大,但是在高并发下的上行带宽却是被打满的。
于是针对这种情况,我们在保存到redis前,用gzip压缩字符串后保存到redis中。
为什么使用gzip压缩方式,因为我们的志愿信息是一个数组,很多重复的数据其实都是字段名称,gzip和其他几个压缩算法比较后,综合考虑到压缩率和性能,在当时选择了这种压缩算法。
针对超过限制的字符串,我们同时会将其拆封成多个(实际没有超过三个的)key存储。
继续压测
又一轮压测下来,效果很不错,TPS从4k来到了8k。不错不错,但是远远不够啊,目标2W,还没到它的一半。
节点不够?加了几个节点,有效果,但不多,最终过不了1W。
继续深入分析,它慢在哪?最后发现卡在了rocketMQ同步落盘上。
同步落盘效率太低?于是压测一波发现,确实如此。
因为同步落盘无论怎么走,都会卡在rocketMQ写磁盘的地方,而且因为前面已经对字符串压缩,也没有带宽问题。
问题到这突然停滞,不知道怎么处理rocketMQ这个点。
同时,另一个同事在测试查询接口时也带来了噩耗,查询接口在1W2左右的地方就上不去了,原因还是卡在带宽上,即使压缩了字符串,带宽仍被打满。
怎么办?考虑许久,最后决定采用较常规的处理方式,那就是数据分区,既然单个rocketMQ服务性能不达标,那么就水平扩展,多增加几个rocketMQ。
不同考生访问的MQ不一样,同时redis也可以数据分区,幸运的是正好redis有哈希槽的架构支持这种方式。
而剩下的问题就是如何解决考生分区的方式,开始考虑的是根据id进行求余的分区,但后来发现这种分区方式数据分布及其不均匀。
后来稍作改变,根据正件号后几位取余分区,数据分布才较为均匀。有了大体解决思路,一顿操作后继续开始压测。
一点小意外
压测之后,结果再次不如人意,TPS和QPS双双不增反降,继续通过arthas排查。
最后发现,redis哈希槽访问时会在主节点先计算key的槽位,而后再将请求转到对应的节点上访问,这个计算过程竟然让性能下降了20%-30%。
于是重新修改代码,在java内存中先计算出哈希槽位,再直接访问对应槽位的redis。如此重新压测,QPS达到了惊人的2W,TPS也有1W2左右。
不错不错,但是也只到了2W,在想上去,又有了瓶颈。
不过这次有了不少经验,马上便发现了问题所在,问题来到了nginx,仍然是一样的问题,带宽!
既然知道原因,解决起来也比较方便,我们将唯一有大带宽的物理机上放上两个节点nginx,通过vip代理出去,访问时会根据考生分区信息访问不同的地址。
压测
已经记不清第几轮压测了,不过这次的结果还算满意,主要查询接口QPS已经来到了惊人的4W,甚至个别接口来到6W甚至更高。
胜利已经在眼前,唯一的问题是,TPS上去不了,最高1W4就跑不动了。
什么原因呢?查了每台redis主要性能指标,发现并没有达到redis的性能瓶颈(上行带宽在65%,cpu使用率也只有50%左右)。
MQ呢?MQ也是一样的情况,那出问题的大概率就是java服务了。分析一波后发现,cpu基本跑到了100%,原来每个节点的最大链接数基本占满,但带宽竟然还有剩余。
静下心来继续深入探讨,连接数为什么会满了?原因是当时使用的SpringBoot的内置容器tomcat,无论如何配置,最大连接数最大同时也就支持1k多点。
那么很简单的公式就能出来,如果一次请求的响应时间在100ms,那么1000 * 1000 / 100 = 10000。
也就是说单节点最大支持的并发也就1W,而现在我们保存的接口响应时间却有300ms,那么最大并发也就是3k多,目前4个分区,看来1W4这个TPS也好像找到了出处了。
接下来就是优化接口响应时间的环节,基本是一步一步走,把能优化的都优化了一遍,最后总算把响应时间控制在了100ms以内。
那么照理来说,现在的TPS应该会来到惊人的4W才对。
再再次压测
怀着忐忑又激动的心情,再一次进入压测环节,于是,TPS竟然来到了惊人的2W5。
当时真心激动了一把,但是冷静之后却也奇怪,按正常逻辑,这里的TPS应该能达到3W6才对。
为了找到哪里还有未发现的坑(怕上线后来惊喜),我们又进一步做了分析,最后在日志上找到了些许端倪。
个别请求在链接redis时报了链接超时,存在0.01%的接口响应时间高于平均值。
于是我们将目光投向了redis连接数上,继续一轮监控,最终在业务实现上找到了答案。
一次保存志愿的接口需要执行5次redis操作,分别是获取锁、获取考生信息、获取志愿信息、修改志愿信息、删除锁,同时还有redis的事务。
而与之相比,查询接口只处理了两次操作,所以对于一次保存志愿的操作来看,单节点的redis最多支持6k多的并发。
为了验证这个观点,我们尝试将redis事务和加锁操作去掉,做对照组压测,发现并发确实如预期的一样有所提升(其实还担心一点,就是抢锁超时)。
准备收工
至此,好像项目的高并发需求都已完成,其他的就是完善完善细节即可。
于是又又又一次迎来了压测,这一次不负众望,重要的两个接口均达到了预期。
这之后便开始真正进入业务实现环节,待整个功能完成,在历时一个半月带两周的加班后,终于迎来了提测。
提测后的问题
功能提测后,第一个问题又又又出现在了redis,当高并发下突然kill掉redis其中一个节点。
因为用的是哈希槽的方式,如果挂掉一个节点,在恢复时重新算槽将非常麻烦且效率很低,如果不恢复,那么将严重影响并发。
于是经过讨论之后,决定将redis也进行手动分区,分区逻辑与MQ的一致。
但是如此做,对管理端就带来了一定影响,因为管理端是列表查询,所以管理端获取数据需要从多个节点的redis中同时获取。
于是管理端单独写了一套获取数据分区的调度逻辑。
第二个问题是管理端接口的性能问题,虽然管理端的要求没考生端高,但扛不住他是分页啊,一次查10个,而且还需要拼接各种数据。
不过有了前面的经验,很快就知道问题出在了哪里,关键还是redis的连接数上,为了降低链接数,这里采用了pipeline拼接多个指令。
上线
一切准备就绪后,就准备开始上线。说一下应用布置情况,8+4+1+2个节点的java服务,其中8个节点考生端,4个管理端,1个定时任务,2个消费者服务。
3个ng,4个考生端,1个管理端。
4个RocketMQ。
4个redis。
2个mysql服务,一主一从,一个定时任务服务。
1个ES服务。
最后顺利上线,虽然发生了个别线上问题,但总体有惊无险,
而真是反馈的并发数也远没有到达我们的系统极限,开始准备的水平扩展方案也没有用上,无数次预演过各个节点的宕机和增加分区,一般在10分钟内恢复系统,不过好在没有排上用场。
最后
整个项目做下来感觉越来越偏离面试中的高并发模式,说实在的也是无赖之举,
偏离的主要原因我认为是项目对数据准确性的要求更高,同时需要完成高并发的要求。
但是经过这个项目的洗礼,在其中也收获颇丰,懂得了去监控服务性能指标,然后也加深了中间件和各种技术的理解。
做完之后虽然累,但也很开心,毕竟在有限的资源下去分析性能瓶颈并完成项目要求后,还是挺有成就感的。
再说点题外话,虽然项目成功挽回了公司在该省的形象,也受到了总公司和领导表扬,但最后也就这样了,
实质性的东西一点没有,这也是我离开这家公司的主要原由。不过事后回想,这段经历确实让人难忘,也给我后来的工作带来了很大的帮助。
从以前的crud,变得能去解决接口性能问题。这之前一遇上,可能两眼茫然或是碰运气,现在慢慢的会根据蛛丝马迹去探究优化方案。
不知道我在这个项目的经历是否能引起大家共鸣?希望这篇文章能对你有所帮助。
来源:juejin.cn/post/7346021356679675967
【年终总结】置顶帖 我们的2023 (• ᴗ •͈)◞︎ᶫᵒᵛᵉ ♡ & 订婚快乐
前言
想着给自己的博客每年一个置顶帖子,于是便有了此文。
1. 博主的自我介绍
我是一名Java开发工作者,20年大学毕业,专业:计算机科学与技术。
当年高考填报志愿的时候,计算机是一个新兴的蓬勃发展的行业,当时选择这个专业的原因时:一是想着毕业之后进入到这个行业从事相关工作,二是想着这个专业在公务员报考中能报考的岗位也很多。但是大四规划未来人生方向的时候,市场给我们开了一个大大的玩笑,互联网行业乱成了一锅粥,有一句话叫做万物皆可转码,再加之培训班疯狂的向社会输送程序员,市场乱象频频发生,培训班出来的新人能把自己包装成4年5年工作经验,然后各个公司为了能降低用人成本,也为了节省培养新人的时间和开支,也是只招聘工作经验大于3、4年以上的程序员,导致市场对于应届生的接收程度极差,都不愿意招聘应届生,班里的很多学生该培训的培训,该考研考公的备考,该转行的转行....
这个时期能走的路只有三条
- 考研 毕业成为大厂码农 (幻想的)
- 考公
- 和现在就成为码农。
考研呢,也考了,大四下半学期备战了半年,怎么说呢,结果不意外,对自己的认知有偏差,如果能报考一个相对稳妥的双非本科,现在说必定也是一个失业的研究生呢,但是收获最少我把计算机四大件(数据结构、计算机网络、操作系统、计算机组成原理)背了个底朝天,也算是在将近毕业的时候努力的学习了一把大学专业基础的知识。
考公呢,那时候我莫名觉得考公绝对不行,当时运筹帷幄,指点江山,告知自己,现在体制内的各种发生的事情和情况导致未来的公务员绝对不是铁饭碗,加之公务员工资毕竟不像成为程序员肉眼可见的工资那么可观,于是果断的放弃了考公务员,真的,现在有巴掌我第一个扇回去。当时可是20年呀,公务员真的不是很卷,你看看现在,后悔 ....
考研失败之后还是找了个互联网的厂子,现在从事这个行业也有三四年时间了,但是行业的前景却并不像七八年前那样的好。
2、2023年
2023年对我来说绝对是人生最重要的一年之一。
上半年是我老婆人生中最重要的时期之一,她面临着毕业压力(一度让她面临抑郁和崩溃,可能你们会说不就是毕业嘛,哪有那么夸张,我只能说每个人的情况不一样,希望你们一辈子也不要遇见像她导师那样的人...)。
正在外边商场吃饭,然后接到导师电话开始开临时组会。
我们如履薄冰,战战兢兢度过了这段时间。终于,在六月份,她顺利毕业了(万幸)。
(顺利毕业)
(surprise)
七月,她也找到了人生中的第一份工作,我们的生活开始步入正轨。
同样七月终于拥有了人生第一辆车。
(开心)
在家人和我们自己的决定下,我们如期在十月订婚了。这对我们来说是非常重要的一步。
(去银行买的金条,打的金镯子,很划算哈 ~)
(自己买的订婚现场的装饰,pdd 买的,至今为止,我还是觉得我布置的最好看。)
(怎么样呀?jym)
(家人们,教你们一招 五粮液、软华子直接拿下老丈人)
(订婚现场,主持人是我一个妹妹,这张图能看清我桌子上都摆了啥,当天我没拍桌子。)
(订婚书一签,你可不就是我的人了 ~)
(直接拿下)
(小样,还想跑)
(往后余生,我们一起走)
本以为人生会一帆风顺地继续下去,但工作没多久,因为是制造业,厂里的有毒气体使得她呼吸道和身体都在变差,她不得不选择辞职(裸辞)。23年的社会结构变迁使得学历贬值,大学生人数的激增导致工作变得更加稀缺,失业潮开始席卷。
老婆从失业到现在一直住在我租的小房间里,但是我相信人生不可能总是一帆风顺的。
3、技术成长
除了完成基本的工作业务(今年也上线了 3个web网站 和 3个小程序)之外的技术栈扩充:
- 学习了 SpringBoot3.0 和 对应的 Spring 的生态。比如 SpringSecurity 6.0 以上 ...
- 还有 Java 17 的一些新特性和性能。
- 学习了 C# 和 .Net (只能说和 Java 很像,补全版的Java),正在用C# 做了一个对接 OpenAI的一个类似 ChatGpt 一样的对话类的网站。(还不是很熟,但是能写了,面向百度编程)
- 学习了 领域驱动 DDD ,真的感觉要长脑子了,很多系统性的东西在慢慢融合。
- 接触了 Vue3 和 TypeScript (嗯,万物都在往面向对象发展 ~)
- 可以从 需求制定,域名、服务器、云服务 产品的选型,技术选型,前后端开发,部署上线,性能优化,中间件搭建,后期的运维测试。都能自己一个人搞下来了。(还是勉勉强强,但是按照我们现在这种模式的话,稍微简单一点的服务还是没啥问题的、单体服务就更没问题了。没错,我就是小公司全能手)
- 现在 Python 和 Shell 脚本 玩得 6 的一 。(脚本真方便,而且这种解释器语言真好用~)
4、个人成长
这一年中,有一丝丝的人生感悟,分享给大家。
在工作上,大家都知道,写代码到一定程度会有质的飞跃。今年我在工作方面有了一些感悟,但随着对知识的深入,我也意识到自己所不知道的更多。我从最基础的 Java、数据库和 Web 开发开始,逐渐深入研究技术,包括 Java、系统设计和架构、各种数据库进阶以及项目管理,然后扩展到安全与性能优化、沟通能力、业务和行业理解,以及技术趋势和新技术。然而,最近我意识到,一切都归结于一个道理:先做人,再做事。懂得做人,任何问题都能迎刃而解。我对此有些许领悟。
在工作中,技术的学习和提升固然至关重要,但随着不断的实践,我逐渐意识到成功并不仅取决于技术能力,更关键的是人格魅力和人际交往能力。我开始认识到要从意识开始转变。
从最开始的技术基础学习,到技术的深入研究,再到全面提升,这是职业发展的必经之路。但是当在职场中积累了一定经验后,我渐渐明白技术只是成功的一部分。在实际工作中,需要与人打交道、管理团队、协调资源,这些都需要更多的人际关系、沟通技巧和领导力。虽然我现在还在追求进步,但这是必经之路。
要学会做人,在职场上就意味着有坚定的原则和良好的职业道德。这意味着以诚实、正直、宽容的态度对待他人,学会尊重和理解他人的不同,善于倾听,愿意分享和帮助他人。
此外,学会做人也包括与同事的良好合作、与上级的有效沟通、与下属的关心和激励。建立良好的人际关系,培养团队合作精神。
我曾听过这样一段话,我们这一代,因为互联网的高速发展,能轻易接触到信息,就以为自己掌握了很多,但这个世界上真正有价值的东西都需付出等价的代价。先学会做人,再学做事。
5、总结与展望
新的一年:
- 常回家看看,暂时定的两个月最少回去一次吧。
- 好好给老婆准备明年春招。
- 好好写一些博客,将自己的技术分享出来。将自己的博客好好运营一下。
- 好好吃饭、锻炼,我实在是太瘦了,身体是革命的本钱。
- 交易,看看大A水有多深。兄弟们 2700 抄底了~
- 攒点钱,再去一次 汉中-留坝县自驾游。
- 抽时间把婚纱照拍了~
什么都会过去的,抓住现在,不是吗?
(我的头像,在汉中狮子沟牧场拍的)
(开心最重要,不是吗?)
(去年全球总决赛[ wbg vs t1]的时候线下场馆拍的,笑死 ~)
(前几天下雪 恐龙 vs 鸭子)
就这些,希望明年年度总结的时候可以将这些都完成 ~
引用《人世间》种的一句话:
从你出生的那一刻起, 端什么碗,吃什么饭, 经历什么事,什么时候和谁结婚, 都是定数。 别太难为自己,顺其自然就好。 人生的剧本, 你早在天堂就看过了, 你之所以选择这个剧本是因为, 这一生中有你认为值得的地方。
jym 加油 !
来源:juejin.cn/post/7310101033635725375
UI: 为啥你这个页面边框1px看起来这么粗?
背景
最近在开发H5,ui稿给的border:1px solid,因为ui稿上的宽度是750px,我们运行的页面宽度是375px,所以我们需要把所以尺寸/2。所以我们可能会想写border:0.5px solid。但是实际上,我们看页面渲染,仍然是渲染1px而不是0.5
示例代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
}
.flex {
display: flex;
}
.item {
margin-right: 10px;
padding: 10px;
font-size: 13px;
line-height: 1;
background-color: rgba(242, 243, 245,1);
}
.active {
color: rgba(250, 100, 0, 1);
font-size: 14px;
border: 0.5px solid ;
}
</style>
</head>
<body>
<div class="flex">
<!-- <div class="item active">
active
</div> -->
<div class="item">
item1
</div>
<div class="item">
item2
</div>
<div class="item">
item3
</div>
</div>
</body>
</html>
在没active的情况下
他们的内容都是占13px
在有active的情况下
active占了14px这个是没问题的,因为它font-size是14px嘛,但是我们是设置了border的宽度是0.5px,但展现的却是1px。
再来看看item
它内容占了16px,它受到相邻元素影响是14px+2px的上下边框
为啥border是1px呢
在 CSS 中,边框可以设置为 0.5px,但在某些情况下,尤其是低分辨率的屏幕上,浏览器可能会将其渲染为 1px 或根本不显示。这是因为某些浏览器和显示设备不支持小于 1px 的边框宽度或不能准确渲染出这样的细小边框。
浏览器渲染机制
- 不同浏览器对于小数像素的处理方式不同。一些浏览器可能会将
0.5px
边框四舍五入为1px
,以确保在所有设备上的一致性。
设备像素比
- 在高 DPI(如 Retina 显示器)设备上,
0.5px
边框可能看起来更清晰,因为这些设备可以渲染更细的边框。 - 在低 DPI 设备上,
0.5px
边框可能会被放大或者根本不会被显示。
解决办法
方法一:使用伪类和定位
.active {
color: rgba(250, 100, 0, 1);
font-size: 14px;
position: relative;
}
.active::after {
content: "";
pointer-events: none;
display: block;
position: absolute;
left: 0;
top: 0;
transform-origin: 0 0;
border: 1px #ff892e solid;
box-sizing: border-box;
width: 100%;
height: 100%;
}
另外的item的内容高度也是14px了符合要求
方法二:使用阴影,使用F12看的时候感觉还是有些问题
.active2 {
margin-left: 10px;
color: rgba(250, 100, 0, 1);
font-size: 14px;
position: relative;
box-shadow: 0 0 0 0.5px #ff892e;
}
方法三:使用svg,但这种自己设置了宽度。
<div class="active">
<svg width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none">
<rect x="0" y="0" width="100" height="100" fill="none" stroke="#ff892e" stroke-width="0.5"></rect>
</svg>
active
</div>
方案四:使用svg加定位,也比较麻烦,而且有其他的问题
<div class="active">
<svg viewBox="0 0 100 100" preserveAspectRatio="none">
<rect x="0" y="0" width="100" height="100" fill="none" stroke="#ff892e" stroke-width="0.5"></rect>
</svg>
<div class="content">active</div>
</div>
.active {
color: rgba(250, 100, 0, 1);
font-size: 14px;
position: relative;
display: inline-block;
}
.active svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
box-sizing: border-box;
}
.active .content {
position: relative;
z-index: 1;
}
方法五:使用一个父元素 比较麻烦
<div class="border-container">
<div class="active">active</div>
</div>
.border-container {
display: inline-block;
padding: 0.5px;
background-color: #ff892e;
}
.active {
color: rgba(250, 100, 0, 1);
font-size: 14px;
background-color: white;
}
最后
在公司里,我们使用的都是方案一,这样active和item它们的内容高度都是14px了。然后我们再给他们的父盒子加上 align-items: center。这样active的高度是14px,其他都是13px了。但是active的高度会比其他item的盒子高1px,具体看个人需求是否添加吧。
来源:juejin.cn/post/7393656776539963407
基于英雄联盟人物的加载动画,奇怪的需求又增加了!
1、背景
前两天老板找到我说有一个需求,要求使用英雄联盟的人物动画制作一个加载中的组件,类似于下面这样:
我定眼一看:这个可以实现,但是需要UI妹子给切图。
老板:UI? 咱们啥时候招的UI !
我:老板,那不中呀,不切图弄不成呀。
老板:下个月绩效给你A。
我:那中,管管管。
2、调研
发动我聪明的秃头,实现这个需求有以下几种方案:
- 切动画帧,没有UI不中❎。
- 去lol客户端看看能不能搞到什么美术素材,3D模型啥的,可能行❓
- 问下 gpt4o,有没有哪个老表收集的有lol英雄的美术素材,如果有那就更得劲了✅。
经过我一番搜索,发现了这个网站:model-viewer,收集了很多英雄联盟的人物模型,模型里面还有各种动画,还给下载。老表,这个需求稳了50%了!
接下来有几种选择:
- 将模型动画转成动画帧,搞成雪碧图,较为麻烦,且动画不支持切换。
- 直接加载模型,将模型放在进度条上,较为简单,支持切换不同动画,而且可以自由过渡。就是模型文件有点大,初始化加载可能耗时较长。但是后续缓存一下就好了。
聪明的我肯定先选第二个方案呀,你糊弄我啊,我糊弄你。
3、实现
web中加载模型可以使用谷歌基于threejs
封装的 model-viewer
, 使用现代的 web component 技术。简单易用。
先初始化一个vue工程
npm create vue@latest
然后将里面的初始化的组件和app.vue里面的内容都删除。
安装model-viewer
依赖:
npm i three // 前置依赖
npm i @google/model-viewer
修改vite.config.js
,将model-viewer
视为自定义元素,不进行编译
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue({
template: {
// 添加以下内容
compilerOptions: {
isCustomElement: (tag) => ['model-viewer'].includes(tag)
}
}
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
assetsInclude: ['./src/assets/heros/*.glb']
})
新建 src/components/LolProgress.vue
<template>
<div class="progress-container">
<model-viewer
:src="hero.src"
disable-zoom
shadow-intensity="1"
:camera-orbit="hero.cameraOrbit"
class="model-viewer"
:style="heroPosition"
:animation-name="animationName"
:camera-target="hero.cameraTarget"
autoplay
ref="modelViewer"
></model-viewer>
<div
class="progress-bar"
:style="{ height: strokeWidth + 'px', borderRadius: strokeWidth / 2 + 'px' }"
>
<div class="progress-percent" :style="currentPercentStyle"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch, type PropType } from 'vue'
/** 类型 */
interface Hero {
src: string
cameraOrbit: string
progressAnimation: string
finishAnimation: string
finishAnimationIn: string
cameraTarget: string
finishDelay: number
}
type HeroName = 'yasuo' | 'yi'
type Heros = {
[key in HeroName]: Hero
}
const props = defineProps({
hero: {
type: String as PropType<HeroName>,
default: 'yasuo'
},
percentage: {
type: Number,
default: 100
},
strokeWidth: {
type: Number,
default: 10
},
heroSize: {
type: Number,
default: 150
}
})
const modelViewer = ref(null)
const heros: Heros = {
yasuo: {
src: '/src/components/yasuo.glb',
cameraOrbit: '-90deg 90deg',
progressAnimation: 'Run2',
finishAnimationIn: 'yasuo_skin02_dance_in',
finishAnimation: 'yasuo_skin02_dance_loop',
cameraTarget: 'auto auto 0m',
finishDelay: 2000
},
yi: {
src: '/src/components/yi.glb',
cameraOrbit: '-90deg 90deg',
progressAnimation: 'Run',
finishAnimationIn: 'Dance',
finishAnimation: 'Dance',
cameraTarget: 'auto auto 0m',
finishDelay: 500
}
}
const heroPosition = computed(() => {
const percentage = props.percentage > 100 ? 100 : props.percentage
return {
left: `calc(${percentage + '%'} - ${props.heroSize / 2}px)`,
bottom: -props.heroSize / 10 + 'px',
height: props.heroSize + 'px',
width: props.heroSize + 'px'
}
})
const currentPercentStyle = computed(() => {
const percentage = props.percentage > 100 ? 100 : props.percentage
return { borderRadius: `calc(${props.strokeWidth / 2}px - 1px)`, width: percentage + '%' }
})
const hero = computed(() => {
return heros[props.hero]
})
const animationName = ref('')
watch(
() => props.percentage,
(percentage) => {
if (percentage < 100) {
animationName.value = hero.value.progressAnimation
} else if (percentage === 100) {
animationName.value = hero.value.finishAnimationIn
setTimeout(() => {
animationName.value = hero.value.finishAnimation
}, hero.value.finishDelay)
}
}
)
onMounted(() => {
setTimeout(() => {
console.log(modelViewer.value.availableAnimations)
}, 2000)
})
</script>
<style scoped>
.progress-container {
position: relative;
width: 100%;
}
.model-viewer {
position: relative;
background: transparent;
}
.progress-bar {
border: 1px solid #fff;
background-color: #666;
width: 100%;
}
.progress-percent {
background-color: aqua;
height: 100%;
transition: width 100ms ease;
}
</style>
组件非常简单,核心逻辑如下:
- 根据传入的英雄名称加载模型
- 指定每个英雄的加载中的动画,
- 加载100%,切换完成动作进入动画和完成动画即可。
- 额外的细节处理。
最后修改
app.vue
:
<script setup lang="ts">
import { ref } from 'vue'
import LolProgress from './components/LolProgress.vue'
const percentage = ref(0)
setInterval(() => {
percentage.value = percentage.value + 1
}, 100)
</script>
<template>
<main>
<LolProgress
:style="{ width: '200px' }"
:percentage="percentage"
:heroSize="200"
hero="yasuo"
/>
</main>
</template>
<style scoped></style>
这不就完成了吗,先拿给老板看看。
老板:换个女枪的看看。
我:好嘞。
老板:弄类不赖啊小伙,换个俄洛伊的看看。
4、总结
通过本次需求,了解到了 model-viewer
组件。
老板招个UI妹子吧。
在线体验:github-pages
来源:juejin.cn/post/7377217883305279526
做了这么久前端还不会手写瀑布流?(H5 & 小程序)
前言
做了7年前端我一直不知道瀑布流是什么(怪设计师不争气啊,哈哈哈),我一直以为就是个普通列表,几行css解决的那种。
当然瀑布流确实有css解决方案,但是这个方案对于分页列表来说完全不能用,第二页内容一出来位置都变了。
我看了一下掘金的一些文章,好长啊,觉得还是自己想一下怎么写吧。就自己实现了一遍。希望思路给大家一点帮助。
分析瀑布流
以小红书的瀑布流为例,相同宽度不同高度的卡片堆叠在一起形成瀑布流。
这里有两个难点:
- 卡片高度如何确定?
- 堆叠布局如何实现?
卡片的高度 = padding + imageHeight + textHeight....
不固定的内容包括:图片高度、标题行数
也就是说当我们解决了图片和标题的高度问题,那么瀑布流的第一个问题就解决了。(感觉已经写好代码了一样)
堆叠问题——因为css没有这样的布局方式,所以肯定得用js实现。最简单的解决方案就是对每一个盒子进行绝对定位。
这个问题就转换成计算现有盒子的定位问题。
从问题到代码
第一个问题——图片高度
无论是企业业务场景还是个人开发,通过后端返回图片的width、height都是合理且轻松的。
前端去获取图片信息,无疑让最重要的用户体验变得糟糕。前端获取图片信息并不困难,但是完全没有必要。
所以我直接考虑后端返回图片信息的情况。
const realImageHeight = imageWidth / imageHeight * cardContentWidth;
图片高度轻松解决,无平台差异
第二个问题——文字高度
从小红书可以看出,标题有些两行有些一行,也有些三行。
如果你固定一行,这个问题完全可以跳过。
- 方案一:我们可以用字数和宽度来计算可能得行数
优势:速度快,多平台复用
劣势:不准确(标题包括英文中文) - 方案二:我们可以先渲染出来再获取行数
优势:准确
劣势:相对而言慢,不同平台方法不同
准确是最重要的!选择方案二
其实方案二也有两种方案,一种是用canvas模拟,这样可以最大限度摆脱平台(h5、小程序)的限制,
然而我试验后,canvas还没找到准确的计算的方法(待后续更新)
第二种就是用div渲染一遍,获取行数或者高度。
创建一个带有指定样式的 div 元素
function createDiv(style: string): HTMLDivElement {
const div = document.createElement('div');
div.style.cssText = style;
document.body.appendChild(div);
return div;
}
计算文本数组在指定字体大小和容器宽度下的行数
/**
* 计算文本数组在指定字体大小和容器宽度下的行数
* @param texts - 要渲染的文本数组
* @param fontSize - 字体大小(以像素为单位)
* @param lineHeight - 字体高度(以像素为单位)
* @param containerWidth - 容器宽度(以像素为单位)
* @param maxLine - 最大行数(以像素为单位)
* @returns 每个文本实际渲染时的行数数组
*/
export function calculateTextLines(
texts: string[],
fontSize: number,
lineHeight: number,
containerWidth: number,
maxLine?: number
): number[] {
// 创建一个带有指定样式的 div 元素
const div = createDiv(`font-size: ${fontSize}px; line-height: ${lineHeight}px; width: ${containerWidth}px; white-space: pre-wrap;`);
const results: number[] = [];
texts.forEach((text) => {
div.textContent = text;
// 获取 div 的高度,并根据字体大小计算行数
const divHeight = div.offsetHeight;
const lines = Math.ceil(divHeight / lineHeight);
maxLine && lines > maxLine ? results.push(maxLine) : results.push(lines);
});
// 清理 div
removeElement(div);
return results;
}
这个问题小程序如何解决放在文末
第三个问题——每个卡片的定位问题
解决了上面的问题,就解决了盒子高度的问题,这个问题完全就是相同宽度不同高度盒子的堆放问题了
问题的完整描述是这样的:
写一个ts函数实现将一堆小盒子,按一定规则顺序推入大盒子里
函数输入:小盒子高度列表
小盒子:不同小盒子高度不一致,宽度为stackWidth,彼此间隔gap
大盒子:高度无限制,宽度为width
堆放规则:优先放置高度低的位置,高度相同时优先放在左侧
返回结果:不同盒子的高度和位置信息
如果你有了这么清晰的描述,接下去的工作你只需要交给gpt来写你的函数
// 返回的盒子信息
export interface Box {
x: number;
y: number;
height: number;
}
// 盒子堆叠的方法类
export class BoxPacker {
// 返回的小盒子信息列表
private boxes: Box[] = [];
// 大盒子宽度
private width: number;
// 小盒子宽度
private stackWidth: number;
// 小盒子间隔
private gap: number;
constructor(width: number, stackWidth: number, gap: number) {
this.width = width;
this.stackWidth = stackWidth;
this.gap = gap;
this.boxes = [];
}
// 添加单个盒子
public addBox(height: number): Box[] {
return this.addBoxes([height]);
}
// 添加多个盒子(一般用这个方法)
public addBoxes(heights: number[], isReset?: boolean): Box[] {
isReset && (this.boxes = [])
console.log('this.boxes—————— ', JSON.stringify(this.boxes) )
for (const height of heights) {
const position = this.findBestPosition();
const newBox: Box = { x: position.x, y: position.y, height };
this.boxes.push(newBox);
}
return this.boxes;
}
// 查找定位函数
private findBestPosition(): { x: number; y: number } {
let bestX = 0;
let bestY = Number.MAX_VALUE;
for (let x = 0; x <= this.width - this.stackWidth; x += this.stackWidth + this.gap) {
const currentY = this.getMaxHeightInColumn(x, this.stackWidth);
if (currentY < bestY || (currentY === bestY && x < bestX)) {
bestX = x;
bestY = currentY;
}
}
return { x: bestX, y: bestY };
}
private getMaxHeightInColumn(startX: number, width: number): number {
return this.boxes
.filter(box => box.x >= startX && box.x < startX + width)
.reduce((maxHeight, box) => Math.max(maxHeight, box.y + box.height + this.gap), 0);
}
}
这样我们就实现了根据高度获取定位的功能了
来实现一波
核心的代码就是获取每个盒子的定位、宽高信息
// 实例
const boxPacker = useMemo(() => {
return new BoxPacker(width, stackWidth, gap)
}, []);
const getCurrentPosition = (currentData: DataItem[], reset?: boolean) => {
// 获取标题文本行数列表
const textLines = calculateTextLines(currentData.map(item => item.title),card.fontSize,card.lineHeight, cardContentWidth)
// 获取图片高度列表
const imageHeight = currentData.map(item => (item.imageHeight / item.imageWidth * cardContentWidth))
// 获取小盒子高度列表
const cardHeights = imageHeight.map((h, index) => (
h + textLines[index] * card.lineHeight + card.padding * 2 + (card?.otherHeight || 0)
)
);
// 获取盒子定位信息
const boxes = boxPacker.addBoxes(
cardHeights,
reset
)
// 返回盒子列表信息
return boxes.map((box, index) => ({
...box,
title: currentData[index]?.title,
url: currentData[index]?.url,
imageHeight: imageHeight[index],
}))
}
set获取到的盒子信息
const [boxPositions, setBoxPositions] = useState<(Box & Pick<DataItem, 'url' | 'title' | 'imageHeight'>)[]>([]);
useEffect(() => {
// 首次和刷新
if (page === 1) {
setBoxPositions(getCurrentPosition(data, true))
} else {
// 加载更多
setBoxPositions(getCurrentPosition(data.slice((page - 1) * pageSize, page * pageSize)))
}
}, [])
效果如下
小程序获取文本高度
从上面的分析可以看出来只有文本高度实现是不同的,如果canvas方案实验成功,说不定还能做到大一统。
目前没成功大家就先看看我的目前方案:先实际渲染文字然后读取信息,然后获取实际高度
import React, {useEffect, useMemo, useState} from 'react'
import { View } from '@tarojs/components'
import Taro from "@tarojs/taro";
import './index.less'
import {BoxPacker} from "./flow";
const data = [
'vwyi这是一个标题,这是一个标题,这是一个标题,这是一个标题',
'这是一个标题',
'这是一个标题,这是一个标题,这是一个标题,这是一个标题',
'这是一个标题',
'这是一个标题,这是一个标题,这是一个标题,一个标题',
'这是一个标题,这是一个标题,这是一个标题,这题',
'这是一个标题,这是一个标题,这是一',
'这是一个标题,这是一个标题,这是一',
];
function Index() {
const boxPacker = useMemo(() => new BoxPacker(320, 100, 5), []);
const [boxPositions, setBoxPositions] = useState<any[]>([])
function getTextHeights() {
return new Promise((resolve, reject) => {
Taro.createSelectorQuery()
.selectAll('#textContainer .text-item')
.boundingClientRect()
.exec(res => {
if (res && res[0]) {
const heights = res[0].map(item => item.height);
resolve(heights);
} else {
reject('No buttons found');
}
});
});
}
useEffect(() => {
getTextHeights().then(h => {
setBoxPositions(boxPacker.addBoxes(h))
})
}, [])
return (
<View className="flow-container">
<View id="textContainer">
{
data.map((item, index) => (<View key={index} className="text-item">{item}</View>))
}
</View>
<View className="text-box-container">
{boxPositions.map((position, index) => (
<View
key={index}
className="text-box"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
height: `${position.height}px`,
width: '100px', // 假设盒子的宽度固定为100px
}}
>
{`${data[index]}`}
</View>
))}
</View>
</View>
)
}
export default Index
来源:juejin.cn/post/7397278180644372521
Llama + Dify,在你的电脑搭建一套AI工作流
点赞 + 关注 + 收藏 = 学会了
本文简介
最近字节在推Coze,你可以在这个平台制作知识库、制作工作流,生成一个具有特定领域知识的智能体。
那么,有没有可能在本地也部署一套这个东西呢?这样敏感数据就不会泄露了,断网的时候也能使用AI。
刚好最近 Llama 3.1
发布了,本文就以 Llama 3.1
作为基础模型,配合 Dify
在本地搭建一套“Coze”。
跟着本文一步步操作,保证能行!
Dify是什么?
Dify 官网(difyai.com/) 的自我介绍:Dify
是开源的 LLM
应用开发平台。提供从 Agent
构建到 AI workflow
编排、RAG
检索、模型管理等能力,轻松构建和运营生成式 AI 原生应用。比 LangChain
更易用。
动手搭建
在本地搭建这个平台很简单,其实 Dify文档(docs.dify.ai/v/zh-hans) 里都写得明明白白了,而且还有中文文档。
具体来说需要做以下几步:
- 安装
Ollama
- 下载大模型
- 安装
Docker
- 克隆
Dify
源代码至本地 - 启动
Dify
- 配置模型
接下来一步步操作。
安装 Ollama
简单来说 Ollama
是运行大语言模型的环境,这是 Ollama
的官网地址 (ollama.com/ ),打开它,点击 Download 按钮下载 Ollama
客户端,然后傻瓜式安装即可(一直点“下一步”)。
安装完成后就能看到一个羊驼的图标,点击运行它即可。
下载大模型
安装完 Ollama
后,我们到 Ollama
官网的模型页面(ollama.com/library)挑选一下模型。
这里面有很多开源模型,比如阿里的千问2,搜索 qwen2
就能找到它。
本文使用 Llama 3.1
,这是前两天才发布的模型,纸面参数贼强。
打开 Llama 3.1
模型的地址(ollama.com/library/lla…),根据你需求选择合适的版本,我选的是 8b 版。
选好版本后,复制上图右侧红框的命令,到你电脑的终端中运行。
如果你还没下载过这个模型它就会自动下载,如果已经下载过它就会运行这个模型。
运行后,你就可以在终端和大模型对话了。
当然,我们不会这么原始的在终端和大模型对话,我们可是要搞工作流的!
安装 Docker
前面的基础步骤都搞掂了,接下来就要开始为运行 Dify
做准备了。
先安装一下 Docker
,打开 Docker
官网(http://www.docker.com/),根据你系统下载对应的安装包,然后还是傻瓜式安装即可。
克隆 Dify 源代码至本地
要使用 Dify
,首先要将它拉到你电脑里。
git clone https://github.com/langgenius/dify.git
在你电脑里找个位置(目录),用 git
将 Dify
克隆下来,用上面这条命令克隆就可以了。
启动 Dify
进入 Dify 源代码的 docker 目录,执行一键启动命令:
cd dify/docker
cp .env.example .env
docker compose up -d
启动完成后,你的 docker
里就会看到这个
此时你在浏览器输入 http://localhost
就能看到这个界面。
首次打开 Dify
需要你设置一下管理员的账号。
然后用管理员账号登录,可以看到下面这个页面。
点击“创建空白应用”就可以创建聊天助手、文本生成应用、Agent、工作流。
我们点击"工作流"就能看到类似Coze的工作流编辑界面了。
配置模型
在配置工作流之前,我们需要给 Dify
配置大语言模型。
点击页面右上角的管理员头像,然后选择“设置”。
选择“模型供应商”,然后点击“Ollama”的卡片添加模型。
在添加 Ollama
模型时,弹窗的左下角有一个“如何继承 Ollama”的按钮,点击它会跳转到 Dify
官方文档教你怎么配置,但这里可能会有个小坑。
前面我们已经使用 Ollama
把 Llama 3.1
运行起来了,在浏览器打开 `http://localhost:11434 看到这个界面证明模型运行成功。
此时在“添加 Ollama”将资料填写好,“基础 URL”里输入 http://localhost:11434
即可。
如果你是 Mac 电脑,填入以上资料有可能会报这个错:
An error occurred during credentials validation: HTTPConnectionPool(host='localhost', port=11434): Max retries exceeded with url: /api/chat (Caused by NewConnectionError('<urllib3.connection.HTTPConnection object at 0xffff5e310af0>: Failed to establish a new connection: [Errno 111] Connection refused'))
此时你需要在“基础 URL”里填入 http://host.docker.internal:11434
。
遇到问题可以看 Dify
官方文档的 FAQ。
添加完成后你就可以在模型列表里看到它了。
除了接入 Ollama
外,Dify
还支持接入 OpenAI
等闭源模型,但需要你去 OpenAI
那边买个服务。
以上就是本文的全部内容啦,如果本文对你有帮助的话也请你分享给你的朋友~
点赞 + 关注 + 收藏 = 学会了
来源:juejin.cn/post/7395902224091971594
好消息!uniapp也能开发鸿蒙了,但坏消息是……
相信不少前端从业者一听uniapp支持开发鸿蒙Next后非常振奋。猫林老师作为7年前端er也是非常激动,第一时间体验了下。在这里也给大家分享一下我的看法
uniapp开发鸿蒙优势
- 对于前端开发者而言,几乎无需增加额外的学习成本
- 一套代码,通用在Android、iOS、HarmonyOS,小公司狂喜(可以只招一位牛马完成所有工作)
- 能迅猛将现有项目移植到鸿蒙平台,迅速掌握鸿蒙用户流量以及争取政府补贴
- 以及更多猫林老师没想到的优点(抱歉,实在憋不出来了)
uniapp开发鸿蒙缺点
- 这真的是可以大吐特吐的地方了,uniapp目前支持鸿蒙的方案是web渲染方案,也就是说相当于利用鸿蒙内部的
webview
显示一个网页 - 那这有什么不好呢?
- 首先是渲染性能达不到原生、其次是逻辑代码是JS实现,而JS引擎慢,这就导致启动速度和运行速度弱于原生
- JS与原生UI层或者原生API通信可能会卡顿
- 其次是目前仅支持vue3,对于还在守着vue2的古早前端也不友好
- 以上结论来自uniapp官网说明,如下图
- 因此猫林老师不认为目前的uniapp适合鸿蒙开发,所以如果有志于抢占鸿蒙风口的同学,可以坚定信心了,还是得好好学习鸿蒙原生开发。
uniapp未来会好吗?
- 上述缺点其实DCloud官方(uniapp所属)也意识到了,所以一直在打造新一代的uniapp,也即uni-app x
- 这套新平台追求解决所有跨平台开发框架性能无法媲美原生的痛点,通过不同平台编译成不同语言来实现:在iOS平台编译为swift、在Android平台编译为kotlin、在Web和小程序平台编译为js、在鸿蒙next平台上编译为ArkTS。就相当于你用vue的语法写了原生的代码。
- 因此,未来的uniapp还是非常值得期待的!
- 但现阶段,虽然uni-app x也已经对外发布,但是对于鸿蒙的支持还在不断的完善。并且鸿蒙自身也在不断的升级迭代,所以现阶段的uni-app x暂时还是无法展现完整的鸿蒙开发之美。期望未来能越来越好,为鸿蒙生态提供强有效的生产力。
总结
uniapp支持鸿蒙是一个好消息,未来也值得期待。但是现阶段用来作为找鸿蒙开发岗位的工作还是不太合适。
来源:juejin.cn/post/7397323478851158050
不戴眼镜也可以看清啦!记录我的摘镜经历
大家好,我是 Gopal。好久不见,甚是想念!
本篇文章记录一下今年我做的一个比较大胆的决定——做近视手术。
首先声明一下,本篇文章不是广告,纯分享个人经历,看完这篇文章,至于要不要做视力矫正?怎么做?个人可以根据需要自行决定哈。
我为什么要摘镜
先说一下,我的情况。我是初中毕业后开始慢慢近视的,至今的话,也有十来年了,眼镜都配了好多副。有近视的同学应该都知道,近视确确实实带给我们很多的烦恼,我举几个例子:
- 看 3D 电影,我得带两副眼镜,一副是自己的,一副是 3D 眼镜。
- 游泳的时候,脱掉眼镜,啥也看不到。
- 打篮球或者一些激烈的运动,眼镜经常脱落。
- 每天都得擦拭眼镜,对于我这种有点强迫症来讲的人,会是每天的工作。
- 早上起来第一件事情,找眼镜。
- ...
基于以上的种种,摘镜意愿,我个人是比较强烈的。当我听说现在有手术可以进行视力矫正时候,是非常兴奋的。我之后做了相关的调查,我们来看一下视力矫正有哪些方案吧。
视力矫正有哪些方案
这里我会介绍得比较粗糙一些,毕竟我不是专业的,知识都是我从网络中总结得来。
近视原因:我们大部分人的近视都是轴性近视,是由于眼球的前后长度(轴长)延长造成的,可以看以下的图片。在正常眼中,平行光线入眼后在视网膜上形成焦点。而在近视眼中,焦点位置落在视网膜之前,近视眼想看清,就得调整屈光度,使其聚焦在视网膜上。
眼镜通过镜片调节屈光度,而近视手术的大致有两种方式进行:
- 第一种,通过角膜手术改变角膜的形态和屈光力,使物象成像到视网膜上。
- 第二种,更换晶体或向眼内植入补偿晶体,从而达到类似的效果,这种类似于带隐形眼镜。
其中有一个特点就是晶体是有可逆性,激光手术的话,是不可逆的。以上两种并没有说哪种是最优解,需要通过医院检查才能决定。一般来说,角膜屈光手术建议近视度数小于 1000 度,散光小于 600 度。如果因为度数过高、或角膜太薄不能做角膜屈光手术,可以考虑做晶体植入手术。
现在主流的手术方案有以下四种(前面三种都属于角膜手术),具体的这里不展开了,大家有想法可以自行搜索哈。
- Smart全激光
- 半飞秒
- 全飞秒Smile
- ICL晶体植入术
而我通过检查之后,选择了全飞秒Smile。主要是我度数还好,角膜厚度也足够。
手术前中后
首先约了一家上市的机构做检查,看得出还是比较慎重,各个方面评估能不能做,以及有哪些方案,最后会有专门的人给我讲解。
做完检查之后,我当时就约了隔周去做,因为手术前几天是需要滴眼药水,而且不能佩戴隐形眼镜等。
手术当天,做术前检查,交代注意事项/术后用药、签署同意书等。说实话,我还是比较紧张的,毕竟把眼睛这么重要的部位交给医生。
然后就是手术了,真正的手术时间也就几分钟,当躺在手术室中,一开始我以为医生还在给我做前置检查,没想到几分钟后医生告诉我已经结束了,可以回家了。手术过程滴麻药,基本不会痛的。整个激光过程,眼睛是会有开睑器撑开固定,手术过程眼睛是同时睁开/闭上的。手术过程中,需要在打激光的过程中配合,眼睛保持不动不眨,听好医生指令。
手术后,我和我对象就坐地铁回去了,回家的路上,我一直戴着墨镜,随着麻药的失效,会有一种较强的异物感。虽然我知道这是一个正常现象(医生提前已告知,手术后将会在4-6小时内会出现流泪、畏光、白雾感、异物感、酸胀感等刺激症状,其轻重因人而异),但是当时真的挺怕的,毕竟可不是小事。(所以最好手术当天最好还是有人一起去)。
当天下午,我就感觉好很多了。我做的事全飞秒,周六做的手术,周一的时候,我就可以正常上下班了,基本不会影响工作。唯一我感知到的一点点影响就是切换屏幕的时候,聚焦会需要一点点时间,很短,医生说是正常现象。不过中间需要注意用眼,然后注意按照医嘱滴眼药水等。这里我贴几个术后的注意事项,当然你应该根据你的主治医生来:
- 术后一周内清淡饮食,吃辛辣刺激食物。
- 术后两周内洗头洗脸时不宜将水溅入眼内,切忌揉眼。(手术室给的眼罩睡觉时带上)。
- 术后一个月内勿游泳,不要在眼部使用化妆品并避免异物进入眼内。
- 术后一个月在室外请戴太阳镜(全激光术后戴三个月),室内、晚上不需佩戴。
- 术后三个月内尽量避免剧烈运动,术后一周后可以适量健身运动,但须注意在活动中不要伤及手术眼。
- 术后视力恢复是逐步提高的过程,开始阶段看近会感到稍有模糊,雾感。此症状会逐渐消失,视力提高有快有慢,双眼视力恢复会有波动与近视度数及个人对治疗反应差异有关,六个月基本稳定。
在写本文的时候,已经有两个多月了,感觉日常生活没啥影响。在一个月复诊的时候,我的视力一边是 1.2,一边是 1.5。如果说有啥「副作用」的话,有两点。一个是晚上的视力(在光线比较差的地方)会稍微有点差,另外一个看一些光源(比如路灯),会有一点炫光。不过这些术前医生有给我说过,我是有做好心理准备的。
整体而言,目前为止,我对这次的近视手术还是很满意的。
手术价格以及副作用
这个我相信不少人比较关注,我做的全飞秒,全部下来大概 1.8w 左右(公司跟这家机构有合作,跟我说是打了折扣,我了解了一下,在深圳,这个价格其实差不多)。激光和半分秒会稍微便宜一些。晶体植入是最贵的(大概 3w 多)。具体的还需要根据不同的城市和机构看。据说虽然近视手术简单,但是它那个机器是需要给专利费用的,用一次给一次专利费用。整体上讲,这个还是可以接受的。另外,近视手术不能报医保。
比如价格,我觉得大家最关心的问题是副作用,或者说风险系数。任何手术都是有风险的,包括近视手术。网络上会有人说:
- 近视手术这么好,为什么医生也还带眼镜?
- 近视手术这么好,为什么马化腾还带眼镜?
- ...
首先,先不否认大家的疑虑,毕竟眼睛这么重要的东西,要在上面动手术,想想都怕。但是,我们也需要辩证的看待这些问题。
- 近视手术是需要满足一定条件的,不满足的话,医院是不敢给你做的。
- 近视手术是不会导致你瞎的,从原理上来说,近视手术是眼睛前面部分的手术,不涉及眼部深层组织,比如晶状体、视网膜等,更加不可能致盲。
- 近视手术存在发生并发症的可能性,可能性大小而已。(别人没有,并不代表自己没有)比如眼干等。虽然现在手术已经很成熟,但是在你决定要做之前,一定要先了解清楚,看你能不能承担这个风险。
- 近视手术只是当前帮你调整好你的视力,假如你不注意用眼的话,是存在再次近视的风险的。所以一般建议成年之后,度数稳定之后再做这个手术。
- ...
最后再强调一遍,近视手术是一种改善型的手术,不做其实本质上对于自身身体健康没有影响,如果摘镜意愿强烈的同学可以尝试去了解一下。
写在最后
目前为止,我感受到更多的是摘镜之后给我生活带来的便利,基本算是满足了我摘镜的预期!
我之前一直在想,假如哪天我落在一座荒岛上,估计是活不下去的。因为假如一旦我眼镜坏了,那么我就「看不清」这个世界了。
不过现在医学科技的进步解决了我的这个问题。
来源:juejin.cn/post/7293788137662038050
面试官:假如有几十个请求,如何去控制并发?
面试官:看你简历上做过图片或文件批量下载,那么假如我一次性下载几十个,如何去控制并发请求的?
让我想想,额~, 选中ID,循环请求?,八嘎!肯定不是那么沙雕的做法,这样做服务器直接崩溃啦!突然灵光一现,请求池!!!
我:利用Promise模拟任务队列,从而实现请求池效果。
面试官:大佬!
废话不多说,正文开始:
众所周知,浏览器发起的请求最大并发数量一般都是6~8
个,这是因为浏览器会限制同一域名下的并发请求数量,以避免对服务器造成过大的压力。
首先让我们来模拟大量请求的场景
const ids = new Array(100).fill('')
console.time()
for (let i = 0; i < ids.length; i++) {
console.log(i)
}
console.timeEnd()
一次性并发上百个请求,要是配置低一点,又或者带宽不够的服务器,直接宕机都有可能,所以我们前端这边是需要控制的并发数量去为服务器排忧解难。
什么是队列?
先进先出就是队列,push
一个的同时就会有一个被shift
。我们看下面的动图可能就会更加的理解:
我们接下来的操作就是要模拟上图的队列行为。
定义请求池主函数函数
export const handQueue = (
reqs // 请求数量
) => {}
接受一个参数reqs
,它是一个数组,包含需要发送的请求。函数的主要目的是对这些请求进行队列管理,确保并发请求的数量不会超过设定的上限。
定义dequeue函数
const dequeue = () => {
while (current < concurrency && queue.length) {
current++;
const requestPromiseFactory = queue.shift() // 出列
requestPromiseFactory()
.then(() => { // 成功的请求逻辑
})
.catch(error => { // 失败
console.log(error)
})
.finally(() => {
current--
dequeue()
});
}
}
这个函数用于从请求池中取出请求并发送。它在一个循环中运行,直到当前并发请求数current
达到最大并发数concurrency
或请求池queue
为空。对于每个出队的请求,它首先增加current
的值,然后调用请求函数requestPromiseFactory
来发送请求。当请求完成(无论成功还是失败)后,它会减少current
的值并再次调用dequeue
,以便处理下一个请求。
定义返回请求入队函数
return (requestPromiseFactory) => {
queue.push(requestPromiseFactory) // 入队
dequeue()
}
函数返回一个函数,这个函数接受一个参数requestPromiseFactory
,表示一个返回Promise的请求工厂函数。这个返回的函数将请求工厂函数加入请求池queue
,并调用dequeue
来尝试发送新的请求,当然也可以自定义axios,利用Promise.all
统一处理返回后的结果。
实验
const enqueue = requestQueue(6) // 设置最大并发数
for (let i = 0; i < reqs.length; i++) { // 请求
enqueue(() => axios.get('/api/test' + i))
}
我们可以看到如上图所示,请求数确实被控制了,只有有请求响应成功的同时才会有新的请求进来,极大的降低里服务器的压力,后端的同学都只能喊6。
整合代码
import axios from 'axios'
export const handQueue = (
reqs // 请求总数
) => {
reqs = reqs || []
const requestQueue = (concurrency) => {
concurrency = concurrency || 6 // 最大并发数
const queue = [] // 请求池
let current = 0
const dequeue = () => {
while (current < concurrency && queue.length) {
current++;
const requestPromiseFactory = queue.shift() // 出列
requestPromiseFactory()
.then(() => { // 成功的请求逻辑
})
.catch(error => { // 失败
console.log(error)
})
.finally(() => {
current--
dequeue()
});
}
}
return (requestPromiseFactory) => {
queue.push(requestPromiseFactory) // 入队
dequeue()
}
}
const enqueue = requestQueue(6)
for (let i = 0; i < reqs.length; i++) {
enqueue(() => axios.get('/api/test' + i))
}
}
实战文章
之前写过一篇关于web-worker
大文件切片的案例文章,就是利用了此特性感兴趣的小伙伴可以看看web-worker的基本用法并进行大文件切片上传(附带简易node后端)
来源:juejin.cn/post/7356534347509645375
原来我们是这样对工作失去兴趣的
一、前言
相信很多人有过接手别人的系统,也有将自己负责的系统交接给别人的经历。既有交接出去不用在费力治理维护技术债务的喜悦,也有接手对方系统面对一系列维护问题的愁容满面。
但是被人嫌弃的系统曾经也是「创建人」心中的白月光啊,是什么导致了「白月光」变成了「牛夫人」呢?是996,工期倒排、先上再优化,还是随时变动的需求?
让我们来复盘系统是怎么一步一步腐化的,让我们丢失了最初的兴趣,同时总结一些经验教训以及破局之策。
二、白月光到牛夫人的经历
一般当我们设计一个系统时,总是会抱着要把该项目打造为「干净整洁」的项目的想法,
但是随着时间的推移,最后总是不可避免的变成了这样:
2.1、从0到1
我们发现大多数人对于创建新项目总是会抱有极大的激情兴趣,会充分的考虑架构设计。但是对于接手的项目就会缺乏耐心。
这种心理在《人月神话》一书中被说为编程职业的乐趣:
“首先,这种快乐是一种创建事物的纯粹快乐。如同小孩在玩泥巴时 感到快乐一样,成年人喜欢创建事物,特别是自己进行设计 。我想这种 快乐是上帝创造世界的折射,一种呈现在每片独特的、崭新的树叶和雪 花上的喜悦。”
“第四,这种快乐是持续学习的快乐,它来自于这项工作的非重复特性。人们所面临的问题总有这样那样的不同,因而解决问题的人可以从 中学习新的事物,有时是实践上的,有时是理论上的,或者兼而有之。”
正是由于这样的心理,人们在面对新系统时,可以实践自身所学,主动思考如何避开曾经遇到的坑。满足了内心深处的对于创造渴望。
当一个项目是从0到1开始设计的,并且前期是由少数「高手」成员主导开发的话,一般不会有债务体现。当然明面上没有债务,不代表没有埋下债务的种子。
2.2、抢占市场、快速迭代
系统投入市场得到验证后,如果顺利,短期会收获大量用户。伴随着用户指数增长的同时,各种产品需求也会随着而来。一般在这个阶段将会是「工期倒排、先上再优化,需求随时变动」的高发期。
同时由于需求的爆发,为了提高团队的交付率,在这个阶段会引入大量的“新人”。随着带来的就是新老思想的碰撞,新的同学不一定认同之前的架构设计。
在这个阶段,如果团队存在主心骨,可以“游说”多方势力,平衡技术产品、新老开发之间的矛盾,那么在这个阶段引入的债务将会还好。但是如果团队缺乏这样的角色,就会导致公说公有理婆说婆有理,最后的结果就是架构会朝着多个方向发展,一个项目里充斥着多种不同思路的设计。有些甚至是矛盾的。如果还有【又不是不能用】的想法出现,那将是灭顶之灾。
但是在这个阶段对于参与者又是幸福的,一份【有市场、有用户、有技术、有价值】的项目,无论是对未来的晋升还是跳槽都是极大的谈资。
2.3、维护治理
褪去了前期“曾经沧海难为水,除却巫山不是云”的热恋后,剩下的就是生活的柴米油盐。系统的最终结局也是“维护治理”。
在这个阶段,需求的数量将大大减少,但是由于前期的“快速建设”,一个小小的需求,我们可能需要耗费数周的时间去迭代。而且系统越来越复杂,迭代越来越困难。
同时每天需要花费大量的时间处理客诉、定位bug,精力被完全分散。系统的设计和技术慢慢的变得僵化。并且由于用户量巨大,每次改动都要承担很大的线上风险。这样的情况对于程序员的精力、体力都是一场内耗,并且如果领导的重心也不在此项目时,更会加重这种内耗。于是该系统就会显得“人老色衰”,曾经的「白月光」也就变成了「牛夫人」。
三、牛夫人不好吗?
3.1、缺乏成就感
《人月神话》中关于程序员职业的苦恼曾说过以下几点:
- 对于系统编程人员而言,对其他人的依赖是一件非常痛苦的事情。他依靠其他人的程序,而这些程序往往设计得并不合理、实现拙劣、发布不完整(没有源代码或测试用例)或者文档记录得很糟。所以,系统编程人员不得不花费时间去研究和修改,而它们在理想情况下本应该是可靠的、完整的。
- 下一个苦恼---概念性设计是有趣的,但寻找琐碎的bug却是一项重复性的活动。伴随着创造性活动的,往往是枯燥沉闷的时间和艰苦的 劳动。程序编制工作也不例外。
- 最后一个苦恼 ,有时也是一种无奈—当投入了大量辛苦的劳动 ,***产品在即将完成或者终于完成的时候,却已显得陈旧过时。***可能是同事或竞争对手已在追逐新的、更好的构思; 也许替代方案不仅仅是在构思 ,而且己经在安排了。
随着业务趋于稳定,能够发挥创造性的地方越来越少,剩下的更多是沉闷、枯燥的维护工作。并且公司的资源会更聚焦在新业务、新方向,旧系统获得的关注更少,自然而然就缺乏成就感。也就形成了【只见新人笑,不见旧人哭】
3.2、旧系统复杂、难以维护
《A Philosophy of Software Design》一书中对复杂性进行了如下定义:“Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system.”,即任何使得软件难于理解和修改的因素都是复杂性。
作者John 教授又分别从三个角度进行了解释复杂性的来源:
3.2.1、变更放大
复杂性的第一个征兆是,看似简单的变更需要在许多不同地方进行代码修改。对于复杂的系统来说,如果代码没有聚敛,那么一次小小的需求可能会导致多处修改。同时为了保证不出故障,需要对涉及的修改点、功能点进行完整的覆盖测试。这种隐藏在背后的工作量是巨大的。
3.2.2、认知负荷
复杂性的第二个症状是认知负荷,这是指开发人员需要多少知识才能完成一项任务。当一个系统经过多年的迭代开发,其复杂度将是指数级别的。并且会充斥这很多只有“当事人”才能理解的“离谱”功能。这就对维护者提出了极高的要求,需要掌握很多“冰山下”的知识才能做到手到擒来。而这对维护者的耐心、能力又是一次挑战。
3.2.3、未知的未知
未知的未知是指必须修改哪些代码才能完成任务,或者说开发人员必须获得哪些信息才能成功地执行任务。这一项也是John Ousterhout教授认为复杂性中最糟糕的一个表现形式。
这句话看起来比较抽象,如果映射到我们日常的工作中就是“我也不知道为啥这么改就好了”、“在我这是好的呀”、“刚刚还能运行啊,不知道为啥现在突然不行了”。
这种情况就是我们不知道改动的这行代码是否能让程序正常运转,也不知道这行代码的改动是否又会引发新的问题。这时候我们发现,那些“上帝类”真的就只有上帝能拯救了。
我曾经维护一个老系统,源码是通过文件相互传递的,没有仓库,里面的框架是自己撸的轮子,没有任何说明文档。服务部署是先在本地编译成二进制文件,然后在上传到服务器启动。每次改动上线都是就是一次生死劫,幸好没过多久,这个系统就被放弃了。
四、为何变成了牛夫人
4.1、伪敏捷
“敏捷”已经成为了国内公司的银弹了。
需求不做市场分析、不考虑用户体验、不做设计分析、不考虑前因后果,美其名曰“敏捷”。
工期倒排、先上再说、明天能不能上、这个问题上了再优化,美其名曰“敏捷”。
我曾经参与过一个项目,最开始是给了三个月的时间完成产品规划+开发,但是项目立项后领导层迟迟无法达成统一,一直持续进行了两个月的讨论后,终于确定了产品模型,进入开发。到这里留给开发的时间还剩一个月,咬咬牙也能搞定。但是开发真正进场,上报了需要开发的周期后,上面觉得太慢了,要求3周搞定,过了一天还是觉得太慢,要求2周,最后变成了要求4天搞定。遇到这种情况能怎么办,只能加人,只能怎么快怎么来。
之前阿里程序员也开玩笑式的说出了类似的场景:“2月13号上午省领导问逍遥子全省的健康码今天上线行不行,逍遥子说可以。等消息传达到产研团队的时候已经是中午了,然后团队在下午写了第一行代码。”
4.2、人的认知局限
《人月神话》一书中提到了一种组建团队的方式「外科手术团队」:“十个人,其中七个专业人士在解决问题,而系统是一个人或者最多两个人思考的产物,因此客观上达到了概念的一致性。”
也就是团队只需要一个或者两个掌舵人,负责规划团队的方向和系统架构,其余人配合他完成任务。目前我所待过的团队也基本是按照这个模式组成的,领导会负责重要事情的决策和团队分歧时的拍板,其余人则相互配合完成目标任务。但是这样的模式也导致了一个问题:掌舵人的认知上限就决定了团队的上限,而这种认知上限天然就会导致系统架构设计存在局限性,而这种局限性又会随着“伪敏捷”放大。
4.3、人员流动
经历过这种离职交接、活水交接的打工人应该深有体会,很多项目一旦步入这个阶段,大多数负责人就会开始放飞自我,怎么快怎么来,只想快点结束这段工作,快速奔赴下一段旅程。
从人性的角度是很难评价这种情况的,毕竟打工人和老板天然就不是一个战线的,甚至可能是对立面的。而我们大多数人都不可能是圣人,从自身角度从发这种行为是无可厚非的。
五、如何保持白月光
这里想首先抛个结论,系统变腐化是不可避免的。就类似人一样,随着时间的流逝,也会从以前一个连续熬夜打游戏看小说第二天依旧生龙活虎的青年变为一个在工位坐半小时都腰酸背痛,快走几步都喘的中年人。而这都来源于生活的压力、家庭的压力、工作的压力。同样的,面对业务的压力、抢占市场的压力、盈利的压力,系统也不可避免会变成“中年人”。
就像人一样会使用护肤品、健身等手段延缓自己的衰老一样,我们也可以使用一些手段延缓系统“衰老”。
在网上,已经有无数的文章教怎么避免代码腐化了,例如“DDD领域驱动设计”、“业务建模”、“重构”等等。
今天我想从别的角度聊聊怎么延缓代码腐化。
5.1、避免通用
软件领域有个特点,那就是复用。程序员们总是在思考怎么样写一段到处通用的代码,以不变应万变。特别是当国内提出中台战略后,这种情况就如脱缰的野马一般,不可阻挡。要是你做的业务、架构不带上xx中台,赋能xx,你都觉得你低人一等。
但是其实我们大部分人做的都是业务系统,本身就是面向某块特定市场的、特定用户的。这就天然决定了其局限性。
很多时候你会发现你用了100%的力气,设计了一个80%你认为有用的通用中台,最后只有20%产生了作用,剩下60%要么再也没有动过,要么就是被后来参与者喷的体无完肤。
当然这里也不说,设计时就按照当前产品提出的需求设计就行,一点扩展的余地都不留,而是在「通用」与「业务需求」之间取一个平衡。而这种平衡就取决于经验了。如果你没有这方面经验,那你就去找产品要「抄袭」的是哪个产品,看看他们有哪些功能,可以预留这些功能的设计点。
5.2、Clean Code
说实话,国内的业务系统80%都没有到需要谈论架构设计的地步。能够做到以下几点已经赢麻了:
- 良好的代码注释和相关文档存档【重中之重】
- 避免过长参数
- 避免过长方法和类
- 少量的设计模式
- 清晰的命名
- 有效的Code Review【不是那种帮我CR下,对方1秒后回复你一个done】
5.3、学会拒绝
自从国内开始掀起敏捷开发的浪潮后,在项目管理方面就出现了一个莫名其妙的指标:每次迭代的需求数都有会有一个数值,而且还不能比上一次迭代的少。
这种情况出现的原因是需求提出者无法确定这个需求可以带来多大的收益,这个收益是否满足老板的要求。那么他只能一股脑上一堆,这样即使最后效果不及预期,也可以怪罪于用户不买账。不是他不努力。
在这种时候,就需要开发学会识别哪些是真需求,哪些是伪需求,对于伪需求要学会说不。当然说不,不是让你上来就是开喷,而是你可以提出更加合理的理由,甚至你可以提出其他需求代替伪需求。这一般需要你对这块业务有非常深入的研究,同时对系统有上帝视角。
基本上在每个公司的迭代周期都是有时间要求的,比如我们就是两周一迭代,如果需求是你可控的,那么你就有更多的时间和心思在维护系统上,延缓他的衰老。
来源:juejin.cn/post/7312724606605918249
谈谈在大环境低迷下,找工作和入职三个月后的感受
前言
今天是新公司入职后的三个多月了个人也是如愿的转正了,对于我个人而言此时的心情好像没有三个月前刚拿到offer那样的喜悦和兴奋了,更像一件很普通的事情在你身边发生了吧。从2023年底离职在到2024年初开始找工作中间休息了三个月,找工作到入职花了一个多月,在这个过程中也是第一次真真切切感受到了所谓大环境低迷下的“前端已死的言论”,也给大家分享一下自己入职三个月的个人感受吧。
从上一家公司离职时的个人感受
因为上一家公司的工作性质是人力外包驻场开发,年底客户公司(中国移动成都产业研究院)我所在的项目组不需要外包人员了,个人也是被迫拿了赔偿灰溜溜的走人了。
工作感受:对于这段工作经历我个人还是比较认可的,毕竟这里没有任何工作压力,也不加班,工作量少,有很多时间去学习新东西,做做自己的开源,认识了新的朋友等等。
学历的重要性:在这里面随便拎一个人出来可能就是研究生学历的国企单位,自己真实的意识到了学历的重要性(第一学历小专科的我瑟瑟发抖)。
和优秀的人共事:如果在一个长期压抑低沉消极的环境下工作无论你的性格在怎么积极乐观开朗,可能也很容易被影响到。相反如果是和在一群积极,乐观,开朗,充满自信的环境和人一起工作,相信你也会变得积极,乐观,自信这或许也是我这一段工作经历最大的收获吧。
2023年底找工作的市场就业环境
抱着试一试的心态在boss上更新了自己的简历状态,不出所料软件上面安静的奇怪ps:49入国军的感觉,已读未回可能是很失望的感觉吧,但年底找工作令人绝望的是大多数公司都是未读未回,这也就意味着年底基本上是没有正常公司招聘的了。
大概投了两周简历后终于在智联招聘上约到了一个短期三个月岗位的面试,现场两轮面试通过了,不过最终还是没有选择去。
原因有很多:
- 现场的工作环境个人感觉很压抑,从接待我前台和面试官都能感觉满脸写着疲惫
- 说公司最近在996,你也需要和我们一起
- 招聘岗位和工作内容是threejs开发,薪资却说只能给到普通前端开发的水平
- 人力外包公司hr的反复无常令我恶心,二面通过后hr给我打电话最主要就是聊薪资吧,电话内容也很简单hr:成都大部分前端的薪资是在XX-XX,可能给不到你想要的薪资,可能要往下压个1-2K。我:我提的薪资完全是在你们发布招聘岗位薪资的区间,既然你们给不到为什么要这样写了(有感到被侮辱了)。过了几天之后人力外包的hr又给我电话,说可以在原来提的薪资基础上加1.4K,希望能早点去客户公司入职。
总结:年底招聘的公司基本上没啥好鸟,如果你的经济能力还行的话让自己放松休息一段时间也是不错的选择
2024年初找工作:真实的感受到了大环境的低迷下的市场行情
印象最深刻的是在疫情时期的2021年,那会儿出来找工作boos上会有很多HR主动给你打招呼,一周大概能五六个面试,大专学历也有机会去自研公司
解封之后本以为市场行情会变得回缓,结果大概就是今年可是未来十年行情最好的一年
简单总结一下2024年的成都就业环境大概这样的:
- 只有外包公司会招专科学历
- boss上只给hr发一句打招呼的快捷语,99% 都是已读不回
- 大多数要完简历之后就没有后续了
- 待遇好的公司对于学历的要求更严格了(211,985)
- 给你主动打招呼的基本上都是人力外包公司
截至入职新公司前boss上面的投递状况:沟通了1384个岗位,投递了99份简历,一共约到了 8 家公司的面试
今年找工作的个人感受:不怕面试,就怕没有面试机会
首先说一下个人的一些情况吧,因为在创业小公司待过在技术栈方面个人认为算是比较全面的了
项目经验:做过管理系统(CRM,B2C,ERP,saas等管理系统)、商城和门户网站(响应式,自适应)、移动端(H5,小程序,app)、低代码和可视化(工作流,可视化大屏,3d编辑器)、第三方开发(腾讯IM,企业微信侧边栏)、微前端
项目经历:从0-1搭建过整个大项目的基建工作,封装过项目中很多功能性组件和UI组件二次封装(提高开发效率),接手过屎山代码并重构优化,约定项目的开发规范,处理很多比较棘手的疑难bug和提供相关的技术方案,没有需求概念下的敏捷开发,从0-1的技术调研等
代码方面:写过几个开源项目虽然star数量不多(目前最多一个项目是600+),但在代码规范和可读性方面个人认为还是比较OK的(至少不会写出屎山代码吧)
工作经验(4年):2020毕业至今一直从事前端开发工作
学历:自考本科学历(貌似没啥卵用)
学历确实是我很硬伤的一个点但是没办法,人嘛总归要为年轻时的无知买单吧
在这样的背景下开启了24年的找工作,从2月26号开始投递简历到4月1号拿到offer差不多一个多月左右时间,一共约到了8加公司的面试,平均一周两家公司
大概统计了一下这些公司的面试情况:
公司A:
- 数组哪些方法会触发Vue监听,哪些不会触发监听
- position 有哪些属性
- vue watch和computed的区别,computed和method的区别
- vue的watch是否可以取消? 怎么取消?
- position:absolute, position:fixed那些会脱离文档流
- 如何获取到 pomise 多个then 之后的值
- 常见的http状态码
- 谈谈你对display:flex 弹性盒子属性的了解
- 如何判断一个值是否是数组
- typeof 和instanceof的区别
- es6-es10新增了那些东西
- 离职原因,期望薪资,职业规划
公司B
到现场写了一套笔试题,内容记不清楚了
公司C
- vue router 和route 区别
- 说说重绘和重排
- css 权重
- 项目第一次加载太慢优化
- 谈谈你对vue这种框架理解
- sessionstorage cookie localstorage 区别
- 了解过.css 的优化吗?
- 闭包
- 内存泄漏的产生
- 做一个防重复点击你有哪些方案
- 解释一些防抖和节流以及如何实现
- 说一下你对 webScoket的了解,以及有哪些API
- 说一下你对pomise的理解
- vue2,vue3 中 v-for 和v-if的优先级
- 说说你对canvas的理解
公司D
笔试+面试
- vue 首屏加载过慢如何优化
- 说说你在项目中封装的组件,以及如何封装的
- 后台管理系统权限功能菜单和按钮权限如何实现的
- vue 中的一些项目优化
- 期望薪资,离职原因,
- 其他的记不清楚了
公司E
笔试+面试+和老板谈薪资
1.笔试:八股文
2.面试:主要聊的是项目内容比如项目的一些功能点的实现,和项目的技术点
3.老板谈薪资:首先就是非技术面的常规三件套(离职原因,期望薪资,职业规划),然后就是谈薪资(最终因为薪资给的太低了没有选择考虑这家)
公司F
也是最想去的一家公司,一个偏管理的前端岗位(和面试官聊的非常投缘,而且整个一面过程也非常愉快感受到了十分被尊重)
可惜的是复试的时候因为学历原因,以及一些职业规划和加班出差等方面上没有达到公司的预期也是很遗憾的错过了
一面:
- vue 响应式数据原理
- 说说es6 promise async await 以及 promise A+规范的了解
- 谈谈es6 Map 函数
- 如何实现 list 数据结构转 tree结构
- webScoke api 介绍
- webScoke 在vue项目中如何全局挂载
- vuex 和 pinia 区别
- 谈谈你对微任务和宏任务的了解
- call apply bind 区别
- 前端本地数据存储方式有哪些
- 数组方法 reduce 的使用场景
- 说说你对 css3 display:flex 弹性盒模型 的理解
- vue template 中 {{}} 为什么能够被执行
- threejs 加载大模型有没有什么优化方案
- 离职原因,住的地方离公司有多远,期望薪资
- 你有什么想需要了解的,这个岗位平时的工作内容
二面:
1.我看写过一个Express+Mongoose服务端接口的开源项目,说说你在写后端项目时遇到过的难点
2.介绍一下你写的threejs 3d模型可视化编辑器 这个项目
3.以你的观点说一下你对three.js的了解,以及three.js在前端开发中发挥的作用
4.现在的AI工具都很流行,你有没有使用过AI工具来提高你对开发效率
5.说说你认为AI工具对你工作最有帮助的地方是哪些
6.以你的观点谈谈你对AI的看法,以及AI未来发展的趋势
7.你能接受出差时间是多久
8.你是从去年离职的到今天这这几个月时间,你是去了其他公司只是没有写在简历上吗?
9.说说你的职业规划,离职原因,你的优点和缺点,平时的学习方式
公司G
一共两轮面试,也是最终拿到正式offer入职的公司
一面:
- 主要就是聊了一下简历上写的项目
- 项目的技术难点
- 项目从0-1搭建的过程
- 项目组件封装的过程
- vue2 和 vue3 区别
- vue响应式数据原理
- 对于typescript的熟练程度
- 会react吗? 有考虑学习react吗?
- 说一下你这个three.js3d模型可视化编辑器项目的一个实现思路,为什么会想写这样一个项目
二面:
- 说说了解的es6-es10的东西有哪些
- 说说你对微任务和宏任务的了解
- 什么是原型链
- 什么是闭包,闭包产生的方式有哪些
- vue3 生命周期变化
- vue3 响应式数据原理
- ref 和 reactive 你觉得在项目中使用那个更合适
- 前端跨越方式有哪些
- 经常用的搜索工具有哪些?
- 谷歌搜索在国内能使用吗?你一般用的翻墙工具是哪种?
- 用过ChatGPT工具吗? 有付费使用过吗?
- 你是如何看待面试造航母工作拧螺丝螺丝的?
- 谈谈你对加班的看法?
- 你不能接受的加班方式是什么?
- 为什么会选择自考本科?
- 你平时的学习方式是什么?
- 一般翻墙去外网都会干什么?,外网学习对你的帮助大吗?
- 上一家公司的离职原因是什么,期望薪资是多少, 说说你的职业规划
- 手里有几个offer?
hr电话:
- 大概说了一下面试结果通过了
- 然后就是介绍了一下公司的待遇和薪资情况?
- 问了一下上一家公司的离职原因以及上一家公司的规模情况?
- 手里有几个offer?
- 多久能入职?
因为后面没有别的面试了,再加上离职到在到找工作拿到offer已经有四个月时间没有上班了,最终选择了入职这家公司
入职第三天:我想跑路了!
入职后的第一天,先是装了一下本地电脑环境然后拉了一下项目代码熟悉一下,vue3,react,uniapp 项目都有
崩溃的开始:PC端是一个saas 系统由五个前端项目构成,用的是react umi 的微前端项目来搭建的,也是第一次去接触微前端这种技术栈,要命的是这些项目没有一个是写了readme文档的,项目如何启动以及node.js版本这些只能自己去package.json 文件去查看,在经过一番折腾后终于是把这几个项目给成功跑起来了,当天晚上回家也是专门了解了一下微前端
开始上强度: 入职的第二天被安排做了一个小需求,功能很简单就是改个小功能加一下字段,但是涉及的项目很多,pc端两个项目,小程序两个项目。在改完PC端之后,开始启动小程序项目不出所料又是一堆报错,最终在别的前端同事帮助下终于把小程序项目给启动成功了。
人和代码有一个能跑就行:入职的第三天也从别的同事那里了解到了,之前sass项目组被前端大规模裁员过,原因嘛懂得都懂? 能写出这样一堆屎山代码的人,能不被裁吗?
第一次知道 vue 还可以这样写
对于一个有代码强迫症的人来说,在以后的很长一段时间里要求优化和接触完全是一堆屎山一样代码,真的是很难接受的
入职一个月:赚钱嘛不寒掺
在有了想跑路的想法过后,也开始利用上班的空余时间又去投递简历了,不过现实就是在金三银四的招聘季,boss上面依旧是安静的可怕,在退一步想可能其他公司的情况也和这边差不多,于是最终还是选择接受了现实,毕竟赚钱嘛不寒掺
入职两个月:做完一个项目迭代过后,感觉好多了
在入职的前一个月里,基本上每天都要加班,原因也很简单:
1.全是屎山的项目想要做扩展新功能是非常困难的
2.整个项目的逻辑还是很多很复杂的只能边写项目边熟悉
3.因为裁了很多前端,新人还没招到,但是业务量没有减少只能加班消化
功能上线的晚上,加班到凌晨3点
在开发完一个项目迭代过后也对项目有了一些大概的了解,之后的一些开发工作也变得简单了许多
入职三个月:工作氛围还是很重要滴
在入职三个月后,前端组团队的成员也基本上是组建完成了,一共14人,saas项目组有四个前端,虽然业务量依然很多但是好在有更多的人一起分担了,每周的加班时间也渐渐变少了
在一次偶然间了解到我平时也喜欢打篮球后,我和公司后端组,产品组的同事之间也开始变得有话题了,因为大家也喜欢打球,后来还拉了一个篮球群周末有时间大家也会约出来一起打打球
当你有存在价值后一切的人情世故和人际关系都会变得简单起来
在这个世界上大多数时候除了你的父母等直系亲属和另一半,可能会对你无条件的付出
其余任何人对你尊重和示好,可能都会存在等价的利益交换吧
尤其是在技术研发的岗位,只有当你能够完全胜任这份工作时,并且能够体现出足够的价值时才能够有足够的话语权
入职三个月后的感受
- 公司待遇:虽然是一个集团下面的子公司 (200+人)但待遇只能说一般吧,除了工资是我期望的薪资范围,其他的福利待遇都只能是很一般(私企嘛,懂得都懂)
- 工作强度: 听到过很多从大厂来的新同事抱怨说这边的工作量会很大,对我来说其实都还ok,毕竟之前在极端的高压环境下工作过
- 工作氛围:从我的角度来讲的话,还是很不错的,相处起来也很轻松简单,大家也有很多共同话题,没有之前在小公司上班那么累
大环境低迷下,随时做好被裁掉的准备
从2020年毕业工作以来,最长的一段工作经历是1年4个月,有过三家公司的经历
裁员原因也很简单:创业小公司和人力外包,要么就是小公司经营问题公司直接垮掉,或者就是人力外包公司卸磨杀驴
除非你是在国企单位上班,否则需要随时做好被裁掉的准备
什么都不怕,就怕太安逸了
这句话出自《我的团长我的团》电视剧里面龙文章故意对几十个过江的日本人围而不歼时和虞啸卿的对话,龙文章想通过这几十个日本人将禅达搅得鸡犬不宁,来唤醒还在沉睡在自己温柔乡的我们,因为就在我们放松警惕时日本人就已经将枪口和大炮对准了我们。
或许大家会很不认同这句话吧,如果你的父母给你攒下了足够的资本完全可以把我刚才的话当做放屁,毕竟没有哪一个男生毕业之前的梦想是车子和房子,从事自己喜欢的工作不好吗? 但现实却是你喜欢工作的收入很难让你在这座城市里体面的生活
于我而言前端行业的热爱更多是因为能够给我带来不错的收入所以我选择了热爱它吧,所以保持终身学习��状态也是我需要去做的吧
前端已死?
前端彻底死掉肯定是不会的,在前后端分离模式下的软件开发前端肯定是必不可少的一个岗位,只不过就业环境恶劣下的情况里肯定会淘汰掉很多人,不过35岁之后我还是否能够从事前端行业真的是一个未知数
结语
选择卷或者躺平,只是两种不同的生活态度没有对与错,偶尔躺累了起来卷一下也是可以的,偶尔卷累了躺起了休息一下也是不错的。
在这个网络上到处是人均年收入百万以及各种高质量生活的时代,保持独立思考,如何让自己不被负面情绪所影响才是最重要的吧
来源:juejin.cn/post/7391065678546157577
还学鸿蒙原生?vue3 + uniapp 可以直接开发鸿蒙啦!
Hello,大家好,我是 Sunday
7月20号,uniapp 官网“悄咪咪”的上线了 uniapp 开发鸿蒙应用 的文档,算是正式开启了 Vue3 + uniapp 开发鸿蒙应用
的时代。
开发鸿蒙的前置准备
想要使用 uniapp 开发鸿蒙,我们需要具备三个条件:
- DevEco-Studio 5.0.3.400 以上(下载地址:
https://developer.huawei.com/consumer/cn/deveco-studio/
) - 鸿蒙系统版本 API 12 以上 (DevEco-Studio有内置鸿蒙模拟器)
- HBuilderX-alpha-4.22 以上
PS: 这里不得不吐槽一下,一个 DevEco-Studio 竟然有 10 个 G......
安装好之后,我们就可以通过 开发工具 运行 示例代码
运行时,需要用到 鸿蒙真机或者模拟器。但是这里需要 注意: Windows系统需要经过特殊配置才可以启动,mac 系统最好保证系统版本在 mac os 12 以上
windows 系统配置方式(非 windows 用户可跳过):
打开控制面板 - 程序与功能 - 开启以下功能
- Hyper-V
- Windows 虚拟机监控程序平台
- 虚拟机平台
注意: 需要win10专业版或win11专业版才能开启以上功能,家庭版需先升级成专业版或企业版
启动鸿蒙模拟器
整个过程分为三步(中间会涉及到鸿蒙开发者申请):
- 下载 uni-app 鸿蒙离线SDK template-1.3.4.tgz (下载地址:
https://web-ext-storage.dcloud.net.cn/uni-app/harmony/zip/template-1.3.4.tgz
) - 解压刚下载的压缩包,将解压后的模板工程在 DevEco-Studio 中打开
- 等待 Sync 结束,再 启动鸿蒙模拟器 或 连接鸿蒙真机(如无权限,则需要申请(一般 3 个工作日),申请地址:
https://developer.huawei.com/consumer/cn/activity/201714466699051861/signup
)
配置 HBuilderX 吊起 DevEco-Studio
打开HBuilderX,点击上方菜单 - 工具 - 设置,在出现的弹窗右侧窗体新增如下配置
注意:值填你自己的 DevEco-Studio 启动路径
"harmony.devTools.path" : "/Applications/DevEco-Studio.app"
创建 uni-app 工程
- BuilderX 新建一个空白的 uniapp 项目,选vue3
- 在 manifest.json 文件中配置鸿蒙离线SDK路径(SDK 路径可在 DevEco-Studio -> Preferences(设置) z中获取)
编辑 manifest.json
文件,新增如下配置:
然后点击 运行到鸿蒙即可
总结
这样我们就有了一个初始的鸿蒙项目,并且可以在鸿蒙模拟器上运行。关于更多 uniapp 开发鸿蒙的 API,大家可以直接参考 uniapp 官方文档:https://zh.uniapp.dcloud.io/tutorial/harmony/dev.html#nativeapi
来源:juejin.cn/post/7395964591799025679
有财务自由的思维,才能实现财务自由!
前两天在洋哥、竹子姐以及渡心总等大佬的带领下,第一次体验了穷爸爸富爸爸的作者研发的现金流游戏,收获颇丰!
游戏规则说明
心灵创富 现金流游戏分为三步:
一局游戏,时间两个小时;总结分享时刻;以及,最最重要的结合自己的生活,复盘自己关键时间点的选择,是否是符合财务自由的决策。
首先,说一下明面上的游戏规则:每个人都选择一张身份卡,这张身份卡决定了你的工资,还有每个月的现金流。你的身份可能是小学老师、飞机驾驶员、医生等等,他们月工资和现金流(每月结余)各不一样。
老鼠圈,所有玩家没实现财务自由之前都在老鼠圈。
游戏过程中,通过投掷骰子,可以有五种操作:
- 市场风云:变幻莫测的市场,之前不值钱的突然变得值钱,之前值钱的东西也会突然变得不值钱;还有金融政策可能会随时调整,借钱利率忽高忽低等。
- 小买卖:开小店挣小钱等。
- 大机会:买卖股票、房产等能带来大额现金流的操作。
- 意外支出:生孩子、买球拍、买游艇等。
- 领工资:领取一个月的现金流,比如:月工资:3300,月支出:2100,则每月现金流:3300-2100=1200。
整个游戏的目标分为两个阶段:第一个阶段,突破老鼠圈,实现财务自由;第二个阶段,实现梦想。
怎么实现财务自由?股票、房产等带来的非工作收入超过你每个月的支出,就算破圈了,实现了财务自由,游戏进入了下一个阶段。
第一次玩游戏,没有人完成第二阶段,就不说了。
二、游戏
带领银行家:竹子
玩家:海子、木川、天雨、Feli、YY、伍六七
身份:小学老师
月工资:3300 $
月支出:2190 $
月现金流:1110 $
一开始选择身份的时候,虽然想选一个工资高的,但是也没有那么强烈,只是不想选工程师了,毕竟现实中是程序员,游戏中想换个身份活一回。所以,也就比较随意选择了工资偏低的小学教师。
整场游戏,我的运气非常不好。本来月现金流就不高,结果第二轮就生了个娃,后面又陆续抽中了额外支出,让本就没钱的我雪上加霜。
唯一称得上的是机会的就是:可以以 5000 $ 购买 10 亩荒地。但是当时没有考虑到可以向银行带款,也没有考虑到可以向其他玩家借款,最终没有购买。
没有考虑到向外部借钱的一部分原因是第一次玩这个游戏,不知道(忘记了)这个规则,另一个原因也是自己平时生活中也是这样一个人,除非迫不得已不向他人借钱。
第一次玩这个游戏,本着要恪守:10% - 20% 的钱投资高风险的产品(如股票),50% 的钱投资低风险的产品(当前最低价的股票、高收益的房产等)。
所以,我也做了两笔投资: 1000 $ 让木川代持的基金, 500 $ 让海子代持的股票。这让我在运气不好的一生中有一定的概率能破圈,虽然最后这两笔投资没有兑现,但是这两笔投资本身,我认为是没有问题的。
另外,太守规则。银行家一开始说的规则是不让给其他人提供决策建议,否则罚款。后来才知道,是可以向其他人提供付费资讯服务的,这种是不受惩罚的。
前两轮 FYY 想向我咨询决策建议,我直接就拒绝了。但是一个是给他人提供建议,可以给其他人好感,可以链接其他人。另外一个,就算有罚款,咨询的人也可以给予相应的补偿。这样就可以相互链接,相互成全。也应该思考,不破坏规则的情况下的有哪些选择。
整场游戏中,印象最深刻的一笔交易是:5000 $ 购买 10 亩荒地
我最终是没有购买,当时第一是看手里的现金,不满足购买要求。
第二是觉得这是一个可以搏一搏的机会,但是手里的现金不满足给自己制定的 10% -20% 投资高风险产品的 rule。
所以,问了在场的玩家,是否有需要这个机会的?拍卖 1000 $。最终,只有海子出价 500 $。当时,我认为这个机会价值还是挺大的,海子本质上是一个愿意花钱投资的人,所以拒绝了。
之后思考,海子是一个愿意投资机会的人,但是他当时手里有几笔房子的带款,而且现金也不充足,所以没有购买这个机会。我只考虑了海子的性格,没有考虑他当时手里的资金,所以,错失了这笔交易。
游戏和自己现实生活中的关系
什么才算财务自由?现实中很多人说有多少多少百万,有说 500 W的,有说 1000 W的。但是这个游戏告诉我们:只要你的非工作收入超过了你的支出,你就实现了财务自由。跟你手里有多少现金是没有关系的。
唯一的目标就是,增加你的非工作收入,减少你的支出,让你的非工作收入超过你的支出。
这个游戏带给我的收获和启发
人这一生,有的人运气好,能碰到很多次大机会。有的人运气不好,可能一生也没什么机会。
不管如何,你需要在一开始就制定好你做决策的依据。这个依据就是你手里的现金流以及你能承受的风险。
你不应该拿手里的大部分现金去投资一个低收益率高风险的产品,但是也不应该守着一大笔资金不做任何投资。
这个决策依据能够让你在没机会的时候,不至于很快破产。也能让你在有机会的时候,能够快速收获第一桶金,实现财务自由。
对我现实的改变
- 正在整理自己的权益表和资产负债表,慢慢减少不必要的支出。
- 正在实践做自己的个人 IP,增加自己的非工作收入。
- 游戏中的小买卖、大机会,在现实中去寻找这样的信息。游戏中,大家都知道且能知道是小买卖还是大机会,但是现实中,你可能不知道什么是小买卖,什么是大机会。
- 在心里植入增加非工作收入,减少支出,实现财务自由这样的理念。
应该去践行的
- 与现实决策点结合
- 早日争取第一桶金
- 结果导向
- 自己反思,反思自己的不足
- 持续来,每次来会有不同的体验,牌面不同,选择不同
- 应该多做利他的事情,资源链接
- 玩家之间可以互相赋能
- 有资源的时候可以投资机会,购买机会
- 整理自己的资产负债表
- 多分享,清晰自己的认识
- 重复玩,玩到财务自由
- 映射现实中,改善自己的财务状态
- 运气不好,心态也要好,积极链接他人,才能保住底线的情况下,去创造更大的成功机会
来源:juejin.cn/post/7293477092259201059
回县城躺平,感觉我的人生过得好失败
从春节前到现在,一个半月没更新了,做啥都提不起劲来。
越来越感觉我的人生过的好失败。
去年我爸因为癌症去世了,家里的门头房用不到了,就想卖掉,找好了买家,价格谈了 140 万。
当时想买我们这个房子的人很多,那个买家怕我们卖给别人,就拿了 20 万定金,和我们签了一个买房合同。
没想到因为这个房子因为在我爸名下,涉及到继承,需要我奶奶放弃继承才可以过户。
我奶奶现在生活不能自理,由我大娘二大娘养着,然后他们提出条件来,要我们的宅基地,也就是村里的老房子。
我妈开始不同意,因为她想之后从那里出殡,和我爸合葬。
后来也同意了,在哪不能殡出去呢?
然后我这边准备好了材料之后,我堂姐跳了出来,一拍桌说,不行,老房子我们也要,另外你还要给我们 25 万。
她们说咨询了律师要是打官司的话,我们青岛的房子也要分,那个门头房也要分,另外我爸的银行流水也要查,起码得分她们 40 万。
我给讲价到了 20 万,但是我妈不同意,说是她和我爸辛苦攒下的家业凭什么白给他们。
我妈这边也找了律师,给出的意见就是拖着就行,一辈子不卖了。
这时候买房子的不干了,说是合同上写了,如果违约,要双倍返回定金,也就是赔她们 20 万。
当时我们以为就是个收到条就签了,没想到在这等着呢。
其实我们早就把定金返给她了,也说了我们家的情况,但她就是不行。
年前就一直威胁我们要告,刚过完年,马上又来了:
我妈问了下和谈的话怎么谈,她说起诉你赔我 25 万,和谈赔我 18 万。
两头挤我们,家里就要我们老房子 + 21 万,卖房子的就要我们 20 万违约金。
我们夹在中间,几度濒临崩溃。
我想了下,这件事早晚得解决,反正都是一家人,给家里人点钱也没啥。
然后我前几天去找我大爷二大爷,还有堂姐、堂哥坐在一起谈了,我说我同意这 21 万。
最终转了她们 18 万(老房子折合了 2 万块),然后又拿了 1 万律师费(他们请的律师让我拿律师费),还有同意给他们老房子。
但我提的要求是和我妈只能说是 10 万 + 老房子,不然我妈不会同意的。
就这样,我们顺利公证过了户。
公证放弃继承那天,我奶奶才刚知道这个事,她说她只要老房子,不要我爸的其他遗产。
但没办法,她不要不行,我大爷二大爷要啊。
这个房子卖了,到手 120 万。
然后还有青岛的房子,这个房子我买的时候是 70万首付 + 100 万带款,一共是 200 万下来的,最近我妈又花了 7 万装修。
因为不去青岛住,也打算卖了,中介说价格不到 80 万还是可以卖掉的。
这么一算,这边亏了 120 万,那边房子卖了剩下 120,相当于我爸就给我留下了 70 多万还不好卖的房子。
其实我爸这辈子攒了不少钱,差不多 300 万,都是省出来的,从小我跟着他就是一天吃一顿那种,从没感觉到家里是有钱的。
再就是他对我妈也不好,前几年的时候经常打骂,后来我妈离家出走了,但是他生病了还是会来照顾他。
我爸癌症住院那段时间,生活不能自理,都是我妈没日没夜的照顾他。
临走之前,我爸一只手抓着我的手,一只手抓着我妈的手,然后让我们好好相处,他一直觉得对不起我妈,口里一直喊着“从头再来、从头再来”。
我送我爸去火葬场的时候,送我骨灰盒爸入土的时候,我也一直在说,“爸,你别怕,我们从头再来”。
其实我爸葬礼上,我没有咋哭出来,可能当时没大反应过来。
但是之后好长一段时间,我在村里别人家坐着聊天,一谈起我爸,就再也忍不住了,哭的稀里哗啦的。
我有个干兄弟,在村里拜了干爹,因为疫情好多年也没走动了,但是我爸的棺材是他帮忙抬出去的。
而我大爷二大爷就在一边看着。
我今年过年给他家送了礼,我说,我妈说我爸是你们抬出去的,让我一辈子记得你们的好。
当时说到这里,没忍住,又哭的稀里哗啦的。
我想我爸这辈子,是攒了不少钱,但是不舍得吃不舍得喝的,还在房子上亏了半辈子的积蓄。
对老婆孩子不好,临走前才后悔想着从头再来。
我想我前五年是赚了不少钱,但因为工作,好多年没回家,和家人一年待在一起也就几天。
而且最后都赔在青岛的房子上了。
人这一辈子,到底图啥呢?
年后这几周我找了找工作,有几个不错的 offer,都是 base 40+ 那种。
但我又不那么想出去了。
我这一年没工作,其实和我妈在一块生活还是很踏实的。
而且家里房子卖了,青岛的房子也快了,这样我的存款差不多能到 300w。存定期的话每年银行利息 8w 左右,够我生活了。
就这样在家躺平一辈子是不是也不错。
王小波说,那一天我二十一岁,在我一生的黄金时代,我有好多奢望。我想爱,想吃,还想在一瞬间变成天上半明半暗的云,后来我才知道,生活就是个缓慢受锤的过程,人一天天老下去,奢望也一天天消逝,最后变得像挨了锤的牛一样。可是我过二十一岁生日时没有预见到这一点。我觉得自己会永远生猛下去,什么也锤不了我。
韩寒说,平凡才是唯一的答案。
小的时候,我希望长大后的自己会光芒万丈。
长大以后,我希望自己有个好的工作打工到退休。
现在的我只想躺平。
我觉得我自己的人生很失败:
打工这些年,钱都赔在房子上了。
我比较社恐,永远达不到我妈对我的会来事、会察言观色的期望。
人家都在大城市结婚生子、买房定居了,而我又回到了小县城。
当年和我同时入职高德的朋友都升 p7 了,我现在啥也不是:
我是 94 年的,今年就 30 了,人生的各种机会和可能性越来越少。
后半辈子,我应该就是在小县城躺平,度过余生了。
但文章我还是想继续写,毕竟人这一生总要坚持点什么。
来源:juejin.cn/post/7343503718183059471
文件上传你会吧?那帮我做个文件下载功能
大家好,又是我,大聪明,立志做个早起吃草的马儿。话说上回解决完部署的问题(部署完了,样式不生效差点让我这个前端仔背锅),我又感觉回到了眼神清澈的大聪明状态,直到今天产品跟我说:“听说你是文件上传高手?做过大文件上传?切片?断点续传?”,听完我一脸戒备和紧张,“难道我面试吹的牛皮被他发现了,现在要捅破了?”我正在犹豫要不要跟他摊牌说,我面试掺了水的时候,他又来了一句,“那帮我做个 文件下载 的功能吧”,我突然放松下来了,原来是要加需求呀,害我白担心一场。作为CVT工程师,这点事根本难不倒我。
好了下面开始我的CV大法。
首先找到后端协调,他让我返回一个file_id
,该file_id是我在文件上传到服务器存储的时候,后端返回给我的,通过此file_id,来找到对应的文件,很好很简单。
接着,看后端提供的文件下载接口,咱就是说,经历的少,不知道对不对,后端直接就是返回文件的字节流(bytes),除此之外没有任何信息,没有文件名,没有文件类型,去问了一下说就是这样的,咱也不敢多问
天无绝人之路,还好我在前端获取文件的时候能找到总的文件列表,通过遍历出来也能拿到文件的信息
下面是判断文件类型方法
getFileTypeMime (key) {
let mimeType = ''
switch (key) {
case 'png':
mimeType = 'image/png'
break
case 'jpeg':
mimeType = 'image/jpeg'
break
case 'pdf':
mimeType = 'application/pdf'
break
case 'xlsx':
mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
break
case 'docx':
mimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
break
default:
mimeType = 'text/plain'
break
}
return mimeType
},
下面是文件下载的方法 (错误的)
downLoad (value, key) {
const type = key.split('.')[1] // 文件类型
const name = key.split('.')[0] // 文件名
axiosBase({
url: `/ass/download?alert_id=${this.alert_id}&file_id=${value}`,
method: 'get',
}).then(res => {
const blob = new Blob([res], { type: this.getFileTypeMime(type) }) // 文件类型
const link = document.createElement('a')
const href = window.URL.createObjectURL(blob) // 创建下载的链接
link.href = href
link.download = name // 下载后文件名
document.body.appendChild(link)
link.click() // 点击下载
document.body.removeChild(link) // 下载完成移除元素
window.URL.revokeObjectURL(href) // 释放掉blob对象
}).catch((err) => { console.log(err) })
}
为什么是错误的呢?点了效果也确实是实现了 文件的下载,但是打开,然后格式错误了??
又是百思不得其解的问题,直接打开度娘,搜索又找到了这篇(神文)解决
downLoad (value, key) {
const type = key.split('.')[1] // 文件类型
const name = key.split('.')[0] // 文件名
axiosBase({
url: `/download?alert_id=${this.alert_id}&file_id=${value}`,
method: 'get',
responseType: 'blob'
}).then(res => {
const blob = new Blob([res], { type: this.getFileTypeMime(type) }) // 文件类型
const link = document.createElement('a')
const href = window.URL.createObjectURL(blob) // 创建下载的链接
link.href = href
link.download = name // 下载后文件名
document.body.appendChild(link)
link.click() // 点击下载
document.body.removeChild(link) // 下载完成移除元素
window.URL.revokeObjectURL(href) // 释放掉blob对象
}).catch((err) => { console.log(err) })
}
又是有惊无险的一天
来源:juejin.cn/post/7389913027654434857
部署完了,样式不生效差点让我这个前端仔背锅
大家好,作为今年刚毕业的大聪明(小🐂🐎),知道今年不好就业,费心费力的面试终于进了个小公司,然后我的奇妙之旅开始了。
叮!闹钟响了!牙都不刷了,直接起床出门去上班,身为🐂🐎就要有早起吃草干活的觉悟,所以我是个很合格的食草动物。只不过,下面的信息,让我一天当动物的好心情都没了。
部署?因为该公司人数比较少,也没有自动化部署操作,只能让前端build一下dist包然后丢给后端,让后端部署,然后该项目也比较急,我刚来,也不好意思@后端,恰巧跟我对接的后端也是刚来不熟悉,所以我就跟负责人继续扯皮拖延时间了。(真是不好意思,假的)↓↓↓
终于经过不断努力(后端),部署好了,但是,我的样式乱了,为什么呢?看了一下网络和源代码,样式文件都请求到了,问了后端,一致说是我前端的问题,我懵了,然后快马加鞭查找问题
解决
ok,那我先看打包的dist文件有样式有问题吗,使用npm安装个 http-server 运行打包后的index.html,运行打开运行后的地址,欧克样式没乱,文件引用也没问题,那到底是什么鬼影响的呢?
在我百思不得其解的时候,我打开了神器--csdn,搜索打包dist部署后样式文件不生效,就遇到了这个神文t.csdnimg.cn/WbQyq
欧克,按照博主说的Nginx没有配置这两个东西而导致的,我有点不确定是不是,有点犹犹豫豫的找到后端说帮我在Nginx配置文件的http加下这两个配置,然后样式就好了,完美!后端由于疏忽,没加上就把问题推给我,结果被我扳回一城。
include mime.types;
default_type application/octet-stream;
- include mime.types;
- 这个指令告诉Nginx去包含一个文件,这个文件通常包含了定义了MIME类型(Multipurpose Internet Mail Extensions)的配置。MIME类型指定了文件的内容类型,例如文本文件、图像、视频等。通过包含
mime.types
文件,Nginx可以识别不同类型的文件并正确地处理它们。 - 示例:假设
mime.types
文件中定义了.html
文件为text/html
类型,Nginx在处理请求时会根据这个定义设置正确的HTTP响应头。
- 这个指令告诉Nginx去包含一个文件,这个文件通常包含了定义了MIME类型(Multipurpose Internet Mail Extensions)的配置。MIME类型指定了文件的内容类型,例如文本文件、图像、视频等。通过包含
- default_type application/octet-stream;
- 这个指令设置了默认的MIME类型。如果Nginx无法根据文件扩展名或其他方式确定响应的MIME类型时,就会使用这个默认类型。
application/octet-stream
是一个通用的MIME类型,表示未知的二进制数据。当服务器无法识别文件类型时,会默认使用这个类型。例如,如果请求的文件没有合适的MIME类型或没有被mime.types
文件中列出,Nginx就会返回application/octet-stream
类型。- 这种设置对于确保未知文件类型的安全传输很有用,因为浏览器通常会下载这些文件而不是尝试在浏览器中打开它们。
总之,添加 include mime.types;
和 default_type application/octet-stream;
配置后,Nginx能够正确地识别和处理CSS文件的MIME类型,从而确保浏览器能够正确加载和应用CSS样式。
所以,前端仔不能只当前端仔,还是要好好学点服务端的知识,不然锅给你,你还认为这是你该背的锅。
以上是开玩笑的描述,只是为了吸引增加阅读量
来源:juejin.cn/post/7388696625689051170
前端更新部署后通知用户刷新
前言
周五晚上组里说前端有bug,正在吃宵夜的我眉头一紧,立即打开了钉钉(手贱...),看了一下这不是前几天刚解决的吗,果然,使用刷新大法就解决,原因不过是用户一直停留在页面上,新的版本发布后,没有刷新拿不到新的资源。
现在大部分的前端系统都是SPA,用户在使用中对系统更新无感知,切换菜单等并不能获取最新资源,如果前端是覆盖性部署,切换菜单请求旧资源,这个旧资源已经被覆盖(hash打包的文件),还会出现一直无响应的情况。
那么,当前端部署更新后,提示一直停留在系统中的用户刷新系统很有必要。
解决方案
- 在public文件夹下加入manifest.json文件,记录版本信息
- 前端打包的时候向manifest.json写入当前时间戳信息
- 在入口JS引入检查更新的逻辑,有更新则提示更新
- 路由守卫router.beforeResolve(Vue-Router为例),检查更新,对比manifest.json文件的响应头Etag判断是否有更新
- 通过Worker轮询,检查更新,对比manifest.json文件的响应头Etag判断是否有更新。当然你如果不在乎这点点开销,可不使用Worker另开一个线程
Public下的加入manifest.json文件
{
"timestamp":1706518420707,
"msg":"更新内容如下:\n--1.添加系统更新提示机制"
}
这里如果是不向用户提示更新内容,可不填,前段开发者也无需维护manifest.json的msg内容,这里主要考虑到如果用户在填长表单的时候,填了一大半,你这时候给用户弹个更新提示,用户无法判断是否影响当前表单填写提交,如果将更新信息展示出来,用户感知更新内容,可判断是否需要立即刷新,还是提交完表单再刷新。
webpack向manifest.json写入当前时间戳信息
// 版本号文件
const filePath = path.resolve(`./public`, 'manifest.json')
// 读取文件内容
readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('读取文件时出错:', err)
return
}
// 将文件内容转换JSON
const dataObj = JSON.parse(data)
dataObj.timestamp = new Date().getTime()
// 将修改后的内容写回文件
writeFile(filePath, JSON.stringify(dataObj), 'utf8', err => {
if (err) {
console.error('写入文件时出错:', err)
return
}
})
})
如果你无需维护更新内容的话,可直接写入timestamp
// 生成版本号文件
const filePath = path.resolve(`./public`, 'manifest.json')
writeFileSync(filePath, `${JSON.stringify({ timestamp: new Date().getTime() })}`)
检查更新的逻辑
入口文件main.js处引入
我这里检查更新的文件是放在utils/checkUpdate
// 检查版本更新
import '@/utils/checkUpdate'
checkUpdate文件内容如下
import router from '@/router'
import { Modal } from 'ant-design-vue'
if (process.env.NODE_ENV === 'production') {
let lastEtag = ''
let hasUpdate = false
let worker = null
async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/manifest.json?v=${Date.now()}`, {
method: 'head'
})
// 获取最新的etag
let etag = response.headers.get('etag')
hasUpdate = lastEtag && etag !== lastEtag
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}
async function confirmReload(msg = '', lastEtag) {
worker &&
worker.postMessage({
type: 'pause'
})
try {
Modal.confirm({
title: '温馨提示',
content: '系统后台有更新,请点击“立即刷新”刷新页面\n' + msg,
okText: '立即刷新',
cancelText: '5分钟后提示我',
onOk() {
worker.postMessage({
type: 'destroy'
})
location.reload()
},
onCancel() {
worker &&
worker.postMessage({
type: 'recheck',
lastEtag: lastEtag
})
}
})
} catch (e) {}
}
// 路由拦截
router.beforeEach(async (to, from, next) => {
next()
try {
await checkUpdate()
if (hasUpdate) {
worker.postMessage({
type: 'destroy'
})
location.reload()
}
} catch (e) {}
})
// 利用worker轮询
worker = new Worker(
/* webpackChunkName: "checkUpdate.worker" */ new URL('../worker/checkUpdate.worker.js', import.meta.url)
)
worker.postMessage({
type: 'check'
})
worker.onmessage = ({ data }) => {
if (data.type === 'hasUpdate') {
hasUpdate = true
confirmReload(data.msg, data.lastEtag)
}
}
}
这里因为缺换路由本来就要刷新页面,用户可无需感知系统更新信息,直接通过请求头的Etag即可,这里的Fetch方法就用head获取相应头就好了。
checkUpdate.worker.js文件如下
let lastEtag
let hasUpdate = false
let intervalId = ''
async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/manifest.json?v=${Date.now()}`, {
method: 'get'
})
// 获取最新的etag和data
let etag = response.headers.get('etag')
let data = await response.json()
hasUpdate = lastEtag !== undefined && etag !== lastEtag
if (hasUpdate) {
postMessage({
type: 'hasUpdate',
msg: data.msg,
lastEtag: lastEtag,
etag: etag
})
}
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}
// 监听主线程发送过来的数据
addEventListener('message', ({ data }) => {
if (data.type === 'check') {
// 每5分钟执行一次
// 立即执行一次,获取最新的etag,避免在setInterval等待中系统更新,第一次获取的etag是新的,但是lastEtag还是undefined,不满足条件,错失刷新时机
checkUpdate()
intervalId = setInterval(checkUpdate,5 * 60 * 1000)
}
if (data.type === 'recheck') {
// 每5分钟执行一次
hasUpdate = false
lastEtag = data.lastEtag
intervalId = setInterval(checkUpdate, 5 * 60 * 1000)
}
if (data.type === 'pause') {
clearInterval(intervalId)
}
if (data.type === 'destroy') {
clearInterval(intervalId)
close()
}
})
如果不使用worker直接讲轮询逻辑放在checkUpdate即可
Worker引入
从 webpack 5 开始,你可以使用 Web Workers 代替 worker-loader
。
new Worker(new URL('./worker.js', import.meta.url));
以下版本的就只能用worker-loader
咯
也可以逻辑写成字符串,然后通过ToURL给new Worker,如下:
function createWorker(f) {
const blob = new Blob(['(' + f.toString() +')()'], {type: "application/javascript"});
const blobUrl = window.URL.createObjectURL(blob);
const worker = new Worker(blobUrl);
return worker;
}
createWorker(function () {
self.addEventListener('message', function (event) {
// 消费信息
self.postMessage('send message')
}, false);
})
worker数据通信
// 主线程
var uInt8Array = new Uint8Array(new ArrayBuffer(10));
for (var i = 0; i < uInt8Array.length; ++i) {
uInt8Array[i] = i * 2; // [0, 2, 4, 6, 8,...]
}
worker.postMessage(uInt8Array);
// Worker 线程
self.onmessage = function (e) {
var uInt8Array = e.data;
postMessage('Inside worker.js: uInt8Array.toString() = ' + uInt8Array.toString());
postMessage('Inside worker.js: uInt8Array.byteLength = ' + uInt8Array.byteLength);
};
但是,拷贝方式发送二进制数据,会造成性能问题。比如,主线程向 Worker 发送一个 500MB 文件,默认情况下浏览器会生成一个原文件的拷贝。为了解决这个问题,JavaScript 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这是为了防止出现多个线程同时修改数据的麻烦局面。这种转移数据的方法,叫做Transferable Objects。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等就非常方便了,不会产生性能负担。
如果要直接转移数据的控制权,就要使用下面的写法。
// Transferable Objects 格式
worker.postMessage(arrayBuffer, [arrayBuffer]);
// 例子
var ab = new ArrayBuffer(1);
worker.postMessage(ab, [ab]);
Web Worker 使用教程 - 阮一峰的网络日志 (ruanyifeng.com)
然而,并不是所有的对象都可以被转移。只有那些被设计为可转移的对象(用[ Transferable ] IDL 扩展属性修饰),比如ArrayBuffer、MessagePort,ImageBitmap,OffscreenCanvas,才能通过这种方式来传递。转移操作是不可逆的,一旦对象被转移,原始上下文中的引用将不再有效。转移对象可以显著减少复制数据所需的时间和内存。
---------------------------------更新-------------------------------------
如果只考虑是否更新,无需告知内容,无需创建manifest.json,直接通过index.html的Etag判断即可,因为打包工具会自动在filename添加hash,如果有内容修改,入口文件的js引入资源文件hash会改变,通过Etag判断即可。
async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/?v=${Date.now()}`, {
method: 'head',
cache: 'no-cache'
})
// 获取最新的etag
let etag = response.headers.get('etag')
hasUpdate = lastEtag && etag !== lastEtag
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}
来源:juejin.cn/post/7329280514628534313
🎲选择困难症的福音-基于threejs+cannonjs的扔骰子小游戏
在一个美好的周末,闲来无事,约上朋友一起在家打麻将,奈何尘封已久的麻将包里翻来翻去也没找到骰子的踪影,于是想在网上找一个骰子模拟器来代替,找了半天都没有发现一款合适好用的软件,于是心血来潮,打算自己做一个🎲模拟器。
1.制定需求设计以及技术方案
1.骰子模型
- 用户可以选择多种骰子模型,如六面 15面 20面等不同风格的供选择
- 并且可以选择要投掷骰子的数量 暂定1-10
2.分数计算规则
- 每次投掷完后会自动计算总点数并显示在屏幕上
- 可以保存历史摇骰子记录 最多支持历史100条记录
- 可以自定义用户参与摇骰子,投掷完成后会显示:luke摇出了xx点。。并且保存在历史记录中
- 支持多人参与摇骰子比赛:比如先加入4名玩家,开始后会依次提示轮到哪位玩家来开始投掷,并且可以选择每局大家需要投掷的次数,最后会统计总点数以及排名。
3.动画特效
- 每次投掷时对骰子随机一个角度以及投掷方向,如果同时投掷多个骰子则随机每一个骰子的角度,给骰子施加一个向赌盘中心的力,让骰子随机落在赌盘中部,在赌盘周围增加一道空气墙来阻止骰子移动到牌桌外。
- 模拟不同骰子的落地音效
4.技术实现
- 利用threejs来实现webgl相关渲染。如骰子模型、场景渲染、相机、棋盘等。
- 利用cannosjs来模拟物理引擎。如骰子碰撞检测、抛出坠落动画、重力加速等物理效果。
2.效果展示
3.开始实现
准备3d骰子模型和棋盘素材
首先是找到合适的gltf模型,这里我们在sketchfab上找到了一款质感很真实的骰子模型。下载下来的模型可以通过gltf-viewer来查看模型效果。
棋盘的话其实是一张图片平铺起来的,这里我用了一张木地板图片。
引入资源以及初始化webgl场景
import * as CANNON from "cannon-es";
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
//创建物理世界对象
const world = new CANNON.World();
// 设置物理世界重力加速度
world.gravity.set(0, -100, 0); //重力加速度: 单位:m/s²
创建物理模型、地面以及网格模型地面
这里简单叙述一下物理世界和webgl世界的联系以及如何在webgl场景里模拟出真实的物理效果。
- 首先在three创建的webgl场景是无法直接创建并感知到物理世界的,threejs只负责实时渲染物理的状态并展示在画布上。而cannos恰好相反,它不负责渲染,只负责创建一个物理世界以及具备物理引擎的物体,并根据物体状态实时计算物体的位置、角度等。并把这些信息实时同步给webgl场景中的模型,把模型渲染到页面上实现物理世界的可视化。
- 所以我们创建骰子、地面等模型都需要创建两份。一份在webgl中创建,一份在物理世界中创建,并且保持同样的尺寸。
//创建骰子网格模型(gltf模型)
const loader = new GLTFLoader();
const gltf = await loader.loadAsync('./assets/model/dice_model.glb');
const meshModel = gltf.scene;//获取箱子网格模型
meshModel.position.set(50,30,50);
meshModel.scale.set(5,5,5);
scene.add(meshModel);
//包围盒计算
const box3 = new THREE.Box3();
box3.expandByObject(meshModel);//计算模型包围盒
const size = new THREE.Vector3();
box3.getSize(size);//包围盒计算箱子的尺寸
// 创建骰子物理模型
const sphereMaterial = new CANNON.Material()//碰撞体材质
// 物理正方体
const bodyModel = new CANNON.Body({
mass: 0.3, // 碰撞体质量0.3kg
position: new CANNON.Vec3(50,30,50), // 位置
shape: new CANNON.Box(new CANNON.Vec3(size.x/2, size.y/2, size.z/2)),
material: sphereMaterial
});
// 物理地面
const groundMaterial = new CANNON.Material()
const groundBody = new CANNON.Body({
mass: 0, // 质量为0,始终保持静止,不会受到力碰撞或加速度影响
shape: new CANNON.Plane(),
material: groundMaterial,
});
// 改变平面默认的方向,法线默认沿着z轴,旋转到平面向上朝着y方向
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);//旋转规律类似threejs 平面
world.addBody(groundBody)
//设置物理世界参数
const contactMaterial = new CANNON.ContactMaterial(groundMaterial, sphereMaterial, {
restitution: 0.5, //反弹恢复系数
})
// 把关联的材质添加到物理世界中
world.addContactMaterial(contactMaterial)
// 网格地面
const planeGeometry = new THREE.PlaneGeometry(1000, 1000);
const texture = new THREE.TextureLoader().load('./assets/textures/hardwood2_diffuse.jpg');
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(10, 10);
const planeMaterial = new THREE.MeshLambertMaterial({
// color:0x777777,
map: texture,
});
const planeMesh = new THREE.Mesh(planeGeometry, planeMaterial);
planeMesh.rotateX(-Math.PI / 2);
scene.add(planeMesh);
创建好模型后,我们在创建光源,相机等,这里就不再赘述。接下来我们开始设计物理世界的骰子抛出后坠落效果,并将物理世界和webgl渲染同步。
//点击屏幕后,设置物理骰子的角度和速度,物理会向上抛出并随着重力下落,触碰到地面后则会发生碰撞反弹
renderer.domElement.addEventListener('click', function (event) {
start_throw = true;
clearPoints();
const randomEuler = Math.random()*3;
bodyModel.quaternion.setFromEuler(Math.PI / randomEuler, Math.PI / randomEuler, Math.PI / randomEuler);
bodyModel.position.set(0,50,0);//点击按钮,body回到下落的初始位置
// 为物体设置初始速度(用以产生抛物线效果)
bodyModel.velocity.set(option.x,option.y,option.z); // x, y, z 方向上的速度
// 选中模型的第一个模型,开始下落
world.addBody(bodyModel);
})
function render() {
world.step(1/60);//更新物理计算
meshModel.position.copy(bodyModel.position); //渲染循环中,同步物理球body与网格球mesh的位置
meshModel.quaternion.copy(bodyModel.quaternion); //同步姿态角度
locateView(); //相机跟随物体移动
requestAnimationFrame(render);
renderer.render(scene, camera);
if (isBodyStopped(bodyModel)&&start_throw) {
showPoints(); //停止运动后,显示点数
start_throw = false;
}
}
render();//根据帧数渲染
接下来,我们再添加骰子点数计算相关逻辑。
//获取朝上面的点数
function getUpperFace(mesh) {
// 定义每个面的局部法线向量(手动定义)
const localNormals = [
new THREE.Vector3(0, 1, 0), // 面1
new THREE.Vector3(0, 0, -1), // 面2
new THREE.Vector3(-1, 0, 0), // 面3
new THREE.Vector3(1, 0, 0), // 面4
new THREE.Vector3(0, 0, 1), // 面5
new THREE.Vector3(0, -1, 0), // 面6
];
let maxDot = -Infinity;
let faceValue = 0;
for (let i = 0; i < localNormals.length; i++) {
// 将局部法线向量转化为世界空间
const worldNormal = localNormals[i].clone().applyQuaternion(mesh.quaternion);
// 与全局上方向的点积
const dot = worldNormal.dot(new THREE.Vector3(0, 1, 0));
// 检查点积,找到最大值
if (dot > maxDot) {
maxDot = dot;
faceValue = i + 1; // 面的点数即为索引加1
}
}
return faceValue;
}
//判断物体是否停止运动
function isBodyStopped(body, linearVelocityThreshold = 0.1, angularVelocityThreshold = 0.1) {
// 获取物体的线性速度和角速度
const linearVelocityMagnitude = body.velocity.length();
const angularVelocityMagnitude = body.angularVelocity.length();
// 判断速度是否低于设定的阈值
return linearVelocityMagnitude < linearVelocityThreshold && angularVelocityMagnitude < angularVelocityThreshold;
}
//展示点数
function showPoints() {
let res = getUpperFace(meshModel);
let point = document.getElementById("points");
point.innerHTML = `点数:${res}`
}
//清空点数
function clearPoints() {
let point = document.getElementById("points");
point.innerHTML = ``
}
实现这些逻辑后,我们已经可以模拟出骰子抛出坠落并触碰地面后反弹,在停止运动后计算点数的效果。已经实现基础功能,但是我们发现如果随机速度过大的时候会移动很远才停下,于是我们增加一个空气墙来限制骰子在固定范围内。并且增加一个碰撞检测来触发撞地声音的效果。
//添加空气墙
const wallShape = new CANNON.Box(new CANNON.Vec3(100, 100, 0.1));
const wall1 = new CANNON.Body({ mass: 0 });
wall1.addShape(wallShape);
wall1.position.set(0, 100, -100); // Back wall
world.addBody(wall1);
const wall2 = new CANNON.Body({ mass: 0 });
wall2.addShape(wallShape);
wall2.position.set(0, 100, 100); // Front wall
world.addBody(wall2);
const wall3 = new CANNON.Body({ mass: 0 });
wall3.addShape(wallShape);
wall3.quaternion.setFromEuler(0, Math.PI / 2, 0); // Rotate for side walls
wall3.position.set(100, 100, 0); // Right wall
world.addBody(wall3);
const wall4 = new CANNON.Body({ mass: 0 });
wall4.addShape(wallShape);
wall4.quaternion.setFromEuler(0, Math.PI / 2, 0); // Rotate for side walls
wall4.position.set(-100, 100, 0); // Left wall
world.addBody(wall4);
//监听物体碰撞回调
const audio = new Audio('./assets/audio/peng.mp3');
bodyModel.addEventListener('collide', (event) => {
const contact = event.contact;
//获得沿法线的冲击速度
const ImpactV = contact.getImpactVelocityAlongNormal();
// 碰撞越狠,声音越大
if(ImpactV/35>1) {
audio.volume = 1;
} else {
audio.volume = ImpactV/35>0?ImpactV/35:0;
}
audio.currentTime = 0;
audio.play();
})
这样我们就初步完成了抛掷一个骰子并获取点数的功能,看似简单的一个场景实际上设计起来并不容易,要考虑很多因素。后续我会继续增加多个骰子同时抛掷的场景,以及比赛模式。源码也会贡献出来供大家一起学习参考,如果有更好的idea也可以在评论区留言或私信,大家一起在webgl中感受物理世界的魅力!
附完整代码:
import * as CANNON from "cannon-es";
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 实例化一个gui对象
// const gui = new GUI();
// //改变交互界面style属性
// gui.domElement.style.right = '0px';
// gui.domElement.style.width = '300px';
const option = {
z: -24,
x: -36,
y: -17,
z1: 1,
x1: 1,
y1: 1,
}
// //gui控制参数
// const folder_position = gui.addFolder('速度方向');
// folder_position.add(option, 'z', -100, 100);
// folder_position.add(option, 'x', -100, 100);
// folder_position.add(option, 'y', -100, 100);
// const folder_rotation = gui.addFolder('角度');
// folder_rotation.add(option, 'z1', -10, 10).step(0.1);
// folder_rotation.add(option, 'x1', -10, 10).step(0.1);
// folder_rotation.add(option, 'y1', -10, 10).step(0.1);
// CANNON.World创建物理世界对象
const world = new CANNON.World();
// 设置物理世界重力加速度
// world.gravity.set(0, -1000, 0); //重力加速度: 单位:m/s²
world.gravity.set(0, -100, 0);
//网格球体(gltf模型)
const loader = new GLTFLoader();
const gltf = await loader.loadAsync('./assets/model/dice_model.glb');
const meshModel = gltf.scene;//获取箱子网格模型
meshModel.position.set(50,30,50);
meshModel.scale.set(5,5,5);
scene.add(meshModel);
//包围盒计算
const box3 = new THREE.Box3();
box3.expandByObject(meshModel);//计算模型包围盒
const size = new THREE.Vector3();
box3.getSize(size);//包围盒计算箱子的尺寸
// 物理球体
const sphereMaterial = new CANNON.Material()//碰撞体材质
// 物理箱子
const bodyModel = new CANNON.Body({
mass: 0.3, // 碰撞体质量0.3kg
position: new CANNON.Vec3(50,30,50), // 位置
shape: new CANNON.Box(new CANNON.Vec3(size.x/2, size.y/2, size.z/2)),
material: sphereMaterial
});
// 骨骼辅助显示
const skeletonHelper = new THREE.SkeletonHelper(meshModel);
scene.add(skeletonHelper);
// world.addBody(bodyModel);
//添加空气墙
// Create air walls
const wallShape = new CANNON.Box(new CANNON.Vec3(100, 100, 0.1));
const wall1 = new CANNON.Body({ mass: 0 });
wall1.addShape(wallShape);
wall1.position.set(0, 100, -100); // Back wall
world.addBody(wall1);
const wall2 = new CANNON.Body({ mass: 0 });
wall2.addShape(wallShape);
wall2.position.set(0, 100, 100); // Front wall
world.addBody(wall2);
const wall3 = new CANNON.Body({ mass: 0 });
wall3.addShape(wallShape);
wall3.quaternion.setFromEuler(0, Math.PI / 2, 0); // Rotate for side walls
wall3.position.set(100, 100, 0); // Right wall
world.addBody(wall3);
const wall4 = new CANNON.Body({ mass: 0 });
wall4.addShape(wallShape);
wall4.quaternion.setFromEuler(0, Math.PI / 2, 0); // Rotate for side walls
wall4.position.set(-100, 100, 0); // Left wall
world.addBody(wall4);
camera.position.set(42,85,21)
camera.lookAt(0,10,0);
// 物理地面
const groundMaterial = new CANNON.Material()
const groundBody = new CANNON.Body({
mass: 0, // 质量为0,始终保持静止,不会受到力碰撞或加速度影响
shape: new CANNON.Plane(),
material: groundMaterial,
});
// 改变平面默认的方向,法线默认沿着z轴,旋转到平面向上朝着y方向
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);//旋转规律类似threejs 平面
world.addBody(groundBody)
//设置物理世界参数
const contactMaterial = new CANNON.ContactMaterial(groundMaterial, sphereMaterial, {
restitution: 0.5, //反弹恢复系数
})
// 把关联的材质添加到物理世界中
world.addContactMaterial(contactMaterial)
//光源设置
const directionalLight = new THREE.DirectionalLight(0xffffff, 2);
directionalLight.position.set(20, 100, 10);
scene.add(directionalLight);
// 网格地面
const planeGeometry = new THREE.PlaneGeometry(1000, 1000);
const texture = new THREE.TextureLoader().load('./assets/textures/hardwood2_diffuse.jpg');
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(10, 10);
const planeMaterial = new THREE.MeshLambertMaterial({
// color:0x777777,
map: texture,
});
const planeMesh = new THREE.Mesh(planeGeometry, planeMaterial);
planeMesh.rotateX(-Math.PI / 2);
scene.add(planeMesh);
// 添加一个辅助网格地面
// const gridHelper = new THREE.GridHelper(50, 50, 0x004444, 0x004444);
// scene.add(gridHelper);
var controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 允许阻尼效果
controls.dampingFactor = 0.25; // 阻尼系数
let start_throw = false;
renderer.domElement.addEventListener('click', function (event) {
start_throw = true;
clearPoints();
const randomEuler = Math.random()*3;
bodyModel.quaternion.setFromEuler(Math.PI / randomEuler, Math.PI / randomEuler, Math.PI / randomEuler);
bodyModel.position.set(0,50,0);//点击按钮,body回到下落的初始位置
// 为物体设置初始速度(用以产生抛物线效果)
bodyModel.velocity.set(option.x,option.y,option.z); // x, y, z 方向上的速度
// 选中模型的第一个模型,开始下落
world.addBody(bodyModel);
})
const audio = new Audio('./assets/audio/peng.mp3');
bodyModel.addEventListener('collide', (event) => {
const contact = event.contact;
//获得沿法线的冲击速度
const ImpactV = contact.getImpactVelocityAlongNormal();
// 碰撞越狠,声音越大
if(ImpactV/35>1) {
audio.volume = 1;
} else {
audio.volume = ImpactV/35>0?ImpactV/35:0;
}
audio.currentTime = 0;
audio.play();
})
//判断物体是否停止运动
function isBodyStopped(body, linearVelocityThreshold = 0.1, angularVelocityThreshold = 0.1) {
// 获取物体的线性速度和角速度
const linearVelocityMagnitude = body.velocity.length();
const angularVelocityMagnitude = body.angularVelocity.length();
// 判断速度是否低于设定的阈值
return linearVelocityMagnitude < linearVelocityThreshold && angularVelocityMagnitude < angularVelocityThreshold;
}
function showPoints() {
let res = getUpperFace(meshModel);
let point = document.getElementById("points");
point.innerHTML = `点数:${res}`
}
//相机跟随物体移动
function locateView() {
camera.position.x = meshModel.position.x;
camera.position.y = meshModel.position.y + 30;
camera.position.z = meshModel.position.z + 20;
camera.lookAt(meshModel.position)
}
function clearPoints() {
let point = document.getElementById("points");
point.innerHTML = ``
}
//获取朝上面的点数
function getUpperFace(mesh) {
// 定义每个面的局部法线向量(手动定义)
const localNormals = [
new THREE.Vector3(0, 1, 0), // 面1
new THREE.Vector3(0, 0, -1), // 面2
new THREE.Vector3(-1, 0, 0), // 面3
new THREE.Vector3(1, 0, 0), // 面4
new THREE.Vector3(0, 0, 1), // 面5
new THREE.Vector3(0, -1, 0), // 面6
];
let maxDot = -Infinity;
let faceValue = 0;
for (let i = 0; i < localNormals.length; i++) {
// 将局部法线向量转化为世界空间
const worldNormal = localNormals[i].clone().applyQuaternion(mesh.quaternion);
// 与全局上方向的点积
const dot = worldNormal.dot(new THREE.Vector3(0, 1, 0));
// 检查点积,找到最大值
if (dot > maxDot) {
maxDot = dot;
faceValue = i + 1; // 面的点数即为索引加1
}
}
return faceValue;
}
function render() {
world.step(1/60);//更新物理计算
meshModel.position.copy(bodyModel.position); //渲染循环中,同步物理球body与网格球mesh的位置
meshModel.quaternion.copy(bodyModel.quaternion); //同步姿态角度
locateView(); //相机跟随物体移动
requestAnimationFrame(render);
renderer.render(scene, camera);
if (isBodyStopped(bodyModel)&&start_throw) {
showPoints(); //停止运动后,显示点数
start_throw = false;
}
}
render();
来源:juejin.cn/post/7394993393125064704
谈谈国内前端的三大怪啖
因为工作的原因,我和一些外国前端开发有些交流。他们对于国内环境不了解,有时候会问出一些有趣的问题,大概是这些问题的启发,让我反复在思考一些更为深入的问题。
今天聊三个事情:
- 小程序
- 微前端
- 模块加载
小程序
每个行业都有一把银座,当坐上那把银座时,做什么便都是对的。
“我们为什么需要小程序?”
第一次被问到这个问题,是因为一个法国的同事。他被派去做一个移动端业务,刚好那个业务是采用小程序在做。于是一个法国小哥就在被痛苦的中文文档和黑盒逻辑中来回折磨着 🤦。
于是,当我们在有一次交流中,他问出了我这个问题:我们为什么需要小程序?
说实话,我试图解释了 19 年国内的现状,以及微信小程序推出时候所带来的便利和体验等等。总之,在我看来并不够深刻的见解。
即便到现在为止,每次当我使用小程序的时候,依旧会复现这个问题。在 ChatGPT 11 月份出来的时候,我也问了它这个很有国内特色的问题:
看起来它回答的还算不错,至少我想如果它来糊弄那些老外,应该会比我做的更好些。
但如果扪心自问,单从技术上来讲。以上这些事情,一定是有其他方案能解决的。
所以从某种程度上来看,这更像是一场截胡的商业案例:
应用市场
全世界的互联网人都知道应用市场是非常有价值的事情,可以说操作系统最值钱的部分就在于他们构建了自己的应用市场。
只要应用在这里注册发行,雁过拔毛,这家公司就是互联网世界里的统治阶级,规则的制定者。
反之则需要受制于人,APP 做的再大,也只是应用市场里的一个应用,做的好与坏还得让应用商店的评判。
另外,不得不承认的是,一个庞大如苹果谷歌这样的公司,他们的应用商店对于普通国内开发者来说,确实是有门槛的。
在国内海量的 APP 需求来临之前,能否提供一个更低成本的解决方案,来消化这些公司的投资?
毕竟不是所有的小企业都需要 APP,其实他们大部分需求 Web 就可以解决,但是 Web 没牌面啊,做 Web 还得砸搜索的钱才有流量。(某度搜索又做成那样...)
那做操作系统?太不容易,那么多人都溺死在水里了,这水太深。
那有没有一种办法可以既能构建生态,又有 APP 的心智,还能给入驻企业提供流量?
于是,在 19 年夏天,滨海大厦下的软件展业基地里,每天都在轮番播放着,做 XX小程序,拥抱下一个风口...
全新体验心智
小程序用起来挺方便的。
你有没有想过,这些美妙感觉的具体都来自哪些?以及这些真的是 Web 技术本身无法提供的吗?
- 靠谱感,每个小程序都有约束和规范,于是你可以将他们整整齐齐的陈放在你的列表里,仿佛你已经拥有了这一个个精心雕琢的作品,相对于一条条记不住的网页地址和鱼龙混杂的网页内容来说,这让你觉得小程序更加的有分量和靠谱。
- 安全感,沉浸式的头部,没有一闪而过的加载条,这一切无打扰的设计,都让你觉得这是一个在你本地的 APP,而不是随时可能丢失网页。你不会因为网速白屏而感到焦虑,尽管网络差的时候,你的 KFC 依旧下不了单 😂
- 沉浸感,我不知道是否打开网页时顶部黑黑的状态栏是故意留下的,还是不小心的... 这些限制都让用户非常强烈的意识到这是一个网页而不是 APP,而小程序中虽然上面也存在一个空间的空白,但是却可以被更加沉浸的主题色和氛围图替代。网页这个需求做不了?我不信。
H5 | 小程序 |
---|---|
- 顺滑感,得益于 Native 的容器实现,小程序在所有的视图切换时,都可以表现出于原生应用一样的顺滑感。其实这个问题才是在很多 Hybrid 应用中,主要想借助客户端来处理和解决的问题。类似容器预开、容器切换等技术是可以解决相关问题的,只是还没有一个标准。
我这里没有提性能,说实话我不认为性能在小程序中是有优势的(Native 调用除外,如地图等,不是一个讨论范畴)。作为普通用户,我们感受到的只是离线加载下带来的顺滑而已。
而上述提到的许多优势,这对于一个高品质的 Web 应用来说是可以做得到的,但注意这里是高品质的 Web 应用。而这种“高品质”在小程序这里,只是入驻门槛而已。
心智,这个词,听起来很黑话,但却很恰当。当小程序通过长期这样的筛选,所沉淀出来一批这样品质的应用时。就会让每个用户即便在还没打开一个新的小程序之前,也有不错体验的心理预期。这就是心智,一种感觉跟 Web 不一样,跟 APP 有点像的心智。
打造心智,这件事情好像就是国内互联网企业最擅长做的事情,总是能从一些细微的差别中开辟一条独立的领域,然后不断强化灌输本来就不大的差异,等流量起来再去捞钱变现。
我总是感觉现在的互利网充斥着如何赚钱的想法,好像永远赚不够。“赚钱”这个事情,在这些公司眼里就是圈人圈地抢资源,看看谁先占得先机,别人有的我也得有,这好像是最重要的事情。
很少有企业在思考如何创造些没有的市场,创造些真正对技术发展有贡献,对社会发展有推动作用的事情。所以在国内互联网圈也充斥着一种奇怪的价值观,有技术的不一定比赚过钱的受待见。
管你是 PHP 还是 GO,管你是在做游戏还是直播带货,只要赚到钱就是高人。并且端的是理所应当、理直气壮,有些老板甚至把拍摄满屋子的程序员为自己打工作为一种乐趣。什么情怀、什么优雅、什么愿景,人生就俩字:搞钱。
不是故意高雅,赚钱这件事情本身不寒碜,只是在已经赚到盆满钵满、一家独大的时候还在只是想着赚更多的钱,好像赚钱的目的就是为了赚钱一样,这就有点不合适。企业到一定程度是要有社会责任的,龙头企业每一个决定和举措,都有会影响接下来的几年里这个行业的价值观走向。
当然也不是完全没有好格局的企业,我非常珍惜每一个值得尊重的中国企业,来自一个蔚来车主。
小程序在商业上固然是成功的,但吃的红利可以说还是来自 网页 到 应用 的心智变革。将本来流向 APP 的红利,截在了小程序生态里。
但对于技术生态的发展却是带来了无数条新的岔路,小程序的玩法就决定了它必须生长在某个巨型应用里面,不论是用户数据获取、还是 API 的调用,其实都是取决于应用容器的标准规范。
不同公司和应用之间则必然会产生差异,并且这种差异是墒增式的差异,只会随着时间的推移越变越大。如果每个企业都只关注到自己业务的增长,无外部约束的话,企业必然会根据自己的业务发展和政策需要,选择成本较低的调整 API,甚至会故意制造一些壁垒来增加这种差异。
小程序,应该是 浏览器 与 操作系统 的融合,这本应该是推动这两项技术操刀解决的事情。
微前端
qiankun、wujie、single-spa 是近两年火遍前端的技术方案,同样一个问题:我们为什么需要微前端?
我不确定是否每个在使用这项技术的前端都想清楚了这个问题,但至少在我面试过的候选人中,我很少遇到对自己项目中已经在使用的微前端,有很深的思考和理解的人。
先说下我的看法:
- 微前端,重在解决项目管理而不在用户体验。
- 微前端,解决不了该优化和需要规范的问题。
- 微前端,在挽救没想清楚 MPA 的 SPA 项目。
没有万能银弹
银色子弹(英文:Silver Bullet),或者称“银弹”“银质子弹”,指由纯银质或镀银的子弹。在欧洲民间传说及19世纪以来哥特小说风潮的影响下,银色子弹往往被描绘成具有驱魔功效的武器,是针对狼人、吸血鬼等超自然怪物的特效武器。后来也被比喻为具有极端有效性的解决方法,作为杀手锏、最强杀招、王牌等的代称。
所有技术的发展都是建立在前一项技术的基础之上,但技术依赖的选择过程中一定需要保留第一性原理的意识。
当 React、Vue 兴起,当 打包技术(Webpack) 兴起,当 网页应用(SPA) 兴起,这些杰出的技术突破都在不同场景和领域中给行业提供了新的思路、新的方案。
不知从何时开始,前端除了 div 竟说不出其他的标签(还有说 View 的),项目中再也不会考虑给一个通用的 class 解决通用样式问题。
不知从何时开始,有没有权限需要等到 API 请求过后才知道,没有权限的话再把页面跳转过去申请。
不知从何时开始,大家的页面都放在了一个项目里,两个这样的巨石应用融合竟然变成了一件困难的事。
上面这些不合理的现状,都是在不同的场景下,不思考适不适合,单一信奉 “一招吃遍天” 下演化出的问题。
B 端应用,是否应该使用 SPA? 这其实是一个需要思考的问题。
微前端从某种程度上来讲,是认准 SPA 了必须是互联网下一代应用标准的产物,好像有了 SPA 以后,MPA 就变得一文不值。甭管页面是移动端的还是PC的;甭管页面是面对 C 端的还是 B 端的;甭管一个系统是有 20 个页面还是 200 个页面,一律行这套逻辑。
SPA 不是万能银弹,React 不是万能银弹,Tailwind 不是万能银弹。在新技术出现的时候,保持热情也要保持克制。
ps. 我也十分痛恨 React 带来的这种垄断式的生态,一个 React 组件将 HTML 和 Style 都吃进去,让你即使在做一个移动端的纯展示页面时,也需要背上这些称重的负担。
质疑 “墨守成规”,打开视野,深度把玩,理性消费。
分而治之
分治法,一个很基本的工程思维。
在我看来在一个正常商业迭代项目中的主要维护者,最好不要超过 3 个人,注意是主要维护者(Maintainer) 。
你应该让每个项目都有清晰的责任人,而不是某行代码,某个模块。责任人的理解是有归属感,有边界感的那种,不是口头意义上的责任人。(一些公司喜欢搞这种虚头巴脑的事情,什么连坐…)
我想大部分想引入微前端的需求都是类似 如何更好的划分项目边界,同时保留更好的团队协同。
比如 导航菜单 应该是独立收口独立管理的,并且当其更新时,应该同时应用于所有页面中。类似的还有 环境变量、时区、主题、监控及埋点。微前端将这些归纳在主应用中。
而具体的页面内容,则由对应的业务进行开发子应用,最后想办法将路由关系注册进主应用即可。
当然这样纯前端的应用切换,还会出现不同应用之间的全局变量差异、样式污染等问题,需要提供完善的沙箱容器、隔离环境、应用之间通信等一系列问题,这里不展开。
当微前端做到这一部分的时候,我不禁在想,这好像是在用 JavaScript 实现一个浏览器的运行容器。这种本应该浏览器本身该做的事情,难道 JS 可以做的更好?
只是做到更好的项目拆分,组织协同的话,引入后端服务,由后端管控路由表和页面规则,将页面直接做成 MPA,这个方案或许并不比引入微前端成本高多少。
体验差异
从 SPA 再回 MPA,说了半天不又回去了么。
所以不防想想:在 B端 业务中使用 SPA 的优势在哪里?
流畅的用户体验:
这个话题其实涵盖范围很广,对于 SPA 能带来的 “流畅体验”,对于大多数情况下是指:导航菜单不变,内容变化 发生变化,页面切换中间不出现白屏。
但要做到这个点,其实对于 MPA 其实并没有那么困难,你只需要保证你的 FCP 在 500ms 以内就行。
以上的页面切换全部都是 MPA 的多页面切换,我们只是简单做了导航菜单的 拆分 和 SWR,并没有什么特殊的 preload、prefetch 处理,就得到了这样的效果。
因为浏览器本身在页面切换时会在 unload 之前先 hold 当前页面的视图不变,发起一下一个 document 的请求,当页面的视图渲染做到足够快和界面结构稳定就可以得到这样的效果。
这项浏览器的优化手段我找了很久,想找一篇关于它的博文介绍,但确实没找到相关内容,所以 500ms 也是我的一个大致测算,如果你知道相关的内容,可以在评论区补充,不胜感激。
所以从这个角度来看,浏览器本身就在尽最大的努力做这些优化,并且他们的优化会更底层、更有效的。
离线访问 (PWA)
SPA 确实会有更好的 PWA 组织能力,一个完整的 SPA 应用甚至可以只针对编译层做改动就可以支持 PWA 能力。
但如果看微前端下的 SPA 应用,需要支持 PWA 那就同样需要分析各子应用之间的元数据,定制 Service Worker。这种组织关系和定制 SW,对于元数据对于数据是来自前端还是后端,并不在意。
也就是说微前端模式下的 PWA,同样的投入成本,把页面都管理在后端服务中的 MPA 应用也是可以做到相同效果的。
项目协同、代码复用
有人说 SPA 项目下,项目中的组件、代码片段是可以相互之间复用的,在 MPA 下就相对麻烦。
这其实涉及到项目划分的领域,还是要看具体的需求也业务复杂度来定。如果说整个系统就是二三十个页面,这做成 SPA 使用前端接管路由高效简单,无可厚非。
但如果你本身在面对的是一个服务于复杂业务的 B 端系统,比如类似 阿里云、教务系统、ERP 系统或者一些大型内部系统,这种往往需要多团队合作开发。这种时候就需要跟完善的项目划分、组织协同和系统整合的方案。
这个时候 SPA 所体现出的优势在这样的诉求下就会相对较弱,在同等投入的情况下 MPA 的方案反而会有更少的执行成本。
也不是所有项目一开始就会想的那么清楚,或许一开始的时候就是个简单的 SPA 项目,但是随着项目的不断迭代,才变成了一个个复杂的巨石应用,现在如果再拆出来也会有许多迁移成本。引入微前端,则可以...
这大概是许多微前端项目启动的背景介绍,我想说的是:对于屎山,我从来不信奉“四两拨千斤”。
如果没有想好当下的核心问题,就引入新的“银弹”解决问题,只会是屎山雕花。
项目协同,抽象和复用这些本身不是微前端该解决的问题,这是综合因素影响下的历史背景问题。也是需要一个个资深工程师来把控和解决的核心问题,就是需要面对不同的场景给出不同的治理方案。
这个道理跟防沙治沙一样,哪有那么多一蹴而就、立竿见影的好事。
模块加载
模块加载这件事情,从玉伯大佬的成名作 sea.js 开始就是一个非常值得探讨的问题。在当时 jQuery 的时代里,这是一个绝对超前的项目,我也在实际业务中体会过在无编译的环境下 sea.js 的便捷。
实际上,不论是微前端、低代码、会场搭建等热门话题离不开这项技术基础。
import * from *
我们每天都在用,但最终的产物往往是一个自运行的 JS Bundle,这来自于 Webpack、Vite 等编译技术的发展。让我们可以更好的组织项目结构,以构建更复杂的前端应用。
模块的概念用久了,就会自然而然的在遇到浏览器环境中,遇到动态模块加载的需求时,想到这种类似模块加载的能力。
比如在遇到会场千奇百怪的个性化营销需求时,能否将模块的 Props 开放出来,给到非技术人员,以更加灵活的方式让他们去做自由组合。
比如在低代码平台中,让开发者自定义扩展组件,动态的将开发好的组件注册进低代码平台中,以支持更加个性的需求。
在万物皆组件的思想影响下,把一整个完整页面都看做一个组件也不是不可以。于是在一些团队中,甚至提倡所有页面都可以搭建、搭建不了的就做一个大的页面组件。这样及可以减少维护运行时的成本,又可以统一管控和优化,岂不美哉。
当这样大一统的“天才方案”逐渐发展成为标准后,也一定会出现一些特殊场景无法使用,但没关系,这些天才设计者肯定会提供一种更加天才的扩展方案出来。比如插件,比如扩展,比如 IF ELSE。再后来,就会有性能优化了,而优化的 追赶对象 就是用原来那种简单直出的方案。
有没有发现,这好像是在轮回式的做着自己出的数学题,一道接一道,仿佛将 1 + 1的结论重新演化了一遍。
题外话,我曾经自己实现过一套通过 JSON Schema 描述 React 结构的 “库” ,用于一个低代码核心层的渲染。在我的实现过程中,我越发觉得我在做一件把 JSX 翻译成 JS 的事情,但 JSX 或者 HTML 本身不就是一种 DSL 么。为什么一定要把它翻译成 JSON 来传输呢?或者说这样的封装其本身有意义么?这不就是在做 PHP、.Net 直接返回带有数据的 HTML Ajax 一样的事情么。
传统的浏览器运行环境下要实现一个模块加载器,无非是在全局挂载一个注册器,通过 Script 插入一段新的 JS,该 JS 通过特殊的头尾协议,将运行时的代码声明成一个函数,注册进事先挂载好的注册器。
但实际的实现场景往往要比这复杂的多,也有一些问题是这种非原生方式无法攻克的问题。比如全局注册器的命名冲突;同模块不同版本的加载冲突;并发加载下的时序问题;多次模块加载的缓存问题 等等等等等...
到最后发现,这些事情好像又是在用 JS 做浏览器该做的事情。然而浏览器果然就做了,,Vite 就主要采用这种模式实现了它 1 年之内让各大知名项目切换到 Vite 的壮举。
“但我们用不了,有兼容性问题。”
哇哦,当我看着大家随意写出的 display: grid
样式定义,不禁再次感叹人们对未知的恐惧。
import.meta
的兼容性是另外一个版本,是需要 iOS12 以上,详情参考:caniuse.com/?search=imp…
试想一下,现在的低代码、会场搭建等等各类场景的模块加载部分,如果都直接采用 ESM 的形式处理,这对于整个前端生态和开发体验来说会有多么大的提升。
模块加载,时至今日,本来就已经不再需要 loader。 正如 seajs 中写的:前端模块化开发那点历史
历史不是过去,历史正在上演。随着 W3C 等规范、以及浏览器的飞速发展,前端的模块化开发会逐步成为基础设施。一切终究都会成为历史,未来会更好。
结语
文章的结尾,我想感叹另外一件事,国人为什么一定要有自己的操作系统?为什么一定需要参与到一些规范的制定中?
因为我们的智慧需要有开花的土壤,国内这千千万开发者的抱负需要有地方释放。
如果没有自己掌握核心技术,就是只能在问题出现的时候用另类的方式来解决。最后在一番折腾后,发现更底层的技术只要稍稍一改就可以实现的更好。这就像三体中提到的 “智子” 一样,不断在影响着我们前进的动力和方向。
不论是小程序、微前端还是模块加载。试想一下,如果我们有自己的互联网底蕴,能决定或者影响操作系统和浏览器的底层能力。这些 “怪啖” 要么不会出现,要么就是人类的科技创新。
希望未来技术人不用再追逐 Write Once, Run Everywhere 的事情...
来源:juejin.cn/post/7267091810366488632
为什么删除node_modules文件夹那么慢
Windows系统 为什么删除node_modules文件夹那么慢?
在Windows系统中删除node_modules
文件夹可能会比较慢的原因有以下几点:
- 文件数量过多:
node_modules
文件夹通常包含大量的文件和文件夹,如果其中文件数量过多,系统需要逐一扫描并删除每个文件,这会导致删除过程变得缓慢。 - 文件路径过长:在Windows系统中,文件路径的长度有限制,如果
node_modules
文件夹中存在过长的文件路径,系统在删除这些文件时可能会变得缓慢。 - 文件占用:
node_modules
文件夹中可能包含一些被其他程序占用的文件,这会导致系统无法立即删除这些文件,从而延长删除时间。 - 磁盘速度:如果
node_modules
文件夹位于机械硬盘上而非固态硬盘,机械硬盘的读写速度相对较慢,也会影响删除操作的速度。 - 杀软扫描:有些杀毒软件在删除文件时会对文件进行扫描,以确保文件不包含恶意代码。这个额外的扫描过程也会增加删除文件的时间。
为什么在苹果系统上删除node_modules文件夹就很快?
- 文件系统差异:Windows采用的是NTFS文件系统,而macOS使用的是APFS文件系统,APFS 在快速复制、文件元数据管理、空间分配等方面具有优势,支持快速文件复制、快速目录大小计算、快速空间释放等功能,而 NTFS 和 exFAT 在某些方面可能不如 APFS 那么快速和高效。
- 文件路径处理:Windows对文件路径长度有限制,而macOS对文件路径长度的限制相对较宽松。如果
node_modules
文件夹中存在过长的文件路径,Windows系统在处理这些文件时可能会变得缓慢。 - 文件锁定:Windows系统在处理被其他程序占用的文件时,可能会出现文件锁定的情况,导致删除操作变得缓慢。而macOS系统在这方面可能更加灵活。
- 文件系统碎片:Windows系统在长时间使用后可能会产生文件系统碎片,这会影响文件的读写和删除速度。而macOS对文件系统碎片的处理可能更加高效。
Windows中删除慢解决方案
为了加快在Windows系统中删除文件夹的速度,可以尝试使用命令行删除、关闭占用文件的程序、使用专门的删除工具等方法,以提高删除效率。
- 在删除前关闭占用文件的程序:确保
node_modules
文件夹中的文件没有被其他程序占用,可以提前关闭相关程序再进行删除操作。 - 使用固态硬盘:如果可能的话,将
node_modules
文件夹放在固态硬盘上,可以显著提高文件的读写速度。 - 使用命令行删除:在命令行中使用
rd /s /q node_modules
命令可以快速删除node_modules
文件夹,避免Windows资源管理器中的删除操作。 - 使用专门的删除工具:例如 npm 全局安装 rimraf,以后直接使用删除命令即可。
npm install rimraf -g
~
rimraf node_modules/
来源:juejin.cn/post/7350107540325875721
什么是系统的鲁棒性?
嗨,你好啊,我是猿java
现实中,系统面临的异常情况和不确定性因素是不可避免的。例如,网络系统可能会遭受网络攻击、服务器宕机等问题;金融系统可能会受到市场波动、黑天鹅事件等因素的影响;自动驾驶系统可能会遇到天气恶劣、道路状况复杂等情况。
在这些情况下,系统的鲁棒性就显得尤为重要,它能够确保系统能够正确地处理各种异常情况,保持正常运行。因此,这篇文章我们将分析什么是系统的鲁棒性?如何保证系统的鲁棒性?
什么是系统的鲁棒性?
鲁棒性,英文为 Robustness
,它是一个多学科的概念,涉及控制理论、计算机科学、工程学等领域。
在计算机领域,系统的鲁棒性是指系统在面对各种异常情况和不确定性因素时,仍能保持稳定运行和正常功能的能力。
鲁棒性是系统稳定性和可靠性的重要指标,一个具有良好鲁棒性的系统能够在遇到各种异常情况时做出正确的响应,不会因为某些异常情况而导致系统崩溃或失效。
鲁棒性要求系统在在遇到各种异常情况都能正常工作,各种异常很难具像化,这看起来是一种比较理想的情况,那么系统的鲁棒性该如何评估呢?
系统鲁棒性的评估
系统的鲁棒性可以从多个方面来考虑和评估,这里主要从三个方面进行评估:
首先,系统的设计和实现应该考虑到各种可能的异常情况,并采取相应的措施来应对
例如,在网络系统中,可以采用防火墙、入侵检测系统等技术来保护系统免受网络攻击;在金融系统中,可以采用风险管理技术来降低市场波动对系统的影响;在自动驾驶系统中,可以采用传感器融合、路径规划等技术来应对复杂的道路状况。
其次,系统在面临异常情况时应该具有自我修复和自我调整的能力
例如,当网络系统遭受攻击时,系统应该能够及时发现并隔离攻击源,同时自动恢复受影响的服务;当金融系统受到市场波动影响时,系统应该能够自动调整投资组合,降低风险;当自动驾驶系统面临复杂道路状况时,系统应该能够根据实时的道路情况调整行驶策略。
此外,系统的鲁棒性还包括对数据异常和不确定性的处理能力
在现实生活中,数据往往会存在各种异常情况,例如数据缺失、噪声数据等。系统应该能够对这些异常数据进行有效处理,保证系统的正常运行。同时,系统也应该能够对数据的不确定性进行有效处理,例如通过概率模型、蒙特卡洛方法等技术来处理数据不确定性,提高系统的鲁棒性。
鲁棒性的架构策略
对于系统的鲁棒性,有没有一些可以落地的策略?
如下图,展示了一些鲁棒性的常用策略,核心思想是:事前-事中-事后!
预防故障(事前)
对于技术人员来说,要有防范未然的意识,因此,对于系统故障要有预防措施,主要的策略包括:
- 代码质量:绝大部分软件系统是脱离不了代码,因此代码质量是预防故障很核心的一个前提。
- 脱离服务:脱离服务(Removal from service)这种策略指的是将系统元素临时置于脱机状态,以减轻潜在的系统故障。
- 替代:替代(Substitution)这种策略使用更安全的保护机制-通常是基于硬件的-用于被视为关键的软件设计特性。
- 事务:事务(Transactions)针对高可用性服务的系统利用事务语义来确保分布式元素之间交换的异步消息是原子的、一致的、隔离的和持久的。这四个属性被称为“ACID属性”。
- 预测模型:预测模型(Predictive model.)结合监控使用,用于监视系统进程的健康状态,以确保系统在其标称操作参数内运行,并在检测到预测未来故障的条件时采取纠正措施。
- 异常预防:异常预防(Exception prevention)这种策略指的是用于防止系统异常发生的技术。
- 中止:如果确定某个操作是不安全的,它将在造成损害之前被中止(Abort)。这种策略是确保系统安全失败的常见策略。
- 屏蔽:系统可以通过比较几个冗余的上游组件的结果,并在这些上游组件输出的一个或多个值不同时采用投票程序,来屏蔽(Masking)故障。
- 复盘:复盘是对事故的整体分析,发现问题的根本原因,查缺补漏,找到完善的方案。
检测故障(事中)
当故障发生时,在采取任何关于故障的行动之前,必须检测或预测故障的存在,故障检测策略主要包括:
- 监控:监控(Monitor)是用于监视系统的各个其他部分的健康状态的组件:处理器、进程、输入/输出、内存等等。
- **Ping/echo:**Ping/echo是指在节点之间交换的异步请求/响应消息对,用于确定通过相关网络路径的可达性和往返延迟。
- 心跳:心跳(Heartbeat)是一种故障检测机制,它在系统监视器和被监视进程之间进行周期性的消息交换。
- 时间戳:时间戳(Timestamp)这种策略用于检测事件序列的不正确性,主要用于分布式消息传递系统。
- 条件监测:条件检测(Condition monitoring.)这种策略涉及检查进程或设备中的条件或验证设计过程中所做的假设。
- 合理性检查:合理性检查(Sanity checking)这种策略检查特定操作或计算结果的有效性或合理性。
- 投票:投票(Voting)这种策略的最常见实现被称为三模块冗余(或TMR),它使用三个执行相同操作的组件,每个组件接收相同的输入并将其输出转发给投票逻辑,用于检测三个输出状态之间的任何不一致。
- 异常检测:异常检测(Exception detection)这种策略用于检测改变执行正常流程的系统状态。
- 自检测:自检测(Self-test)要求元素(通常是整个子系统)可以运行程序来测试自身的正确运行。自检测程序可以由元素自身启动,或者由系统监视器不时调用。
故障恢复(事后)
故障恢复是指系统出现故障之后如何恢复工作。这是对团队应急能力的一个极大考验,如何在系统故障之后,将故障时间缩小到最短,将事故损失缩减到最小?这往往决定了一个平台,一个公司的声誉,决定了很多技术人员的去留。故障恢复的策略主要包括:
- 冗余备用:冗余备用(Redundant spare)有三种主要表现形式:主动冗余(热备用)、被动冗余(温备用)和备用(冷备用)。
- 回滚:回滚(Rollback)允许系统在检测到故障时回到先前已知良好状态,称为“回滚线”——回滚时间。
- 异常处理:异常处理(Exception handling)要求在检测到异常之后,系统必须以某种方式处理它。
- 软件升级:软件升级(Software upgrade)的目标是在不影响服务的情况下实现可执行代码映像的在线升级。
- 重试:重试(Retry)策略假定导致故障的故障是暂时的,重试操作可能会取得成功。
- 忽略故障行为:当系统确定那些消息是虚假的时,忽略故障行为(Ignore faulty behavior)要求忽略来自特定来源的消息。
- 优雅降级:优雅降级(Graceful degradation)这种策略在元素故障的情况下保持最关键的系统功能,放弃较不重要的功能。
- 重新配置:使用重新配置(Reconfiguration),系统尝试通过将责任重新分配给仍在运行的资源来从系统元素的故障中恢复,同时尽可能保持关键功能。
上述这些策略看起来很高大上,好像离你很远,但是其实很多公司都有对应的措施,比如:系统监控,系统告警,数据备份,分布式,服务器集群,多活,降级策略,熔断机制,复盘等等,这些术语应该就和我们的日常开发息息相关了。
总结
系统的鲁棒性是指系统在面对各种异常情况和不确定性因素时,仍能保持稳定运行和正常功能的能力。系统鲁棒性看似一个理想的状态,却是业界一直追求的终极目标,比如,系统稳定性如何做到 5个9(99.999%),甚至是 6个9(99.9999%),这就要求技术人员时刻保持工匠精神、在自己的本职工作上多走一步,只有在各个相关岗位的共同协作下,才能确保系统的鲁棒性。
学习交流
如果你觉得文章有帮助,请帮忙点个赞呗,关注公众号:猿java
,持续输出硬核文章。
来源:juejin.cn/post/7393312386571370536
uniapp适配android、ios的引导页、首页布局
uniapp适配Android、Ios的引导页和首页布局
真是很久没来掘金写文章了,最近一直在学习Nest和Next这些后端知识,忙只是一方面,更多的还是懒吧。其实今年来北京工作之后,完全独立挑大梁来写app收获还是蛮多的,但是我一般忙完就完事,着急学习自己的东西去,没有把工作中遇到的一些问题及时总结。这点感觉很不好,以后尽量把工作中遇到的有价值的问题总结下来,也算是给自己这段时间工作的复习,也能锻炼自己的表达能力。
引导页
原型图和需求
需求大致是这样:一共有三页,每页有2-3组图片,产品想要炫酷的视觉效果
我接收到需求后,首先想的是gif图,于是让UI帮我做了一张12帧的gif,大家来感受一下效果
不知道大家感受怎么样,放到手机来模拟的时候有些模糊、有些卡顿,且占用空间很大,一张12帧的图片已经20M+,
整个应用不过才30M的情况下,绝对接受不了这种情况,于是我就放弃的gif,想要用代码来实现。
思路
留给我的开发时间并不多,只有半天,自己本身css能力一般,按照gif这样估计最多做出来一页,所以我和产品决定阉割掉一部分动效,做三页。
- UI负责把每条图片列表切图给我
- 引导页用
swiper
实现,这样页面切换动画可以省时间 - 第一页水平做动画两两一组,交替实现动画
- 第二页垂直做动画,交替实现
- 第三页原图和AI图在一个父盒子下,原图动态改变宽度来实现交替播放
- 每页文字和按钮通过
position:fixed
置底 - 最后一页手动加上滑动事件,可以不点击按钮进入首页
代码实现
- template布局
<view class="swiperLayout">
<swiper
:current="current"
class="swiper"
duration="350"
@change="change"
:indicator-active-color=" '#FFF272' "
:indicator-color="'#ccc'"
indicator-dots="true"
>
<swiper-item class="swiperItem">
<view class="itemLayout">
<image
class="img an1"
src="@/static/guide/guide1_1.png"
mode="scaleToFill"
/>
<image
class="img an2"
src="@/static/guide/guide1_2.png"
mode="scaleToFill"
/>
<image
class="img an1"
src="@/static/guide/guide1_3.png"
mode="scaleToFill"
/>
<image
class="img an2"
src="@/static/guide/guide1_4.png"
mode="scaleToFill"
/>
<view class="buttonBox">
<view class="title">海量模板</view>
<view class="button" @click="next(1)">下一步</view>
</view>
</view>
</swiper-item>
<swiper-item class="swiperItem">
<view class="itemLayout">
<view class="guide2Box">
<image
class="img2 an3"
src="@/static/guide/guide2_1.png"
mode="scaleToFill"
/>
<image
class="img2 an4"
src="@/static/guide/guide2_2.png"
mode="scaleToFill"
/>
<image
class="img2 an3"
src="@/static/guide/guide2_3.png"
mode="scaleToFill"
/>
</view>
<view class="buttonBox">
<view class="title">5000+云端照片存储</view>
<view class="button" @click="next(2)">下一步</view>
</view>
</view>
</swiper-item>
<swiper-item
class="swiperItem"
@touchstart="handlerStart($event)"
@touchmove="handerMove($event)"
>
<view class="itemLayout">
<view class="guide3">
<-- img3动态改变自己的宽度,来实现动画效果 -->
<image
class="img3 an5 z"
src="@/static/guide/guide3_1.png"
mode="aspectFill"
/>
<image
class="img4 "
src="@/static/guide/guide3_2.png"
mode="heightFix"
/>
</view>
<view class="buttonBox">
<view class="title">高清照片,无水印无广告</view>
<view class="button" @click="toIndex">继续</view>
</view>
</view>
</swiper-item>
</swiper>
</view>
- css部分
.swiper {
width: 100vw;
height: 100vh;
background: #000;
.swiperItem {
.itemLayout {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 60rpx;
.img {
width: 220vw;
height: 35vw;
margin: 20rpx 0 0rpx 0;
}
.img2 {
width: 30vw;
height: 256vw;
}
.title {
color: $themeColor;
margin-top: 40rpx;
text-align: center;
font-size: 36rpx;
font-weight: 600;
margin-bottom: 40rpx;
}
.button {
background: $themeColor;
color: #000;
height: 88rpx;
line-height: 88rpx;
width: 88%;
text-align: center;
border-radius: 48rpx;
font-size: 32rpx;
font-weight: 600;
}
}
.guide2Box{
display: flex;
justify-content: space-evenly;
width: 100%;
overflow: hidden;
height: 70vh;
}
}
}
// 动画1 执行三秒 匀速 无限次 镜像执行
.an1 {
animation: guide1 3s linear infinite alternate-reverse ;
}
// 水平X轴正向
@keyframes guide1 {
from {
transform: translateX(0);
}
50% {
transform: translateX(200rpx);
}
to {
transform: translateX(400rpx);
}
}
.an2 {
animation: guide2 3s linear infinite alternate-reverse ;
}
// 水平X轴负向
@keyframes guide2 {
from {
transform: translateX(0);
}
50% {
transform: translateX(-200rpx);
}
to {
transform: translateX(-400rpx);
}
}
.an3 {
animation: guide3 3s linear infinite alternate-reverse ;
}
// 水平正向 但是起始点要给负数 不然会有空缺的部分
@keyframes guide3 {
from {
transform: translateY(-500rpx);
}
50% {
transform: translateY(-250rpx);
}
to {
transform: translateY(0rpx);
}
}
.an4 {
animation: guide4 3s linear infinite alternate-reverse ;
}
// 水平负向
@keyframes guide4 {
from {
transform: translateY(0);
}
50% {
transform: translateY(-250rpx);
}
to {
transform: translateY(-500rpx);
}
}
.buttonBox{
position: fixed;
bottom: 120rpx;
width: 80vw;
display: flex;
flex-direction: column;
align-items: center;
z-index: 999;
}
// 最后一页动画 父盒子开启相对定位
.guide3{
position: relative;
width: 100%;
height: 100%;
// 两张图片都开始绝对定位 一左一右分布
.img3{
position: absolute;
top: 0;
left: 0;
height: 147vw;
border-right: 12rpx solid #fff;
}
.img4{
position: absolute;
top: 0;
right: 0;
height: 147vw;
}
}
// img3 缩小自己的宽度来实现动画
.an5 {
animation: changeImg 2s linear infinite alternate-reverse;
}
@keyframes changeImg {
from {
width: 0%;
}
to {
width: 100%;
}
}
.z{
z-index: 99;
}
- js部分
data() {
return {
current: 0,
// 触摸事件用到的数据
touchInfo: {
touchX: "",
touchY: "",
},
};
},
methods: {
next(num) {
this.current = num;
},
change(e) {
this.current = e.detail.current;
},
toIndex() {
uni.switchTab({ url: "/pages/index/index" });
},
handlerStart(e) {
let { clientX, clientY } = e.changedTouches[0];
this.touchInfo.touchX = clientX;
this.touchInfo.touchY = clientY;
},
handerMove(e) {
let { clientX, clientY } = e.changedTouches[0];
let diffX = clientX - this.touchInfo.touchX,
diffY = clientY - this.touchInfo.touchY,
absDiffX = Math.abs(diffX),
absDiffY = Math.abs(diffY),
type = "";
if (absDiffX > 50 && absDiffX > absDiffY) {
type = diffX >= 0 ? "right" : "left";
}
if (absDiffY > 50 && absDiffX < absDiffY) {
type = diffY < 0 ? "up" : "down";
}
if(type === 'left'){
this.toIndex()
}
},
},
最终效果
首页布局
原型图和需求
- 画风
- 贴纸
- 换脸
上面三图均为UI设计。首页的模板接口截止到目前(7.22)一共三种类型:styler(画风)、sticker(贴纸)、face_swap(换脸),本来按照UI的设计来看,每个分类的样式应该是固定写死的,我只需要v-for去不同的组件就可以,正当我写了一半时,很快老板的需求又下来:每个分类可能会杂糅在一起。说白了就是某个分类里可能既有画风、又有换脸、又有贴纸
思路
- 分析需求
在一个父组件中渲染所有的数据,根据不同的type 进入不同的子组件,三个子组件分别对应画风、贴纸、换脸,其中贴纸数据中有一个
mode
字段,根据mode
展示轮播、九宫格、一大八小的布局,这其中一大八小最不好实现。
一大八小的布局
- 将数据中的九张模板图片进行分组(剔除第一张,因为第一张要做“一大”),分为两组布局是上下分布(
display:flex
)实现,同时将第一张和分组的view
盒子的父元素也要开启display:flex
- 编译到chrome调试 看html结构
- 代码
<scroll-view class="scroll_view" scroll-x="true">
<image
class="img"
:src="sceneItem.json_content.cover_image_list[0].path"
mode="scaleToFill"
/>
<view>
<view
class="Item_2"
v-for="(Item, index) in columnData"
:key="index"
>
<view v-for="item in Item" :key="item.id">
<image
class="ss"
:src="item.path"
mode="scaleToFill"
/>
</view>
</view>
</view>
</scroll-view>
...
computed:{
columnData() {
if (this.sceneItem.json_content.display_mode === "2") {
const setData = this.sceneItem.json_content.cover_image_list.filter(
(item, index) => index > 0
);
const resultArray = setData.reduce(
(acc, cur, index) => {
const targetIndex = index % 2;
acc[targetIndex].push(cur);
return acc;
},
Array.from(Array(2), () => [])
);
return resultArray;
}
},
}
...
::v-deep .uni-scroll-view-content {
display: flex;
}
.scroll_view {
white-space: nowrap;
.img {
min-width: 324rpx;
height: 324rpx;
border-radius: 24rpx;
margin-right: 24rpx;
}
.Item {
display: inline-block;
.img {
width: 324rpx;
height: 324rpx;
border-radius: 24rpx;
margin-right: 24rpx;
}
}
.Item_2 {
display: flex;
.ss {
width: 158rpx;
height: 158rpx;
margin-right: 12rpx;
border-radius: 16rpx;
}
}
}
实现效果
来源:juejin.cn/post/7394005582774960182
身份认证的尽头竟然是无密码 ?
概述
几乎所有的系统都会面临安全认证相关的问题,但是安全相关的问题是一个很麻烦的事情。因为它不产生直接的业务价值,而且处理起来复杂繁琐,所以很多时都容易被忽视。很多后期造成重大的安全隐患,往往都是前期的不重视造成的。但庆幸的是安全问题是普遍存在的,而且大家面临的问题几乎相同,所以可以制定行业标准来规范处理,甚至是可以抽出专门的基础设施(例如:AD、LDAP 等)来专门解决这类共性的问题。总之,关于安全问题非常复杂而且麻烦,对于大多数 99% 的系统来说,不要想着在安全问题领域上搞发明和创新,容易踩坑。而且行业的标准解决方案已经非常成熟了。经过长时间的检验。所以在安全领域,踏踏实实的遵循规范和标准就是最好的安全设计。
HTTP 认证
HTTP 认证协议的最初是在 HTTP/1.1标准中定义的,后续由 IETF 在 RFC 7235 中进行完善。HTTP 协议的主要涉及两种的认证机制。
基本认证
常见的叫法是 HTTP Basic,是一种对于安全性不高,以演示为目的的简单的认证机制(例如你家路由器的登录界面),客户端用户名和密码进行 Base64 编码(注意是编码,不是加密)后,放入 HTTP 请求的头中。服务器在接收到请求后,解码这个字段来验证用户的身份。示例:
GET /some-protected-resource HTTP/1.1
Host: example.com
Authorization: Basic dXNlcjpwYXNzd29yZA==
虽然这种方式简单,但并不安全,因为 base64
编码很容易被解码。建议仅在 HTTPS 协议下使用,以确保安全性。
摘要认证
主要是为了解决 HTTP Basic 的安全问题,但是相对也更复杂一些,摘要认证使用 MD5 哈希函数对用户的密码进行加密,并结合一些盐值(可选)生成一个摘要值,然后将这个值放入请求头中。即使在传输过程中被截获,攻击者也无法直接从摘要中还原出用户的密码。示例:
GET /dir/index.html HTTP/1.1
Host: example.com
Authorization: Digest username="user", realm="example.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", uri="/dir/index.html", qop=auth, nc=00000001, cnonce="0a4f113b", response="6629fae49393a05397450978507c4ef1", opaque="5ccc069c403ebaf9f0171e9517f40e41"
**补充:**另在 RFC 7235 规范中还定义当用户没有认证访问服务资源时应返回 401 Unauthorized
状态码,示例:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Restricted Area"
这一规范目前应用在所有的身份认证流程中,并且沿用至今。
Web 认证
表单认证
虽然 HTTP 有标准的认证协议,但目前实际场景中大多应用都还是基于表单认证实现,具体步骤是:
- 前端通过表单收集用户的账号和密码
- 通过协商的方式发送服务端进行验证的方式。
常见的表单认证页面通常如下:
html>
<html>
<head>
<title>Login Pagetitle>
head>
<body>
<h2>Login Formh2>
<form action="/perform_login" method="post">
<div class="container">
<label for="username"><b>Usernameb>label>
<input type="text" placeholder="Enter Username" name="username" required>
<label for="password"><b>Passwordb>label>
<input type="password" placeholder="Enter Password" name="password" required>
<button type="submit">Loginbutton>
div>
form>
body>
html>
为什么表单认证会成为主流 ?主要有以下几点原因:
- 界面美化:开发者可以创建定制化的登录界面,可以与应用的整体设计风格保持一致。而 HTTP 认证通常会弹出一个很丑的模态对话框让用户输入凭证。
- 灵活性:可以在表单里面自定义更多的逻辑和流程,比如多因素认证、密码重置、记住我功能等。这些功能对于提高应用的安全性和便利性非常重要。
- 安全性:表单认证可以更容易地结合现代的安全实践,背后也有 OAuth 2 、Spring Security 等框架的主持。
表单认证传输内容和格式基本都是自定义本没啥规范可言。但是在 2019 年之后 web 认证开始发布标准的认证协议。
WebAuthn
WebAuthn 是一种彻底抛弃传统密码的认证,完全基于生物识别技术和实体密钥作为身份识别的凭证(有兴趣的小伙伴可以在 github 开启 Webauhtn 的 2FA 认证体验一下)。在 2019 年 3 月,W3C 正式发布了 WebAuthn 的第一版规范。
相比于传统的密码,WebAuthn 具有以下优势:
- 减少密码泄露:传统的用户名和密码登录容易受到钓鱼攻击和数据泄露的影响。WebAuthn,不依赖于密码,不存在密码丢失风险。
- 提高用户体验:用户不需要记住复杂的密码,通过使用生物识别等方式可以更快捷、更方便地登录。
- 多因素认证:WebAuthn 可以作为多因素认证过程中的一部分,进一步增强安全性。使用生物识别加上硬件密钥的方式进行认证,比短信验证码更安全。
总的来说,WebAuthn 是未来的身份认证方式,通过提供一个更安全、更方便的认证方式,目的是替代传统的基于密码的登录方法,从而解决了网络安全中的一些长期问题。WebAuthn 目前已经得到流程的浏览器厂商(Chrome、Firefox、Edge、Safari)、操作系统(WIndows、macOS、Linux)的广泛支持。
实现效果
当你的应用接入 WebAuthn 后,用户便可以通过生物识别设备进行认证,效果如下:
实现原理
WebAuthn 实现较为复杂,这里不做详细描述,具体可参看权威的官方文档,大概交互过程可以参考以下时序图:
登录流程大致可以分为以下步骤:
- 用户访问登录页面,填入用户名后即可点击登录按钮。
- 服务器返回随机字符串 Challenge、用户 UserID。
- 浏览器将 Challenge 和 UserID 转发给验证器。
- 验证器提示用户进行认证操作。
- 服务端接收到浏览器转发来的被私钥加密的 Challenge,以此前注册时存储的公钥进行解密,如果解密成功则宣告登录成功。
WebAuthn 采用非对称加密的公钥、私钥替代传统的密码,这是非常理想的认证方案,私钥是保密的,只有验证器需要知道它,连用户本人都不需要知道,也就没有人为泄漏的可能;
备注:你可以通过访问 webauthn.me 了解到更多消息的信息
文章不适合加入过多的演示代码,想要手上体验的可以参考 okta 官方给出基于 Java 17 和 Maven 构建的 webauthn 示例程序,如下:
来源:juejin.cn/post/7354632375446061083
如何优雅的给SpringBoot部署的jar包瘦身?
一、需求背景
我们知道Spring Boot项目,是可以通过java -jar 包名 启动的。
那为什么Spring Boot项目可以通过上述命令启动,而其它普通的项目却不可以呢?
原因在于我们在通过以下命令打包时
mvn clean package
一般的maven项目的打包命令,不会把依赖的jar包也打包进去的,所以这样打出的包一般都很小
但Spring Boot项目的pom.xml文件中一般都会带有spring-boot-maven-plugin插件。
该插件的作用就是会将依赖的jar包全部打包进去。该文件包含了所有的依赖和资源文件。
也就会导致打出来的包比较大。
打完包就可以通过java -jar 包名 启动,确实是方便了。
但当一个系统上线运行后,肯定会有需求迭代和Bug修复,那也就免不了进行重新打包部署。
我们可以想象一种场景,线上有一个紧急致命Bug,你也很快定位到了问题,就改一行代码的事情,当提交代码并完成构建打包并交付给运维。
因为打包的jar很大,一直处于上传中.......
如果你是老板肯定会发火,就改了一行代码却上传几百MB的文件,难道没有办法优化一下吗?
如今迭代发布是常有的事情,每次都上传一个如此庞大的文件,会浪费很多时间。
下面就以一个小项目为例,来演示如何瘦身。
二、瘦身原理
这里有一个最基础 SpringBoot 项目,整个项目代码就一个SpringBoot启动类,单是打包出来的jar就有20多M;
我们通过解压命令,看下jar的组成部分。
tar -zxvf spring-boot-maven-slim-1.0.0.jar
我们可以看出,解压出来的包有三个模块
分为 BOOT-INF,META-INF,org 三个部分
打开 BOOT-INF
classes: 当前项目编译好的代码是放在 classes 里面的,classes 部分是非常小的。
lib: 我们所依赖的 jar 包都是放在 lib 文件夹下,lib部分会很大。
看了这个结构我们该如何去瘦身呢?
项目虽然依赖会很多,但是当版本迭代稳定之后,依赖基本就不会再变动了。
如果可以把这些不变的依赖提前都放到服务器上,打包的时候忽略这些依赖,那么打出来的Jar包就会小很多,直接提升发版效率。
当然这样做你肯定有疑问?
既然打包的时候忽略这些依赖,那通过java -jar 包名 还可以启动吗?
这种方式打的包,在项目启动时,需要通过-Dloader.path指定lib的路径,就可以正常启动
java -Dloader.path=./lib -jar xxx.jar
三、瘦身实例演示
1、依赖拆分配置
只需要在项目pom.xml文件中添加下面的配置:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<executable>true</executable>
<layout>ZIP</layout>
<!--这里是填写需要包含进去的jar,
必须项目中的某些模块,会经常变动,那么就应该将其坐标写进来
如果没有则nothing ,表示不打包依赖 -->
<includes>
<include>
<groupId>nothing</groupId>
<artifactId>nothing</artifactId>
</include>
</includes>
</configuration>
</plugin>
<!--拷贝依赖到jar外面的lib目录-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<!--指定的依赖路径-->
<outputDirectory>
${project.build.directory}/lib
</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
再次打包
mvn clean package
发现target目录中多了个lib文件夹,里面保存了所有的依赖jar。
自己业务相关的jar也只有小小的168kb,相比之前20.2M,足足小了100多倍;
这种方式打的包,在项目启动时,需要通过-Dloader.path指定lib的路径:
java -Dloader.path=./lib -jar spring-boot-maven-slim-1.0.0.jar
虽然这样打包,三方依赖的大小并没有任何的改变,但有个很大的不同就是我们自己的业务包和依赖包分开了;
在不改变依赖的情况下,也就只需要第一次上传lib目录到服务器,后续业务的调整、bug修复,在没调整依赖的情况下,就只需要上传更新小小的业务包即可;
2、自己其它项目的依赖如何处理?
我们在做项目开发时,除了会引用第三方依赖,也会依赖自己公司的其它模块。
比如
这种依赖自己其它项目的工程,也是会经常变动的,所以不宜打到外部的lib,不然就会需要经常上传更新。
那怎么做了?
其实也很简单 只需在上面的插件把你需要打进jar的填写进去就可以了
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<executable>true</executable>
<layout>ZIP</layout>
<!--这里是填写需要包含进去的jar,如果没有则nothing -->
<includes>
<include>
<groupId>com.jincou</groupId>
<artifactId>xiaoxiao-util</artifactId>
</include>
</includes>
</configuration>
</plugin>
这样只有include中所有添加依赖依然会打进当前业务包中。
四、总结
使用瘦身部署,你的业务包确实小了 方便每次的迭代更新,不用每次都上传一个很大的 jar 包,从而节省部署时间。
但这种方式也有一个弊端就是增加了Jar包的管理成本,多人协调开发,构建的时候,还需要专门去关注是否有人更新依赖。
来源:juejin.cn/post/7260772691501301817
UNIAPP开发电视app教程
目前开发安卓TV的方法相对开说是十分的少的,使用uniapp开发相对来说几乎是没有的,因此,写下这篇文章供大家参考。
开发难点
- 如何方便的开发调试
- 如何使需要被聚焦的元素获取聚焦状态
- 如何使被聚焦的元素滚动到视图中心位置
- 如何在切换路由时,缓存聚焦的状态
- 如何启用wgt和apk两种方式的升级
一、如何方便的开发调试
之前我在论坛看到人家说,没办法呀,电脑搬到电视,然后调试。
其实大可不必,安装android studio里边创建一个模拟器就可以了。
注意:最好安装和电视系统相同的版本号,我这里是长虹电视,安卓9所以使用安卓9的sdk
二、如何使需要被聚焦的元素获取聚焦状态
uniapp的本质上是webview, 因此我们可以在它的元素上添加tabIndex, 就可以获取焦点了。
<view class="card" tabindex="0">
<image :src="`${VITE_URL}${props.image}`" fade-show lazy-load mode="aspectFill"></image>
<view class="bottom">
<text class="name">{{ props.name }}</text> <text class="remark">{{ props.remark }}</text>
<div class="footer">
<view class="tags">
<text class="tag" v-for="tag in tags" :key="tag">{{ tag }}</text>
</view>
<text class="price">¥ {{ props.price }}</text>
</div>
</view>
</view>
.card {
border-radius: 1.25vw;
overflow: hidden;
}
.card:focus {
box-shadow: 0 0 0 0.3vw #fff, 0 0 1vw 0.3vw #333;
outline: none;
transform: scale(1.03);
transition: box-shadow 0.3s ease, transform 0.3s ease;
}
三、如何使被聚焦的元素滚动到视图中心位置
使用renderjs进行实现如下
<script module="homePage" lang="renderjs">
export default {
mounted() {
let isScrolling = false; // 添加一个标志位,表示是否正在滚动
document.body.addEventListener('focusin', e => {
if (!isScrolling) {
// 检查是否正在滚动
isScrolling = true; // 设置滚动标志为true
requestAnimationFrame(() => {
// @ts-ignore
e.target.scrollIntoView({
behavior: 'smooth', // @ts-ignore
block: e.target.dataset.index ? 'end' : 'center'
});
isScrolling = false; // 在滚动完成后设置滚动标志为false
});
}
});
}
};
</script>
就可以使被聚焦元素滚动到视图中心,requestAnimationFrame的作用是缓存
四、如何在切换路由时,缓存聚焦的状态
通过设置tabindex属性为0和1,会有不同的效果:
- tabindex="0":将元素设为可聚焦,并按照其在文档中的位置来确定焦点顺序。当使用Tab键进行键盘导航时,tabindex="0"的元素会按照它们在源代码中的顺序获取焦点。这可以用于将某些非交互性元素(如、等)设为可聚焦元素,使其能够被键盘导航。
- tabindex="1":将元素设为可聚焦,并将其置于默认的焦点顺序之前。当使用Tab键进行键盘导航时,tabindex="1"的元素会在默认的焦点顺序之前获取焦点。这通常用于重置焦点顺序,或者将某些特定的元素(如重要的输入字段或操作按钮)置于首位。
需要注意的是,如果给多个元素都设置了tabindex属性,那么它们的焦点顺序将取决于它们的tabindex值,数值越小的元素将优先获取焦点。如果多个元素具有相同的tabindex值,则它们将按照它们在文档中的位置来确定焦点顺序。同时,负数的tabindex值也是有效的,它们将优先于零和正数值获取焦点。
我们要安装缓存插件,如pinia或vuex,需要缓存的页面单独配置
import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('global', {
state: () => ({ home_active_tag: 'active0', hot_active_tag: 'hot0', dish_active_tag: 'dish0' })
});
更新一下业务代码
组件区域
<view class="card" :tabindex="home_active_tag === 'packagecard' + props.id ? 1 : 0">
<image :src="`${VITE_URL}${props.image}`" fade-show lazy-load mode="aspectFill"></image>
<view class="bottom">
<text class="name">{{ props.name }}</text> <text class="remark">{{ props.remark }}</text>
<div class="footer">
<view class="tags">
<text class="tag" v-for="tag in tags" :key="tag">{{ tag }}</text>
</view>
<text class="price">¥ {{ props.price }}</text>
</div>
</view>
</view>
const { home_active_tag } = storeToRefs(useGlobalStore());
页面区域
<view class="content">
<FoodCard
v-for="_package in list.dishes"
@click="goShopByFood(_package)"
:id="_package.id"
:name="_package.name"
:image="_package.image"
:tags="_package.tags"
:price="_package.price"
:shop_name="_package.shop_name"
:shop_id="_package.shop_id"
:key="_package.id"
></FoodCard>
<image
class="card"
@click="goMore"
:tabindex="home_active_tag === 'more' ? 1 : 0"
style="width: 29.375vw; height: 25.9375vw"
src="/static/home/more.png"
mode="aspectFill"
/>
</view>
const goShopByFood = async (row: Record<string, any>) => {
useGlobalStore().home_active_tag = 'foodcard' + row.id;
uni.navigateTo({
url: `/pages/shop/index?shop_id=${row.shop_id}`,
animationDuration: 500,
animationType: 'zoom-fade-out'
});
};
如果,要设置启动默认焦点 id和index可默认设置,推荐启动第一个焦点组用index,它可以确定
<view class="active">
<image
v-for="(active, i) in list.active"
:key="active.id"
@click="goActive(active, i)"
:tabindex="home_active_tag === 'active' + i ? 1 : 0"
:src="`${VITE_URL}${active.image}`"
data-index="0"
fade-show
lazy-load
mode="aspectFill"
class="card"
></image>
</view>
import { defineStore } from 'pinia';
export const useGlobalStore = defineStore('global', {
state: () => ({
home_active_tag: 'active0', //默认选择
hot_active_tag: 'hot0',
dish_active_tag: 'dish0'
})
});
对于多层级的,要注意销毁,在前往之前设置默认焦点
const goHot = (index: number) => {
useGlobalStore().home_active_tag = 'hotcard' + index;
useGlobalStore().hot_active_tag = 'hot0';
uni.navigateTo({ url: `/pages/hot/index?index=${index}`, animationDuration: 500, animationType: 'zoom-fade-out' });
};
五、如何启用wgt和apk两种方式的升级
pages.json
{
"path": "components/update/index",
"style": {
"disableScroll": true,
"backgroundColor": "#0068d0",
"app-plus": {
"backgroundColorTop": "transparent",
"background": "transparent",
"titleNView": false,
"scrollIndicator": false,
"popGesture": "none",
"animationType": "fade-in",
"animationDuration": 200
}
}
}
组件
<template>
<view class="update">
<view class="content">
<view class="content-top">
<text class="content-top-text">发现版本</text>
<image class="content-top" style="top: 0" width="100%" height="100%" src="@/static/bg_top.png"> </image>
</view>
<text class="message"> {{ message }} </text>
<view class="progress-box">
<progress
class="progress"
border-radius="35"
:percent="progress.progress"
activeColor="#3DA7FF"
show-info
stroke-width="10"
/>
<view class="progress-text">
<text>安装包正在下载,请稍后,系统会自动重启</text>
<text>{{ progress.totalBytesWritten }}MB/{{ progress.totalBytesExpectedToWrite }}MB</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app';
import { reactive, ref } from 'vue';
const message = ref('');
const progress = reactive({ progress: 0, totalBytesExpectedToWrite: '0', totalBytesWritten: '0' });
onLoad((query: any) => {
message.value = query.content;
const downloadTask = uni.downloadFile({
url: `${import.meta.env.VITE_URL}/${query.url}`,
success(downloadResult) {
plus.runtime.install(
downloadResult.tempFilePath,
{ force: false },
() => {
plus.runtime.restart();
},
e => {}
);
}
});
downloadTask.onProgressUpdate(res => {
progress.progress = res.progress;
progress.totalBytesExpectedToWrite = (res.totalBytesExpectedToWrite / Math.pow(1024, 2)).toFixed(2);
progress.totalBytesWritten = (res.totalBytesWritten / Math.pow(1024, 2)).toFixed(2);
});
});
</script>
<style lang="less">
page {
background: transparent;
.update {
/* #ifndef APP-NVUE */
display: flex; /* #endif */
justify-content: center;
align-items: center;
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.65);
.content {
position: relative;
top: 0;
width: 50vw;
height: 50vh;
background-color: #fff;
box-sizing: border-box;
padding: 0 50rpx;
font-family: Source Han Sans CN;
border-radius: 2vw;
.content-top {
position: absolute;
top: -5vw;
left: 0;
image {
width: 50vw;
height: 30vh;
}
.content-top-text {
width: 50vw;
top: 6.6vw;
left: 3vw;
font-size: 3.8vw;
font-weight: bold;
color: #f8f8fa;
position: absolute;
z-index: 1;
}
}
}
.message {
position: absolute;
top: 15vw;
font-size: 2.5vw;
}
.progress-box {
position: absolute;
width: 45vw;
top: 20vw;
.progress {
width: 90%;
border-radius: 35px;
}
.progress-text {
margin-top: 1vw;
font-size: 1.5vw;
}
}
}
}
</style>
App.vue
import { onLaunch } from '@dcloudio/uni-app';
import { useRequest } from './hooks/useRequest';
import dayjs from 'dayjs'; onLaunch(() => {
// #ifdef APP-PLUS
plus.runtime.getProperty('', async app => { const res: any = await useRequest('GET', '/api/tv/app'); if (res.code === 2000 && res.row.version > (app.version as string)) { uni.navigateTo({ url: `/components/update/index?url=${res.row.url}&type=${res.row.type}&content=${res.row.content}`, fail: err => { console.error('更新弹框跳转失败', err); } }); } });
// #endif
});
如果要获取启动参数
plus.android.importClass('android.content.Intent');
const MainActivity = plus.android.runtimeMainActivity();
const Intent = MainActivity.getIntent();
const roomCode = Intent.getStringExtra('roomCode');
if (roomCode) {
uni.setStorageSync('roomCode', roomCode);
} else if (!uni.getStorageSync('roomCode') && !roomCode) {
uni.setStorageSync('roomCode', '8888');
}
来源:juejin.cn/post/7272348543625445437
Fuse.js一个轻量高效的模糊搜索库
最近逛github的时候发现了一个非常好用的轻量工具库,Fuse.js,支持模糊搜索。感觉还是非常好用的,所以有了此篇博客,这篇文章主要是介绍Fuse的使用,同样,我对这个开源项目的实现也非常感兴趣。后续会出一篇Fuse源码解析的文章来分析其实现原理。
Fuse.js是什么?
强大、轻量级的模糊搜索库,没有任何依赖关系。
什么是模糊搜索?
一般来说,模糊搜索(更正式的名称是近似字符串匹配)是查找与给定模式近似相等(而不是完全相等)的字符串的技术。
通常我们项目中的的模糊搜索大多数情况下有几种方案可用:
- 前端工程通过正则表达式或者字符串匹配来实现
- 调用后端接口去匹配搜索
- 使用搜索引擎如:ElasticSearch或Algolia等
但是这些方案都有各自的缺陷,比如正则表达式和字符串匹配的效率较低,且无法处理复杂的搜索需求,而调用后端接口和搜索引擎虽然效率高,但是需要额外的服务器资源,且需要维护一套搜索引擎。
所以,Fuse.js的出现就是为了解决这些问题,它是一个轻量级的模糊搜索库,没有依赖关系,支持复杂的搜索需求,且效率高,当然Fuse并不适用于所有场景。
Fuse.js的使用场景
它可能不适用于所有情况,但根据您的搜索要求,它可能是最理想的。例如:
- 当您想要对小型到中等大型数据集进行客户端模糊搜索时
- 当您无法证明设置专用后端只是为了处理搜索时
- ElasticSearch 或 Algolia 虽然都是很棒的服务,但对于您的特定用例来说可能有些过度
Fuse.js的使用
安装
Fuse支持多种安装方式
NPM
npm install fuse.js
Yarn
yarn add fuse.js
CDN 引入
<script src="https://cdn.jsdelivr.net/npm/fuse.js@7.0.0">script>
引入
ES6 模块语法
import Fuse from 'fuse.js'
CommonJS 语法
const Fuse = require('fuse.js')
Tips: 使用npm或者yarn引入,支持两种模块语法引入,如果是使用cdn引入,那么Fuse将被注册为全局变量。直接使用即可
使用
以下是官网一个最简单的例子,只要简单的构造new Fuse对象,就能模糊搜索匹配到你想要的结果
// 1. List of items to search in
const books = [
{
title: "Old Man's War",
author: {
firstName: 'John',
lastName: 'Scalzi'
}
},
{
title: 'The Lock Artist',
author: {
firstName: 'Steve',
lastName: 'Hamilton'
}
}
]
// 2. Set up the Fuse instance
const fuse = new Fuse(books, {
keys: ['title', 'author.firstName']
})
// 3. Now search!
fuse.search('jon')
// Output:
// [
// {
// item: {
// title: "Old Man's War",
// author: {
// firstName: 'John',
// lastName: 'Scalzi'
// }
// },
// refIndex: 0
// }
// ]
从上述代码中可以看到我们要通过Fuse 对books的这个数组进行模糊搜索,构建的Fuse对象中,模糊搜索的key定义为['title', 'author.firstName'],支持对title及author.firstName这两个字段进行搜索。然后执行fuse的search API就能过滤出我们的期望结果。整体代码还是非常简单的。
高级配置
Demo示例只是提供了一个基础版本的模糊搜索。如果用户想获得更灵活的搜索能力,比如搜索结果排序、权重控制、搜索结果高亮等,那么就需要对Fuse进行一些高级配置。
Fuse的所有配置都是通过new Fuse时传入的参数来配置的,下面列举一些常用的配置项:
const options = {
keys: ['title', 'author'], // 指定搜索key值,可多选
isCaseSensitive: false, //是否区分大小写 默认为false
includeScore: false, //结果集中是否展示匹配项的分数字段, 分数越大代表匹配程度越低,区间值为0-1,注意:当此项为true时,会返回完整的结果集,只不过每一项中携带了score分数字段
includeMatches: false, //匹配项是否应包含在结果中。当时true,结果的每条记录都包含匹配项的索引。这个通常我们用来对搜索内容做高亮处理
threshold: 0.6, // 阈值控制匹配的敏感度,默认值为0.6,如果要完全匹配这里要设置为0
shouldSort: true, // 是否对结果进行排序
location: 0, // 匹配的位置,0 表示开头匹配
distance: 100, // 搜索的最大距离
minMatchCharLength: 2, // 最小匹配字符长度
};
出了上述常用的一些配置项之外,Fuse还支持更高阶模糊搜索,如权重搜索,嵌套搜索,运算符拓展搜索,具体高阶用法可以参考官方文档。
Fuse的主要实现原理是通过改写Bitap 算法(近似字符串匹配)算法的内部实现来支撑其模糊搜索的算法依据,后续会出一篇文章看一下作者源码的算法实现。
总结
Fuse的文章到此就结束了,你没看错就这么一点介绍就基本能支撑我们在项目中的应用,谢谢阅读,如果哪里有不对的地方请评论博主,会及时进行改正。
来源:juejin.cn/post/7393172686115569705
不使用代理,我是怎么访问Github的
背景
最近更换了 windows系统的电脑, git clone
项目的时候会连接超时的错误,不管我怎么把环境变量放到终端里尝试走代理都无果,于是开始了排查
以下命令是基于 git bash 终端使用的
检测问题
通过 ssh -T git@github.com
命令查看,会报如下错误:
ssh: connect to host github.com port 22: : Connection timed out
思索了一下,难道是端口的问题吗, 于是从 overflow 上找到回答:
修改 ~/.ssh/config
路径下的内容,增加如下
Host github.com
Hostname ssh.github.com
Port 443
这段配置实际上是让 github.com
走 443 端口去执行,评论上有些说 22端口被占用,某些路由器或者其他程序会占用它,想了一下有道理,于是使用 vim ~/.ssh/config
编辑加上,结果...
ssh: connect to host github.com port 443: : Connection timed out
正当我苦苦思索,为什么 ping github.com
超时的时候,脑子里突然回忆起那道久违的八股文面试题: “url输入网址到浏览器上会发生什么",突然顿悟:是不是DNS解析出了问题,找不到服务器地址?
网上学到一行命令,可以在终端里看DNS服务器的域名解析
nslookup baidu.com
先执行一下 baidu.com
的,得到如下:
Server: 119.6.6.6
Address: 119.6.6.6#53
Non-authoritative answer:
Name: baidu.com
Address: 110.242.68.66
Name: baidu.com
Address: 39.156.66.10
再执行一下 nslookup github.com
,果然发现不对劲了:
Name: github.com
Address: 127.0.0.1
返回了 127.0.0.1
,这不对啊,笔者可是读过书的,这是本地的 IP 地址啊,原来是这一步出了问题..
解决问题
大部分同学应该都改过本地的 DNS 域名映射文件,这也是上面那道八股文题中回答的知识点之一,我们打开资源管理器输入一下路径改一下:
C:\Windows\System32\drivers\etc\hosts
MacOs的同学可以在终端使用 sudo vi /etc/hosts 命令修改
在下面加上下面这一行, 其中 140.82.113.4
是 github 的服务器地址,添加后就可以走本地的域名映射了
140.82.113.4 github.com
保存之后,就可以不使用代理,快乐访问 github.com 了,笔者顺利的完成了梦想第一步: git clone
结语
我是饮东,欢迎点赞关注,我们江湖再会
来源:juejin.cn/post/7328112739335372810
简单聊聊使用lombok 的争议
大家好,我是G探险者。
项目里,因为我使用了Lombok插件,然后代码走查的时候被领导点名了。
我心想,这么好用的插件,为啥不推广呢,整天写那些烦人的setter,getter方法就不嫌烦么?
领导既然不让用,自然有他的道理。
于是我查了一番关于lombok的一些绯闻。就有了这篇文章。
首先呢,Lombok 是一个在 Java 项目中广泛使用的库,旨在通过注解自动生成代码,如 getter 和 setter 方法,以减少重复代码并提高开发效率。然而,Lombok 的使用也带来了一些挑战和争议,特别是关于代码的可读性和与 Java Bean 规范的兼容性。
Lombok 基本使用
示例代码
不使用 Lombok:
public class User {
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
// 其他 getter 和 setter
}
使用 Lombok:
import lombok.Data;
@Data
public class User {
private String name;
private int age;
// 无需显式编写 getter 和 setter
}
Lombok 的争议
- 代码可读性和透明度:Lombok 自动生成的代码在源代码中不直接可见,可能对新开发者造成困扰。
- 工具和 IDE 支持:需要特定的插件或配置,可能引起兼容性问题。
- 与 Java Bean 规范的兼容性:Lombok 在处理属性命名时可能与 Java Bean 规范产生冲突,特别是在属性名以大写字母开头的情况。
下面我就列举一个例子进行说明。
属性命名的例子
假设有这么一个属性,aName;
标准 Java Bean 规范下:
- 属性
aName
的setter getter 方法应为setaName() getaName()
。 - 但是 Lombok 可能生成
getAName()
。
这是因为Lombok 在生成getter和setter方法时,将属性名的首字母也大写,即使它是小写的。所以对于aName属性,Lombok生成的方法可能是getAName()和setAName()。
在处理JSON到Java对象的映射时,JSON解析库(如Jackson或Gson)会尝试根据Java Bean规范匹配JSON键和Java对象的属性。它通常期望属性名的首字母在getter和setter方法中是小写的。因此,如果JSON键为"aName",解析库会寻找setaName()方法。
所以,当你使用Lombok的@Data注解,且Lombok生成的setter方法为setAName()时,JSON解析库可能找不到匹配的方法来设置aName属性,因为它寻找的是setaName()。
这种差异可能在 JSON 到 Java 对象的映射中引起问题。
Java Bean 命名规范
根据 Java Bean 规范,属性名应遵循驼峰式命名法:
- 单个单词的属性名应全部小写。
- 多个单词组成的属性名每个单词的首字母通常大写。
结论
Lombok 是一个有用的工具,可以提高编码效率并减少冗余代码。但是,在使用它时,团队需要考虑其对代码可读性、维护性和与 Java Bean 规范的兼容性。在决定是否使用 Lombok 时,项目的具体需求和团队的偏好应该是主要的考虑因素。
来源:juejin.cn/post/7310786611805863963
苦撑多年,老爷子70多!这个软件快要没人维护了
0x01、
在粒子物理学的发展过程中,有这样一个计算软件,它一度被视为粒子物理学研究的基础工具之一。
它就是:FORM。
众所周知,高能物理学领域中涉及很多超长且复杂的方程和公式,这时候就需要有一个能满足特定需求的计算软件(或者程序)来完成对应的工作。
而FORM则是一个可以进行大规模符号运算的计算程序,可以计算伽马矩阵、并行计算、包括模式匹配等。
多年来FORM一直扮演着粒子物理学领域关键工具的角色,并支撑着领域的研究和发展,行业内甚至有很多软件包都依赖于它。
但是就是这样一个领域必备的软件工具,其维护人现在都已经70多岁了,而如今却快要落得没人维护的田地了。。
0x02、
FORM自1984年就开始开发,距今已经有好几十年的历史了。
FORM的开发者是来自于荷兰的粒子物理学家乔斯·维马塞伦(Jos Vermaseren),也是现在该程序的维护者,现如今也已经70多岁高龄了。
而作为一个源自上世纪80年代的程序,彼时计算机方开始普及,软件工具也才逐渐开始兴起。
FORM的前身是由荷兰物理学家马蒂努斯·维尔特曼(Martinus Veltman)所创建的一个名为Schoonschip的程序,但是受限于当时的存储和外设条件等一系列原因,使用起来并不方便。
于是Jos Vermaseren开始着手研究该如何做出一个更易于获取和使用的工具程序。
起初Jos Vermaseren使用的是FORTRAN语言来写的这个程序,但是后来在FORM 1.0版本正式发布以前,Jos Vermaseren又重新使用C语言把该工具给重写了一遍。
就这样,从最早的Apollo工作站到后来的奔腾PC,这个程序慢慢开始被推广使用并流行起来。
经过多年的发展,目前FORM支持的版本如下:
- FORM:顺序版,可以在单个处理器上运行;
- ParFORM:多处理器版本,它可以使用集群和系统,处理器有自己的内存;
- TFORM:支持处理器共享内存系统的多线程版本,主要用于处理器数量有限的系统。
0x03、
聊回到FORM项目70多岁的维护人Jos Vermaseren老爷子,说实话还是非常佩服的。
进入Jos Vermaseren的GitHub主页(github.com/vermaseren)…
并在同期创建了他个人的首个GitHub仓库,也就是form。
截止到目前,这也是Jos Vermaseren在GitHub上的唯一一个维护的项目仓库。
不过比较遗憾的是,这个开源项目不管是访问量还是star、fork数,都十分惨淡。
0x04、
既然这个软件如此重要且无法完全被替代,那为什么现如今想找一些后继的维护人都不那么容易呢?
关于这个问题,Jos Vermaseren本人也曾说过:
“这么多年我一直都有看到,在计算工具上花费大量时间的科学家却无法得到一个物理学领域的终身职位。”
Jos Vermaseren表示自己还算是幸运的,拥有一个在荷兰国家亚原子物理研究所的终身职位,并且还有一个欣赏这个项目的老板,然而很多相关的研究者却不一定都能这样了。
所以这么看来,这也算是被一些现实的问题,所困扰到了。
投入大量精力却得不到对应的回报,而且还要求维护人员有跨学科的知识技能,不少相关领域的研究者也望而却步了。
而且在物理学术界,大家对于物理学本领域的成果产出和论文发表普遍比较看重,而程序开发的努力和关注度则往往被低估了。
可能这也某一程度上导致了像FORM这种软件工具想要找到持续的维护者都变得不那么容易了。
所以说到底,这也算是一个“坐冷板凳”的现实问题了。
文章的最后也附上和FORM相关的开源项目地址,分享给大家。
- FORM主页:http://www.nikhef.nl/~form
- GitHub主页:github.com/vermaseren/…
感兴趣的同学可以上去看一看,除此之外,大家有兴趣也可以研究一下对应的项目代码。
注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。
来源:juejin.cn/post/7394788843207376947
2024年中总结-月亮你不懂六便士到底有多重
时光磨灭了许多东西,如今掘金也不再搞年中总结活动了。
别说年中活动了,整体互联网已经“毕业”了一大批员工,互联网缩水,程序员是最大的边际递减成本,但这里也不想再继续说关于焦虑的了。
自己还是照常写写总结吧。
24年已经过去了一半,不止,7.22是大暑,转眼下一个节气就又得是【立秋】了。
想到哪就写到哪吧,有时候很痴迷于这种随机性。
因为随机性,上半年先是出版了训练时长2年半的书《程序员成长手记》,没别的,只是有人找写书,按照流程坚持完成下来了。
因为随机性,后来4月的时候,又出了一本小册《AI BOTS 通关指南》,没别的,产品需要运营,运营需要声音,但大家早就知道了,AI Agent、或者说智能体,都在谈应用、何谈应用?估摸 GPT5 出来之前,所谓的这些 AIGC 都只玩具,无法深度参与生产。赚钱的都是教人赚钱的。
因为随机性,断断续续的更文,一方面工作、草、卷起来了,一方面生活耗时占比提升,一方面自己也没动力、懈怠了。
其实,无所谓生活吧,即使每天下午6点离开工位下班,到家也约近于7点,说是早上8.30上班,7点就要开始起床、做准备,有时候还要回想、梳理、做思想建设等等。一天12个小时围绕工作这件事(摸鱼时间也减少)、8小时围绕睡觉这件事,何谈生活呢?
但是生活确实又在持续发生,比如:2024上半年最大的变化,自己身份再转变,马上要为人父了。。
初为人父、这是一个过程。
从备孕、到验孕、到验血、到查胎心胎芽、到B超-查到积液、到多轮产检、到NT、到无创、到二维等等等等,每一个点都会分散出许多新的点,需要不断打破、建设认识。
然后,似乎又回到感觉有些焦虑了?在《何以为父》这本书上看到、这种心态或许是正常的。好的吧,总之无法作甩手掌柜、也不能。
想想,上半年,还有什么?
项目的工作更加熟练了,对其本质(解决方案、商务、PPT等)似乎有了更清晰的认识。期间也发生过一些插曲,也拿不准后面事态会去向何方,总之,好像也不是自己能定的,反复看《大明1566》桥段,“打工、晋升”是不是应该蛰伏?或者是不是自己这辈子连“田县丞”都见不到,那还想那么多干嘛?
还有什么?年初定的目标完成的并不好。
还有什么?离家多年、人在广东已经漂泊十年。理想=离乡?平民就必须拼命。
想起,最开始最开始写年中总结,引用《老人与海》,竟然现在更适用吧。
还有什么?
没有了,马上10.30了,洗洗睡了。
来源:juejin.cn/post/7394279685969199139
token是用来鉴权的,session是用来干什么的?
使用JWT进行用户认证和授权,而Session在一定程度上起到了辅助作用。
让我们详细讨论JWT和Session在这种结合模式中的各自作用以及为什么需要Session。
JWT的作用
- 用户认证:JWT包含了用户的身份信息和权限信息,客户端每次请求时将JWT发送给服务器,服务器通过验证JWT来确认用户身份。
- 无状态性:JWT不需要在服务器端存储用户会话信息,因此服务器可以是无状态的,便于扩展和负载均衡。
Session的作用
- 附加的安全层:即使JWT是无状态的,但在某些应用场景中,仅依赖JWT可能存在一些安全问题,例如Token的泄露或滥用。Session可以作为一个额外的安全层,确保Token即使有效,也必须在服务器的Session管理器中存在对应的会话。
- 管理Token的生命周期:通过Session,可以更方便地管理Token的生命周期,例如强制用户重新登录、手动注销Token等操作。
- 控制“记住我”功能:如果用户选择了“记住我”选项,Session可以记录这个状态,并在JWT过期后,通过Session来决定是否允许继续使用旧的Token。
为什么需要创建Session
尽管JWT可以在无状态环境中使用,但Session的引入带来了以下好处:
- 防止Token滥用:通过在服务器端验证Session,可以确保即使Token有效,也必须是经过服务器端认证的,从而防止Token被恶意使用。
- 支持用户主动注销:当用户选择注销时,可以直接删除服务器端的Session记录,确保Token即使没有过期,也无法再被使用。
- 提供更精细的控制:通过Session,可以实现更精细的权限控制和用户状态管理,例如强制下线、会话过期时间控制等。
- 状态追踪:在某些场景下,追踪用户状态是必要的,例如监控用户的活跃度、登录历史等,这些信息可以通过Session进行管理。
结合JWT和Session的优势
结合使用JWT和Session,可以同时利用两者的优点,实现安全性和扩展性的平衡:
- 无状态认证:JWT可以实现无状态认证,便于系统的水平扩展和负载均衡。
- 状态管理和安全性:Session可以提供额外的状态管理和安全性,确保Token的使用更加安全可靠。
代码示例
以下是一个简化的代码示例,展示了如何在用户登录时创建JWT和Session:
java
Copy code
public LoginResponse login(String username, String password) throws AuthException {
// 验证用户名和密码
User user = userService.authenticate(username, password);
if (user == null) {
throw new AuthException("Invalid username or password");
}
// 生成JWT Token
String token = createJwt(user.getId(), user.getRoles());
// 创建会话
sessionManagerApi.createSession(token, user);
// 返回Token
return new LoginResponse(token);
}
public void createSession(String token, User user) {
LoginUser loginUser = new LoginUser();
loginUser.setToken(token);
loginUser.setUserId(user.getId());
loginUser.setRoles(user.getRoles());
sessionManagerApi.saveSession(token, loginUser);
}
在请求验证时,首先验证JWT的有效性,然后检查Session中是否存在对应的会话:
java
Copy code
@Override
public DefaultJwtPayload validateToken(String token) throws AuthException {
try {
// 1. 先校验jwt token本身是否有问题
JwtContext.me().validateTokenWithException(token);
// 2. 获取jwt的payload
DefaultJwtPayload defaultPayload = JwtContext.me().getDefaultPayload(token);
// 3. 如果是7天免登陆,则不校验session过期
if (defaultPayload.getRememberMe()) {
return defaultPayload;
}
// 4. 判断session里是否有这个token
LoginUser session = sessionManagerApi.getSession(token);
if (session == null) {
throw new AuthException(AUTH_EXPIRED_ERROR);
}
return defaultPayload;
} catch (JwtException jwtException) {
if (JwtExceptionEnum.JWT_EXPIRED_ERROR.getErrorCode().equals(jwtException.getErrorCode())) {
throw new AuthException(AUTH_EXPIRED_ERROR);
} else {
throw new AuthException(TOKEN_PARSE_ERROR);
}
} catch (io.jsonwebtoken.JwtException jwtSelfException) {
throw new AuthException(TOKEN_PARSE_ERROR);
}
}
总结
在这个场景中,JWT用于无状态的用户认证,提供便捷和扩展性;Session作为辅助,提供额外的安全性和状态管理。通过这种结合,可以充分利用两者的优点,确保系统既具备高扩展性,又能提供细致的安全控制。
来源:juejin.cn/post/7383017171180568630
微信小程序 折叠屏适配
最近维护了将近的一年的微信小程序(某知名企业),突然提出要兼容折叠屏,这款小程序主要功能一些图表汇总展示,也就是专门给一些领导用的,也不知道为啥领导们为啥突然喜欢用折叠屏手机了,一句话需求,苦的还是咱们程序员,但没办法,谁让甲方是爸爸呢,硬着头皮改吧,好在最后解决了,因为是甲方内部使用的小程序,这里不便贴图,但有官方案例图片,以供参考
启用大屏模式
从小程序基础库版本 2.21.3 开始,在 Windows、Mac、车机、安卓 WMPF 等大屏设备上运行的小程序可以支持大屏模式。可参考小程序大屏适配指南。方法是:在 app.json 中添加 "resizable": true
看到这里我心里窃喜,就加个配置完事了?这也太简单了,但后面证明我想简单了,
主要有两大问题:
- 1 尺寸不同的情况下内容展示效果兼容问题
- 2 预览版和体验版 大屏模式冷启动会生效,但热启动 和 菜单中点击重新进入小程、授权操作,会失效变成窄屏
解决尺寸问题
因为css的长度单位大部分用的 rpx,窄屏和宽屏展示差异出入较大,别说客户不认,自己这关就过不了,简直都不忍直视,整个乱成一片,尤其登录页,用了定位,更是乱上加乱。
随后参考了官方的文档 小程序大屏适配指南和自适应布局,方案对于微信小程序原生开发是可行的,但这个项目用的 uni-app开发的,虽然uni-app 也有对应的响应式布局组件,再加上我是个比较爱偷懒的人(甲方给的工期事件也有限制),不可能花大量时间把所有也页面重新写一遍布局,这是不现实的。
于是又转战到uni-app官网寻找解决方案 uni-app宽屏适配指南
内容缩放拉伸的处理 这一段中提出了两个策略
- 1.局部拉伸:页面内容划分为固定区域和长宽动态适配区域,固定区域使用固定的px单位约定宽高,长宽适配区域则使用flex自动适配。当屏幕大小变化时,固定区域不变,而长宽适配区域跟着变化
- 2.等比缩放:根据页面屏幕宽度缩放。rpx其实属于这种类型。在宽屏上,rpx变大,窄屏上rpx变小。
随后看到这句话特别符合我的需求,哈哈 省事 省事 省事
策略2省事,设计师按750px屏宽出图,程序员直接按rpx写代码即可。但策略2的实际效果不如策略1好。程序员使用策略1,分析下界面,设定好局部拉伸区域,这样可以有更好的用户体验
具体实现
1.配置 pages.json 的 globeStyle
{
"globalStyle": {
"rpxCalcMaxDeviceWidth": 1200, // rpx 计算所支持的最大设备宽度,单位 px,默认值为 960
"rpxCalcBaseDeviceWidth": 375, // rpx 计算使用的基准设备宽度,设备实际宽度超出 rpx 计算所支持的最大设备宽度时将按基准宽度计算,单位 px,默认值为 375
"rpxCalcIncludeWidth": 750 // rpx 计算特殊处理的值,始终按实际的设备宽度计算,单位 rpx,默认值为 750
},
}
2.单位兼容
还有一点官方也提出来了很重要,那就很多时候 会把宽度750rpx 当成100% 使用,这在宽屏的设备上就会有问题, uniapp给了两种解决方案
- 750rpx 改为100%
- 另一种是配置rpxCalcIncludeWidth,设置某个特定数值不受rpxCalcMaxDeviceWidth约束
想要用局部拉伸:页面内容划分为固定区域和长宽动态适配区域”的策略,单位必须用px
添加脚本
项目根目录新增文件 postcss.config.js 内容如下。则在编译时,编译器会自动转换rpx单位为px。
// postcss.config.js
const path = require('path')
module.exports = {
parser: 'postcss-comment',
plugins: {
'postcss-import': {
resolve(id, basedir, importOptions) {
if (id.startsWith('~@/')) {
return path.resolve(process.env.UNI_INPUT_DIR, id.substr(3))
} else if (id.startsWith('@/')) {
return path.resolve(process.env.UNI_INPUT_DIR, id.substr(2))
} else if (id.startsWith('/') && !id.startsWith('//')) {
return path.resolve(process.env.UNI_INPUT_DIR, id.substr(1))
}
return id
}
},
'autoprefixer': {
overrideBrowserslist: ["Android >= 4", "ios >= 8"],
remove: process.env.UNI_PLATFORM !== 'h5'
},
// 借助postcss-px-to-viewport插件,实现rpx转px,文档:https://github.com/evrone/postcss-px-to-viewport/blob/master/README_CN.md
// 以下配置,可以将rpx转换为1/2的px,如20rpx=10px,如果要调整比例,可以调整 viewportWidth 来实现
'postcss-px-to-viewport': {
unitToConvert: 'rpx',
viewportWidth: 200,
unitPrecision: 5,
propList: ['*'],
viewportUnit: 'px',
fontViewportUnit: 'px',
selectorBlackList: [],
minPixelValue: 1,
mediaQuery: false,
replace: true,
exclude: undefined,
include: undefined,
landscape: false
},
'@dcloudio/vue-cli-plugin-uni/packages/postcss': {}
}
}
大屏模式失效问题
下面重头戏来了,这期间经历 蜿蜒曲折 ,到头来发现都是无用功,我自己都有被wx蠢到发笑,唉,
样式问题解决后 开始着重钻研 大屏失效的问题,但看了官方的多端适配示例demo,人家的就是好的,那就应该有解决办法,于是转战github地址 下项目,谁知这项目暗藏机关,各种报错,让你跑不起来。。。。,让我一度怀疑腾讯也这么拉跨
还好issues 区一位大神有解决办法 感兴趣的老铁可以去瞅瞅
另外 微信小程序开发工具需要取消这两项,最后当项目跑起来后我还挺开心,模拟器上没有问题,但用真机预览的时候我啥眼了,还是窄屏,偶尔可以大屏,后面发现 冷启动是大屏,热启动和点击右上角菜单中的重新进入小程序按钮都会自己变成窄屏幕
这是官方的项目啊,为啥人家的可以,我本地跑起来却不可以,让我一度怀疑这里有内幕,经过几轮测试还是不行,于是乎,我开始了各种询问查资料,社区、私聊、评论、github issues,最后甚至 统计出来了 多端适配示例demo 开发者的邮箱 挨个发了邮件,但都结果无一例外,全部石沉大海
结果就是,没有办法了,想看看是不是只有预览和体验版有问题,后面发布到正式版后,再看居然没问题了,就是这么神奇,也是无语!!!! 原来做了这么多无用功。。。。
来源:juejin.cn/post/7273764921456492581
前端项目公共组件封装思想(Vue)
1. 通用组件(表单搜索+表格展示+分页器)
在项目当中我们总会遇到这样的页面:页面顶部是一个表单筛选项,下面是一个表格展示数据。表格下方是一个分页器,这样的页面在我们的后台管理系统中经常所遇到,有时候可能不止一个页面,好几个页面的结构都是这种。如图:
本人记得,在react中的高级组件库中有这么一个组件,就实现了这么一个效果。就拿这个页面来说我们实现一下组件封装的思想:1.首先把每个页面的公共部分抽出来,比如标题等,用props或者插槽的形式传入到组件中进行展示 2. 可以里面数据的双向绑定实现跟新的效果 3. 设置自定义函数传递给父组件要做上面事情
1.将公共的部分抽离出来
TableContainer组件
<template>
<div class="container">
<slot name="navbar"></slot>
<div class="box-detail">
<div class="detail-box">
<div class="box-left">
<div class="left-bottom">
<div class="title-bottom">{{ title }}</div>
<div class="note">
<div class="note-detail">
<slot name="table"></slot>
</div>
</div>
</div>
</div>
</div>
</div>
<el-backtop style="width: 3.75rem; height: 3.75rem" :bottom="10" :right="5">
<div
style="
{
width: 5.75rem;
flex-shrink: 0;
border-radius: 2.38rem;
background: #fff;
box-shadow: 0 0.19rem 1rem 0 #2b4aff14;
}
"
>
<i class="el-icon-arrow-up" style="color: #6e6f74"></i>
</div>
</el-backtop>
</div>
</template>
这里的话利用了具名插槽插入了navbar、table组件,title通过props的属性传入到子组件当中。进行展示,
父组件
<TableContainer title="资源审核">
<template v-slot:navbar>
<my-affix :offset="0">
<Navbar/>
</my-affix>
</template>
<template v-slot:table>
<SourceAuditTable/>
</template>
</TableContainer>
当然这是一个非常非常简单的组件封装案例
接下来我们看一个高级一点的组件封装
父组件
<template>
<div>
<hr>
<HelloWorld :page.sync="page" :limit.sync="limit" />
</div>
</template>
<script>
import HelloWorld from './components/HelloWorld.vue';
export default {
data() {
return {
page: 1,
limit: 5
}
},
components: {
HelloWorld
},
}
</script>
父组件传递给子组件各种必要的属性:total(总共多少条数据)、page(当前多少页)、limit(每页多少条数据)、pageSizes(选择每页大小数组)
子组件
<template>
<el-pagination :current-page.sync="currentPage" :page-size.sync="pageSize" :total="20" />
</template>
<script>
export default {
name: 'HelloWorld',
props: {
page: {
default: 1
},
limit: {
default: 5
},
},
computed: {
currentPage: {
get() {
return this.page
},
set(val) {
//currentPage 这里对currentPage做出来改变就会走这里
//这边更新数据走这里
console.log('currentPage', this.currentPage)
this.$emit('update:page', val)
}
},
pageSize: {
get() {
return this.limit
},
set(val) {
this.$emit('update:limit', val)
}
}
},
methods: {
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
这里的page.sync、limit.sync目的就是为了实现数据的双向绑定,computed中监听page和limit的变化,子组件接收的数据通过computed生成的currentPage通过sync绑定到了 el-pagination中, 点击分页器的时候会改变currentPage 此时会调用set函数设置新的值,通过代码 this.$emit(update:page,value) 更新父组件中的值,实现双向的数据绑定
本文是作者在闲暇的时间随便记录一下, 若有错误请指正,多多包涵。感谢支持!
来源:juejin.cn/post/7312353213347708940
这一年我优化了一个46万行的超级系统
背景
我曾带领团队治理了一个超级工程,是我毕业以来治理的最庞大、最复杂的工程系统,涉及到开发的方方面面。下面我给大家列几个数字,大家感受一下:
指标 数据 菜单数量 250+ 代码行数 46 万 路由数量 300+ 业务组件、util 600+ 构建时间 6min 关联业务 报表、CRM、订单、车辆、配置、财务...
这是上一任留给我的烫手山芋,而且从需求迭代频次来看,这个系统占据了这个业务部门50%的需求,也就是说,之前的伙计把几乎所有的业务功能都做进了一个系统中,像屎山一般堆积,构建一次的时间长达6-9min,作为一个合格的前端,简直怒火冲天。
我曾带领团队治理了一个超级工程,是我毕业以来治理的最庞大、最复杂的工程系统,涉及到开发的方方面面。下面我给大家列几个数字,大家感受一下:
指标 | 数据 |
---|---|
菜单数量 | 250+ |
代码行数 | 46 万 |
路由数量 | 300+ |
业务组件、util | 600+ |
构建时间 | 6min |
关联业务 | 报表、CRM、订单、车辆、配置、财务... |
这是上一任留给我的烫手山芋,而且从需求迭代频次来看,这个系统占据了这个业务部门50%的需求,也就是说,之前的伙计把几乎所有的业务功能都做进了一个系统中,像屎山一般堆积,构建一次的时间长达6-9min,作为一个合格的前端,简直怒火冲天。
问题
面对这样的超级应用,想要解决,必须先把问题整理出来,才好对症下药。
- 构建时间过长,影响开发体验。
- 系统单一,代码量庞大,未做拆分,对于后期维护难度很大,且存在增量上线风险(改一个字,也要整个系统打包上线)。
- 代码业务组件太少,复用率低,需要整理业务代码,封装高效业务组件库。
- 工具函数、请求Axios、目录规范、菜单权限、公共能力等未做统一封装和梳理。
- 存在大量重复的代码、大量重复的组件(有很多都是拷贝了一份,改个名字)。
- 页面加载极其缓慢,无论是首屏还是菜单加载,很明显存在严重的性能问题。
- 工程中随意引入各种插件,比如:big.js、xlsx.js、echarts、velocity-animate、lodash、file-saver等。
- 代码中存在很多mixin写法,导致调试难度很大。
以上是针对46万行的应用做出的问题分析报告,找出这些问题以后,我们就能开启优化之路。
面对这样的超级应用,想要解决,必须先把问题整理出来,才好对症下药。
- 构建时间过长,影响开发体验。
- 系统单一,代码量庞大,未做拆分,对于后期维护难度很大,且存在增量上线风险(改一个字,也要整个系统打包上线)。
- 代码业务组件太少,复用率低,需要整理业务代码,封装高效业务组件库。
- 工具函数、请求Axios、目录规范、菜单权限、公共能力等未做统一封装和梳理。
- 存在大量重复的代码、大量重复的组件(有很多都是拷贝了一份,改个名字)。
- 页面加载极其缓慢,无论是首屏还是菜单加载,很明显存在严重的性能问题。
- 工程中随意引入各种插件,比如:big.js、xlsx.js、echarts、velocity-animate、lodash、file-saver等。
- 代码中存在很多mixin写法,导致调试难度很大。
以上是针对46万行的应用做出的问题分析报告,找出这些问题以后,我们就能开启优化之路。
目标
我觉得不管哪一行,进入这个社会最重要的能力是生存能力,面对生存最重要的技能是解决问题的能力。想要把问题解决好,就要有章法可循,比如:找到问题要害、制定目标、提供解决方案、复盘总结。
我觉得不管哪一行,进入这个社会最重要的能力是生存能力,面对生存最重要的技能是解决问题的能力。想要把问题解决好,就要有章法可循,比如:找到问题要害、制定目标、提供解决方案、复盘总结。
方案
- 250+菜单归类整理、废弃菜单下线
- 搭建业务组件库
- 搭建工具函数库
- 基础框架优化
- 基于microApp做微服务拆分、引入webpack5的module-federation机制
- 引入rocket-render插件,对局部页面、基础功能做重构,后续逐步替换。
- 性能优化
- 250+菜单归类整理、废弃菜单下线
- 搭建业务组件库
- 搭建工具函数库
- 基础框架优化
- 基于microApp做微服务拆分、引入webpack5的module-federation机制
- 引入rocket-render插件,对局部页面、基础功能做重构,后续逐步替换。
- 性能优化
菜单整理
300 多个菜单,业务和产研人员可能已经更换过好几次,通过跟产品的沟通,可以得知,受业务调整影响,很多功能已经废弃,系统未及时下线导致日积月累。然而由于对业务不熟,产研很难判断哪些菜单需要下线,我们也不能随意凭感觉下线某一个菜单或功能,必须采取谨慎的态度,有法可依,因此我们采用如下措施:
菜单汇总
产品牵头,汇总系统所有菜单,按环境进行逐个确认。前端会根据确认结果依次删除路由配置、菜单、关联组件、调用 API 等相关代码。
- 同业务方确认后,直接下线。
- 线上访问异常菜单,进行标注。
- 数据异常菜单,进行标注。
- 对于无法确认的,通过监控查看页面访问量,通过1-3个月的观察,最终决定是否下线。
通过1个月的菜单整理,下线了大概50+的菜单、删除了近100+页面,以及不计其数的业务组件代码,整体代码量减少近8万,同时,我们将250多个菜单进行归类,拆分了6个大类,供后续微服务做准备。
300 多个菜单,业务和产研人员可能已经更换过好几次,通过跟产品的沟通,可以得知,受业务调整影响,很多功能已经废弃,系统未及时下线导致日积月累。然而由于对业务不熟,产研很难判断哪些菜单需要下线,我们也不能随意凭感觉下线某一个菜单或功能,必须采取谨慎的态度,有法可依,因此我们采用如下措施:
菜单汇总
产品牵头,汇总系统所有菜单,按环境进行逐个确认。前端会根据确认结果依次删除路由配置、菜单、关联组件、调用 API 等相关代码。
- 同业务方确认后,直接下线。
- 线上访问异常菜单,进行标注。
- 数据异常菜单,进行标注。
- 对于无法确认的,通过监控查看页面访问量,通过1-3个月的观察,最终决定是否下线。
通过1个月的菜单整理,下线了大概50+的菜单、删除了近100+页面,以及不计其数的业务组件代码,整体代码量减少近8万,同时,我们将250多个菜单进行归类,拆分了6个大类,供后续微服务做准备。
框架优化
一个稳定的系统,必然有一个稳定的框架做支撑。我们在梳理的过程中发现系统极其脆弱,无 ESLint 语法检查规范、没有 Prettier 格式化、Hard Code、随处可见的自定义组件、到处报错的控制台...
- 引入
ESLint
做语法检查. - 引入
Pettier
做代码格式化。 - 引入
Husky
lint-staged
规范代码提交。 - 对于硬编码部分跟产品沟通做成可视化配置或者json配置形式。
- Axios的二次封装:常规请求、全局Loading、文件下载、登录失效拦截、报错拦截提示、状态码统一适配。
- 插件删减:
big.js
剔除改为手动计算、lodash
替换为 lodash-es
、删除动画插件、文件导出统一由后端返回二进制,前端通过Axios封装下载函数进行文件下载等等。 - 配置文件封装:多环境配置、常量定义。
- 菜单权限封装。
- 按钮权限指令封装:
v-has="'create'"
- router路由提取优化,针对路由守卫处理一些特殊跳转问题。
- 接入前端监控平台,对于资源请求失败、接口请求超时、接口请求异常等做异常监控。
- 对于
eslint
和prettier
大家自行参考其他文章,此处不再赘述。 - 对于Axios二次封装,大家可能会比较奇怪,为什么要封装文件下载?因为文件导出本身也是一个请求,只是跟常规get/post有差异,所以我们为了方便调用,把它放在一个文件里面定义,比如:
export default {
get(url, params = {}, options = { showLoading: true, showError: true }) {
return instance.get(url, { params, ...options })
},
post(url, params = {}, options = { showLoading: true, showError: true }) {
return instance.post(url, params, options)
},
download(url, data, fileName = 'fileName.xlsx') {
instance({
url,
data,
method: 'post',
responseType: 'blob'
}).then(response => {
const blob = new Blob([response.data], {
type: response.data.type
})
const name = (response.headers['file-name'] as string) || fileName
const link = document.createElement('a')
link.download = decodeURIComponent(name)
link.href = URL.createObjectURL(blob)
document.body.append(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(link.href)
})
}
}
我们在api.js
中调用的时候,就会变的很简单,如:api.get('/user/list')
、api.download('/export',{ id: 123 })
。当然可能每个人有自己的开发习惯,这些都是我个人的经验,也不一定适合大家。对于上面的参数有一个options
变量,用来做参数扩展的,比如展示loading
和error
,为了做全局Loading
和全局错误提示用的。
- 如果你担心页面并发请求,导致重复
loading
问题,可以通过计数的方式来控制,避免多次重复Loading
。 - 插件删除部分大家可能有争议,这个纯属于个人行为,因为我们不是金融行业,不需要高精度,如果出现计算我们直接四舍五入即可,而
lodash-es
主要为了做tree-shaking
,还有很多插件根据自身情况考虑要不要引入。 - 指令封装,对于按钮权限非常管用,举个例子:
一个稳定的系统,必然有一个稳定的框架做支撑。我们在梳理的过程中发现系统极其脆弱,无 ESLint 语法检查规范、没有 Prettier 格式化、Hard Code、随处可见的自定义组件、到处报错的控制台...
- 引入
ESLint
做语法检查. - 引入
Pettier
做代码格式化。 - 引入
Husky
lint-staged
规范代码提交。 - 对于硬编码部分跟产品沟通做成可视化配置或者json配置形式。
- Axios的二次封装:常规请求、全局Loading、文件下载、登录失效拦截、报错拦截提示、状态码统一适配。
- 插件删减:
big.js
剔除改为手动计算、lodash
替换为lodash-es
、删除动画插件、文件导出统一由后端返回二进制,前端通过Axios封装下载函数进行文件下载等等。 - 配置文件封装:多环境配置、常量定义。
- 菜单权限封装。
- 按钮权限指令封装:
v-has="'create'"
- router路由提取优化,针对路由守卫处理一些特殊跳转问题。
- 接入前端监控平台,对于资源请求失败、接口请求超时、接口请求异常等做异常监控。
- 对于
eslint
和prettier
大家自行参考其他文章,此处不再赘述。 - 对于Axios二次封装,大家可能会比较奇怪,为什么要封装文件下载?因为文件导出本身也是一个请求,只是跟常规get/post有差异,所以我们为了方便调用,把它放在一个文件里面定义,比如:
export default {
get(url, params = {}, options = { showLoading: true, showError: true }) {
return instance.get(url, { params, ...options })
},
post(url, params = {}, options = { showLoading: true, showError: true }) {
return instance.post(url, params, options)
},
download(url, data, fileName = 'fileName.xlsx') {
instance({
url,
data,
method: 'post',
responseType: 'blob'
}).then(response => {
const blob = new Blob([response.data], {
type: response.data.type
})
const name = (response.headers['file-name'] as string) || fileName
const link = document.createElement('a')
link.download = decodeURIComponent(name)
link.href = URL.createObjectURL(blob)
document.body.append(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(link.href)
})
}
}
我们在api.js
中调用的时候,就会变的很简单,如:api.get('/user/list')
、api.download('/export',{ id: 123 })
。当然可能每个人有自己的开发习惯,这些都是我个人的经验,也不一定适合大家。对于上面的参数有一个options
变量,用来做参数扩展的,比如展示loading
和error
,为了做全局Loading
和全局错误提示用的。
- 如果你担心页面并发请求,导致重复
loading
问题,可以通过计数的方式来控制,避免多次重复Loading
。 - 插件删除部分大家可能有争议,这个纯属于个人行为,因为我们不是金融行业,不需要高精度,如果出现计算我们直接四舍五入即可,而
lodash-es
主要为了做tree-shaking
,还有很多插件根据自身情况考虑要不要引入。 - 指令封装,对于按钮权限非常管用,举个例子:
封装指令
import { getPageButtons } from '@hll/css-oms-utils'
// 权限指令
Vue.directive('has', {
inserted: (el, binding) => {
let pageButtons = getPageButtons().map;
if (!pageButtons[binding.value]) {
el.parentNode && el.parentNode.removeChild(el)
}
}
})
权限判断
// 1. 通过v-if判断
<el-button type="primary" v-if="pageButtons.create">创建el-button>
// 2. 通过v-has指令判断
<el-button type="primary" v-has="'create'">创建el-button>
getPageButtons 其实是为了兼容历史代码而封装的函数。
整个系统权限极其混乱,代码替换,耗时近 2 周,覆盖近 500 个文件才完成。
- 状态码适配
这个我觉得有必要提一下,我相信很多前端可能都遇到这个问题,一个系统对应n个后台服务,不同的后台服务有不同的格式,比如:A系统返回code=0
,B系统返回result=0
,C系统返回res=0
,那前端就要做不同的适配,其实也有不同的方法可以做:
- 让后端接入网关,统一在网关做适配。
- 前端在拦截器中开发
adapter
函数,针对响应码做适配。 - 针对分页、返回码、错误信息等都可以在适配函数里面做几种适配,或者提供单一函数在不同的页面单独调用,类似于
request
的to
模块。
业务组件库建设
这一部分工作非常重要,不管你在哪个公司,做任何行业,我都建议封装业务组件库,当然封装有三种形式:
- 基于公司自建的
npm
平台开发业务组件库,通过npm
方式引入。 - 对于小体量项目,直接把业务组件库放在
components
中进行维护,但是无法跨项目使用。 - 基于
webpack5
的module federation
能力开发公共组件,跨项目提供服务。
MF文档参考:http://www.webpackjs.com/concepts/mo… 我觉得在某些场景下,这个能力非常好用,它不需要像npm
一样,发布以后还需要给项目做升级,只要你改了组件源码,发布以后,其他引用的项目就会立即生效。想要快速了解这个能力,我建议大家先看一下插件文档,然后了解两个概念exposes
和remotes
,如果还是看不懂,就去找个掘进入门文章再结合本地实践一次基本就算入门了。
我是基于上面三种方式来搭建系统的业务组件库,对于通用部分全部提取封装,基于rollup
和vite
搭建一套npm
包,最终发布到公司私有npm
平台。对于一些频繁改动,链路较长部分通过module federation
进行封装和暴露。
梳理业务后,其实封装了很多业务组件,上面只是给大家提供了思路,没有一一列举具体组件,下面给大家简单列一个大纲图:
业务组件库建设对于这种大体量的超级系统收益非常大,而且是长期可持续的。
微服务搭建
前面第一部分梳理完菜单以后,其实已经将250+的菜单进行了归类,归类的目的其实就是为了服务拆分。拆分的好处非常明显:
- 服务解耦,便于维护。
- 局部需求可单独上线,不需要整包上传,减小线上风险。
- 缩小每个服务模块的构建时间,提升开发体验。
本次基于pnpm + microApp + module federation
来实现的微服务拆分,为什么?
- 微服务拆分以后,创建了 7 个仓库,非常不利于代码维护。
pnpm
天然具备monorepo
能力,可以把多个仓库合并为一个仓库,而且可以继续按7个项目的开发构建方式去工作。 - 微服务使用的是京东的
microApp
框架,集成非常简单,上手成本很低,文档友好,我觉得比阿里的乾坤简单太多了(个人主观看法)。 - 对于难于抽取的组件,直接通过
module federation
对外暴露组件服务。
上面在搭建业务组件库的时候,其实遇到了一个非常棘手的问题,有些组件跨了很多项目,链路很长,在抽取的过程中难度非常大,大家看一个图:
服务之间耦合太严重,从而导致组件抽取难度很大,那如何解决? 答案就是module federation
,抽取不了,就不抽取了,直接通过exposes
对外暴露组件服务,在其它子服务中调用即可。
下面给大家举一个接入microApp
的例子:
基座服务(主应用)
import microApp from '@micro-zoe/micro-app';
microApp.start()
添加组件容器(主应用)
<template>
<micro-app name="vms" :url="url" baseroute="/" @datachange='handleDataChange'>micro-app>
template>
<script>
export default {
name: 'ChildApp',
data() {
return {
activeMenu: '',
url: 'http://xxxx',
};
},
methods:{
handleDataChange({ detail: { data } }) {
// todo
},
}
};
script>
分配菜单(主应用)
{
path: '/child',
name: 'child',
component: () => import('./../layout/microLayout.vue'),
meta: {
appName: 'vms',
title: '子服务A'
}
}
就这样,一个主服务就搭建好了,等子服务上线以后,点击/child
菜单,就能打开对应的子服务了。 当然因为我们是一个超级应用,所以我们在做的过程中其实遇到了很多问题,比如:跨域问题、变量问题、包共享问题、本地开发调试问题、远程加载问题等等,不过大家可以参考官方文档,都是可以解决的。
Rocket-render接入
这是我个人开源的一套基于Vue2
的渲染引擎,通过json
定义就可以快速搭建各种简单或复杂的表单、表格场景,跟formly
这一类非常相似。
- 插件文档:rocket-render
- 开发文档:rocket-doc
给大家举一个简单的例子:
- 安装插件
yarn add rocket-render -S
- 组件注册
// 导入包
import RocketRender from 'rocket-render';
// 导入样式
import 'rocket-render/lib/rocket-render.css';
// 默认安装
Vue.use(RocketRender);
// 或者使用自定义属性,以下的值都是系统默认值,如果你的业务不同,可以配置下面的全局变量进行修改。
Vue.use(RocketRender, {
size: 'small',
empty: '-',
inline: 'flex',
toolbar: true,
align: 'center',
stripe: true,
border: true,
pager: true,
pageSize: 20,
emptyText: '暂无数据',
});
插件支持自定义属性,建议默认即可,我们已经给大家调好,如果确实想改,就通过自定义属性的方式进行修改。
- 页面应用
search-form 自带背景色和内填充,如果你有特殊需要,可以添加 class 进行覆盖,另外建议给 model 添加 sync 修饰符。
<template>
<search-form
:json="form"
:model.sync="queryForm"
@handleQuery="getTableList"
/>
template>
<script>
export default {
data() {
return {
queryForm: {},
form: [
{
type: 'text',
model: 'user_name',
label: '用户',
placeholder: '请输入用户名称',
},
],
};
},
};
script>
我们针对需要自定义部分提供了slot插槽,所以不用担心用了以后,对于复杂需求支持不了,导致返工的现象,完全不存在,除了json
以外,我们还内置了非常多的开发技巧,保证你爱不释手,继续看几个例子:
- 日期范围组件,通过
export
直接暴露两个字段。
{
type: 'daterange',
model: 'login_time',
label: '日期范围',
// 对于日期范围控件来说,一般接口需要拆分为两个字段,通过export可以很方便的实现字段拆分
export: ['startTime', 'endTime'],
// 日期转换为时间戳单位
valueFormat: 'timestamp', // 支持:yyyy-MM-dd HH:mm:ss
defaultTime: ['00:00:00', '23:59:59'], //可以设置默认时间,有时候非常有用。后端查询的时候,必须从0点开始才能查到数据。
}
前端是一个组件对应一个字段,但是接口往往需要让你传开始和结束日期,通过export可以直接拆分,太好用了。
- 下拉组件支持一步请求
{
type: 'select',
model: 'userStatus',
label: '用户状态',
multiple: true, // 支持多选
filterable: true, //支持输入过滤
clearable: true,
// 如果下拉框的值是动态的,可以使用fetchOptions获取,必须返回promise
fetchOptions: async () => {
return [
{ name: '全部', id: 0 },
{ name: '已注销', id: 1 },
{ name: '老用户', id: 2 },
{ name: '新用户', id: 3 },
];
},
// 字段映射,用来处理接口返回字段,避免前端去循环处理一次。
field: {
label: 'name',
value: 'id',
},
options: [],
// 如果想要修改其它表单值,可以通过change事件来处理
change: this.getSelectList,
}
通过fetchOptions可以直接在里面调用接口来返回下拉的值。如果是静态的,直接定义options即可。 如果接口返回的类别字段不是label和value结构,可以通过field来做映射。我觉得用的太爽了。
还有很多,我就不举例子了,大家去看文档就好了,文档写的非常清楚。
性能优化
前面几部分做完以后,我们的代码总量基本已经降到了 30 万行左右了,而且整个构建时长从9min降到了 30s ,有了业务组件库的加持,简直不要太爽,不过你看到的这些,看起来容易,其实我们花费了近1年的时间才完成。 剩下就是提升系统性能了:
- 资源全部上
cdn
,不仅上cdn
,还要再阿里云针对图片开启webp
(需要做兼容处理),cdn
记得添加Cache-Control
缓存。 - 服务器全部支持
gzip
压缩。 - 添加
external
配置,我在npm
开发了一个vite-plugin-external-new
插件,可以帮你解决。
- 这个大家一定要做,因为可以有效降低vender体积,你通过LightHouse做性能分析,就能知道原因,对于体积较大的js会给你提示的。
- 通过
external
,我们可以直接让vue
、vue-router
、vuex
、element-ui
等等全部通过defer
加载。
- 建议在根html
中加一个
Loading
标签
<div id="app">
<div class="loading">加载中...div>
div>
这样做的好处是,如果vue.js
还没有加载完成之前,可以让页面先loading
,等new Vue({ el: '#app' })
执行以后,才会覆盖#app
里面的 内容,这样可以提升FCP
指标。 5. 对于比较大的插件,建议按需
export const loadScript = (src: string) => {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.type = 'text/javascript';
script.defer = true;
script.onload = resolve;
script.onerror = reject;
script.src = src;
document.head.append(script);
});
};
某一个页面如果使用较大的插件,比如:xlsx、html2Canvas等,可以单独在某一个页面内做按需加载。
- 有些页面也可以针对
vue
组件或者大图片做按需加载。 - 其它性能优化,我觉得可做可不做,针对LightHouse分析报告针对性下手即可,可能很多文章将性能优化会非常细致,比如chunk分包等等,我倒觉得不是很重要。 只要把上面的做完,理论上前端的性能已经会有巨大的提升了。
结果指标
指标 | 优化前 | 优化后 |
---|---|---|
构建时长 | 6-9min | 30-45s |
代码行数 | 46万 | 30 万 |
服务 | 1个 | 7个 |
业务组件库 | 乱七八糟 | 基于rollup开发构建 |
基础框架 | 乱七八糟 | 高逼格 |
性能评分 | 30分 | 92分 |
团队成员 | 9个 | 4个 |
以上就是我面对一个 46 万行代码的超级系统,耗时大概一年的时间所做的一些成果,很多技术细节也只是泛泛而谈,但是我希望给大家提供的是工作方法、解决思路等偏软实力方面,技术功底和技术视野还是要靠自己一步一个脚印。
这一年我过的很充实,面对突如其来的问题,我心情很复杂,这一年,我感觉也老了很多。最后,感谢你耐心的倾听,我是河畔一角,一个普通的前端工程师。
来源:juejin.cn/post/7394095950383710247
在自己没有价值前,就不要谈什么人情世故了!
Hello,大家好,我是 Sunday。
昨天有位同学跟我吐槽,大致的意思是:“感觉自己不会搞 人情世故,导致在职场中很吃亏。领导也不罩着自己!”
在我的印象中,好像很多程序员都是 “不懂人情世故的典型”,至少很多程序员都认为自己是 “不懂人情世故的”。
但是,人情世故是什么?它真的有用吗?你跟领导关系好,他就会罩着你,帮你背锅吗?
恐怕是:想多了!!
一个真实的故事
给大家讲一个之前我经历过的真实故事,里面涉及到两个人物,我们用:领导 和 员工A 代替。
员工A是一个很懂 “人情世故” 的人,主要体现在两个方面:
- 酒桌文化:不像我这种压根就不能喝酒的人。员工A的酒量很好,并且各种喝酒的说法了熟于心(可以把领导说的很舒服的那种)
- 开会文化:各种反应都在领导的 “兴奋点” 上。我不知道怎么进行形容,类似于罗老师的这张图片,大家自己体会
其他方面的事情(私下的吃饭、逢年过节送礼),这些我就不清楚了,所以这里就不乱说了。
在我的眼里看来,这应该就是 熟通人情世故 的了。不知道,大家认为是不是。
不过,结果呢?
当公司决定裁员时,员工A 是 最早一批 出现在裁员名单中的。
领导会帮他争取留下来的机会吗?并不会
当你只能为对方带来 “情绪价值” 时,对方并不会把你当成心腹,更多的只是类似“马屁精”的存在。而这样的情绪价值,并没有太大的意义。更不要指望 领导会为了你做一些影响他自己利益,或者为他自己带来风险的事情了。
在自己没有价值前,就不要谈什么人情世故了!
国人在很多时候都会探讨 “人情世故” 的重要性。因为我生在 山东,对此更是感触颇深。(山东是受 儒家思想 熏陶最为严重的地方)。甚至,在之前,我也一度认为 “人情世故” 是非常重要的一件事情。
但是,当我工作之后进入企业以来。我越来越发现,在企业之中,所谓的 “人情世故” 并没有太大的意义。
人都是非常现实的,别人对你的看法,完全取决于你能为对方带来什么价值。
而这个价值通常需要体现在 金钱上 或者 事业上!
当你无法在这两个方面为对方提供价值时,那么你做的所谓的 “人情世故” 也会被对方认为是“马屁精”的嫌疑。
所以,与其把精力放到所谓的“人情世故”中,甚至为此而感到苦恼(就像开头所提到的同学一样),是 大可不必 的!
在你无法为对方带来价值之前,先努力提升自己的的能力,当你可以和对方处于一个平等的位置进行交流时,再去谈所谓的人情世故,也不迟!
来源:juejin.cn/post/7393713240995676175