当前位置: 首页 > news >正文

【Golang学习笔记】从零开始搭建一个Web框架(二)

文章目录

    • 模块化路由
    • 前缀树路由

前情提示:

【Golang学习笔记】从零开始搭建一个Web框架(一)-CSDN博客

模块化路由

路由在kilon.go文件中导致路由和引擎交织在一起,如果要实现路由功能的拓展增强,那将会非常麻烦,这无疑降低了代码的可读性和可维护性。现在的工作是将路由从引擎里剥离出来,引擎中仅对路由进行包装。

新建文件router.go,当前目录结构为:

myframe/├── kilon/│   ├── context.go│   ├── go.mod      [1]│   ├── kilon.go│   ├── router.go├── go.mod          [2]├── main.go

在router中添加下面内容:

package kilonimport ("net/http"
)type router struct {Handlers map[string]HandlerFunc
}
// 创建router对象
func newRouter() *router {return &router{make(map[string]HandlerFunc)}
}
// 剥离路由注册的具体实现
func (r *router) addRoute(method string, pattern string, handler HandlerFunc) {key := method + "-" + patternr.Handlers[key] = handler
}
// 剥离SeverHTTP中路由处理的具体实现
func (r *router) handle(ctx *Context) {key := ctx.Method + "-" + ctx.Pathif handler, ok := r.Handlers[key]; ok {handler(ctx)} else {ctx.String(http.StatusNotFound, "404 NOT FOUND: %s\n", ctx.Path)}
}

修改kilon.go文件:

package kilonimport ("net/http"
)type HandlerFunc func(*Context)type Origin struct {router *router // 修改路由
}func New() *Origin {return &Origin{router: newRouter()} // 修改构造函数
}func (origin *Origin) addRoute(method string, pattern string, handler HandlerFunc) {origin.router.addRoute(method, pattern, handler) // 修改调用
}func (origin *Origin) GET(pattern string, hander HandlerFunc) {origin.addRoute("GET", pattern, hander) 
}func (origin *Origin) POST(pattern string, hander HandlerFunc) {origin.addRoute("POST", pattern, hander) 
}func (origin *Origin) ServeHTTP(w http.ResponseWriter, req *http.Request) {ctx := newContext(w, req)origin.router.handle(ctx) // 调用router.go中的处理方法
}func (origin *Origin) Run(addr string) (err error) {return http.ListenAndServe(addr, origin)
}

至此,实现了路由的模块化,后续路由功能的增强将不会改动kilon.go文件。

前缀树路由

目前的路由表使用map存储键值对,索引非常高效,但是有一个弊端,键值对的存储的方式,只能用来索引静态路由而无法实现动态路由。在实际的应用中,可能需要使用正则表达式或者其他匹配规则来实现更复杂的路由匹配,而 map 无法提供这种功能。接下来,将使用前缀树(Tire树)实现动态路由,主要实现两个功能:

  • 参数匹配:。例如 /p/:name/doc,可以匹配 /p/zhangsan/doc/p/lisi/doc
  • 通配*(仅允许最后一个有"*"号)。例如 /static/*filepath,可以匹配/static/fav.ico/static/js/jQuery.js

新建文件trie.go,当前文件目录结构为:

myframe/├── kilon/│   ├── context.go│   ├── go.mod      [1]│   ├── kilon.go│   ├── router.go│   ├── tire.go├── go.mod          [2]├── main.go

在trie.go中创建前缀树的节点:

type node struct {patten   string  // 待匹配路由part     string  // 路由当前部分children []*node // 孩子节点isWild   bool    // 是否为模糊搜索,当含有":"和通配符"*"时为true
}

当注册路由"/p/:name/doc"、“/p/:name/png”、“/p/:lang/doc”、"/p/:lang/png"后,树中内容如下:

在这里插入图片描述

可以看到,pattern只有在插入最后一个子节点后才会设置,这是为了在查询路由信息时可以根据 pattern==""来判断改路由是否注册。isWaild的作用在于当part不匹配时,如果isWaild为true可以继续搜索,这样就实现了模糊匹配。

先实现路由注册时的前缀树插入逻辑:

func (n *node) insert(pattern string, parts[]string, index int)

pattern是注册路由地址,parts是解析pattern后的字符串数组(使用方法strings.Split(pattern, "/")进行解析)如"/p/:name/doc"对应 [“p”,“:name”,“doc”],parts[index]是当前需要插入的part。可以通过index判断是否退出。(疑问:如果只用Split解析那pattren="/"的时候不就无法注册了吗?答:开始时树的根节点的part为空,不会匹配,“p"一定会插入到根节点的子节点切片中。而当pattern为”/“时解析字符串切片为空,进入根节点的时候len(parts) = index = 0,会将根节点的pattern设置为”/“,也可以实现路由”/"的注册。)

代码如下:

func (n *node) insert(pattern string, parts[]string, index int){// 进来的时候说明 n.part = parts[index-1] 即最后一个 part 则直接设置 pattenif len(parts) == index {n.patten = patternreturn}// 还需匹配 part// 先在 n.children 切片中匹配 partpart := parts[index]child :=  n.matchChild(part)// 如果没有找到,则构建一个 child 并插入 n.children 切片中if child == nil {child = &node{part: part,// 含有":"或者通配符"*"时为 trueisWild: part[0] ==':' || part[0] == '*',}// 插入 n.children 切片n.children = append(n.children, child)}// 递归插入child.insert(pattern, parts, index + 1)
}
// 查找匹配 child
func (n *node) matchChild(part string) *node {// 遍历 n.children 查找 part 相同的 childfor _, child := range n.children {// 如果找到匹配返回 child, 当 isWild 为 true 时视为匹配实现模糊搜索if child.part == part || child.isWild == true {return child}}	// 没找到返回nilreturn nil
}

接下来实现接受请求时查询路由信息时的前缀树搜索逻辑:

func (n *node) search(parts []string, index int) *node

parts是路由地址的解析数组,index指向当前part索引

代码如下:

// 搜索
func (n *node) search(parts []string, index int) *node {// 如果匹配将节点返回if len(parts) == index || strings.HasPrefix(n.part, "*") {if n.pattern == "" {return nil}return n}part := parts[index]// 获取匹配的所有孩子节点nodes := n.matchChildren(part)// 递归搜索匹配的child节点for _, child := range nodes {result := child.search(parts, index+1)if result != nil {return result}}return nil
}
// 查找匹配的孩子节点,由于有":"和"*",所以可能会有多个匹配,因此返回一个节点切片
func (n *node) matchChildren(part string) []*node {nodes := make([]*node, 0)for _, child := range n.children {if child.part == part || child.isWild == true {nodes = append(nodes, child) // 将符合的孩子节点添入返回切片}}return nodes
}

至此trie.go暂时写完,现在在路由中进行应用,回到router.go文件。为了区分不同的方法如GET和POST,为每一个Method建立一颗前缀树,并以键值对的形式存储在一个map中:map[Method] = tire。修改router结构体与构造方法:

type router struct {roots     map[string]*node       // 前缀树mapHandlers map[string]HandlerFunc // 将pattern作为key获取/注册方法
}
func newRouter() *router {return &router{make(map[string]*node),make(map[string]HandlerFunc),}
}

将pattern插入前缀树之前,要先解析成字符串切片,现在需要实现一个解析函数。

func parsePattern(pattern string) []string {temp := strings.Split(pattern, "/")parts := make([]string, 0)for _, item := range temp {if item != ""{parts = append(parts, item)if item[0] == '*' {break}}	}return parts
}

修改注册路由的逻辑:

func (r *router) addRoute(method string, pattern string, handler HandlerFunc) {parts := parsePattern(pattern) // 解析patternkey := method + "-" + patternif _, ok := r.roots[key]; !ok {r.roots[method] = &node{} // 如果没有则创建一个节点}r.roots[method].insert(pattern, parts, 0) // 前缀树插入patternr.Handlers[key] = handler			     // 注册方法
}

当接受请求时,需要对请求中携带的路由信息解析,并获取匹配的节点以及":“,”*"匹配到的参数,现在需要写一个路由获取方法:

func (r *router) getRoute(method string, path string) (*node, map[string]string) {searchParts := parsePattern(path) // 解析路由信息params := make(map[string]string) // 参数字典root, ok := r.roots[method]if !ok {return nil, nil}// 搜索匹配节点n := root.search(searchParts, 0)if n!= nil {parts := parsePattern(n.pattern) // 解析pattern// 寻找'*'和':',找到对应的参数。for index, part := range parts {if part[0] == ':' {params[part[1:]] = searchParts[index]}if part[0] == '*' && len(part) >1 {// 将'*'后切片内容拼接成路径params[part[1:]] = strings.Join(searchParts[index:],"/")break // 仅允许一个通配符'*'}return n, params}}return nil, nil
}

路径中的参数应该交给上下文对象让用户便捷获取。在Context结构体中添加Params属性,并包装获取方法:

type Context struct {Writer     http.ResponseWriterReq        *http.RequestPath       stringMethod     stringParams     map[string]string // 路由参数属性StatusCode int
}
// 获取路径参数
func (c *Context) Param(key string) string {value := c.Params[key]return value
}

在router.go中的handle中应用路由获取方法,并将路径参数提交给上下文对象。

func (r *router) handle(ctx *Context) {n, params := r.getRoute(ctx.Method, ctx.Path) // 获取路由节点及参数字典ctx.Params = paramsif n != nil {key := ctx.Method + "-" + n.pattern // key为n的patternr.Handlers[key](ctx) // 调用注册函数} else {ctx.String(http.StatusNotFound, "404 NOT FOUND: %s\n", ctx.Path)}
}

现在router.go内容为:

package kilonimport ("net/http""strings"
)type router struct {roots    map[string]*nodeHandlers map[string]HandlerFunc
}func newRouter() *router {return &router{make(map[string]*node),make(map[string]HandlerFunc),}
}func (r *router) addRoute(method string, pattern string, handler HandlerFunc) {parts := parsePattern(pattern)key := method + "-" + pattern_, ok := r.roots[method]if !ok {r.roots[method] = &node{}}r.roots[method].insert(pattern, parts, 0)r.Handlers[key] = handler
}func (r *router) handle(ctx *Context) {n, params := r.getRoute(ctx.Method, ctx.Path)ctx.Params = paramsif n != nil {key := ctx.Method + "-" + n.patternr.Handlers[key](ctx)} else {ctx.String(http.StatusNotFound, "404 NOT FOUND: %s\n", ctx.Path)}
}func parsePattern(pattern string) []string {temp := strings.Split(pattern, "/")parts := make([]string, 0)for _, item := range temp {if item != "" {parts = append(parts, item)if item[0] == '*' {break}}}return parts
}func (r *router) getRoute(method string, path string) (*node, map[string]string) {searchParts := parsePattern(path)params := make(map[string]string)root, ok := r.roots[method]if !ok {return nil, nil}n := root.search(searchParts, 0)if n != nil {parts := parsePattern(n.pattern)for index, part := range parts {if part[0] == ':' {params[part[1:]] = searchParts[index]}if part[0] == '*' && len(part) > 1 {params[part[1:]] = strings.Join(searchParts[index:], "/")break}}return n, params}return nil, nil
}

在main.go测试一下:

package mainimport ("kilon""net/http"
)func main() {r := kilon.New()r.GET("/hello", func(ctx *kilon.Context) {ctx.JSON(http.StatusOK, kilon.H{"message": "Hello World",})})r.GET("/hello/:username", func(ctx *kilon.Context) {ctx.JSON(http.StatusOK, kilon.H{"message": ctx.Param("username"),})})r.GET("/hello/:username/*filename", func(ctx *kilon.Context) {ctx.JSON(http.StatusOK, kilon.H{"username": ctx.Param("username"),"filename": ctx.Param("filename"),})})r.Run(":8080")
}

分别访问下面地址,都可以看到响应信息

127.0.0.1:8080/hello

127.0.0.1:8080/hello/zhangsan

127.0.0.1:8080/hello/zhangsan/photo.png

相关文章:

【Golang学习笔记】从零开始搭建一个Web框架(二)

文章目录 模块化路由前缀树路由 前情提示: 【Golang学习笔记】从零开始搭建一个Web框架(一)-CSDN博客 模块化路由 路由在kilon.go文件中导致路由和引擎交织在一起,如果要实现路由功能的拓展增强,那将会非常麻烦&…...

高精度地图导航论文汇总

文章目录 2021基于车载激光点云的高精地图矢量化成图[J] 2022基于高精度地图的智能车辆路径规划与跟踪控制研究[M] 2023一种无人驾驶融合决策方案的设计与实现[M] 2021 基于车载激光点云的高精地图矢量化成图[J] 摘要: 针对车载激光点云中对各特征物提取结果后矢量…...

【域适应】基于域分离网络的MNIST数据10分类典型方法实现

关于 大规模数据收集和注释的成本通常使得将机器学习算法应用于新任务或数据集变得异常昂贵。规避这一成本的一种方法是在合成数据上训练模型,其中自动提供注释。尽管它们很有吸引力,但此类模型通常无法从合成图像推广到真实图像,因此需要域…...

从零实现诗词GPT大模型:pytorch框架介绍

专栏规划: https://qibin.blog.csdn.net/article/details/137728228 因为咱们本系列文章主要基于深度学习框架pytorch进行,所以在正式开始之前,现对pytorch框架进行一个简单的介绍,主要面对深度学习或者pytorch还不熟悉的朋友。 一、安装pytorch 这一步很简单,主要通过p…...

[目标检测] OCR: 文字检测、文字识别、text spotter

概述 OCR技术存在两个步骤:文字检测和文字识别,而end-to-end完成这两个步骤的方法就是text spotter。 文字检测数据集摘要 daaset语言体量特色MTWI中英文20k源于网络图像,主要由合成图像,产品描述,网络广告(淘宝)MS…...

Windows环境下删除MySQL

文章目录 一、关闭MySQL服务1、winR打开运行,输入services.msc回车2、服务里找到MySQL并停止 二、卸载MySQL软件1、打开控制模板--卸载程序--卸载MySQL相关的所有组件 三、删除MySQL在物理硬盘上的所有文件1、删除MySQL的安装目录(默认在C盘下的Program …...

uniapp:uview-plus的一些记录

customStyle 并不是所有的组件都有customStyle属性来设置自定义属性,有的还是需要通过::v-deep来修改内置样式 form表单 labelStyle 需要的是一个对象 :labelStyle"{color: #333333,fontSize: 32rpx,fontWeight: 500}"dateTimePicker选择器设置默认值…...

OLTP 与 OLAP 系统说明对比和大数据经典架构 Lambda 和 Kappa 说明对比——解读大数据架构(五)

文章目录 前言OLTP 和 OLAPSMP 和 MPPlambda 架构Kappa 架构 前言 本文我们将研究不同类型的大数据架构设计,将讨论 OLTP 和 OLAP 的系统设计,以及有效处理数据的策略包括 SMP 和 MPP 等概念。然后我们将了解经典的 Lambda 架构和 Kappa 架构。 OLTP …...

步骤大全:网站建设3个基本流程详解

一.领取一个免费域名和SSL证书,和CDN 1.打开网站链接:https://www.rainyun.com/z22_ 2.在网站主页上,您会看到一个"登陆/注册"的选项。 3.点击"登陆/注册",然后选择"微信登录"选项。 4.使用您的…...

利用Sentinel解决雪崩问题(二)隔离和降级

前言: 虽然限流可以尽量避免因高并发而引起的服务故障,但服务还会因为其它原因而故障。而要将这些故障控制在一定范围避免雪崩,就要靠线程隔离(舱壁模式)和熔断降级手段了,不管是线程隔离还是熔断降级,都是对客户端(调…...

基于springboot的房产销售系统源码数据库

基于springboot的房产销售系统源码数据库 摘 要 随着科学技术的飞速发展,各行各业都在努力与现代先进技术接轨,通过科技手段提高自身的优势;对于房产销售系统当然也不能排除在外,随着网络技术的不断成熟,带动了房产…...

【MATLAB】基于Wi-Fi指纹匹配的室内定位-仿真获取WiFi RSSI数据(附代码)

基于Wi-Fi指纹匹配的室内定位-仿真获取WiFi RSSI数据 WiFi指纹匹配是室内定位最为基础和常见的研究,但是WiFi指纹的采集可以称得上是labor-intensive和time-consuming。现在,给大家分享一下我们课题组之前在做WiFi指纹定位时的基于射线跟踪技术仿真WiFi…...

深圳晶彩智能ESP32-3248S035R使用LovyanGFX实现手写板

深圳晶彩智能ESP32-3248S035R介绍 深圳晶彩智能出品ESP32-3248S035R为3.5寸彩色屏采用分辨率480x320彩色液晶屏,驱动芯片是ST7796。板载乐鑫公司出品ESP-WROOM-32,Flash 4M。型号尾部“R”标识电阻膜的感压式触摸屏,驱动芯片是XPT2046。 Lo…...

【Spring Boot】深入解密Spring Boot日志:最佳实践与策略解析

💓 博客主页:从零开始的-CodeNinja之路 ⏩ 收录文章:【Spring Boot】深入解密Spring Boot日志:最佳实践与策略解析 🎉欢迎大家点赞👍评论📝收藏⭐文章 目录 Spring Boot 日志一. 日志的概念?…...

ISTQB选择国内版,还是国际版呢

1, ISTQB简介 ISTQB(International Software Testing Qualifications Board)是一个国际软件测试资格认证机构,旨在提供一个统一的软件测试认证标准。ISTQB成立于2002年,是非盈利性的组织,由世界各地的国家或地区软件测…...

头歌-机器学习 第11次实验 softmax回归

第1关:softmax回归原理 任务描述 本关任务:使用Python实现softmax函数。 相关知识 为了完成本关任务,你需要掌握:1.softmax回归原理,2.softmax函数。 softmax回归原理 与逻辑回归一样,softmax回归同样…...

Qt for MCUs 2.7正式发布

本文翻译自:Qt for MCUs 2.7 released 原文作者:Qt Group高级产品经理Yoann Lopes 翻译:Macsen Wang Qt for MCUs的新版本已发布,为Qt Quick Ultralite引擎带来了新功能,增加了更多MCU平台的支持,并且我们…...

共享IP和独享IP如何选择,两者有何区别?

有跨境用户在选择共享IP和独享IP时会有疑问,不知道该如何进行选择,共享IP和独享IP各有其特点和应用场景,选择哪种方式主要取决于具体需求和预算。以下是对两者的详细比较: 首先两者的主要区别在于使用方式和安全性:共…...

文心一言VSchatGPT4

文心一言和GPT-4各有优势,具体表现在不同的测试场景下。 在某些测试场景中心一言的表现优于GPT-4,例如在故事的完整度和情节吸引力方面,文心一言表现得更加符合指令,情节更吸引人。这可能得益于其模型在训练时对中文语境的深入理…...

Linux 目录结构与基础查看命令

介绍 目录结构如下 /bin:存放着用户最经常使用的二进制可执行命令,如cp、ls、cat等。这些命令是系统管理员和普通用户进行日常操作所必需的。 /boot:存放启动系统使用的一些核心文件,如引导加载器(bootstrap loader…...

【matlab】如何解决打开缓慢问题(如何让matlab在十几秒内打开)

【matlab】如何解决打开缓慢问题(如何让matlab在十几秒内打开) 找到我们解压缩时Crack中的license_standalone.lic文件,将其拷贝 在安装matlab的路径下新建一个文件,粘贴上面的license_standalone.lic文件 在桌面鼠标移动到matl…...

【stata】求滚动波动情况

0.计算对象 计算 t t t、 t 1 t1 t1、 t 2 t2 t2 这三起滚动波动情况 V o l i , t l n ( ∑ n t n t 2 ( g n − g ˉ ) 2 3 ) Vol_{i,t} ln(\sqrt{\frac{\sum_{nt}^{nt2}(g_{n}-\bar{g})^2}{3}}) Voli,t​ln(3∑ntnt2​(gn​−gˉ​)2​ ​) e . g e.g e.g: 假设 200…...

The C programming language (second edition,KR) exercise(CHAPTER 2)

E x c e r c i s e 2 − 1 Excercise\quad 2-1 Excercise2−1&#xff1a;输出结果如图1和图2所示&#xff0c;这道练习题需要文章1和文章2的知识。 #include <stdio.h> #include <limits.h>float getFloat(char sign, unsigned char exp, unsigned mantissa); do…...

rust实现循环链表

作为一个计算机技术专家&#xff0c;针对你的问题&#xff0c;我将首先解释如何使用Rust语言实现循环链表&#xff0c;并提供相应的代码示例。然后&#xff0c;我将解释一个可能的报错问题及其解决方法。 循环链表的实现 在Rust中实现循环链表&#xff0c;首先需要定义链表节…...

2. Spring的创建和Bean的存取

经过前面的学习我们已经大体明白了 IOC 思想以及它的实现方式 DI &#xff0c;本节要讲的是如何Spring框架实现实现DI。 本节目标&#xff1a; Spring(Core) 项目创建将对象存储到 Spring 中将对象(bean)从 Spring 中取出 1. 创建 Spring 项目 与开篇演示的 Spring Boot 项目不…...

策略模式【行为模式C++】

1.概述 策略模式是一种行为设计模式&#xff0c; 它能让你定义一系列算法&#xff0c; 并将每种算法分别放入独立的类中&#xff0c; 以使算法的对象能够相互替换。 策略模式通常应用于需要多种算法进行操作的场景&#xff0c;如排序、搜索、数据压缩等。在这些情况下&#x…...

php中session相关知识(目前了解部分)

#记录学习知识 一.ini_set() 在PHP中&#xff0c;ini_set() 函数用于在脚本运行时设置指定的配置选项的值。这些配置选项可以是PHP的核心设置&#xff0c;例如文件上传的最大大小、脚本的最大执行时间、错误报告级别等。使用 ini_set() 可以临时改变PHP.ini文件中的设置&am…...

从零实现诗词GPT大模型:GPT是怎么生成内容的?

专栏规划: https://qibin.blog.csdn.net/article/details/137728228 再开始编写GPT之前,我们得对GPT是怎么生成内容的有一个大致的了解。目前的神经网络我们大多都可以看成是一个黑盒,即我们把数据输送给网络后,网络给我我们输出,我们可以不用关心这个黑盒里到底是怎么实现…...

8路HDMI+8路AV高清视频流媒体编码器JR-3218HD

产品简介&#xff1a; JR-3218HD高清音视频编码产品支持8路高清HDMI音视频采集功能&#xff0c;8路AV视频采集功能&#xff0c;8路3.5MM独独立音频接口采集功能。编码输出双码流H.264格式&#xff0c;音频MP3/AAC格式。编码码率可调&#xff0c;画面质量可控制。支持HTTP/RTSP…...

LangChain入门:14.LLMChain:最简单的链的使用

摘要 本文将介绍LangChain库中LLMChain工具的使用方法。LLMChain将提示模板、语言模型&#xff08;LLM&#xff09;和输出解析器整合在一起&#xff0c;形成一个连贯的处理链&#xff0c;简化了与语言模型的交互过程。我们将探讨LLMChain的技术特点、应用场景以及它解决的问题…...

网站关键词做标签/发帖推广哪个平台好

kernel density estimation是在概率论中用来估计未知的密度函数&#xff0c;属于非参数检验方法之一&#xff0c;由Rosenblatt (1955)和Emanuel Parzen(1962)提出&#xff0c;又名Parzen窗&#xff08;Parzen window&#xff09; 本文翻译自英国雷丁大学&#xff08;Reading Un…...

网站如何做双语言/网站在线生成app

JSP&#xff08;Java Server Pages)是由Sun Microsystems公司倡导、许多公司参与一起建立的一种动态网页技术标准。JSP技术有点类似ASP技术&#xff0c;它是在传统的网页HTML文件(*.htm,*.html)中插入Java程序段(Scriptlet)和JSP标记(tag)&#xff0c;从而形成JSP文件(*.jsp)。…...

网站认证方式有几种/免费网络推广

Dom&#xff1a; Attribute&#xff1a;$("p").addClass(css中定义的样式类型); 给某个元素添加样式$("img").attr({src:"test.jpg",alt:"test Image"}); 给某个元素添加属性/值&#xff0c;参数是map$("img").attr("s…...

北京网站优化wyhseo/neotv

IO的缓冲区的存在就是为了提高效率,把要操作的数据放进缓冲区,然后一次性把缓冲区的内容写到目的地,而不是写一次就往目的地写一次.在这里要注意的是当我们关闭了缓冲区对象实际也关闭了与缓冲区关联的流对象.BufferWriter类FileWriter fw null;try{fw newFileWriter("tes…...

哪个网站做ppt模板赚钱/公司企业网站开发

第十八章 学会使用简单的MySQL操作在前面两个章节中已经介绍过MySQL的安装了&#xff0c;但是光会安装还不够&#xff0c;还需要会一些基本的相关操作。当然了&#xff0c;关于MySQL的内容也是非常多的&#xff0c;只不过对于linux系统管理员来讲&#xff0c;一些基本的操作已经…...

做网站需要哪些成本/广州白云区疫情实时动态

excel怎么冻结前两行不动?其实只要直接使用冻结窗格功能就可以了。下面我们来学习一下。冻结窗口怎么冻结多行步骤1、打开excel文件&#xff0c;例如这个表格。步骤2、只冻结前两行需要选中第三行&#xff0c;选择【视图】-【冻结窗格】&#xff1b;步骤3、在三角处打开&#…...