GVKun编程网logo

直播预告:WiFi 配网的N种姿势(wifi配网过程)

7

对于想了解直播预告:WiFi配网的N种姿势的读者,本文将是一篇不可错过的文章,我们将详细介绍wifi配网过程,并且为您提供关于ByteHouse直播预告:揭秘基于OLAP降本增效的四大硬招、Java内

对于想了解直播预告:WiFi 配网的N种姿势的读者,本文将是一篇不可错过的文章,我们将详细介绍wifi配网过程,并且为您提供关于ByteHouse直播预告:揭秘基于OLAP降本增效的四大硬招、Java内存泄漏、性能优化、宕机死锁的N种姿势、JS前端监控采集用户行为的N种姿势、python字符串拼接N种姿势的有价值信息。

本文目录一览:

直播预告:WiFi 配网的N种姿势(wifi配网过程)

直播预告:WiFi 配网的N种姿势(wifi配网过程)


Hello,各位小伙伴好久不见,明天又是双周一次【在线面对面】RT-Thread社区直播的日子~先说下明晚访谈直播的主题:



WiFi 配网的N种姿势

直播内容: 本次直播主题来源于公众号留言,感谢上期留言的小伙伴!明晚RT-Thread直播间的主讲,仍然是我们被技术耽误的吉他手——国际哥!他将从介绍配网基础知识,通用原理开始,再逐一介绍各种配网方法,讲解的同时配合实物演示,最后总结对比各种方式优劣。


那么明晚(第二十二期)的直播如何安排的,请看以下说明:


时间:2020.9.10 周四(明天)晚8点

Bilibili直播间地址:http://live.bilibili.com/21644795

直播间 ID: 21644795 (记得点关注哦,观看更流畅)


直播福利抽奖:东软载波开发板*1+RT-Thread限量版高级polo衫*2





时间


平台


主讲人



今晚8点

2020年9月10日

RT-Thread直播间

Bilibili

官方支持

国际哥


  • 抽奖活动(必须关注公众号才可以参与哦)

  • 直播大纲:

WiFi配网目的、配网原理与流程、各种配网姿势介绍

(接触式(直接输入)、键盘、触摸屏、串口/电缆 直接连接、非接触式、AP模式、airkiss、零配网(自定义管理帧配网)、声波配网、蓝牙BLE配网、二维码)、不同配网方式优缺点对比

  • 答疑环节:弹幕互动答疑,随心所欲畅所欲言
  • 建议用电脑/手机横屏观看,保证网络环境畅通~



新一期直播主题征集

活动:最后,欢迎大家在文末留言,告诉我们你想听的主题,凡是被采纳的用户,均可获得RT-Thread官方书籍一本!


关注RT-ThreadB站账号,不错过任何一场精彩直播!(Tips:直播每两周一次,每周四晚8点直播,根据社区需求设不同主题)


你可以添加微信17775982065为好友,注明:公司+姓名,拉进 RT-Thread 官方微信交流群!



RT-Thread


让物联网终端的开发变得简单、快速,芯片的价值得到最大化发挥。Apache2.0协议,可免费在商业产品中使用,不需要公布源码,无潜在商业风险。





长按二维码,关注我们


点击阅读原文,进入 哔哩哔哩直播间
你点的每个“在看”,我都认真当成了喜欢

本文分享自微信公众号 - RTThread物联网操作系统(RTThread)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

ByteHouse直播预告:揭秘基于OLAP降本增效的四大硬招

ByteHouse直播预告:揭秘基于OLAP降本增效的四大硬招

在数字化转型浪潮中,企业数据量正以惊人的速度增长,随之而来的数据存储、处理与分析挑战也日益严峻。在这一背景下,如何既保障查询性能,又尽可能降低资源成本,已成为企业亟需解决的核心问题。

为此,ByteHouse将于10月23日19:00举办线上直播活动,围绕“降本增效”话题,深入探讨企业如何在保障效率的同时,实现成本最优化。作为火山引擎基于OLAP架构的云原生数据仓库,ByteHouse对ClickHouse进行升级和优化,在实时数据分析、海量数据分析等场景中都具备显著优势。

本次活动将聚焦于ByteHouse如何通过其独特架构、先进技术、丰富生态以及一体化解决方案,为企业带去“四大红利”,在数据仓库中实现极致降本。活动中,ByteHouse将带来抖音集团内部实践及金融、游戏等外部行业降本经验分享。

面对不断增长的海量数据,ByteHouse不仅帮助抖音集团内部相关业务确保了数据处理的实时性和准确性,还大幅降低了资源与运维成本,为业务快速发展提供了坚实的数据支撑。

此外,ByteHouse还将通过某游戏头部公司案例,展示如何在资源降低30%的同时,实现性能提升200%。对于正在进行数字化转型的企业而言,数据仓库的选择至关重要。好的数据仓库,不仅要满足业务性能、功能的要求,更要兼顾资源、运维、人力等成本,而ByteHouse正是这样一款产品。

欢迎扫码报名活动,了解云数仓ByteHouse降本增效的硬核方案:

图片

Java内存泄漏、性能优化、宕机死锁的N种姿势

Java内存泄漏、性能优化、宕机死锁的N种姿势

导读

本文介绍Java诸多优化实例:第一,排查堆上、堆外内存泄露;第二,使用arthas、jaeger、tcpdump、jstack做性能优化;第三,排查进程异常退出的原因,如被杀、System.exit、Java调用的C++发生Crash、Java内Crash;第四,排查死锁的原因,如log4j死锁、封装不严谨导致的死锁

内存泄漏

内存泄露在C++里排查很简单,用钩子函数勾住内存分配和释放函数malloc和free,统计哪些malloc的内存没有free,就可以找出内存泄露的源头。但在Java里问题复杂的多,主要因为Java在内存之上有层JVM管理内存。

JVM先从操作系统申请大内存,接着自己管理这部分内存。所以Java程序的内存泄露分为两种:堆上内存泄露、堆外内存泄露,而堆外内存泄露又分为两种:Java使用堆外内存导致的内存泄露、Java程序使用C++导致的内存泄露。

分析内存泄露首先需要确认是堆上泄漏还是堆外泄露。可以通过jmap -heap pid确认,如下图所示,老年代PS Old Generation使用率占99.99%,再结合gc log,如果老年代回收不掉,基本确认为堆上内存泄露,也不排除进程本身需要这么多内存,此时需要分析堆。而堆外内存泄露的显著表现是top命令查出来的物理内存显著比通过xmx配置的最大内存大。

堆上内存泄漏

堆上内存泄露是最常见的,申请的对象引用和内存全在JVM堆上,而对象使用完后,对象引用被其他长生命周期的对象一直拿着,导致无法从堆上释放。首先用jdk/bin/jmap -dump:live,format=b,file=heap.hprof {pid},导出堆里所有活着的对象。然后用工具分析heap.hprof。

分析堆上内存泄露的主流工具有两种:JDK自带的bin目录下的jvisualvm.exe、Eclipse的MemoryAnalyzer。MemoryAnalyzer更强大,可自动分析可疑的内存泄露。使用MemoryAnalyzer时,需要在MemoryAnalyzer.ini里通过-Xmx参数配置最大内存,否则无法打开大堆。接下来介绍堆上内存泄露的若干实例。

对象被静态对象引用

使用MemoryAnalyzer自动分析内存泄露,报告如下,可以看到RaftServerMetrics占了44.68%的内存,所有实例大小98M内存,且所有的RaftServerMetrics实例被一个ConcurrentHashMap引用。

 

接着在直方图里过滤RaftServerMetrics,共找到2065个实例。

 

然后右键RaftServerMetrics->Merge shortest path to GC Roots ->with all references查找所有引用RaftServerMetrics的地方,结果如下,可看到所有的RaftServerMetrics实例被变量metricsMap引用,问题原因是RaftServerMetrics使用完后,未从静态变量metricsMap里删除。

RPC连接使用完后未关闭

MemoryAnalyzer自动分析内存泄露时,有时并不能准确的找到,此时需要自己分析哪些对象占用内存过多。下图是使用jvisualvm.exe打开堆的结果,查看数目或者内存异常的对象,可以看到很多对象数目都是111580个,且最后一列显示的内存占用大,从对象的包分析,都和netty有关,且是client相关的对象,基本确认这些对象和内存泄露有关。进一步分析代码,发现大量RPC连接使用完后未关闭。

堆外内存泄露

Java使用堆外内存

JDK提供绕过JVM直接在操作系统申请内存的接口,例如通过Unsafe类的allocateMemory、freeMemory直接分配、释放内存,内存对象的引用在堆上,但内存在堆外。排查此类内存泄露,首先开启:

-XX:NativeMemoryTracking=detail

然后jcmd pid VM.native_memory detail,打出内存分配信息,注意NativeMemoryTracking显示的内存不包含C++分配的内存。此处需要关注两个点,第一,Total行的committed数值是否等于进程占用的物理内存,如果不等,说明有C++等native code分配的内存,可参考Java调用C++组件 分析;第二,Native Memory Tracking的committed数值是否过大,如果过大,说明有Unsafe.allocateMemory分配了太多内存。

Unsafe.allocateMemory的使用场景有两个:第一,封装在DirectByteBuffer内;第二,业务直接使用Unsafe.allocateMemory。

DirectByteBuff通常被用于通信框架如netty中,不仅可以减少GC压力,而且避免IO操作时将对象从堆上拷贝到堆外。为了快速验证是否DirectByteBuffer导致内存泄露,可使用参数-XX:MaxDirectMemorySize限制DirectByteBuffer分配的堆外内存大小,如果堆外内存仍然大于MaxDirectMemorySize,可基本排除DirectByteBuffer导致的内存泄露。

分析DirectByteBuffer的内存首先可用Java Mission Control,绑定到进程,并查看DirectByteBuffer占的内存如2.24GB。此处也可直接用MemoryAnalyzer打开dump的堆,统计所有DirectByteBuffer的capacity之和,计算DirectByteBuffer申请的堆外内存大小。

然后用命令jdk/bin/jmap -dump:live,format=b,file=heap.hprof {pid},导出堆里所有活着的对象,并用MemoryAnalyzer打开dump的堆,分析所有的DirectByteBuffe:Merge shortest path to GC Roots ->with all references。

如果排除DirectByteBuffer,那就是应用程序直接用Unsafe类的allocateMemory分配的内存,例如Spark的off heap memory[1]。此时可排查代码所有Unsafe.allocateMemory的地方。

Java调用C++组件

例如RocksDB采用C++实现,并通过JNI提供给Java调用的接口,如果Java通过JNI创建了新的RocksDB实例,RocksDB会启动若干后台线程申请、释放内存,这部分内存都对Java不可见,如果发生泄漏,也无法通过dump jvm堆分析。

分析工具可采用google的gperftools,也可用jemalloc,本文采用jemalloc,首先安装jemalloc到/usr/local/lib/libjemalloc.so。

 
  •  

git clone 

然后在进程启动脚本里,添加如下命令,LD_PRELOAD表示JVM申请内存时不再用glibc的ptmalloc,而是使用jemalloc。MALLOC_CONF的lg_prof_interval表示每次申请2^30Byte时生成一个heap文件。

 
  •  

  •  

export LD_PRELOAD=/usr/local/lib/libjemalloc.soexport MALLOC_CONF=prof:true,lg_prof_interval:30

 

并在进程的启动命令里添加参数-XX:+PreserveFramePointer。进程启动后,随着不断申请内存,会生成很多dump文件,可把所有dump文件通过命令一起分析:jeprof --show_bytes --pdf jdk/bin/java *.heap > leak.pdf。

leak.pdf如下所示,可看到所有申请内存的路径,进程共申请过88G内存,而RocksDB申请了74.2%的内存,基本确定是不正常的行为,排查发现不断创建新的RocksDB实例,共1024个,每个实例都在运行,优化方法是合并RocksDB实例。

需要注意的是,88G是所有申请过的内存,包含申请但已经被释放的,因此通过该方法,大部分情况下能确定泄露源头,但并不十分准确,准确的方法是在C++代码里用钩子函数勾住malloc和free,记录哪些内存未被释放。

性能优化

arthas

perf是最为普遍的性能分析工具,在Java里可采用阿里的工具arthas进行perf,并生成火焰图,该工具可在docker容器内使用,而系统perf命令在容器里使用有诸多限制。

下载arthas-bin.zip[2],运行./a.sh,然后绑定到对应的进程,开始perf: profiler start,采样一段时间后,停止perf: profiler stop。结果如下所示,可看到getServiceList耗了63.75%的CPU。

另外,常用优化小建议:热点函数避免使用lambda表达式如stream.collect等、热点函数避免使用正则表达式、避免把UUID转成String在协议里传输等。

jaeger

perf适用于查找整个程序的热点函数,但不适用于分析单次RPC调用的耗时分布,此时就需要jaeger。

 jaeger是Uber开源的一个基于Go的分布式追踪系统。jaeger基本原理是:用户在自己代码里插桩,并上报给jaeger,jaeger汇总流程并在UI显示。非生产环境可安装jaeger-all-in-one[3],数据都在内存里,有内存溢出的风险。在需要追踪的服务的启动脚本里export JAEGER_AGENT_HOST={jaeger服务所在的host}。

下图为jaeger的UI,显示一次完整的流程,左边为具体的插桩名称,右边为每块插装代码耗时,可以看到最耗时的部分在including leader create container和including follower create container,这部分语义是leader创建完container后,两个follower才开始创建container,而创建container非常耗时,如果改成leader和两个follower同时创建container,则时间减少一半。

 

tcpdump

tcpdump常用来抓包分析,但也能用来优化性能。在我们的场景中,部署Ozone集群(下一代分布式对象存储系统),并读数据,结果发现文件越大读速越慢,读1G文件,速度只有2.2M每秒,使用perf未发现线索。

用命令tcpdump -i eth0 -s 0 -A ''tcp dst port 9878 and tcp[((tcp[12:1] & 0xf0) >> 2):4] = 0x47455420'' -w read.cap,该命令在读200M文件时会将所有GET请求导出到read.cap文件,然后用wireshark打开read.cap,并过滤出HTTP协议,因为大部分协议都是TCP协议,用于传输数据,而HTTP协议用于请求开始和结束。

从下图的wireshark界面,可看到读200M文件,共有10个GET请求:GET /goofys-bucket/test.dbf HTTP/1.1,每个GET请求读20M文件,每个GET请求读完后回复:HTTP/1.1 200 OK。第1个GET请求到达S3gateway时间为0.2287秒,第10个GET请求到达Ozone集群时间为1.026458秒。第1个GET请求完成时间为1.869579秒,第10个GET请求完成时间为23.640925秒。

可见10个GET请求在1秒内全部到达Ozone集群,但每个请求耗时越来越长。因此只需要分析后续的GET请求读同样大小的数据块,比前序GET请求多做了哪些事情即可。

最后通过分析日志和阅读代码发现,Ozone采用的第三方库commons-io采用read实现skip。例如读第10个GET请求时,实际只需要读[180M, 200M),但commons-io实现skip前180M时,会将前180M读出来,导致第10个GET请求读完整的[0M, 200M),因此GET请求越来越慢。优化后,性能提升一百倍。

 

jstack

jstack用来查询线程状态,但在极端情况下也可以用于性能优化。在部署服务时,发现进程迅速占满所有CPU,24核的机器进程使用CPU达到2381%。

CPU使用如此之高,无法运行arthas进行perf分析,只能采用其他策略。首先用top -Hp pid命令打出进程pid的所有线程及每个线程的CPU消耗。如下图,第一列PID为线程号,%CPU列代表CPU消耗,注意该图只是展示作用,该图的进程并不是使用CPU达到2381%的进程,原进程的信息当初没保存。

然后计算出使用CPU最高的线程号的十六进制表示0x417,再用jstack -l pid > jstack.txt命令打出所有线程状态,用0x417在jstack.txt查询消耗CPU最高的线程,即下图所示ThreadPoolExecutor里的线程,该线程一直处于RUNNABLE,且队列为empty,基本确认该部分线程出了问题,因为正常的线程不会一直空转,状态会有TIMED_WAITING的时刻。

因为线程堆栈不包含业务代码,都是JDK的源码,因此用线程堆栈搜索JDK相关问题,最终发现是JDK8的Bug:JDK-8129861,该Bug在创建大小为0的线程池时容易触发,因此在应用代码里,将大小为0的线程池修改即可。

宕机

被其他进程杀

在生产环境发生过进程被清理脚本杀掉。排查工具有两个:linux自带的auditd和systemtap。

首先使用auditd,因为该工具简单易用,不用安装。使用service auditd status检查服务状态,如果未启动可用service auditd restart启动。然后使用命令:auditctl -a exit,always -F arch=b64 -S kill,监听所有的Kill信号。如下图所示,从type=OBJ_PID行里可以看到:捕捉到的Kill信号杀的进程号opid=40442,线程名ocomm=”rocksdb:pst_st”,注意这里打出的线程名而不是进程名。

从type=SYSCALL行里可以看到:a1=9表示kill -9;发出kill -9的进程是exe=”/usr/bin/bash”,进程号是pid=98003。从这些信息并不能找到相应的进程,因为脚本往往运行完就停止,生命周期非常短。

接下来使用systemtap分析,systemtap需要安装:yum install systemtap systemtap-runtime。先写systemtap脚本findkiller.stp,如下所示,该systemtap脚本捕捉杀进程sig_pid的KILL信号,并使用task_ancestry打印发出KILL信号进程的所有祖先进程。

 
  •  

probe signal.send{if(sig_name == "SIGKILL" && sig_pid == target()) {printf("%s, %s was sent to %s (pid:%d) by %s (pid:%d) uid :%d\n", ctime(gettimeofday_s()), sig_name, pid_name , sig_pid, execname(), pid(), uid());printf("parent of sender: %s(%d)\n", pexecname(), ppid());printf("task_ancestry:%s\n", task_ancestry(pid2task(pid()), 1));  }}

 

然后stap -p4 findkiller.stp生成ko文件:stap_XX.ko,有的机器需要将ko文件补上签名才能运行。然后运行:nohup staprun  -x 98120  stap_XX.ko >nohup.out 2>&1 &,此处的98120即为脚本中的target()。

捕捉结果如下,从图里可以看出发出KILL命令的进程是通过crond启动的,也就是说定时任务运行了某些脚本杀了进程。但仍然不知道定时任务启动了哪个脚本杀了进程。

接下来再用auditd排查,使用命令:auditctl -a exit,always -F arch=b64 -S execve捕捉所有的系统调用,结果如下,最后一行是捕捉到杀进程opid=20286的信号,从图中可看出kill信号附近出现的都是/data/tools/clean命令。

/data/tools/clean里调用了若干脚本,在每个脚本里用打出当前脚本名和进程号到crontab.pid里。并和systemtap抓到的进程号62118对比,找到了KILL信号是从kill_non_run_app.sh脚本里发出。

 

调用System的exit

如果在Java程序里显式调用System.exit结束进程,可以用arthas排查。首先写脚本system_exit.as如下。

 
  •  

  •  

options unsafe truestack java.lang.System exit -n 1

运行命令nohup ./as.sh -f system_exit.as 69001 -b > system_exit.out 2>&1 &,即可监控进程69001调用的所有System.exit。

 

Java调用的C++发生Crash

此处发生的Crash案例和下文Java内Crash产生的原因一样,但现象不一样,大部分情况下,是Crash在C++代码,只产生core文件,不产生Java内Crash的Crash log;少量情况下Crash在JVM里,产生Java内Crash的Crash log。

如果Java通过JNI调用C++代码,在C++里发生Crash,JVM有时不会产生任何信息就退出,此时借助操作系统产生的core file分析进程退出原因,但操作系统默认关闭该功能,如下图所示core file size为0表示关闭该功能。

因此需要在进程的启动脚本里(只影响当前进程)设置ulimit -c ulimited来设置core file的大小,启动进程后,打开/proc/{pid}/limits,查看Max core file size的大小确认是否开启。

当发生Crash时,会生成core.pid文件,一般core.pid文件会非常大,因为该文件包含了所有虚拟内存大小,所以大于物理内存,如下图所示core.44729共53GB。

接下来使用命令gdb bin/java core.44729打开core文件,发现是rocksdb start thread时挂的,挂在libstdc++里,这是glibc库,基本不可能出问题,因此该堆栈可能是表象,有其他原因导致start thread失败。

注意到打开core文件时,有太多线程-LWP轻量级进程。

然后在gdb里用info threads,发现有三万多个线程,都在wait锁状态,基本确认三万多个线程,导致内存太大,创建不出来新的线程,因此挂在start thread里。

接着分析三万多个线程都是什么线程,随机选几十个线程,打出每个线程的堆栈,可以看到大部分线程都是jvm线程。因为rocksdb创建出来的线程是:

从/tmp/librocksdbjni8646115773822033422.so来的;而jvm创建出来的线程都是从/usr/java/jdk1.8.0_191-amd64/jre/lib/amd64/server/libjvm.so来的,这部分线程占了大部分。

 

 

因此问题出在Java代码里,产生core.pid文件的进程,虽然没有产生crash log,但也是因为Java 线程太多,导致C++代码创建线程时挂掉。至于为什么Java线程太多请看Java内Crash。
另外,core.pid完整的保留了C++组件Crash时的现场,包括变量、寄存器的值等,如果真的因为C++组件有Bug而Crash,例如空指针等。首先自行找到C++源码,找出怀疑空指针的变量{variableName},通过在gdb里执行命令:p {variableName},可以看出每个变量的值,从而找出空指针的变量。

 

 

Java内Crash

排查Java内Crash的原因如OOM等,需要配置JVM的如下参数:

 

-XX:ErrorFile

-XX:+HeapDumpOnOutOfMemoryError

-XX:HeapDumpPath。

 

JVM内发生Crash时,会在-XX:ErrorFile配置的路径下生成crash log。而-XX:+HeapDumpOnOutOfMemoryError、-XX:HeapDumpPath用于发生OOM时生成Dump堆,用于还原现场。下图所示为产生的crash log。可以看到创建线程时发生OutOfMemory导致进程挂掉。

从下图crash log可以看到有两万四千个Datanode State Machine Thread线程都在等锁。到此确认上文Java调用C++发生Crash 产生core.pid的进程和产生crash log的进程都是因为两万多个Datanode State Machine Thread挂掉。

接着分析为何有两万多个Datanode State Machine Thread,代码里可以看到该线程用线程池newCacheThreadPool创建。该newCacheThreadPool在没有线程可用,例如线程都在等锁的情况下,会创建新的线程,因此创建了两万多个线程。接着分析Datanode State Machine Thread等的什么锁。在进程的线程数超过5000时,用jstack -l pid > jstack.txt打出所有线程的状态。

可以看到几乎所有Datanode State Machine Thread在等锁,而只有一个Datanode State Machine Thread – 5500 拿到了锁,但是卡在提交RPC请求submitRequest。至此Java调用C++发生Crash 和Java内Crash的原因找到。

死锁

log4j导致的死锁

jstack打出的死锁信息如下所示。grpc-default-executor-14765线程拿到了log4j的锁,在等RaftServerImpl的锁;grpc-default-executor-14776线程拿到了RaftServerImpl的锁,在等log4j的锁,导致这两个线程都拿到了对方等待的锁,所以造成两个线程死锁。可以看出,仅仅打日志的log4j,不释放锁是最值得怀疑的地方。最后发现log4j存在死锁的缺陷[4]。该缺陷在log4j2得到解决,升级log4j即可。

 

封装不严谨导致的死锁

jstack打出的死锁信息如下所示。grpc-default-executor-3449线程拿到了RaftLog的锁,在等DataBlockingQueue的锁;SegmentedRaftLogWorker拿到了DataBlockingQueue的锁,在等RaftLog的锁。

这里最值得怀疑的是SegmentedRaftLogWorker拿到了DataBlockingQueue的锁却不释放,因为queue的操作只是在队列里增、删、查元素。如下图所示DataBlockingQueue的方法poll,使用的锁是自己封装的锁AutoCloseableLock implement AutoCloseable,锁的释放依赖于AutoCloseableLock重载的close方法。

再看acquire方法,先用lock.lock()拿到锁,再创建新的AutoCloseableLock对象,如果拿到锁后,在创建新对象AutoCloseableLock时发生OOM等异常,锁就无法释放。

 

 

来自 “开源世界 ” ,链接:http://ym.baisou.ltd/?id=510,如需转载,请注明出处,否则将追究法律责任。

JS前端监控采集用户行为的N种姿势

JS前端监控采集用户行为的N种姿势

引言

上一篇我们详细介绍了前端如何采集异常数据。采集异常数据是为了随时监测线上项目的运行情况,发现问题及时修复。在很多场景下,除了异常监控有用,收集用户的行为数据同样有意义。

怎么定义行为数据?顾名思义,就是用户在使用产品过程中产生的行为轨迹。比如去过哪几个页面,点过哪几个按钮,甚至在某个页面停留了多长时间,某个按钮点击了多少次,如果有需求都可以记录下来。

但是记录行为数据是一个和业务紧密关联的事情,不可能把每个用户每一步操作都极其详细的记录下来,这样会产生极其庞大的数据,很显然不现实。

合理的做法是,根据产品的实际情况评估,哪个模块哪个按钮需要重点记录,则可以采集的详细一些;哪些模块不需要重点关注,则简单记录一下基本信息。

根据这个逻辑,我们可以把行为数据分为两类:

  • 通用数据
  • 特定数据

下面分别介绍这两类数据该如何收集。

通用数据

在一个产品中,用户最基本的行为就是切换页面。用户使用了哪些功能,也能从切换页面中体现出来。因此通用数据一般是在页面切换时产生,表示某个用户访问了某个页面。

页面切换对应到前端就是路由切换,可以通过监听路由变化来拿到新页面的数据。Vue 在全局路由守卫中监听路由变化,任意路由切换都能执行这里的回调函数。

// Vue3 路由写法
const router = createRouter({ ... })
router.beforeEach(to => {
  // to 代表新页面的路由对象
  recordBehaviors(to)
})

React 在组件的 useEffect 中实现相同的功能。不过要注意一点,监听所有路由变化,则需要所有路由都经过这个组件,监听才有效果。具体的方法是配置路由时加 * 配置:

import HomePage from ''@/pages/Home''
<Route path="*" component={HomePage} />,

然后在这个组件的的 useEffect 中监听路由变化:

// HomePage.jsx
const { pathname } = useLocation();
useEffect(() => {
  // 路由切换这个函数触发
  recordBehaviors(pathname);
}, [pathname]);

上面代码中,在路由切换时都调用了 recordBehaviors() 方法并传入了参数。Vue 传的是一个路由对象,React 传的是路由地址,接下来就可以在这个函数内收集数据了。

明确了在哪里收集数据,我们还要知道收集哪些数据。收集行为数据最基本的字段如下:

app:应用的名称/标识

env:应用环境,一般是开发,测试,生产

version:应用的版本号

user_id:当前用户 ID

user_name:当前用户名

page_route:页面路由

page_title:页面名称

start_at:进入时间

end_at:离开时间

上面的字段中,应用标识、环境、版本号统称应用字段,用于标志数据的来源。其他字段主要分为 用户页面时间三类,通过这三类数据就可以简单的判断出一件事:谁到过哪个页面,并停留了多长时间。

应用字段的配置和获取方式我们在上一节 搭建前端监控,如何采集异常数据? 中讲过,就不做多余介绍了,获取字段的方式都是通用的。

下面介绍其他的几类数据如何获取。

获取用户信息

现代前端应用存储用户信息的方式基本都是一样的,localStorage 存一份,状态管理里存一份。因此获取用户信息从这两处的任意一处获得即可。这里简单介绍下如何从状态管理中获取。

最简单的方法,在函数 recordBehaviors() 所处的 js 文件中,直接导入用户状态:

// 从状态管理里中导出用户数据
import { UserStore } from ''@/stores'';
let { user_id, user_name } = UserStore;

这里的 @/stores 指向我项目中的文件 src/stores/index.ts,表示状态管理的入口文件,使用时替换成自己项目的实际位置。实际情况中还会有用户数据为空的问题,这里需要单独处理一下,方便我们在后续的数据查看中能看出分别:

import { UserStore } from ''@/stores'';
// 收集行为函数
const recordBehaviors = ()=> {
  let report_date = {
    ...
  }
  if(UserStore) {
    let { user_id, user_name} = UserStore
    report_date.user_id = user_id || 0
    report_date.user_name = user_name || ''未命名''
  } else {
    report_date.user_id = user_id || -1
    report_date.user_name = user_name || ''未获取''
  }
}

上面代码中,首先判断了状态管理中是否有用户数据,如果有则获取,没有则指定默认值。这里指定默认值的细节要注意,不是随便指定的,比如 user_id 的默认值有如下意义:

  • user_id 为 0:表示有用户数据,但没有 user_id 字段或该字段为空
  • user_id 为 -1:表示没有用户数据,因而 user_id 字段获取不到

用户数据是经常容易出错的地方,因为涉及到登录状态和权限等复杂问题。指定了上述默认值后,就可以从收集到的行为数据中判断出某个页面用户状态是否正常。

获取页面信息

前面我们在监听路由变化的地方调用了 recordBehaviors 函数并传入了参数,页面信息可以从参数中拿到,我们先看在 Vue 中怎么获取:

// 路由配置
{
  path: ''/test'',
  meta: {
    title: ''测试页面''
  },
  component: () => import(''@/views/test/Index.vue'')
}
// 获取配置
const recordBehaviors = (to)=> {
  let page_route = to.path
  let page_title = to.meta.title
}

Vue 中比较简单,可以直接从参数中拿到页面数据。相比之下,React 的参数只是一个路由地址,想拿到页面名称还需要做单独处理。

一般在设计权限时,我们会在服务端会维护一套路由数据,包含路由地址和名称。路由数据在登录后获取,存在状态管理中,那么有了 pathname 就可以从路由数据中找到对应的路由名称。

// React 中
import { RouteStore } from ''@/stores'';
const recordBehaviors = (pathname) => {
  let { routers } = RouteStore; // 取出路由数据
  let route = routers.find((row) => (row.path = pathname));
  if (route) {
    let page_route = route.path;
    let page_title = route.title;
  }
};

这样,页面信息的 page_route、page_title 两个字段也拿到了。

设置时间

行为数据中用两个字段 start_atend_at 分别表示用户进入页面和离开页面的时间。这两个字段非常重要,我们在后续使用数据的时候可以判断出很多信息,比如:

  • 某个用户在某个页面停留了多久?
  • 某个段时间内,某个用户停留在哪几个页面?
  • 某个时间段内,哪个页面的用户停留时间最长?
  • 某个页面,哪些用户的使用率最高?

还有很多信息,都能根据这两个时间字段判断。开始时间很好办,函数触发时直接获取当前时间:

var start_at = new Date();

结束时间这里需要考虑的情况比较多。首先要确定数据什么时候上报?用户进入页面后上报,还是离开页面时上报?

如果进入页面时上报,可以保证行为数据一定会被记录,不会丢失,但此时 end_at 字段必然为空。这样的话,就需要在离开页面时再调接口,将这条记录的 end_time 更新,这种方式的实现比较麻烦一些:

// 进入页面时调用
const recordBehaviors = () => {
  let report_date = {...} // 此时 end_at 为空
  http.post(''/behaviors/insert'', report_date).then(res=> {
    let id = res.id // 数据 id
    localStorage.setItem(''CURRENT_BEHAVIOR_ID'', id)
  })
}
// 离开页面时调用:
const updateBehaviors = ()=> {
  let id = localStorage.getItem(''CURRENT_BEHAVIOR_ID'')
  let end_at = new Date()
  http.post(''/behaviors/update/''+id, end_at) // 根据 id 更新结束时间
  localStorage.removeItem(''CURRENT_BEHAVIOR_ID'')
}

上面代码中,进入页面先上报数据,并保存下 id,离开页面再根据 id 更新这条数据的结束时间。

如果在离开页面时上报,那么就要保证离开页面前上报接口已经触发,否则会导致数据丢失。在满足这个前提条件下,上报逻辑会变成这样:

// 进入页面时调用
const recordBehaviors = () => {
  let report_date = {...} // 此时 end_at 为空
  localStorage.setItem(''CURRENT_BEHAVIOR'', JSON.stringify(report_date));
}
// 离开页面时调用
const reportBehaviors = () => {
  let end_at = new Date()
  let report_str = localStorage.getItem(''CURRENT_BEHAVIOR'')
  if(report_str) {
    let report_date = JSON.parse(report_str)
    report_date.end_at = end_at
    http.post(''/behaviors/insert'', report_date)
  } else {
    console.log(''无行为数据'')
  }
}

对比一下这两种方案,第一种的弊端是接口需要调两次,这会使接口请求量倍增。第二种方案只调用一次,但是需要特别注意可靠性处理,总体来说第二种方案更好些。

特定数据

除了通用数据,大部分情况我们还要在具体的页面中收集某些特定的行为。比如某个关键的按钮有没有点击,点了多少次;或者某个关键区域用户有没有看到,看到(曝光)了多少次等等。

收集数据还有一个更专业的叫法 ———— 埋点。直观理解是,哪里需要上报数据,就埋一个上报函数进去。

通用数据针对所有页面自动收集,特定数据就需要根据每个页面的实际需求手动添加。以一个按钮为例:

<button onClick={onClick}>点击</button>;
const onClick = (e) => {
  // console.log(e);
  repoerEvents(e);
};

上面代码中,我们想记录这个按钮的点击情况,所以做了一个简单的埋点 ———— 在按钮点击事件中调用 repoerEvents() 方法,这个方法内部会收集数据并上报。

这是最原始的埋点方式,直接将上报方法放到事件函数中。repoerEvents() 方法接收一个事件对象参数,在参数中获取需要上报的事件数据。

特定数据与通用数据的许多字段是一样的,收集特定数据需要的基本字段如下:

app:应用的名称/标识

env:应用环境,一般是开发,测试,生产

version:应用的版本号

user_id:当前用户 ID

user_name:当前用户名

page_route:页面路由

page_title:页面名称

created_at:触发时间

event_type:事件类型

action_tag:行为标识

action_label:行为描述

这些基本字段中,前 7 个字段与前面通用数据的获取完全一样,这里就不赘述了。实际上特定数据需要获取的专有字段只有 3 个:

event_type:事件类型

action_tag:行为标识

action_label:行为描述

这三个字段也非常容易获取。event_type 表示事件触发的类型,比如点击、滚动、拖动等,可以在事件对象中拿到。action_tag 和 action_label 是必须指定的属性,表示本次埋点的标识和文字描述,用于在后续的数据处理时方便查阅和统计。

了解了采集特定数据是怎么回事,接下来我们用代码实现。

手动埋点上报

假设要为登录按钮做埋点,按照上面的数据采集方式,我们书写代码如下:

<button data-tag="user_login" data-label="用户登录" onClick={onClick}>
  登录
</button>;
const onClick = (e) => {
  // console.log(e);
  repoerEvents(e);
};

代码中,我们通过元素的自定义属性传递了 tag 和 label 两个标识,用于在上报函数中获取。

上报函数 repoerEvents() 代码逻辑如下:

// 埋点上报函数
const repoerEvents = (e)=> {
  let report_date = {...}
  let { tag, label } = e.target.dataset
  if(!tag || !label) {
    return new Error(''上报元素属性缺失'')
  }
  report_date.event_type = e.type
  report_date.action_tag = tag
  report_date.action_label = label
  // 上报数据
  http.post(''/events/insert'', report_date)
}

这样就实现了一个基本的特定数据埋点上报功能。

全局自动上报

现在我们回过头来梳理一下这个上报流程,虽然基本功能实现了,但是还有些不合理之处,比如:

  • 必须为元素指定事件处理函数
  • 必须为元素添加自定义属性
  • 在原有事件处理函数中手动添加埋点,侵入性高

首先我们的埋点方式是基于事件的,也就是说,不管元素本身是否需要事件处理,我们都要给他加上,并在函数内部调用 repoerEvents() 方法。如果一个项目需要埋点的地方非常多,这种方式的接入成本就会非常高。

参考之前做异常监控的逻辑,我们换一个思路:能否全局监听事件自动上报呢?

思考一下,如果要做全局监听事件,那么只能监听需要埋点的元素的事件。那么如何判断哪些元素需要埋点呢?

上面我们为埋点的元素指定了 data-tag 和 data-label 两个自定义属性,那是不是根据这两个自定义属性判断就可以?我们来试验一下:

window.addEventListener(''click'', (event) => {
  let { tag, label, trigger } = event.target.dataset;
  if (tag && label && trigger == ''click'') {
    // 说明该元素需要埋点
    repoerEvents(event);
  }
});

上面代码还多判断了一个自定义属性 dataset.trigger,表示元素在哪种事件触发时需要上报。全局监听事件需要这个标识,这样可避免事件冲突。

添加全局监听后,收集某个元素的特定数据就简单了,方法如下:

<button data-tag="form_save" data-label="表单保存" data-trigger="click">
  保存
</button>

试验证明,上述全局处理的方式是可行的,这样的话就不需要在每一个元素上添加或修改事件处理函数了,只需要在元素中添加三个自定义属性 data-tagdata-labeldata-trigger 就能自动实现数据埋点上报。

组件上报

上面全局监听事件上报的方式已经比手动埋点高效了许多,现在我们再换一个场景。

一般情况下当埋点功能成熟之后,会封装成一个 SDK 供其他项目使用。如果我们将采集数据按照 SDK 的思路实现,让开发者在全局监听事件,是不是一个好的方式呢?

显然是不太友好的。如果是一个 SDK,那么最好的方式是将所有内容聚合成一个组件,在组件内实现上报的所有功能,而不是让使用者在项目中添加监听事件。

封装组件的话,那么组件的功能最好是将要添加埋点的元素包裹,这样自定义元素也就不需要指定了,而转为组件的属性,然后在组件内实现事件监听。

以 React 为例,我们看一下如何将上面的采集功能封装为组件:

import { useEffect, useRef } from ''react'';
const CusReport = (props) => {
  const dom = useRef(null);
  const handelEvent = () => {
    console.log(props); // {tag:xx, label:xx, trigger:xx}
    repoerEvents(props);
  };
  useEffect(() => {
    if (dom.current instanceof HTMLElement) {
      dom.current.addEventListener(props.trigger, handelEvent);
    }
  }, []);
  return (
    <span ref={dom} className="custom-report">
      {props.children}
    </span>
  );
};
export default CusReport;

组件使用方式如下:

<CusReport tag="test" label="功能测试" trigger="click">
  <button>测试</button>
</CusReport>

这样就比较优雅了,不需要修改目标元素,只要把组件包裹在目标元素之外即可。

总结

本文介绍了搭建前端监控如何采集行为数据,将数据分为 通用数据 和 特定数据 两个大类分别处理。同时也介绍了多种上报数据的方式,不同的场景可以选择不同的方式。

其中的数据部分只介绍了实现功能的基础字段,实际情况中可以根据自己的业务需求添加。

许多小伙伴留言这套前端监控能否开源,肯定是要开源的,不过内容比较多我还在做,等到基本完善了我会发一个版本,感谢小伙伴们的关注。

本系列文章如下

  • 为什么前端不能没有监控系统?
  • 前端监控的搭建步骤,别再一头雾水了!
  • 搭建前端监控,如何采集异常数据?

以上就是JS前端监控采集用户行为的N种姿势的详细内容,更多关于JS前端监控采集用户行为的资料请关注其它相关文章!

您可能感兴趣的文章:
  • javascript 实现纯前端将数据导出excel两种方式
  • js前端获取用户位置及ip属地信息
  • js前端实现word excel pdf ppt mp4图片文本等文件预览
  • JavaScript架构搭建前端监控如何采集异常数据
  • js前端面试常见浏览器缓存强缓存及协商缓存实例
  • JS前端常见的竞态问题解决方法详解

python字符串拼接N种姿势

python字符串拼接N种姿势

  字符串大家都不陌生,应用比较广泛,强大,总是会给你一些惊喜的数据类型。我们本篇文章主要介绍的就是关于字符串的多种方法的拼接。

第一种:直接通过+号拼接

输出结果:

2.通过 str.join()方法拼接

输出结果:

3.通过 str.format操作符拼接 

输出结果:

4.通过(%)操作符进行拼接 

输出结果:

5.通过()进行拼接  

输出结果:

6.通过F-string拼接 (该方法适用于python3.6.2+版本)

在python3.5.xx版本不兼容报错:

完整代码:

#coding=utf-8
#1.第一种
s = "Hello" +  " " + "world" + " I"
print(s)

# 2.第二种 str.join() 方法拼接
liststr = [''Hello'',''World'',''I'']
strlist = ''''.join(liststr)
print(''转换后的数据类型是:'',type(strlist))
print(''转换后的数据是:'',strlist)

# 3.第三种:通过str.format方法拼接
s = "{} {}".format(''Hello'',''World'')
print(''输出结果是:'',s)

s1 = "{0} {1}".format(''Hello'',''world'')
print(''s1的结果是 {}''.format(s1))

s2 = "{a} {b}".format(a=''Hello'',b=''world'')
print(''s2的结果是 {}''.format(s2))


#4.通过(%)操作符进行拼接
s = "%s %s "%(''Hello'',''world'')
print(s)


#5.通过() 拼接
s=(
    ''hello'' + ''\n''
    ''''
    ''World'' + ''\n''
    ''!''
)
print(s)


#6.通过f-string进行拼接
s1="Hello"
s2="World"
print(f''{s1} {s2}'')

关于直播预告:WiFi 配网的N种姿势wifi配网过程的问题就给大家分享到这里,感谢你花时间阅读本站内容,更多关于ByteHouse直播预告:揭秘基于OLAP降本增效的四大硬招、Java内存泄漏、性能优化、宕机死锁的N种姿势、JS前端监控采集用户行为的N种姿势、python字符串拼接N种姿势等相关知识的信息别忘了在本站进行查找喔。

本文标签:

上一篇Docker Swarm 容器集群管理(docker swarm集群部署)

下一篇Wifi设备配网问题(wifi设备配网的原理)