K8s operator从0到1实战
Operator基础知识
Kubernetes Operator是一种用于管理和扩展Kubernetes应用程序的模式和工具。它们是一种自定义的Kubernetes控制器,可以根据特定的应用程序需求和业务逻辑扩展Kubernetes功能。
Kubernetes Operator基于Kubernetes的控制器模式,通过自定义资源定义(CRD)来描述和管理应用程序的状态。它们利用Kubernetes的控制循环(control loop)机制,监视和响应与应用程序相关的事件,并采取适当的操作来维护所需的状态。
自定义资源与内置资源关系:

行业内使用现状
operator基本成为应用上云,计算框架等上云的标准方案
主流的开源operator,被统一收录在开源商城,涵盖大数据、数据库、机器学习、devops等领域。目前收录了300+款框架应用,用户在k8s集群可实现开箱即用。

redHat维护的一个operator商城:operatorhub
CRD基本概念
crd与controller一般是配套使用,在这里简单描述一下在实际运转中,他们的流程关系。
crd定义了自定义资源的结构和资源状态信息等,自定义的内置资源一般以yaml或者json结构形式被使用。一个crd定义完毕并成功注册到k8s后,会自动生成一个独有端口号的k8s api,这个api可以被kubectl工具以命令行的形式执行、或者以k8s client的方式被调用。最终用户在使用自定义cr(自定义资源实例对象)时,能享受到调用内置资源时的便捷。
controller依靠k8s提供的控制循环机制监视资源,调用对应资源的k8s api,依据资源的状态和期望状态之间的差异采取适当的操作,在这个循环机制中涉及创建、更新或删除其他 Kubernetes 资源。


云平台上的使用现状
典型应用案例:
1. 分布式训练training-operator
以pytorch ddp流程为例:
- 按自定义资源结构生成master和worker的pod、service配置和数量
- 将master和worker环境变量添加到对应pod
- worker ping master service
- 监听master和worker的运行状态

2. 工作流argo-workflow operator
定义、监听上下游任务顺序相关元数据
监听解析为pod的运行实例
容器的输出同步到自定义数据库,并负责将上下游stage数据在容器内传递

3. 分布式计算框架Spark on k8s operator
从spark2.3版本开始支持on k8s
只支持指定资源量、启停一个Spark Application集群,并指派作业任务到这些Executor中执行。

park operator对标spark on yarn生命周期和流程管理
- application事件监控、控制、管理;
- 自定义executor配置;
- 任务监控;
- 日志相关;
- Ui;
- …

更多应用:
- 快速服务seldon operator
- tensorboard应用tensorboard operator
- 算法开发web ide notebook operator
- 云存储缓存加速工具fluid dataset runtime operator
- …
Kubebuilder构建operator实践
在k8s集群部署一个服务应用,默认方式是需要同时创建Deployment和Service这两个默认资源对象。通过 Pod 的 label标签将service资源对象与deployment关联,最后通过 Ingress 或者 type=NodePort 类型的 Service 来暴露服务。
这一通流程下来比较繁琐,在创建多个服务应用时尤为突出。为了降低服务创建时过多的资源对象定义,这里以名为EasyService的CRD为例,从0开发一个简化版本的服务创建流程。
开发工具包
这里推荐使用脚手架工具kubebuilder
使用脚手架工具,能生成项目模板,开发人员只需要关注核心逻辑和方法即可
安装流程
mac安装流程
brew install kubebuilder
linux安装流程
在github下载最新最新:
https://github.com/kubernetes-sigs/kubebuilder/releases
我在这个例子中使用的go version 1.18.3,为了避免麻烦直接下载v3.5.0版本
# 重命名
$ mv kubebuilder_linux_amd64 kubebuilder# 赋予可执行权限
$ chmod a+x kubebuilder# 移动可执行文件到bin路径
$ mv kubebuilder /usr/local/bin# 为 PATH 环境变量追加 kubebuilder 二进制路径
$ export PATH=$PATH:/usr/local/bin
使用流程
example1.切入到项目文件夹
$ cd webapp-operator/2.初始化go modulm
$ go mod init webapp-operator3.初始化项目模板
$ kubebuilder init --domain kubebuilder.io4.创建api
这里我们创建一个 group 为 app, version 为 v1, kind 为 EasyService 的 api:
$ kubebuilder create api --group app --version v1 --kind EasyService
核心逻辑编写和测试
按以上流程,自动生成项目文件夹,文件夹的结构如下图所示:
.
├── Dockerfile # 用于构建控制器镜像的 Dockerfile
├── Makefile # 用于控制器构建及部署的 Makefile
├── PROJECT # 勇于生成组件的 kubebuilder 元数据
├── README.md
├── api # API 模板代码所在目录
│ └── v1
│ ├── easyservice_types.go # API 类型文件, 主要关注 Spec 与 Status 结构体
│ ├── groupversion_info.go # 此文件包含了 Group Version 的一些元信息
│ └── zz_generated.deepcopy.go # 自动生成的 runtime.Object 实现
├── bin
│ └── manager
├── config # 采用 Kustomize YAML 定义的配置
│ ├── certmanager/ # 证书管理相关
│ ├── crd/ # CRD 相关, 当 make install 将 apply 此目录 yaml
│ ├── default/ # 控制器相关, 当 make deploy 将 apply 此目录 yaml
│ ├── manager/
│ ├── prometheus/ # 监控相关
│ ├── rbac/ # RBAC 权限管理
│ ├── samples/ # CR 样例
│ └── webhook/ # webhook相关
├── controllers # 控制器逻辑所在目录
│ ├── easyservice_controller.go # 控制器 reconcile 逻辑实现所在文件
│ └── suite_test.go # 测试文件
├── cover.out
├── go.mod # Go Mod 配置文件,记录依赖信息
├── go.sum
├── hack
│ └── boilerplate.go.txt
└── main.go # 程序入口
核心逻辑编写
使用kubebuilder脚手架工具修改自定义operator,只需要修改2两处核心逻辑:
- api/v1/xxx_types.go的结构定义
- controllers/xxx_controller.go的调协、状态监控、更新等方法…
eg. 新建CR(用户自定义对象的实例)创建指定副本deployment和service,并对外暴露nodeport端口
对象结构、状态定义需要的字段:
// crd结构定义
// EasyService is the Schema for the easyservices API
type EasyService struct {metav1.TypeMeta `json:",inline"`metav1.ObjectMeta `json:"metadata,omitempty"`Spec EasyServiceSpec `json:"spec,omitempty"`Status EasyServiceStatus `json:"status,omitempty"`
}// 创建cr结构定义
// EasyServiceSpec defines the desired state of EasyService
type EasyServiceSpec struct {// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster// Important: Run "make" to regenerate code after modifying this file// Foo is an example field of EasyService. Edit easyservice_types.go to remove/update// Foo string `json:"foo,omitempty"`Size *int32 `json:"size"`Image string `json:"image"`Resources corev1.ResourceRequirements `json:"resources,omitempty"`Envs []corev1.EnvVar `json:"envs,omitempty"`Ports []corev1.ServicePort `json:"ports,omitempty"`
}// 监控cr状态的内容(这里直接拿内置资源DeploymentStatus的实现)
// EasyServiceStatus defines the observed state of EasyService
type EasyServiceStatus struct {// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster// Important: Run "make" to regenerate code after modifying this fileappsv1.DeploymentStatus `json:",inline"`
}
在调协代码中主要需要实现的方法:
func (r *EasyServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {// 如果不存在,则创建关联资源// 如果存在,判断是否需要更新// 如果需要更新,则直接更新// 如果不需要更新,则正常返回deploy := &appsv1.Deployment{}if err := r.Get(ctx, req.NamespacedName, deploy); err != nil && errors.IsNotFound(err) {// 1. 关联 Annotationsdata, _ := json.Marshal(easyService.Spec)if easyService.Annotations != nil {easyService.Annotations["spec"] = string(data)} else {easyService.Annotations = map[string]string{"spec": string(data)}}if err := r.Client.Update(ctx, &easyService); err != nil {return ctrl.Result{}, err}// 创建关联资源// 2. 创建 Deploymentdeploy := resources.NewDeploy(&easyService)if err := r.Client.Create(ctx, deploy); err != nil {return ctrl.Result{}, err}// 3. 创建 Serviceservice := resources.NewService(&easyService)if err := r.Create(ctx, service); err != nil {return ctrl.Result{}, err}return ctrl.Result{}, nil}oldspec := appv1.EasyServiceSpec{}if err := json.Unmarshal([]byte(easyService.Annotations["spec"]), &oldspec); err != nil {return ctrl.Result{}, err}// 当前规范与旧的对象不一致,则需要更新if !reflect.DeepEqual(easyService.Spec, oldspec) {// 更新关联资源newDeploy := resources.NewDeploy(&easyService)oldDeploy := &appsv1.Deployment{}if err := r.Get(ctx, req.NamespacedName, oldDeploy); err != nil {return ctrl.Result{}, err}oldDeploy.Spec = newDeploy.Specif err := r.Client.Update(ctx, oldDeploy); err != nil {return ctrl.Result{}, err}newService := resources.NewService(&easyService)oldService := &corev1.Service{}if err := r.Get(ctx, req.NamespacedName, oldService); err != nil {return ctrl.Result{}, err}// 需要指定 ClusterIP 为之前的,不然更新会报错newService.Spec.ClusterIP = oldService.Spec.ClusterIPoldService.Spec = newService.Specif err := r.Client.Update(ctx, oldService); err != nil {return ctrl.Result{}, err}return ctrl.Result{}, nil}
对象结构(API)、控制器(controller)修改完毕后,需要更新crd的定义
更新crd定义的指令:
root@dev06:/home/liuweibin/learn-kubebuilder/webapp-operator$ make manifests
/home/liuweibin/learn-kubebuilder/webapp-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
测试controller
前提条件:
登入到在集群master上操作
第一步:将CRD安装到集群
$ make install
安装完毕后,可以在集群查到crd的信息
liuweibin@dev06:~/learn-kubebuilder/webapp-operator/controllers$ sudo kubectl get crd | grep easyservice
easyservices.app.kubebuilder.io 2023-07-15T15:26:15Z
第二步:启动控制器
root@dev06:/home/liuweibin/learn-kubebuilder/webapp-operator$ make run
/home/liuweibin/learn-kubebuilder/webapp-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
/home/liuweibin/learn-kubebuilder/webapp-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
controllers/easyservice_controller.go
go vet ./...
go run ./main.go
I0718 14:33:26.878953 13419 request.go:601] Waited for 1.033483673s due to client-side throttling, not priority and fairness, request: GET:https://localhost:6443/apis/serving.kserve.io/v1beta1?timeout=32s
1.6896620081329308e+09 INFO controller-runtime.metrics Metrics server is starting to listen {"addr": ":8080"}
1.6896620081333506e+09 INFO setup starting manager
1.6896620081337626e+09 INFO Starting server {"kind": "health probe", "addr": "[::]:8081"}
1.689662008133777e+09 INFO Starting server {"path": "/metrics", "kind": "metrics", "addr": "[::]:8080"}
1.6896620081339505e+09 INFO Starting EventSource {"controller": "easyservice", "controllerGroup": "app.kubebuilder.io", "controllerKind": "EasyService", "source": "kind source: *v1.EasyService"}
1.689662008134056e+09 INFO Starting Controller {"controller": "easyservice", "controllerGroup": "app.kubebuilder.io", "controllerKind": "EasyService"}
1.689662008235348e+09 INFO Starting workers {"controller": "easyservice", "controllerGroup": "app.kubebuilder.io", "controllerKind": "EasyService", "worker count": 1}
控制器启动后,启动相应的事件源、开始监听事件
第三步:新建CR
新建名为easyservice-sample的自定义资源实例,创建副本数量和对应的nodeport端口
新建成功会controller会有事件变更:
1.689662008235704e+09 INFO fetch easyservice objects {"controller": "easyservice", "controllerGroup": "app.kubebuilder.io", "controllerKind": "EasyService", "easyService": {"name":"easyservice-sample","namespace":"default"}, "namespace": "default", "name": "easyservice-sample", "reconcileID": "31643862-0be9-4aed-b206-6759d72bbb3d", "easyservice": {"kind":"EasyService","apiVersion":"app.kubebuilder.io/v1","metadata":{"name":"easyservice-sample","namespace":"default","selfLink":"/apis/app.kubebuilder.io/v1/namespaces/default/easyservices/easyservice-sample","uid":"7f32f186-6641-46db-a2e2-413d3d678212","resourceVersion":"113468805","generation":1,"creationTimestamp":"2023-07-18T05:58:49Z","annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{\"apiVersion\":\"app.kubebuilder.io/v1\",\"kind\":\"EasyService\",\"metadata\":{\"annotations\":{},\"name\":\"easyservice-sample\",\"namespace\":\"default\"},\"spec\":{\"image\":\"nginx:1.7.9\",\"ports\":[{\"nodePort\":31002,\"port\":80,\"targetPort\":80}],\"size\":2}}\n"},"managedFields":[{"manager":"kubectl-client-side-apply","operation":"Update","apiVersion":"app.kubebuilder.io/v1","time":"2023-07-18T05:58:49Z","fieldsType":"FieldsV1","fieldsV1":{"f:metadata":{"f:annotations":{".":{},"f:kubectl.kubernetes.io/last-applied-configuration":{}}},"f:spec":{".":{},"f:image":{},"f:ports":{},"f:size":{}}}}]},"spec":{"size":2,"image":"nginx:1.7.9","resources":{},"ports":[{"protocol":"TCP","port":80,"targetPort":80,"nodePort":31002}]},"status":{}}}
apiVersion: app.kubebuilder.io/v1
kind: EasyService
metadata:name: easyservice-sample
spec:# TODO(user): Add fields heresize: 2image: nginx:1.7.9ports:- port: 80targetPort: 80nodePort: 31002
在k8s管理端能成功看到CR创建,并启动了对应数量的内置资源实例:




打镜像和集群部署
第一步:制作推送controller镜像
$ make docker-build docker-push IMG=<some-registry>/<project-name>:tag
第二步:把controller部署到集群
建议:在正式上线时,使用git控制上线controller版本
make deploy IMG=<some-registry>/<project-name>:tag
参考项目:
https://github.com/Crazybean-lwb/webapp-operator (Kind=EasyService)
展望使用场景
在云原生场景,便捷定义流程化应用(弹性云:云资源类型不限,应用范畴:弹性服务、输出类任务…)
- 优化(自定义)训练框架使用流程化
- 批量流程化业务输出
- 申请带生命周期的自定义运行时资源
- …
相关文章:
K8s operator从0到1实战
Operator基础知识 Kubernetes Operator是一种用于管理和扩展Kubernetes应用程序的模式和工具。它们是一种自定义的Kubernetes控制器,可以根据特定的应用程序需求和业务逻辑扩展Kubernetes功能。 Kubernetes Operator基于Kubernetes的控制器模式,通过自…...
【LangChain学习】基于PDF文档构建问答知识库(三)实战整合 LangChain、OpenAI、FAISS等
接下来,我们开始在web框架上整合 LangChain、OpenAI、FAISS等。 一、PDF库 因为项目是基于PDF文档的,所以需要一些操作PDF的库,我们这边使用的是PyPDF2 from PyPDF2 import PdfReader# 获取pdf文件内容 def get_pdf_text(pdf):text "…...
阿里云国际站对象储存OSS的常见问题?
1.什么是阿里云OSS? 阿里云对象存储服务OSS(Object Storage Service),是阿里云提供的海量、安全、低成本、高持久性的云存储服务,并可无限扩展。其数据设计持久性不低于99.9999999999%(12个9)&a…...
spss什么是描述性分析,以及如何去处理。
描述性分析是数据分析的第一步,是了解和认识数据基本特征和结构的方法,只有在完成了描述性统计分析,充分的了解和认识数据特征后,才能更好地开展后续更复杂的数据分析。因此,描述性分析是开展数据分析过程中最基础且必…...
OSCS 闭门研讨第一期实录:软件供应链安全建设价值
2023 年 7 月 18 日晚 19:30,软件供应链安全技术交流群(OSCS)组织了第一次线上的闭门研讨会,本次研讨会我们收到 71 个来自各个企业关注软件供应链安全的技术专家的报名,根据研讨会参与规则要求,我们对报名…...
STM32入门——ADC模数转换
ADC简介 ADC(Analog-Digital Converter)模拟-数字转换器ADC可以将引脚上连续变化的模拟电压转换为内存中存储的数字变量,建立模拟电路到数字电路的桥梁12位逐次逼近型ADC,1us转换时间输入电压范围:0~3.3V,…...
【Fegin技术专题】「原生态」打开Fegin之RPC技术的开端,你会使用原生态的Fegin吗?(下)
内容简介 在项目开发中,除了考虑正常的调用之外,负载均衡和故障转移也是关注的重点,这也是feign ribbon的优势所在,基于上面两篇文章的基础,接下来我们开展最后一篇原生态fegin结合ribbon服务进行服务远程调用且实现负…...
【leetcode】454. 四数相加 II(medium)
给你四个整数数组 nums1、nums2、nums3 和 nums4 ,数组长度都是 n ,请你计算有多少个元组 (i, j, k, l) 能满足: 0 < i, j, k, l < nnums1[i] nums2[j] nums3[k] nums4[l] 0 思路:如果要暴力,那么时间复杂…...
PHP先等比缩放再无损裁剪图片【实例源码】
很多人在使用程序裁剪图片时,是在原图上直接裁剪,这样的裁剪结果是使得图片变得不完整了,理想的做法是先等比缩小图片,再把多余的部分裁掉,这样会保留更多的图片信息。 实现代码: <?php/*** 说明:函数功能是把一个图像裁剪为任意大小的图像,图像不变形** @param …...
共享广告主项目:广告也能共享?全民广告时代来袭
科思创业汇 大家好,这里是科思创业汇,一个轻资产创业孵化平台。赚钱的方式有很多种,我希望在科思创业汇能够给你带来最快乐的那一种! 广告是我们日常生活中在衣食住行中可以看到的一种宣传方式。广告作为互联网社会的信息传播方…...
Flink-间隔联结
间隔联结只支持事件时间间隔联结如果遇到迟到数据,则会关联不上,比如来了一个5秒的数据,它可以关联前2秒的数据,后3秒的数据,就是可以关联3秒到8秒的数据,然后又来了一个6秒的数据,可以关联4秒到…...
redis的持久化
第一章、redis的持久化 1.1)持久化概述 ①持久化可以理解为将数据存储到一个不会丢失的地方,Redis 的数据存储在内存中,电脑关闭数据就会丢失,所以放在内存中的数据不是持久化的,而放在磁盘就算是一种持久化。 ②为…...
藏语翻译器:多功能翻译软件
这是是一款能够将藏语翻译成其他语言或将其他语言翻译成藏语的软件。该软件能够识别并翻译藏语中的常用词汇和短语,并且支持多种常见语言的翻译,例如英语、汉语、法语、德语等等。此外,藏语翻译器还具有简单易用的用户界面,方便用…...
Java课题笔记~ JavaWeb概述/开发基础
JavaWeb概述/开发基础 1.XML基础 (1)XML概述 (2)XML语法 (3)DTD约束 (4)Schema约束(XML Schema 比 DTD 更强大) 2.Web基础知识 Web是一个分布式的超媒…...
【解放ipad生产力】如何在平板上使用免费IDE工具完成项目开发
我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code3o19zyy2pneoo 前言 很多人应该会像我一样吧,有时候身边没电脑突然要写项目,发现自己的平板没有一点作用&…...
IDEA快捷键总结
切换窗口 Alt(1-9) Alt1 打开或者关闭左侧project Alt4 Run窗口 Alt5 Debug窗口 Alt7 类结构窗口 生成构造函数、get、set等方法 Altinsert 快速生成输出语句 Soutenter键 运行程序 chtlshiftf10 运行程序 shiftf9 debug方式运行程序 代码…...
OpenJDK Maven 编译出错: package jdk.nashorn.internal.runtime.logging does not exist
前言 OpenJDK 1.8.0Maven 3.8.5TencentOS Server 3.1 错误信息 [ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.1:compile (default-compile) on project tour-common: Compilation failure: Compilation failure: [ERROR] /opt/tour-c…...
.Net Framework请求外部Api
要在.NET Framework 4.5中进行外部API的POST请求,你可以使用HttpClient类。 1. Post请求 using System; using System.Net.Http; using System.Threading.Tasks;class Program {static async Task Main(string[] args){// 创建一个HttpClient实例using (HttpClien…...
线程池工作原理深入解析
目录 1. 线程正常的生命周期 2. 为什么要用线程池? 3. 线程池的核心原理 4. 怎样创建线程池? 5.线程池的代码实现 6. ThreadPoolExecutor 源码分析 7. ThreadPoolExecutor 工作原理展示(重点) 1. 线程正常的生命周期 我们知…...
chatGPT小白快速入门课程大纲
以下是关于ChatGPT的培训课程大纲,分为7部分,我们会在后续写一个系列的相关文章: 1. 介绍 ChatGPT是什么?ChatGPT是由谁开发的?ChatGPT是一个什么样的语言模型? 2. 功能与特点 ChatGPT可以做什么?ChatGPT有哪些特点?ChatGPT与传统语言模型的区别? 3. 使用方法 如何…...
K8S认证|CKS题库+答案| 11. AppArmor
目录 11. AppArmor 免费获取并激活 CKA_v1.31_模拟系统 题目 开始操作: 1)、切换集群 2)、切换节点 3)、切换到 apparmor 的目录 4)、执行 apparmor 策略模块 5)、修改 pod 文件 6)、…...
Redis相关知识总结(缓存雪崩,缓存穿透,缓存击穿,Redis实现分布式锁,如何保持数据库和缓存一致)
文章目录 1.什么是Redis?2.为什么要使用redis作为mysql的缓存?3.什么是缓存雪崩、缓存穿透、缓存击穿?3.1缓存雪崩3.1.1 大量缓存同时过期3.1.2 Redis宕机 3.2 缓存击穿3.3 缓存穿透3.4 总结 4. 数据库和缓存如何保持一致性5. Redis实现分布式…...
Debian系统简介
目录 Debian系统介绍 Debian版本介绍 Debian软件源介绍 软件包管理工具dpkg dpkg核心指令详解 安装软件包 卸载软件包 查询软件包状态 验证软件包完整性 手动处理依赖关系 dpkg vs apt Debian系统介绍 Debian 和 Ubuntu 都是基于 Debian内核 的 Linux 发行版ÿ…...
ArcGIS Pro制作水平横向图例+多级标注
今天介绍下载ArcGIS Pro中如何设置水平横向图例。 之前我们介绍了ArcGIS的横向图例制作:ArcGIS横向、多列图例、顺序重排、符号居中、批量更改图例符号等等(ArcGIS出图图例8大技巧),那这次我们看看ArcGIS Pro如何更加快捷的操作。…...
JavaScript基础-API 和 Web API
在学习JavaScript的过程中,理解API(应用程序接口)和Web API的概念及其应用是非常重要的。这些工具极大地扩展了JavaScript的功能,使得开发者能够创建出功能丰富、交互性强的Web应用程序。本文将深入探讨JavaScript中的API与Web AP…...
认识CMake并使用CMake构建自己的第一个项目
1.CMake的作用和优势 跨平台支持:CMake支持多种操作系统和编译器,使用同一份构建配置可以在不同的环境中使用 简化配置:通过CMakeLists.txt文件,用户可以定义项目结构、依赖项、编译选项等,无需手动编写复杂的构建脚本…...
es6+和css3新增的特性有哪些
一:ECMAScript 新特性(ES6) ES6 (2015) - 革命性更新 1,记住的方法,从一个方法里面用到了哪些技术 1,let /const块级作用域声明2,**默认参数**:函数参数可以设置默认值。3&#x…...
xmind转换为markdown
文章目录 解锁思维导图新姿势:将XMind转为结构化Markdown 一、认识Xmind结构二、核心转换流程详解1.解压XMind文件(ZIP处理)2.解析JSON数据结构3:递归转换树形结构4:Markdown层级生成逻辑 三、完整代码 解锁思维导图新…...
基于江科大stm32屏幕驱动,实现OLED多级菜单(动画效果),结构体链表实现(独创源码)
引言 在嵌入式系统中,用户界面的设计往往直接影响到用户体验。本文将以STM32微控制器和OLED显示屏为例,介绍如何实现一个多级菜单系统。该系统支持用户通过按键导航菜单,执行相应操作,并提供平滑的滚动动画效果。 本文设计了一个…...
【免费数据】2005-2019年我国272个地级市的旅游竞争力多指标数据(33个指标)
旅游业是一个城市的重要产业构成。旅游竞争力是一个城市竞争力的重要构成部分。一个城市的旅游竞争力反映了其在旅游市场竞争中的比较优势。 今日我们分享的是2005-2019年我国272个地级市的旅游竞争力多指标数据!该数据集源自2025年4月发表于《地理学报》的论文成果…...
