2024年7月

本文分享自华为云社区
《【云驻共创】昇思MindSpore技术公开课 大咖深度解析LLaMA2 模型架构》
,作者: Freedom123。

一、前言

随着人工智能技术的不断发展,
自然语言处理
(NLP)领域也取得了巨大的进步。在这个领域中,LLaMA展示了令人瞩目的性能。今天我们就来学习LLaMA2模型,我们根据
昇思MindSpore技术公开课·大模型专题(第二期)第六讲_云视界live_直播_云社区_华为云 (huaweicloud.com)
视频内容进行了学习整理,输出如下内容,欢迎大家前来指正。

二、LLaMA 介绍

1. LLaMA 简介

LLaMA 由 Meta AI 公司2022年发布的一个开放且高效的大型基础语言模型,共有 7B、13B、33B、65B(650 亿)四种版本。其数据集来源都是公开数据集,无任何定制数据集,整个训练数据集在 token 化之后大约包含 1.4T 的 token。LLaMA 的性能非常优异:具有 130 亿参数Llama 模型「在大多数基准上」可以胜过 GPT-3( 参数量达 1750 亿),而且可以在单块 V100 GPU 上运行;而最大的 650 亿参数的 LLaMA 模型可以媲美谷歌的 Chinchilla-70B 和 PaLM-540B,LLaMA 模型参数如下:

2. LLaMA 1 与 LLaMA 2 对比

LLaMA2是Meta最新开源的大型语言模型,其训练数据集达到了2万亿token,显著扩大了处理词汇的范围。此外,LLaMA2对上下文的理解长度也从原来的LLaMA模型的2048个token扩大到了4096个token,使得这个模型能够理解并生成更长的文本。LLaMA2包含7B、13B和70B三种规模的模型,它们在各种语言模型基准测试集上都展示了优异的性能。值得一提的是,LLaMA2模型不仅适用于语言研究,也可用于商业应用。总的来说,LLaMA2是目前开源的大型语言模型(LLM)中表现最出色的一种。

相比于 LLaMA 1 ,LLaMA 2 的训练数据多了 40%,上下文长度也翻倍,并采用了分组查询注意力机制。具体来说,Llama 2预训练模型是在2 万亿的 token上训练的,精调 Chat 模型是在100 万人类标记数据上训练的,具体对比分析如下:

1)总token数量增加了40%:LLaMA 2训练使用了2 万亿个token的数据,这在性能和成本之间提供了良好的平衡,通过对最真实的来源进行过采样,以增加知识并减少幻觉。

2)加倍上下文长度:LLaMA 2 支持更长的上下文窗口,使模型能够处理更多信息,这对于支持聊天应用中的更长历史记录、各种摘要任务和理解更长文档特别有用。

3)Grouped-query attention (GQA):LLaMA 2支持GQA,允许在多头注意力(MHA)模型中共享键和值投影,从而减少与缓存相关的内存成本。通过使用 GQA,更大的模型可以在优化内存使用的同时保持性能。

三、LLaMA2核心介绍

1. 与Transformers架构的区别

Transformer模型是一种基于自注意力机制的神经网络模型,旨在处理序列数据,特别是在自然语言处理领域得到了广泛应用。Transformer模型的核心是自注意力机制(Self-Attention Mechanism),它允许模型关注序列中每个元素之间的关系。这种机制通过计算注意力权重来为序列中的每个位置分配权重,然后将加权的位置向量作为输出。模型结构上,Transformer由一个编码器堆栈和一个解码器堆栈组成,它们都由多个编码器和解码器组成。编码器主要由
多头自注意力
(Multi-Head Self-Attention)和前馈神经网络组成,而解码器在此基础上加入了编码器-解码器注意力模块。Transformer与LLaMA 的模型结构对比如下:

从Transformer的结构图中我们可以看出,Transformer主要分为编码器(encoder)和解码器(decoder)两部分。相较之下,LLaMA仅使用了Transformer的解码器部分,采取了一个仅解码器(decoder-only)的结构。这种结构现在被大多数生成型的语言模型所采用。在结构上,与Transformer模型相比,LLaMA2的主要变化是将其中的层标准化(LayerNorm)替换为了均方根标准化(RMSNorm),多头注意力(Multi-Head Attention)换成了分组查询注意力(GQA,在LLaMA中则是多查询注意力MQA),并将位置编码(Positional Encoding)替换为了旋转嵌入(Rotary Position Embedding,RoPE)。下面我们分别介绍RMS Normalization均方根标准化 ,Group Multi Query Attention分组查询注意力机制,SwiGLU Activation Function激活函数。

2. RMS Normalization 均方根标准化

LLaMA 2为了提高训练的稳定性,对每个transformer层的输入进行归一化,而不是输出进行归一化。同时,使用 RMS Norm 归一化函数。RMS Norm 的全称为Root Mean Square layer normalization,RMS Norm计算公式如下:

Layer Normalization(层归一化)和RMSNormalization(均方根归一化)都是神经网络中用于稳定训练过程的归一化技术。它们都旨在对神经网络中的激活进行规范化处理,以减少训练过程中的内部协变量偏移(Internal Covariate Shift)问题。尽管它们的目标相似,但在实现和应用上存在一些差异。与Layer Norm 相比,RMS Norm的主要区别在于去掉了减去均值的部分,它可以使得网络的训练更加稳定,加快收敛速度,并在一定程度上改善网络的泛化能力。具体来说,每一层的标准化操作都会调整该层输入数据的均值和方差,使其保持在一个稳定的数值范围内。RMS Norm 的作者认为这种模式在简化了Layer Norm 的计算,可以在减少约 7%∼64% 的计算时间。

3. Group Multi Query Attention 分组查询注意力

在各种多头注意力机制比较中,原始的多头注意力机制(MHA,Multi-Head Attention)使得QKV三部分具有相等数量的“头”,并且它们之间是一一对应的。每一次计算注意力时,各个头部的QKV独立执行自己的计算,最后将所有头部的结果加在一起作为输出。标准的MHA就是这样一个模型,其中Q、K、V分别对应了h个Query、Key和Value矩阵:

相对于MHA,多查询注意力(MQA,Multi-Query Attention)则略有不同。MQA保持了原来的Query头数,但是只为K和V各设置了一个头,即所有的Query头部都共享同一个K和V组合,因此得名为“多查询”。据实验发现,这种机制通常可以提高30%-40%的吞吐量,对性能的影响相对较小。MQA是一种多查询注意力的变体,被广泛用于自回归的解码。与MHA不同,MQA让所有的头部在K和V之间实现共享,每个头部只保留一份Query参数,从而大大降低了K和V矩阵的参数量。

分组查询注意力(GQA,Grouped-Query Attention)综合了MHA和MQA,既避免了过多的性能损失,又能够利用MQA的推理加速。在GQA中,Query部分进行分组,每个组共享一组KV。GQA把查询头分成G组,每个组内部的头部共享一个相同的K和V组合。当G设为1,即GQA-1,则所有Query都共享同一组K和V,这时的GQA等效于MQA;而当G等于头的数量,即GQA-H,那么这时的GQA等效于MHA,具体差异及整体结构见下图:

4. SwiGLU Activation Function激活函数

SwiGLU 激活函数是Shazeer 在文献中提出,并在PaLM等模中进行了广泛应用,并且取得了不错的效果,相较于ReLU 函数在大部分评测中都有不少提升。在LLaMA 中全连接层使用带有SwiGLU 激活函数的FFN(Position-wise Feed-Forward Network)的计算公式如下:

σ(x) 是Sigmoid 函数,Swish 激活函数在参数β 不同取值下的形状,可以看到当β 趋近于0 时,Swish 函数趋近于线性函数y = x,当β 趋近于无穷大时,Swish 函数趋近于ReLU 函数,β 取值为1 时,Swish 函数是光滑且非单调。在HuggingFace 的Transformer 库中Swish1 函数使用silu 函数 代替,见下图Swish 激活函数在参数β 不同取值下的形状:

关于激活函数的作用,种类,以及使用场景,由于篇幅限制,这里不做过多介绍,可自行百度。

四、与国内大模型对比

1. 模型参数

下面我们整理了LLaMA 系列,ChatGLM系列,BaiChuan系列,Qwen系列以及Yi系列等众多模型的数据,我们从训练数据,位置编码,激活函数,归一化方法以及注意力机制这几个维度分别对几个模型进行分析。

1)训练数据

LLaMA 7B和13B使用了1T的训练数据,LLAMA2对应使用了2T的数据,在训练数据上增加了一倍;ChatGLM 6B使用了1T的数据,ChatGLM2 6B使用了1.4T的数据,增加了40%;BaiChuan-7B使用了1.2T的数据,BaiChuan2-7B使用了2.6T的数据,训练数据增加了120%。看的出来,BaiChuan2在6B这个参数量级使用的训练数据是最多的,当然模型的表现也是有目共睹的。

2)位置编码

LLaMA,LLaMA2,ChatGLM,Qwen,Yi模型都是使用了RoPE位置编码,BaiChuan 13B模型使用的是ALiBi编码,BaiChuan 7B使用的是RoPE编码,百川大模型维护了俩套代码,,RoPE是主流位置编码。

3)激活函数

LLAMA,LLAMA2,Qwen,Baichuan,Yi模型都使用的是SwiGLU激活函数,ChatGLM1使用的是GELU激活函数,ChatGLM2使用了SwiGLU激活函数,SwiGLU是目前使用最广泛的激活函数。

4)归一化方法

LLaMA,LLaMA2,Qwen,Baichuan,Yi模型使用的归一化方位为RMS Norm, ChatGLM1使用的是Layer Norm,ChatGLM2使用了RMS Norm,大家默认都是用RMS Norm 归一化方法。

5)注意力机制

LLaMA,LLaMA2 7B,LLAMA2 13B,Qwen,ChatGLM-6B,BaiChuan,Yi模型使用的事MHA(多头注意力机制),LLaMA2 70B和ChatGLM2-6B 使用的是GQA(分组查询注意力机制)。

2. 模型测评

在众多国内开源模型之中,百川智能发布的Baichuan-7B、清华大学和智谱AI发布的ChatGLM2-6B、上海人工智能实验室发布的InternLM-7B等优秀模型广受业界关注。下表列出了这几个7B量级模型在几个有代表性评测集上的表现:

LLaMA-2在知识能力上有明显优势。但在学科、语言、推理和理解能力上,InternLM和ChatGLM2都已经超越了LLaMA-2,而且InternLM的领先优势十分明显。

五、问题解答

问题1:模型的上下文的记忆处理技术有哪些?

解答1:通常有两种方案,即:Fine-Tuning(
模型微调
)和 RAG(检索增强生成)。RAG(Retrieval Augmented Generation),检索增强生成:通过检索的方法来增强
生成模型
的能力。将文本转换为向量后,需要将文档和向量存储在数据库中,以便后续的检索和分析。向量数据库可以高效地存储大量的文本和向量,同时也有快速的的查询性能。Fine-Tuning(
模型微调
):使用准备好的知识库对LLM进行fine-tuning,让LLM成为领域专家,经常使用
lora
等低成本finetune,毕竟LLM的
全参调参
也是非常耗GPU的,从成本考虑不如RAG 向量知识库的形式,效果上很多业务下会比
向量数据库
略优。

问题2:模型的训练及推理,对显存的要求?

解答2:下面以举例的方式进行说明:

如果只是进行推理的话,还是比较容易计算的。目前模型的参数绝大多数都是float32类型, 占用4个字节。所以一个粗略的
计算方法
就是,每10亿个参数,占用4G显存(实际应该是10^9*4/1024/1024/1024=3.725G,为了方便可以记为4G)。比如LLaMA的参数量为7000559616,那么全精度加载这个模型参数需要的显存为:7000559616 * 4 /1024/1024/1024 = 26.08G 。这个数字就有点尴尬,专注游戏的Geforce系列最高只有24G,运行不起来,至少得上
Tesla
系列了。

如果还需要训练的话,则需要同时考虑优化器,梯度,临时显存等额外信息,只看前3部分,需要的显存是至少推理的3-4倍。7B的全精度模型加载需要78G ~ 104G。

问题3:模型精度的验证是怎么做的?

解答3:模型的精度验证目前国内主要采用在标准测评数据集上进行模型测试,测评集包含MMLU,CEval,GSM8K,MATH,TriviaQA,HumanEval,BBH等,在学科,语言,知识,推理,理解等多个维度进行测评。

问题4:大模型的未来发展方向有哪些?

解答4:基于Transformers底层架构的大模型存在被业内所诟病的罔顾事实而产生的“幻觉”问题、深层次的逻辑理解能力、数学推理能力等问题,为了实现大模型的高效训练和推理,有的是从模型底层下手,比如直接改变底层模型架构,将原来的Transformer架构改成近期新出的基于状态空间模型(SSM)的mamba架构;有的是在预训练微调方法上下手,通过采用少量精心策划的风格示例和精心设计的系统提示,对基础LLMs进行有效对齐的URIAL方法;还有一种方法就是对模型大而化之的处理方法,也就是本文要介绍的基于门控网络的混合专家模型(Mixture of Experts:MoE),由于篇幅限制,这里不做展开。

六、小节

今天我们学习了了LLaMA2大模型,我们对比了LLaMA1和 LLaMA2模型的技术细节以及整体同理,同时学习了LLaMA2模型的内部核心算法,包括与Transformers架构的差异, RMS Normalization,Group Multi Query Attention,SwiGLU Activation Function等算法。最后我们对比学习了LLaMA2与国内大模型的异同,更进一步加深了我们对LLaMA的理解。

点击关注,第一时间了解华为云新鲜技术~

前言

咱们国内现在手机分为两类,Android手机与苹果手机,现在用的各类APP,为了手机的使用安全,避免下载到病毒软件,官方都极力推荐使用手机自带的应用商城进行下载,但是国内Android手机品类众多,手机商城各式各样,做不到统一,所以Android的APP上架得一个一个平台去申请上架,一直让开发人员头疼不已,但是还是没能做到统一。但是!App Store作为苹果官方的应用商店,虽然做到了统一,但审核严格周期长、上架流程复杂麻烦,也是一直让开发人员头疼不已,很多app都卡在了审核和上架这两步,那我们就要放弃iOS上架了吗?当然不是!本篇文章就是近俩天公司要上架AppStore,我顺便做了一下笔记给记录下来,我们从iOS app上架流程开始梳理,详细了解下iOS app上架的那些事。

总体流程

打开苹果开发者中心网站:
https://developer.apple.com
,点击右上角 Account 进行开发者账号的申请。

1.申请开发者->(个人账号、公司账号、企业账号)

2.创建AppID、Bundle ID、certificates证书、Profiles配置文件

3.生成Profiles证书、密钥证书

4.创建APP

5.打包发布

流程实现

1.因为我的账号已经申请好了,这里就没法再演示一遍了。

2.创建AppID、Bundle ID

①打开苹果开发者官网,点击Certificates

②侧边栏找到Identifiers进行创建

③这里选择App IDs,点击右上角的Continue按钮

④然后选择APP

⑤在Bundle ID处选择Explicit,填写自己项目的ID,这里填写的ID即是控制台上传证书页面需要填写的App IDs

⑥如需要支付功能、分享功能等需要在Capabilities选择Associated Domains,需要消息推送则选择Push Notifications

⑦点击右上角Continue后确认信息无误后,点击register进行注册

3.生成certSigningRequest文件

①如图,打开应用程序->实用工具->钥匙串访问

②选择从证书颁发机构请求证书

③接下来填写邮件地址,选择存储到磁盘,点击继续

④保存到桌面

4.创建发布证书

①侧边栏找到Certificates进行创建

②选择IOS Distribution (App Store Connect and Ad Hoc)

③上传刚才保存到本地的certSigningRequest文件,点击Continue

④然后点击Download下载cer证书

⑤双击证书添加到钥匙串访问

⑥右键导出证书

⑦选择导出的格式,为个人信息交换(.p12)

⑧导出证书要设置密码

5.创建APP

①选择添加APP

②填写app信息

6.打包发布

进行打包之前要进行项目配置,如果你的APP有支付/跳转功能的话,首先按照上述讲到的勾选Associated Domains,因为IOS的微信支付需要iOS通用链接

Universal Link是苹果在WWDC 2015上提出的iOS 9的新特性之一。此特性类似于深层链接,并能够方便地通过打开一个Https链接来直接启动您的客户端应用(手机有安装App)。对比以往所使用的URLSheme, 这种新特性在实现web-app的无缝链接时,能够提供极佳的用户体验。

由于苹果iOS 13系统版本安全升级,微信SDK1.8.6版本要求支持Universal Links方式跳转,以便进行合法性校验,提升安全性。

简单来说就是以前你的APP要打开其他APP是通过URLScheme实现,后来苹果提出用Https链接来启动,手机上对应的app(已安装),更方便与web-app的无缝对接。微信响应了这个方案。所以大家开发的APP无论是微信登录、微信支付,还是微信分享等一切会跳转到微信,再跳回来的场景,需要提供这个链接。要不然你的应用打开了微信,微信就打不开你的应用。

那么问题来了,这个iOS通用链接改如何获取呢?官方给出的流程是这样的:

在苹果开发者中心:开启Associated Domains服务
获取相关参数,手动创建apple-app-site-association文件
部署apple-app-site-association文件到自己的云服务器,配置SSL证书解析域名
然后手动在manifest.json中配置Associated Domains(域名)
粘贴通用链接到对应权限模块
在微信开放平台配置通用链接

其二就是使用官方给的云服务,但是我个人是不喜欢这样,这样我更感觉麻烦!

其实无非就是在你的服务器上面放上一个名为apple-app-site-association的无后缀的公共访问文件,然后文件内部配置参数即可。

因为我们公司的服务器使用的是宝塔,就以宝塔为例子吧:

拟定一个访问目录,放开这个目录的访问权限,随后新建一个文件apple-app-site-association

文件内容是

{
    "applinks":{
        "apps":[],
        "details":[
            {
                "appID":"你的teamID.你的Bundle ID",
                "paths":["*"]
            }
        ]
    }

}

随后将你的该文件的访问链接(不包括该文件名)填到前端项目的manifest.json->app模块配置->Payment(支付)->IOS通用链接即可

然后点击HbuilderX的发行->原生APP云打包->ios(ipa包)->选择证书,输入导出证书时设置的密码,然后点击发布即可

发布工具的话,我自己用的是AppStore的Transporter工具,直接登录苹果开发者账号,然后选择刚才云打包生成的ipa包进行上传即可,如果你没有xcode的话,会提示你下载,如不需要选择暂不下载即可。

上述为近俩天公司上线项目个人整理内容,水平有限,如有错误之处,望各位园友不吝赐教!如果觉得不错,请点击推荐和关注!谢谢~๑•́₃•̀๑ [鲜花][鲜花][鲜花]

description

引言

当想要压缩一张彩色图像时,彩色图像通常由数百万个颜色值组成,每个颜色值都由红、绿、蓝三个分量组成。因此,如果我们直接对图像的每个像素进行编码,会导致非常大的数据量。为了减少数据量,我们可以尝试减少颜色的数量,从而降低存储需求。

1.主要原理

(一)颜色聚类(Color Clustering):

首先,使用 KMeans 聚类算法将图像中的颜色值聚类为较少数量的颜色簇。聚类的数量由 n_clusters 参数指定。每个像素被归类到与其最接近的聚类中心所代表的颜色簇。颜色聚类的过程大致如下:

  1. 图像转换:
    首先,彩色图像被转换为一个包含所有像素颜色值的数据集。每个像素的颜色通常由红、绿、蓝三个分量组成,因此数据集中的每个样本都是一个三维向量,表示一个像素的颜色。
  2. 选择聚类数量:
    在应用 KMeans 算法之前,需要确定聚类的数量。这个数量通常由用户指定,通过参数 n_clusters 控制。
  3. 应用 KMeans 算法:
    将 KMeans 算法应用于颜色数据集,将颜色值聚类为指定数量的簇。每个簇的质心代表了该簇的平均颜色。
  4. 像素映射:
    每个像素的颜色被映射到最接近的簇的质心所代表的颜色。这样,整个图像被转换为由较少数量的颜色值表示的压缩图像。

通过颜色聚类,彩色图像的颜色数量得以减少,从而实现了数据的压缩。压缩后的图像仍然能够保持视觉上的相似性,同时大大降低了存储空间的需求。

(二)动态规划量化(Dynamic Programming Quantization):

接下来,通过动态规划量化算法对颜色进行压缩。这个算法会进一步减少颜色的数量,并尽可能保持图像的质量。参数 max_colors 指定了压缩后图像中的最大颜色数量。算法会尽量选择与原始图像相似的颜色进行保留,以最大程度地保持图像的质量。而在这部分动态规划量化过程大致如下:

  1. 初始化:
    首先,初始化状态数组,表示不同颜色数量下的最优颜色组合。通常,初始状态可以是一个空数组或者包含少量颜色的数组。
  2. 状态转移:
    根据动态规划的思想,从初始状态开始逐步扩展,计算每个状态下的最优颜色组合。这个过程通常涉及到对每种可能的颜色组合进行评估,并根据优化准则选择最优的组合。
  3. 选择最优解:
    最终,选择最优的颜色组合作为压缩后的图像的颜色集合。这个颜色集合将用于替换原始图像中的颜色,从而实现图像的压缩。
  4. 压缩数据保存:
    压缩后的图像数据以及相关信息(如原始图像的尺寸、选择的颜色集合等)被保存为 NumPy 数组,并通过 np.savez_compressed() 函数保存到指定路径。

通过动态规划量化,我们能够选择一组颜色,使得压缩后的图像在尽可能减少颜色数量的情况下,仍然能够保持与原始图像相似的视觉效果。这样就实现了对图像数据的进一步压缩。

(三)压缩数据保存:

压缩后的图像数据以及相关信息(如原始图像的尺寸、聚类数、最大颜色数、聚类中心颜色等)被保存为 NumPy 数组,并通过 np.savez_compressed() 函数保存到指定路径。

(四)解压缩过程:

解压缩过程与压缩过程相反。首先加载压缩后的图像数据,然后根据聚类中心颜色替换像素颜色,最后将重构后的图像数据重塑为原始形状,并恢复图像的原始尺寸。

2.彩色图像压缩类

(一)类结构介绍

将上面所述的一个彩色图像的压缩功能整合为一个名为’ColorfulImageCompressor’的类,在这个类中有四个函数,它们的函数名称、接受参数以及介绍如下:

ColorfulImageCompressor类

  • __init__(self, n_clusters, max_colors, resize_factor=0.5)
    : 初始化彩色图像压缩器对象。
  • compress(self, image_path, compressed_file_path)
    : 压缩彩色图像并保存到指定路径。
  • decompress(self, compressed_file_path)
    : 解压缩彩色图像并返回解压缩后的图像对象。
  • _dynamic_programming_quantization(self, image_array)
    : 动态规划量化,将彩色图像颜色量化为指定数量的颜色。

(二)初始化参数

在创建一个彩色图像压缩类的时候需要传入以下三个参数,进行参数的初始化。

  • n_clusters
    :聚类数,用于 KMeans 算法,指定图像中的颜色数量。
  • max_colors
    :最大颜色数,用于动态规划量化,指定压缩后图像中的最大颜色数量。
  • resize_factor
    :缩放因子,用于调整图像尺寸,默认为 0.5,表示将图像尺寸缩小到原始的一半。

(三)函数介绍

(1)compress(self, image_path, compressed_file_path)

  1. 介绍:
    该函数的作用是压缩彩色图像并保存到指定路径。

  2. 参数:
    image_path
    :原始图像文件路径。
    compressed_file_path
    :压缩后的图像文件路径。

  3. 函数体:

    def compress(self, image_path, compressed_file_path):
        """
        压缩彩色图像并保存到指定路径。

        参数:
        - image_path:原始图像文件路径。
        - compressed_file_path:压缩后的图像文件路径。
        """
        # 打开图像并转换为 RGB 模式
        image = Image.open(image_path)
        image = image.convert('RGB')

        # 根据缩放因子调整图像大小
        new_size = (int(image.width * self.resize_factor), int(image.height * self.resize_factor))
        image = image.resize(new_size)

        # 将图像转换为 NumPy 数组并重塑为二维数组
        np_image = np.array(image)
        original_shape = np_image.shape
        np_image = np_image.reshape(-1, 3)

        # 使用动态规划量化对图像进行压缩
        compressed_data = self._dynamic_programming_quantization(np_image)

        # 保存压缩后的图像数据到指定路径
        np.savez_compressed(compressed_file_path, np_image=compressed_data['np_image'], original_shape=original_shape, n_clusters=self.n_clusters, max_colors=self.max_colors, center_colors=compressed_data['center_colors'])

(2)decompress(self, compressed_file_path)

  1. 介绍:
    解压缩彩色图像并返回解压缩后的图像对象。
  2. 参数:
    compressed_file_path
    :压缩后的图像文件路径。
    返回:
    reconstructed_image
    :解压缩后的图像对象。
  3. 函数体:
    def decompress(self, compressed_file_path):
        """
        解压缩彩色图像并返回解压缩后的图像对象。

        参数:
        - compressed_file_path:压缩后的图像文件路径。

        返回:
        - reconstructed_image:解压缩后的图像对象。
        """
        # 加载压缩后的图像数据
        compressed_data = np.load(compressed_file_path)
        np_image = compressed_data['np_image'].reshape(-1, 3)
        center_colors = compressed_data['center_colors']

        # 根据聚类中心替换像素颜色
        for i in range(self.n_clusters):
            np_image[np_image[:, 0] == i] = center_colors[i]

        # 将重构后的图像数据重塑为原始形状
        original_shape = compressed_data['original_shape']
        reconstructed_image = np_image.reshape(*original_shape).astype('uint8')
        reconstructed_image = Image.fromarray(reconstructed_image, 'RGB')

        # 恢复图像原始尺寸
        original_size = (int(reconstructed_image.width / self.resize_factor), int(reconstructed_image.height / self.resize_factor))
        reconstructed_image = reconstructed_image.resize(original_size)

        return reconstructed_image

(3)_dynamic_programming_quantization(self, image_array)

  1. 介绍:
    动态规划量化,将彩色图像颜色量化为指定数量的颜色。
  2. 参数:
    image_array
    :图像数据的 NumPy 数组表示。
    返回:
    compressed_data
    :包含压缩后图像数据及相关信息的字典。
  3. 函数体:
    def _dynamic_programming_quantization(self, image_array):
        """
        动态规划量化,将彩色图像颜色量化为指定数量的颜色。

        参数:
        - image_array:图像数据的 NumPy 数组表示。

        返回:
        - compressed_data:包含压缩后图像数据及相关信息的字典。
        """
        # 使用 KMeans 进行聚类
        kmeans = KMeans(n_clusters=self.n_clusters)
        labels = kmeans.fit_predict(image_array)
        quantized_image = np.zeros_like(image_array)

        # 遍历每个聚类簇
        for i in range(self.n_clusters):
            # 获取当前簇的像素颜色及其出现次数
            cluster_pixels = image_array[labels == i]
            unique_colors, color_counts = np.unique(cluster_pixels, axis=0, return_counts=True)
            
            # 选取出现次数最多的前 max_colors 个颜色作为量化后的颜色
            color_indices = np.argsort(color_counts)[::-1][:self.max_colors]
            quantized_colors = unique_colors[color_indices]

            # 计算聚类中像素与量化后颜色的距离
            distances = np.linalg.norm(cluster_pixels[:, None] - quantized_colors, axis=2)
            quantized_indices = np.argmin(distances, axis=1)

            # 使用量化后颜色替换聚类中的像素颜色
            quantized_image[labels == i] = quantized_colors[quantized_indices]

        # 存储聚类中心颜色
        center_colors = kmeans.cluster_centers_.astype('uint8')

        return {'np_image': quantized_image, 'n_clusters': self.n_clusters, 'max_colors': self.max_colors, 'center_colors': center_colors}

(四)使用说明

# 创建压缩器对象  
compressor = ColorfulImageCompressor(n_clusters=4, max_colors=2, resize_factor=0.5)  
  
# 压缩彩色图像  
image_path = "./img/image2.jpg"  
compressed_file_path = "./npz/compressed_image2_n4_c2.npz"  
compressor.compress(image_path, compressed_file_path)  
  
# 解压缩图像并显示  
reconstructed_image = compressor.decompress(compressed_file_path)  
reconstructed_image.show()  
reconstructed_image.save("./img/reconstructed_image2_n4_c2.jpg")  

3.测试结果

测试图片我们使用的采用的一张818*818分辨率,大小为79.49KB的彩色图片。分别使用不同的聚类数量和颜色数量来进行测试。

description description
原始图片 聚类数为8,颜色为2的压缩图片

详细运行数据如下表(下面文件名中的n为聚类数,而c为颜色数):

文件名 原始大小(KB) 压缩后的中间文件大小(KB) 解压缩后的图片大小 (KB)
reconstructed_image2_n4_c2 79.49 29.5 41.7
reconstructed_image2_n4_c4 79.49 49.3 45.2
reconstructed_image2_n4_c8 79.49 70.9 51.3
reconstructed_image2_n4_c16 79.49 94.3 59.3
reconstructed_image2_n8_c2 79.49 48.3 48.7
reconstructed_image2_n8_c4 79.49 73.3 52.5
reconstructed_image2_n8_c8 79.49 101 59.1
reconstructed_image2_n8_c16 79.49 125 61.1


结束语

如果有疑问欢迎大家留言讨论,你如果觉得这篇文章对你有帮助可以给我一个免费的赞吗?你们的认可是我最大的分享动力!

前言

在上一篇
掉了两根头发后,我悟了!vue3的scoped原来是这样避免样式污染(上)
文章中我们讲了使用scoped后,vue是如何给CSS选择器添加对应的属性选择器
[data-v-x]
。这篇文章我们来接着讲使用了scoped后,vue是如何给html增加自定义属性
data-v-x
。注:本文中使用的vue版本为
3.4.19

@vitejs/plugin-vue
的版本为
5.0.4

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

看个demo

我们先来看个demo,代码如下:

<template>
<div class="block">hello world</div>
</template>

<style scoped>
.block {
color: red;
}
</style>

经过编译后,上面的demo代码就会变成下面这样:

<template>
<div data-v-c1c19b25 class="block">hello world</div>
</template>

<style>
.block[data-v-c1c19b25] {
color: red;
}
</style>

从上面的代码可以看到在div上多了一个
data-v-c1c19b25
自定义属性,并且css的属性选择器上面也多了一个
[data-v-c1c19b25]

接下来我将通过debug的方式带你了解,vue使用了scoped后是如何给html增加自定义属性
data-v-x

transformMain
函数


通过debug搞清楚.vue文件怎么变成.js文件
文章中我们讲过了
transformMain
函数的作用是将vue文件转换成js文件。

首先我们需要启动一个debug终端。这里以
vscode
举例,打开终端然后点击终端中的
+
号旁边的下拉箭头,在下拉中点击
Javascript Debug Terminal
就可以启动一个
debug
终端。
debug-terminal

接着我们需要给
transformMain
函数打个断点,
transformMain
函数的位置在
node_modules/@vitejs/plugin-vue/dist/index.mjs

在debug终端执行
yarn dev
,在浏览器中打开对应的页面,比如:
http://localhost:5173/
。此时断点将会停留在
transformMain
函数中,在我们这个场景中简化后的
transformMain
函数代码如下:

async function transformMain(code, filename, options) {
  const { descriptor } = createDescriptor(filename, code, options);

  const { code: templateCode } = await genTemplateCode(
    descriptor
    // ...省略
  );

  const { code: scriptCode } = await genScriptCode(
    descriptor
    // ...省略
  );

  const stylesCode = await genStyleCode(
    descriptor
    // ...省略
  );

  const output = [scriptCode, templateCode, stylesCode];

  const attachedProps = [];
  attachedProps.push([`__scopeId`, JSON.stringify(`data-v-${descriptor.id}`)]);

  output.push(
    `import _export_sfc from '${EXPORT_HELPER_ID}'`,
    `export default /*#__PURE__*/_export_sfc(_sfc_main, [${attachedProps
      .map(([key, val]) => `['${key}',${val}]`)
      .join(",")}])`
  );

  let resolvedCode = output.join("\n");

  return {
    code: resolvedCode,
  };
}

在debug终端来看看
transformMain
函数的入参code,如下图:
code

从上图中可以看到入参code为vue文件的code代码字符串。

在上一篇
掉了两根头发后,我悟了!vue3的scoped原来是这样避免样式污染(上)
文章中我们讲过了
createDescriptor
函数会生成一个
descriptor
对象。而
descriptor
对象的id属性
descriptor.id
,就是根据vue文件的路径调用node的
createHash
加密函数生成的,也就是html标签上的自定义属性
data-v-x
中的
x

genTemplateCode
函数会生成编译后的render函数,如下图:
templateCode

从上图中可以看到在生成的render函数中,div标签对应的是
createElementBlock
方法,而在执行
createElementBlock
方法时并没有将
descriptor.id
传入进去。


genTemplateCode
函数、
genScriptCode
函数、
genStyleCode
函数执行完了后,得到
templateCode

scriptCode

stylesCode
,分别对应的是编译后的render函数、编译后的js代码、编译后的style样式。

然后将这三个变量
const output = [scriptCode, templateCode, stylesCode];
收集到
output
数组中。

接着会执行
attachedProps.push
方法将一组键值对push到
attachedProps
数组中,key为
__scopeId
,值为
data-v-${descriptor.id}
。看到这里我想你应该已经猜到了,这里的
data-v-${descriptor.id}
就是给html标签上添加的自定义属性
data-v-x

接着就是遍历
attachedProps
数组将里面存的键值对拼接到
output
数组中,代码如下:

output.push(
  `import _export_sfc from '${EXPORT_HELPER_ID}'`,
  `export default /*#__PURE__*/_export_sfc(_sfc_main, [${attachedProps
    .map(([key, val]) => `['${key}',${val}]`)
    .join(",")}])`
);

最后就是执行
output.join("\n")
,使用换行符将
output
数组中的内容拼接起来就能得到vue文件编译后的js文件,如下图:
resolvedCode

从上图中可以看到编译后的js文件
export default
导出的是
_export_sfc
函数的执行结果,该函数接收两个参数。第一个参数为当前vue组件对象
_sfc_main
,第二个参数是由很多组键值对组成的数组。

第一组键值对的key为
render
,值是名为
_sfc_render
的render函数。

第二组键值对的key为
__scopeId
,值为
data-v-c1c19b2

第三组键值对的key为
__file
,值为当前vue文件的路径。

编译后的js文件

从前面我们知道编译后的js文件
export default
导出的是
_export_sfc
函数的执行结果,我们在浏览器中给
_export_sfc
函数打个断点。刷新页面,代码会走到断点中,
_export_sfc
函数代码如下:

function export_sfc(sfc, props) {
  const target = sfc.__vccOpts || sfc;
  for (const [key, val] of props) {
    target[key] = val;
  }
  return target;
}

export_sfc
函数的第一个参数为当前vue组件对象
sfc
,第二个参数为多组键值对组成的数组
props

由于我们这里的vue组件对象上没有
__vccOpts
属性,所以
target
的值还是
sfc

接着就是遍历传入的多组键值对,使用
target[key] = val
给vue组件对象上面额外添加三个属性,分别是
render

__scopeId

__file

在控制台中来看看经过
export_sfc
函数处理后的vue组件对象是什么样的,如下图:
sfc

从上图中可以看到此时的vue组件对象中增加了很多属性,其中我们需要关注的是
__scopeId
属性,他的值就是给html增加自定义属性
data-v-x

给render函数打断点

前面我们讲过了在render函数中渲染div标签时是使用
_createElementBlock("div", _hoisted_1, "hello world")
,并且传入的参数中也并没有
data-v-x

所以我们需要搞清楚到底是在哪里使用到
__scopeId
的呢?我们给render函数打一个断点,如下图:
render

刷新页面代码会走到render函数的断点中,将断点走进
_createElementBlock
函数中,在我们这个场景中简化后的
_createElementBlock
函数代码如下:

function createElementBlock(
  type,
  props,
  children,
  patchFlag,
  dynamicProps,
  shapeFlag
) {
  return setupBlock(
    createBaseVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      shapeFlag,
      true
    )
  );
}

从上面的代码可以看到
createElementBlock
并不是干活的地方,而是在里层先调用
createBaseVNode
函数,然后使用其结果再去调用
setupBlock
函数。

将断点走进
createBaseVNode
函数,在我们这个场景中简化后的代码如下:

function createBaseVNode(type, props, children) {
  const vnode = {
    type,
    props,
    scopeId: currentScopeId,
    children,
    // ...省略
  };
  return vnode;
}

此时传入的
type
值为
div

props
值为对象
{class: 'block'}

children
值为字符串
hello world

createBaseVNode
函数的作用就是创建div标签对应的
vnode
虚拟DOM,在虚拟DOM中有个
scopeId
属性。后续将虚拟DOM转换成真实DOM时就会读取这个
scopeId
属性给html标签增加自定义属性
data-v-x

scopeId
属性的值是由一个全局变量
currentScopeId
赋值的,接下来我们需要搞清楚全局变量
currentScopeId
是如何被赋值的。

renderComponentRoot
函数

从Call Stack中可以看到render函数是由一个名为
renderComponentRoot
的函数调用的,如下图:
call-stack

将断点走进
renderComponentRoot
函数,在我们这个场景中简化后的代码如下:

function renderComponentRoot(instance) {
  const { props, render, renderCache, data, setupState, ctx } = instance;

  let result;
  const prev = setCurrentRenderingInstance(instance);

  result = normalizeVNode(
    render.call(
      thisProxy,
      proxyToUse!,
      renderCache,
      props,
      setupState,
      data,
      ctx
    )
  );
  setCurrentRenderingInstance(prev);
  return result;
}

从上面的代码可以看到
renderComponentRoot
函数的入参是一个vue实例
instance
,我们在控制台来看看
instance
是什么样的,如下图:
instance

从上图可以看到vue实例
instance
对象上有很多我们熟悉的属性,比如
props

refs
等。

instance
对象上的type属性对象有没有觉得看着很熟悉?

这个type属性对象就是由vue文件编译成js文件后
export default
导出的vue组件对象。前面我们讲过了里面的
__scopeId
属性就是根据vue文件的路径调用node的
createHash
加密函数生成的。

在生成vue实例的时候会将“vue文件编译成js文件后
export default
导出的vue组件对象”塞到vue实例对象
instance
的type属性中,生成vue实例是在
createComponentInstance
函数中完成的,感兴趣的小伙伴可以打断点调试一下。

我们接着来看
renderComponentRoot
函数,首先会从
instance
实例中解构出
render
函数。

然后就是执行
setCurrentRenderingInstance
将全局维护的vue实例对象变量设置为当前的vue实例对象。

接着就是执行
render
函数,拿到生成的虚拟DOM赋值给result变量。

最后就是再次执行
setCurrentRenderingInstance
函数将全局维护的vue实例对象变量重置为上一次的vue实例对象。

setCurrentRenderingInstance
函数

接着将断点走进
setCurrentRenderingInstance
函数,代码如下:

let currentScopeId = null;
let currentRenderingInstance = null;
function setCurrentRenderingInstance(instance) {
  const prev = currentRenderingInstance;
  currentRenderingInstance = instance;
  currentScopeId = (instance && instance.type.__scopeId) || null;
  return prev;
}


setCurrentRenderingInstance
函数中会将当前的vue实例赋值给全局变量
currentRenderingInstance
,并且会将
instance.type.__scopeId
赋值给全局变量
currentScopeId

在整个render函数执行期间全局变量
currentScopeId
的值都是
instance.type.__scopeId
。而
instance.type.__scopeId
我们前面已经讲过了,他的值是根据vue文件的路径调用node的
createHash
加密函数生成的,也是给html标签增加自定义属性
data-v-x

componentUpdateFn
函数

前面讲过了在
renderComponentRoot
函数中会执行render函数,render函数会返回对应的虚拟DOM,然后将虚拟DOM赋值给变量
result
,最后
renderComponentRoot
函数会将变量
result
进行return返回。

将断点走出
renderComponentRoot
函数,此时断点走到了执行
renderComponentRoot
函数的地方,也就是
componentUpdateFn
函数。在我们这个场景中简化后的
componentUpdateFn
函数代码如下:

const componentUpdateFn = () => {
  const subTree = (instance.subTree = renderComponentRoot(instance));

  patch(null, subTree, container, anchor, instance, parentSuspense, namespace);
};

从上面的代码可以看到会将
renderComponentRoot
函数的返回结果(也就是组件的render函数生成的虚拟DOM)赋值给
subTree
变量,然后去执行大名鼎鼎的
patch
函数。

这个
patch
函数相比你多多少少听过,他接收的前两个参数分别是:旧的虚拟DOM、新的虚拟DOM。由于我们这里是初次加载没有旧的虚拟DOM,所以调用
patch
函数传入的第一个参数是null。第二个参数是render函数生成的新的虚拟DOM。

patch
函数

将断点走进
patch
函数,在我们这个场景中简化后的
patch
函数代码如下:

const patch = (
  n1,
  n2,
  container,
  anchor = null,
  parentComponent = null,
  parentSuspense = null,
  namespace = undefined,
  slotScopeIds = null,
  optimized = !!n2.dynamicChildren
) => {
  processElement(
    n1,
    n2,
    container,
    anchor,
    parentComponent,
    parentSuspense,
    namespace,
    slotScopeIds,
    optimized
  );
};

从上面的代码可以看到在
patch
函数中主要是执行了
processElement
函数,参数也是透传给了
processElement
函数。

接着将断点走进
processElement
函数,在我们这个场景中简化后的
processElement
函数代码如下:

const processElement = (
  n1,
  n2,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  namespace,
  slotScopeIds,
  optimized
) => {
  if (n1 == null) {
    mountElement(
      n2,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      namespace,
      slotScopeIds,
      optimized
    );
  }
};

从上面的代码可以看到如果
n1 == null
也就是当前没有旧的虚拟DOM,就会去执行
mountElement
函数将新的虚拟DOM挂载到真实DOM上。很明显我们这里
n1
的值确实是
null
,所以代码会走到
mountElement
函数中。

mountElement
函数

接着将断点走进
mountElement
函数,在我们这个场景中简化后的
mountElement
函数代码如下:

const mountElement = (
  vnode,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  namespace,
  slotScopeIds,
  optimized
) => {
  let el;
  el = vnode.el = hostCreateElement(vnode.type);
  hostSetElementText(el, vnode.children);
  setScopeId(el, vnode, vnode.scopeId, slotScopeIds, parentComponent);
};

从上面的代码可以看到在
mountElement
函数中首先会执行
hostCreateElement
函数生成真实DOM,并且将真实DOM赋值给变量
el

vnode.el
,所以虚拟DOM的
el
属性是指向对应的真实DOM。这里的
vnode.type
的值为
div
,所以这里就是生成一个div标签。

然后执行
hostSetElementText
函数给当前真实DOM的文本节点赋值,当前
vnode.children
的值为文本
hello world
。所以这里就是给div标签设置文本节点
hello world

最后就是调用
setScopeId
函数传入
el

vnode.scopeId
,给div标签增加自定义属性
data-v-x

接下来我们来看看上面这三个函数。

先将断点走进
hostCreateElement
函数,在我们这个场景中简化后的代码如下:

function hostCreateElement(tag) {
  const el = document.createElement(tag, undefined);
  return el;
}

由于传入的
tag
变量的值是
div
,所以此时
hostCreateElement
函数就是调用了
document.createElement
方法生成一个
div
标签,并且将其return返回。

经过
hostCreateElement
函数的处理后,已经生成了一个
div
标签,并且将其赋值给变量
el
。接着将断点走进
hostSetElementText
函数,代码如下:

function hostSetElementText(el, text) {
  el.textContent = text;
}

hostSetElementText
函数接收的第一个参数为
el
,也就是生成的
div
标签。第二个参数为
text
,也就是要向div标签填充的文本节点,在我们这里是字符串
hello world

这里的
textContent
属性你可能用的比较少,他的作用和
innerText
差不多。给
textContent
属性赋值就是设置元素的文字内容,在这里就是将div标签的文本设置为
hello world

经过
hostSetElementText
函数的处理后生成的div标签已经有了文本节点
hello world
。接着将断点走进
setScopeId
函数,在我们这个场景中简化后的代码如下:

const setScopeId = (el, vnode, scopeId) => {
  if (scopeId) {
    hostSetScopeId(el, scopeId);
  }
};

function hostSetScopeId(el, id) {
  el.setAttribute(id, "");
}


setScopeId
函数中如果传入了
scopeId
,就会执行
hostSetScopeId
函数。而这个
scopeId
就是我们前面讲过的
data-v-x


hostSetScopeId
函数中会调用DOM的
setAttribute
方法,给div标签增加
data-v-x
属性,由于调用
setAttribute
方法的时候传入的第二个参数为空字符串,所以div上面的
data-v-x
属性是没有属性值的。所以最终生成的div标签就是这样的:
<div data-v-c1c19b25 class="block">hello world</div>

总结

这篇文章讲了当使用了scoped后,vue是如何给html增加自定义属性
data-v-x

首先在编译时会根据当前vue文件的路径进行加密算法生成一个id,这个id就是自定义属性
data-v-x
中的
x

然后给编译后的vue组件对象增加一个属性
__scopeId
,属性值就是
data-v-x

在运行时的
renderComponentRoot
函数中,这个函数接收的参数是vue实例
instance
对象,
instance.type
的值就是编译后的vue组件对象。


renderComponentRoot
函数中会执行
setCurrentRenderingInstance
函数,将全局变量
currentScopeId
的值赋值为
instance.type.__scopeId
,也就是
data-v-x


renderComponentRoot
函数中接着会执行render函数,在生成虚拟DOM的过程中会去读取全局变量
currentScopeId
,并且将其赋值给虚拟DOM的
scopeId
属性。

接着就是拿到render函数生成的虚拟DOM去执行
patch
函数生成真实DOM,在我们这个场景中最终生成真实DOM的是
mountElement
函数。


mountElement
函数中首先会调用
document.createElement
函数去生成一个div标签,然后使用
textContent
属性将div标签的文本节点设置为
hello world

最后就是调用
setAttribute
方法给div标签设置自定义属性
data-v-x

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

序言

对于一名开发者来说,独自开发一款小程序与App,也许总会有一些疑问:

1. 需要掌握哪些技术?

答:java、vue、及常规Linux命令

2. 需要多少成本?

答:服务器购买,云服务器新人50多三年;

域名购买,10块的域名够用,后续每年30左右的续期费用;

短信套餐购买,50块钱,够用很久了;

微信小程序发布,需要300块钱的审核费用;

ios版本的App发布,貌似也要钱。

3.需要多久完成?

答:如果第一次,需完成域名备案、服务器环境搭建、程序基础功能开发等,可能用时较久;

如果第二次,仅仅在第一次的基础加改代码,短时间可以完成就够了。

1.    心得说明

本文基于个人开发和发布《九云题库》H5/微信小程序/APP的经验,分享从零到完成一套完整系统开发、发布的全过程,其中涉及到部署资源的获取,开发过程、部分程序设计思路、注意事项以及最终部署方法等。

1.1     功能说明

  1. 实现PC端/H5/App/小程序4端的常规登陆/注册/更新等
  2. 实现题目分类和题目的增删改查功能,支持手机端和PC端操作
  3. 实现题目收藏/取消收藏,移动端
  4. 实现错题记录/移除记录,移动端
  5. 实现刷题/看题/搜题功能,移动端
  6. 实现题目提问/留言讨论功能,移动端
  7. 其它移动端的基础必备功能

1.2    
附件截图

以下是App、H5、微信小程序三个移动端的部分截图





2.   
实现方案

1.1    
资源方案

2.1.1  
服务器选择与规划

服务器选择,系统需要有一个承载平台,因此需要一个服务器,购买云服务器centos7系统是一个不错的选择,或者使用其它服务器。

服务器规划,软件和应用尽可能都放在/home路径,方便统一管理,如:/home/app放系统软件

/home/nginx放nginx配置

/home/docker放docker镜像

/home/minio放minio文件

/home/mysql_tump放mysql备份文件

注:软件安装最好修改默认端口,否则服务器容易被攻击。

2.1.2  
服务器-jdk环境

将下载好的 jdk-8u211-linux-x64.tar.gz 放在 /home/app 路径

# 进入目录解压tar -zxvf jdk-8u211-linux-x64.tar.gz

# 修改环境变量
vi /etc/profile

# 添加以下配置
export JAVA_HOME
=/home/app/jdk1.8.0_211

export CLASSPATH
=${JAVA_HOME}/lib
export PATH
=$PATH:${JAVA_HOME}/bin

# 应用配置
source
/etc/profile

# 校验是否成功
javac
java
-versionecho $PATH

2.1.3   服务器-端口开通

云服务器平台需要开通端口,服务器启用防火墙,开通各个服务的端口,以下是centos7下的防火墙相关操作,同时云服务器平台也需要开通对应的端口

# 查看防火墙状态
systemctl status firewalld.service

# 永久启用防火墙
systemctl enable firewalld.service

# 查看防火墙配置情况
firewall
-cmd --list-all

# 查看端口
netstat
-apn | grep 8080# 添加/移除端口
firewall
-cmd --permanent --zone=public --add-port=8080/tcp
firewall
-cmd --permanent --zone=public --remove-port=80/tcp

# 添加区间类型的端口
firewall
-cmd --zone=public--add-port=4400-4600/udp --permanent
firewall
-cmd --zone=public--add-port=4400-4600/tcp --permanent

# 重新加载防火墙
firewall
-cmd --reload

2.1.4     服务器-Nginx

执行命令-增加支持ssl

# 下载nginx包,解析到/home/app/nginx,安装命令
.
/configure --prefix=/home/app/nginx --with-http_ssl_modulemake make installnginx-V

# 启动nginx服务,切换目录到
/home/app/nginx/sbin下面
.
/nginx

# 重新加载配置
|重启|停止|退出
.
/nginx -s reload|reopen|stop|quit

# 查看nginx服务是否启动成功
ps -ef | grepnginx

# 配置nginx全局
vim
/etc/profile

# nginx
NGINX_HOME
=/home/app/nginx
export PATH
=$PATH:$NGINX_HOME/sbin

# 应用配置
source
/etc/profile

Nginx配置说明,在/home/app/nginx/conf/nginx.conf的http下增加引用配置,其具体nginx配置只需在引用目录添加即可,方便管理和维护

include /home/nginx/conf/conf.d/*.conf;

在引用目录下编写对应各个需求的nginx配置,一个nginx配置写一个文件,cert专门放ssl证书


2.1.5  
  服务器-gitea

安装源码管理工具,gitea很占用服务器性能资源,也可以使用开源平台

# nginx配置
server {
listen
443ssl;
server_name git.ninecloud.top;
ssl_certificate
/home/nginx/conf/conf.d/cert/git.pem;
ssl_certificate_key
/home/nginx/conf/conf.d/cert/git.key;
ssl_session_timeout 5m;
ssl_ciphers ECDHE
-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
ssl_protocols TLSv1 TLSv1.
1 TLSv1.2;
ssl_prefer_server_ciphers on;
location
/{
proxy_pass http:
//127.0.0.1:3000/; proxy_set_header Host $http_host;
proxy_set_header X
-Forwarded-For $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
}
}

2.1.6   服务器-docker

安装docker并设置远程访问,设置远程访问后,方便idea直接发布后端

# 安装dockeryum install docker-ce

# 启动docker
systemctl start docker

# 设置开机自启
systemctl enable docker


# 配置docker远程访问
vi /usr/lib/systemd/system/docker.service

# 在ExecStart
=/usr/bin/dockerd后面添加-H tcp://0.0.0.0:2375 # 重新加载和重启
systemctl daemon
-reload
systemctl restart docker.service

2.1.7   服务器-minio

安装成功后,浏览器打开9090端口,创建Minio仓库并设置权限,既可在后端配置使用,此处通过docker安装minio

# 下载镜像 
docker pull minio
/minio

# 查看镜像
ddocker images

# 创建两个目录,一个用来存放配置,一个用来存储上传文件的目录,启动前需要先创建Minio外部挂载的配置文件(
/home/minio/config),和存储上传文件的目录( /home/minio/data)mkdir -p /home/minio/configmkdir -p /home/minio/data

# 启动
docker run
-p 9000:9000 -p 9090:9090\--net=host \--name minio \-d --restart=always \-e "MINIO_ACCESS_KEY=yourAccount"\-e "MINIO_SECRET_KEY=yourPassword"\-v /home/minio/data:/data \-v /home/minio/config:/root/.minio \
minio
/minio server \/data --console-address ":9090" -address ":9000"


2.1.8  
服务器-redis

# 安装wget http://download.redis.io/releases/redis-6.2.1.tar.gz
tar xzvf redis-6.2.1.tar.gz
cd redis
-6.2.1 makecd srcmake install PREFIX=/home/app/redis

# redis全局环境
/etc/profile
REDIS_HOME
=/home/app/redis
export PATH
=$PATH:$REDIS_HOME/bin

# 设置密码,配置
/home/app/redis/redis.conf
# bind
127.0.0.1 -::1daemonize yes
protected
-mode no
requirepass yourPassword

最后这步很重要,修改redis端口,个人服务器曾因未修改端口被攻击,修改端口号方式

/home/app/redis/redis.conf
# 改变默认端口号port
5268

2.1.9   服务器-mysql8

安装mysql8后,对于每个系统,要使用单独的数据库以及单独的账号,避免某个数据库账号泄露导致所有数据库数据泄露的风险。

备份数据很重要,通过Linux的定时任务crontab,实现数据库每日备份,并删除过期的备份

在scripts中写入备份和删除备份脚本

//备份脚本
vim /home/mysql_tump/scripts/backup.sh#!/bin/bash
# 备份目录
BACKUP_ROOT
=/home/mysql_tump
BACKUP_FILEDIR
=$BACKUP_ROOT/files
BACKUP_LOGDIR
=$BACKUP_ROOT/logs
# 当前日期
DATE
=$(date +%Y%m%d)
DATABASES
=(testdb test2db)
# 循环备份
echo 'Begin for mysql tump!' for db in${DATABASES[@]}do echo 'Is tumpping'${db}
mysqldump
--defaults-extra-file=/etc/my.cnf --default-character-set=utf8 --lock-all-tables --flush-logs --log-error=$BACKUP_LOGDIR/${db}_$DATE.error.log -E -R -B ${db} | gzip> $BACKUP_FILEDIR/${db}_$DATE.sql.gzdone echo 'Success for mysql tump!'
//删除过期备份
vim /home/mysql_tump/scripts/backup_rm.sh#!/bin/bash
# 删除备份
echo 'Begin for remove tump!' find /home/mysql_tump/files -type f -mtime +5 | xargs rm -ffind /home/mysql_tump/logs -type f -mtime +5 | xargs rm -fecho 'Success for remove tump!'

编辑Linux自带的crontab任务,加入备份和删除备份两个任务

//定时任务
crontab -e00 05 * * * bash /home/mysql_tump/scripts/backup.sh
30 05 * * * bash /home/mysql_tump/scripts/backup_rm.sh

2.1.10   域名选择与规划

小程序只支持https域名式地址,因此必须有一个域名。可在云服务商注册购买域名或其它域名方式,域名需要进行备案。

域名规划,可以解析多个子域名。子域名全部指向服务器IP,具体子域名通过nginx指向不同的端口,负载均衡也由nginx完成,通过不同域名指向不同的领域系统或功能,如:

www.ninecloud.top — 网站首页与静态文件路径

api.ninecloud.top — 接口

fs.ninecloud.top — Nginx文件服务

minio.ninecloud.top — Minio文件服务

git.ninecloud.top — Gitea服务

doc.ninecloud.top —文档服务

每个子域名需要各自申请ssl证书,将证书文件放在服务器并在nginx里配置,即可实现https访问,在云服务商购买的域名都有免费的ssl证书可用。


2.1.11  
短信方案

系统存在验证码登录,找回密码等场景,因此有短信发送需求,可购买各服务商平台的短信套餐,接入系统

发送短信需要在服务商的云平台申请签名和模版,各个云平台管理都比较严格,尤其是个人用户,申请签名和模版很难成功,需要耐心。

2.1.12  
微信公众平台

微信发布小程序,需要在微信公众平台注册小程序账号,进行一些认证,https地址白名单设置,权限设置等,拿到后续开发需要的AppID,AppSecret等关键数据。

2.1.13  
其它小程序平台

尝试成功发布过支付宝小程序,和微信小程序大同小异。


2.1.14  
App发布

通过jre生成Android签名证书,然后使用开发工具HbuilderX工具打包成apk文件,放在服务器,用户直接通过浏览器下载即可安装使用。

Window本地生成App证书方式,dos命令进入jre目录

//生成证书
#  testalias为证书别名,test.keystore为证书名称
keytool
-genkey -alias testalias -keyalg RSA -keysize 2048 -validity 36500 -keystore test.keystore

# 中间的内容不用填,在最后的提示中,确认证书密码
Enter key password
for <testalias> (RETURN if same as keystore password):

在HbuilderX中,打包app时,需要用到上面的文件,别名,和密码



1.2    
代码方案

2.2.1  
开发发布说明

工具说明:

后端工具idea 框架若依Plus

前端工具VsCode 框架若依vue3

移动端工具HbuilderX 框架uniapp vue3

微信小程序的devtools

数据库工具dbever

服务器工具Xshell、Xftp

其它工具,接口调试postman,P图工具photoshop,redis客户端等

发布说明:

后端发布,在idea设置docker远程连接,打包jar,以docker命令部署

前端部署,生成静态文件,上传服务器/home/www/msw/目录

移动端H5,生成静态文件,上传服务器/home/www/uquestion目录

移动端App,打包生成Apk后,上传/home/nginx/apk目录,通过系统配置指定路径和版本确定apk地址,App端可自动触发升级,H5端可点击下载

移动端微信小程序,HbuilderX生成小程序项目文件,通过微信小程序工具打开文件上传即可,然后进入微信公众平台,进行审核升级


2.2.2  
后端开发-数据库设计

分类表

问题表

评论表

日志表

2.2.3  
后端开发-框架基于若依Plus

在若依plus基础上增加ruoyi-question模块,单独存放题库相关功能。

其中四个controller分别是:class-题目分类,option-题目,log-收藏和错题,comment-提问与回复

通过SaToken的SaMode.OR模式,给PC端和手机端都需要的接口增加两个任意满足权限,手机端通用基础权限可固定为app:base:api,用户注册会默认给到相关权限,权限代码例如:

@SaCheckPermission(value = {"question:class:list", "app:base:api"}, mode =SaMode.OR)
@GetMapping(
"/getAllList")
public R
<List<QuestionClass>>getAllList(QuestionClass entry) {
return R.ok(baseService.getAllList(entry));
}

2.2.4  
后端开发-登陆/注册机制

登录有两种方式,账号密码登录和手机号验证码登录,个人版的微信小程序只能获取OpenId无法获取手机号,OpenId唯一可用于登录,
但由于多端可注册可登录,避免同一用户出现两个账号,因此小程序放弃OpenId登录,与其它端保持一致。

以下是登陆Body文件, 其中通过登陆类型type来区分两种登陆方式,通过registerFlag来判断手机号验证码登录时,系统没有账号是否自动注册。

@Data
public class AppLoginBody {
/**
* 登录类型,0-密码登录,1-验证码登录
*/@NotBlank(message= "登录类型不允许为空")
private String type;
/**
* 用户账号
*/@NotBlank(message= "{user.username.not.blank}")
@Length(min
= UserConstants.USERNAME_MIN_LENGTH, max = UserConstants.USERNAME_MAX_LENGTH, message = "{user.username.length.valid}")
private String username;
/**
* 用户密码
*/private String password;/**
* 验证码
*/private String smsCode;/**
* 注册标记 为1表示无用户则注册
*/private String registerFlag;

}

2.2.5   后端开发-其它说明

其它还需使用后端的功能有:公告,系统配置,退出登录,更新用户信息或头像,找回密码,发送短信,生成二维码等


2.2.6  
前端开发-框架基于若依Vue3

将若依前端代码进行深度改造,样式代码尽可能单独提出来在index.scss引入,统一管理,如表格样式,弹窗样式,树样式等。

好处是尽可能在具体页面不再写css内容,确保整个系统样式统一,便于维护和修改。

表格样式

将函数方法组件分四个引用文件一次性引入,避免在main.js文件内写太多的内容,不利于维护和管理

如框架工具具体情况如下,后续新增公共js方法文件,直接在此文件添加即可;新增单个的方法,直接在对应文件添加方法即可,无需再引用。

2.2.7  
前端开发-题目分类管理

题库分类,使用树形结构设计,支持增删改查和复制,以及点击题目数量跳转到对应分类的题目管理页面

分类的编辑

2.2.8  
前端开发-题目管理

题目管理,除常规的增删改查外,还有批量删除和复制功能,列表支持单击勾选,表格关键字过滤题目,列显示隐藏等。

编辑功能,选项支持添加与删除,添加时,自动按ABCDE…的顺序排列,删除时重新排列顺序。


2.2.9  
移动端开发-框架参考若依App

仅仅是参考若依App移动端,第一是因为若依App是vue2版本,第二是因为若依App很多功能都没有开发,不足以支撑完整功能。

与PC端一样,尽可能把样式文件单独提取出来,避免重复写css,如图片样式,广告样式,列表样式等

由于移动端的样式复杂多变,很难固定统一,所以用基础样式,将常用的样式全部写好,html的class直接用即可。

公共方法的引用,同前端一样,由一个文件引用全部方法,然后在main.js里面引用此文件,方便维护和管理。

组件引用,uniapp组件只需要按规范写在components文件下即可

2.2.10  
移动端开发-功能规划

App采用常规设计,有三个导航按钮,分别是首页,题库,我的



首页,最上用滚动banner和动态公告,让系统有一些动感,下方放直通分类的题目。

题库页,使用树形结构展示所有分类和题目,最上方搜索栏可以过滤题目和选择是否有答案的题目。

我的页,常规功能是用户信息,上传图像,邀请好友,设置等,其中系统设置、题库分类、题目管理不在App基础权限内,一般用户未授权前不可见,我的收藏和我的错题记录刷题过程中的操作。

2.2.11  
移动端开发-登陆机制

对于移动端,用户登陆后,如果以账号密码方式登陆,则存储用户名密码,同时存储获取到的token等信息,后续登陆同PC前端一样,header中携带token进行访问;

不同之处在于,移动端token过期后,自动触发重登陆机制,用存储中的账号密码自动重新登录,用户是没有任何感觉的,避免每次打开都要重新登陆。同时设置退出和清除缓存功能,让用户可以清除缓存信息。



2.2.12  
移动端开发-上传图像

若依App原版上传图像截取,图片是铺满方式,支持有限,个人用不惯。重写图像裁剪组件,经过一番调试后,兼容H5、App和微信小程序。

组件支持左右上下翻转、不同角度的旋转、不同程度的放大缩小,支持预览等。



2.2.13  
移动端开发-App更新机制

H5端直接显示最新版本,支持下载App。

App端每次更新一个版本进行打包,将打包的apk文件以版本号命名,上传到文件服务器,准备发布新版本直接就改配置参数为对应版本即可;

用户登陆后获取到最新参数配置,其中有App的版本号,和当前系统版本号比对,如果不一致则提醒用户更新。

以下分别是App-我的页面,App关于页面,H5关于页面情况




2.2.14  
移动端开发-分类/题目管理

分类和题目管理,都采用树形结构,移动端录入题目一样支持复制,但如果在手机端操作还是没那么方便,如果是修改或者是新增少量,倒是比较快,如果很多,用电脑以H5地址打开,或者直接在后端管理录入,效果更好





2.2.15  
移动端开发-题库刷题

刷题页面支持搜索,搜索后非末级也显示题目数量,并可点击进入搜索内容的全部题目





2.2.16  
移动端开发-其它功能

邀请好友,服务器根据用户图像生成二维码,更改二维码样式颜色等。

设置-含公告查看,修改密码,联系我们,清空缓存,关于,退出登录

3.   
总结一下

做一套完整的系统,可以完善自己所学的知识,找到自己的弱项,也有一种成就刚。

题库功能还有优化空间,如题库类型需要问答型题目,题目还可以展示做题人数,正确率等信息,评论支持上传图片等。