hadoop源码分析_Spark2.x精通:Job触发流程源码深度剖析(一)

,    

7c46f2557a3aea564a11b8cb26fe3791.gif

一、概述    

    之前几篇文章对Spark集群的Master、Worker启动流程进行了源码剖析,后面直接从客户端角度出发,讲解了spark-submit任务提交过程及driver的启动;集群启动、任务提交、SparkContext初始化等前期准备工作完成之后,后面就是我们的主函数的代码Job如何触发的,本篇文章还是结合源码进行剖析。

    软件版本:

        spark2.2.0

二、Job触发流程源码剖析

1. 我们先上一段最简单的代码,读取本地文件进行WordCount,并打印统计结果,代码如下:

package com.hadoop.ljs.spark220.study;import org.apache.spark.SparkConf;import org.apache.spark.api.java.JavaPairRDD;import org.apache.spark.api.java.JavaRDD;import org.apache.spark.api.java.JavaSparkContext;import org.apache.spark.api.java.function.FlatMapFunction;import org.apache.spark.api.java.function.Function2;import org.apache.spark.api.java.function.PairFunction;import org.apache.spark.api.java.function.VoidFunction;import org.apache.spark.sql.SparkSession;import scala.Tuple2;import java.util.Arrays;import java.util.Iterator;/** * @author: Created By lujisen * @company ChinaUnicom Software JiNan * @date: 2020-03-12 08:26 * @version: v1.0 * @description: com.hadoop.ljs.spark220.study */public class Example1 {    public static void main(String[] args) throws Exception{        /*spark环境初始化*/        SparkConf sparkConf = new SparkConf().setMaster("local[*]").setAppName("Example1");        SparkSession sc = SparkSession.builder().config(sparkConf).getOrCreate();        JavaSparkContext jsc = new JavaSparkContext(sc.sparkContext());        /*读取本地文件*/        JavaRDD<String> sourceRDD = jsc.textFile("D:\\kafkaSSL\\kafka_client_jaas.conf");        /*转换多维为一维数组*/        JavaRDD<String> words = sourceRDD.flatMap(new FlatMapFunction<String, String>() {            @Override            public Iterator<String> call(String s)  {                return Arrays.asList(s.split(" ")).iterator();            }        });        /*转换成(hello,1)格式*/        JavaPairRDD<String, Integer> wordOne = words.mapToPair(new PairFunction<String, String, Integer>() {            @Override            public Tuple2<String, Integer> call(String s) {                return new Tuple2<String, Integer>(s, 1);            }        });        /*根据key进行聚合*/        JavaPairRDD<String, Integer> wordCount = wordOne.reduceByKey(new Function2() {            @Override            public Integer call(Integer v1, Integer v2)  {                return v1+v2;            }        });        /*打印结果*/        wordCount.foreach(new VoidFunctionString, Integer>>() {            @Override            public void call(Tuple2<String, Integer> result){                System.out.println("word:  "+result._1+" count: "+result._2);            }        });    }}

    我们一行行的进行分析,首先看读取本地文件textFile()函数:

  /*这里直接调用的SparkContext的textFile函数*/  def textFile(path: String): JavaRDD[String] = sc.textFile(path)

2. 直接看sc.textFile()函数:

  def textFile(      path: String,      minPartitions: Int = defaultMinPartitions): RDD[String] = withScope {    assertNotStopped()    /*这里调用了hadoopFile函数,传入三个,写过Mapreuce的时候都知道 第二个参数就是Map的输入格式化类型,参数3是行号 4是一行的内容*/    /*hadoopFile()函数,返回了一个HadoopRDD*/    hadoopFile(path, classOf[TextInputFormat], classOf[LongWritable], classOf[Text],    minPartitions).map(pair => pair._2.toString).setName(path)  }

看hadoopFile()函数

 def hadoopFile[K, V](      path: String,      inputFormatClass: Class[_ <: inputformat v>      keyClass: Class[K],      valueClass: Class[V],      minPartitions: Int = defaultMinPartitions): RDD[(K, V)] = withScope {    assertNotStopped()    // This is a hack to enforce loading hdfs-site.xml.    // See SPARK-11227 for details.    FileSystem.getLocal(hadoopConfiguration)    //这里把hadoopConfiguration配置做了一个广播变量    val confBroadcast = broadcast(new SerializableConfiguration(hadoopConfiguration))    /* 传入一个jobConf对输入数据进行格式化*/    val setInputPathsFunc = (jobConf: JobConf) => FileInputFormat.setInputPaths(jobConf, path)    /* 返回一个HadoopRDD实例,这里Hadoop配置文件是以广播变量的方式传进去的*/    /*广播变量 每个Worker保存一份,被多个Executor共享*/    /*HadoopRDD继承自RDD*/    new HadoopRDD(      this,      confBroadcast,      Some(setInputPathsFunc),      inputFormatClass,      keyClass,      valueClass,      minPartitions).setName(path)  }

    上面直接对HadopRDD做了一个map转换,这里Hadoop继承自RDD,调用的是RDD里面的map()函数,我们直接看看map函数代码:

  /* 最后其实是返回了一个MapPartitionsRDD,里面是(key,value),key是行号,value是内容*/  def map[U: ClassTag](f: T => U): RDD[U] = withScope {    val cleanF = sc.clean(f)    new MapPartitionsRDD[U, T](this, (context, pid, iter) => iter.map(cleanF))  }

 上面对返回的RDD是一个键值对,然后.map(pair => pair._2.toString对其进行了转换,其实就是去掉了那个key行号,剩下的是一个vlaue数组,里面是每行的内容,至此textFile这一行剖析完毕。

3.主函数的第30-42行都是对RDD进行了一系列的转换,其实都是调用RDD.scala中的内容对MapPartitionsRDD进行的转换,有兴趣你可以跟进去看一下,比较简单:

def flatMap[U: ClassTag](f: T => TraversableOnce[U]): RDD[U] = withScope {    val cleanF = sc.clean(f)    new MapPartitionsRDD[U, T](this, (context, pid, iter) => iter.flatMap(cleanF))  }
  /* mapToPair函数里面其实是调用的rdd.map函数,刚才上面已经说过了*/  def mapToPair[K2, V2](f: PairFunction[T, K2, V2]): JavaPairRDD[K2, V2] = {    def cm: ClassTag[(K2, V2)] = implicitly[ClassTag[(K2, V2)]]    new JavaPairRDD(rdd.map[(K2, V2)](f)(cm))(fakeClassTag[K2], fakeClassTag[V2])  }

4.最后调用reduceBykey进行了聚合,这里就比较重要了,我们之前讲过一个spark任务里面会有多个job,job的划分依据是action,有几个action就有几个job,而每个job的划分依据是shuffle,只要发生了shuffle就会有新的stage生成,reduceBykey是个action操作,RDD中没有这个函数,是通过里面的隐式转换调用了PairRDDFunctions.scala中的reduceBykey()函数,里面的转换先不用管,因为涉及到shuffle操作,会有新的stage的生成,这里先略过:

  def reduceByKey(partitioner: Partitioner, func: (V, V) => V): RDD[(K, V)] = self.withScope {    combineByKeyWithClassTag[V]((v: V) => v, func, func, partitioner)  }

5. 最后主函数调用了wordCount.foreach()进行了结果打印,这是一个action操作,有几个action就会提交几个job,直接去看代码:

  def foreach(f: T => Unit): Unit = withScope {    val cleanF = sc.clean(f)    /*这里是执行了runJob,跟其他操作不一样,这里会提交一个job*/    sc.runJob(this, (iter: Iterator[T]) => iter.foreach(cleanF))  }

    跟进代码,里面调用了SparkContext.scala中的函数:

  def runJob[T, U: ClassTag](      rdd: RDD[T],      func: Iterator[T] => U,      partitions: Seq[Int]): Array[U] = {      //这里clean函数其实直接输出    val cleanedFunc = clean(func)    runJob(rdd, (ctx: TaskContext, it: Iterator[T]) => cleanedFunc(it), partitions)  }

    跟进了好几层,最后看runJob干了啥:

def runJob[T, U: ClassTag](      rdd: RDD[T],      func: (TaskContext, Iterator[T]) => U,      partitions: Seq[Int],      resultHandler: (Int, U) => Unit): Unit = {    if (stopped.get()) {      throw new IllegalStateException("SparkContext has been shutdown")    }    val callSite = getCallSite    val cleanedFunc = clean(func)    logInfo("Starting job: " + callSite.shortForm)    if (conf.getBoolean("spark.logLineage", false)) {      logInfo("RDD's recursive dependencies:\n" + rdd.toDebugString)    }    //SparkContext初始化的dagScheduler调用runJob函数比较任务,这样就跟之前SparkContext源码剖析内容联系在一起了    dagScheduler.runJob(rdd, cleanedFunc, partitions, callSite, resultHandler, localProperties.get)    progressBar.foreach(_.finishAll())    rdd.doCheckpoint()  }

6.上面调用了DAGScheduler中的runJob函数,这个DAGScheduler是我们在SparkContext初始化的时候执行的初始化,DAGSCheduler主要工作:创建Job,推断出每一个Job的stage划分(DAG),跟踪RDD,实体化stage的输出,调度job,将stage以taskSet的形式提交给TaskScheduler的实现类,在集群上运运行,其中,TaskSet是一组可以立即运行的独立task,基于集群上已存在的数据,直接看下代码:

def runJob[T, U](      rdd: RDD[T],      func: (TaskContext, Iterator[T]) => U,      partitions: Seq[Int],      callSite: CallSite,      resultHandler: (Int, U) => Unit,      properties: Properties): Unit = {    val start = System.nanoTime    /* 这里就一行比较重要,这里调用submitJob进行提交 */    val waiter = submitJob(rdd, func, partitions, callSite, resultHandler, properties)    ThreadUtils.awaitReady(waiter.completionFuture, Duration.Inf)    // 下面这些就是任务结果的一些判断了     waiter.completionFuture.value.get match {      case scala.util.Success(_) =>        logInfo("Job %d finished: %s, took %f s".format          (waiter.jobId, callSite.shortForm, (System.nanoTime - start) / 1e9))      case scala.util.Failure(exception) =>        logInfo("Job %d failed: %s, took %f s".format          (waiter.jobId, callSite.shortForm, (System.nanoTime - start) / 1e9))        // SPARK-8644: Include user stack trace in exceptions coming from DAGScheduler.        val callerStackTrace = Thread.currentThread().getStackTrace.tail        exception.setStackTrace(exception.getStackTrace ++ callerStackTrace)        throw exception    }  }

    下面就是调用了submitJob进行任务的提交,代码如下:

def submitJob[T, U](      rdd: RDD[T],      func: (TaskContext, Iterator[T]) => U,      partitions: Seq[Int],      callSite: CallSite,      resultHandler: (Int, U) => Unit,      properties: Properties): JobWaiter[U] = {    // 这里确认我们提交的Partition存在    val maxPartitions = rdd.partitions.length    partitions.find(p => p >= maxPartitions || p < 0).foreach { p =>      throw new IllegalArgumentException(        "Attempting to access a non-existent partition: " + p + ". " +          "Total number of partitions: " + maxPartitions)    }    val jobId = nextJobId.getAndIncrement()    if (partitions.size == 0) {      // Return immediately if the job is running 0 tasks      return new JobWaiter[U](this, jobId, 0, resultHandler)    }    assert(partitions.size > 0)    val func2 = func.asInstanceOf[(TaskContext, Iterator[_]) => _]    val waiter = new JobWaiter(this, jobId, partitions.size, resultHandler)    //这里会触发DAGSchedulerEventProcessLoop的JobSubmitted,他里面onReceive()函数    //接收消息进行处理,这里调用的是JobSubmitted,触发dagScheduler.handleJobSubmitted    //函数进行处理    eventProcessLoop.post(JobSubmitted(      jobId, rdd, func2, partitions.toArray, callSite, waiter,      SerializationUtils.clone(properties)))    waiter  }

下面就是调用handleJobSubmitted()函数进行处理,它是DAGSchduler的job调度核心入口,代码如下:

 private[scheduler] def handleJobSubmitted(jobId: Int,      finalRDD: RDD[_],      func: (TaskContext, Iterator[_]) => _,      partitions: Array[Int],      callSite: CallSite,      listener: JobListener,      properties: Properties) {    //     var finalStage: ResultStage = null    try {      //使用触发job的最后一个rdd,创建stage      //当hdfs上的文件被删除的时候  stage可能创建失败      finalStage = createResultStage(finalRDD, func, partitions, jobId, callSite)    } catch {      case e: Exception =>        logWarning("Creating new stage failed due to exception - job: " + jobId, e)        listener.jobFailed(e)        return    }    //通过finalStage创创建一个job,    val job = new ActiveJob(jobId, finalStage, callSite, listener, properties)    clearCacheLocs()    logInfo("Got job %s (%s) with %d output partitions".format(      job.jobId, callSite.shortForm, partitions.length))    logInfo("Final stage: " + finalStage + " (" + finalStage.name + ")")    logInfo("Parents of final stage: " + finalStage.parents)    logInfo("Missing parents: " + getMissingParentStages(finalStage))    val jobSubmissionTime = clock.getTimeMillis()    //将job加入到activeJob缓存中    jobIdToActiveJob(jobId) = job    activeJobs += job    finalStage.setActiveJob(job)    val stageIds = jobIdToStageIds(jobId).toArray    val stageInfos = stageIds.flatMap(id => stageIdToStage.get(id).map(_.latestInfo))    listenerBus.post(      SparkListenerJobStart(job.jobId, jobSubmissionTime, stageInfos, properties))    //提交finalStage,但是finalStage肯定不会首先执行,它要先执行它的依赖stage    submitStage(finalStage)  }

7.最后调用了submitStage进行了finalStage的提交,finalStage肯定不会首先执行,它要先执行它的依赖stage,这里面就涉及到了stage的换分了,代码如下:

/** Submits stage, but first recursively submits any missing parents. */  private def submitStage(stage: Stage) {    val jobId = activeJobForStage(stage)    if (jobId.isDefined) {      logDebug("submitStage(" + stage + ")")      if (!waitingStages(stage) && !runningStages(stage) && !failedStages(stage)) {        //获取stage对应的父stage,返回List[Stage]按id排序        val missing = getMissingParentStages(stage).sortBy(_.id)        logDebug("missing: " + missing)        // 如果父stage为空,则调用submitMissingTasks 提交stage,        if (missing.isEmpty) {          logInfo("Submitting " + stage + " (" + stage.rdd + "), which has no missing parents")          submitMissingTasks(stage, jobId.get)        } else {          for (parent            // 如果父stage不为空,则调用submitStage 提交父stage            submitStage(parent)          }          //并将stage放入等待的队列中,先去执行父stage          waitingStages += stage        }      }    } else {      abortStage(stage, "No active job for stage " + stage.id, None)    }  }

   我们看下getMissingParentStages()函数,如何进行stage划分的,代码如下:

 //大体划分流程:遍历rdd的所有的依赖,如果是ShufDep,则通过getShuffleMapStage获取stage, // 并加入到missing队列中。如果是窄依赖的话,将放入waitingForVisit的栈中。 private def getMissingParentStages(stage: Stage): List[Stage] = {    val missing = new HashSet[Stage]    val visited = new HashSet[RDD[_]]    // We are manually maintaining a stack here to prevent StackOverflowError    // caused by recursively visiting    val waitingForVisit = new Stack[RDD[_]]    def visit(rdd: RDD[_]) {      if (!visited(rdd)) {        visited += rdd        val rddHasUncachedPartitions = getCacheLocs(rdd).contains(Nil)        if (rddHasUncachedPartitions) {          for (dep             dep match {            //如果shufDep也就是我们说的宽依赖              case shufDep: ShuffleDependency[_, _, _] =>              //宽依赖,则创建一个shuffleStage,即finalStage之前的stage是shuffle stage                val mapStage = getOrCreateShuffleMapStage(shufDep, stage.firstJobId)                if (!mapStage.isAvailable) {                 //加入到missing队列,返回                  missing += mapStage                }                //如果narrowDep也就是我们说的窄依赖              case narrowDep: NarrowDependency[_] =>              //加入等待队列中                waitingForVisit.push(narrowDep.rdd)            }          }        }      }    }    waitingForVisit.push(stage.rdd)    while (waitingForVisit.nonEmpty) {     // 如果是窄依赖,将rdd放入栈中      visit(waitingForVisit.pop())    }    missing.toList  }

    submitStage()函数中如果父stage为空则,调用submitMissingTasks()函数进行提交,这个函数主要做了一下几件事:

    a.首先获取stage中没有计算的partition;

    b.通过 taskIdToLocations(id) 方法进行tasks运行最佳位置的确定;

    c.调用taskScheduler的submitTasks进行任务的提交。

    至此,Spark任务Job触发流程源码深度剖析的第一部分讲解完毕,后面会写一遍文章专门讲解submitMissingTasks()函数中task最佳位置的定位、task的提交具体流程,请继续关注。

    如果觉得我的文章能帮到您,请关注微信公众号“大数据开发运维架构”,并转发朋友圈,谢谢支持!

2e9cbb68ea3ee91369063482bff778f1.gif

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

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

相关文章

如何在Java中将String转换为int

在本教程中&#xff0c;我们将看到将Java中的String转换为int&#xff08;或Integer&#xff09;的各种方法。 您可以使用以下任何一种方式&#xff1a; –使用Integer.parseInt&#xff08;string&#xff09; –使用Integer.valueof&#xff08;string&#xff09; –使用…

jboss 程序位置_介绍JBoss BPM Suite安装程序

jboss 程序位置本周&#xff0c;我们想向您介绍JBoss BRMS和JBoss BPM Suite产品随附的一个鲜为人知的安装程序组件。 请注意&#xff0c;当前所有的演示项目都要求您下载JBoss BPM Suite可部署的eap zip产品文件和JBoss EAP 6.1.1 zip产品文件。 展望未来&#xff0c;我们将迁…

java换成中文_如果我们的编程替换成中文会变成怎样?

首先大概的看一下中文编码&#xff1a;你以为会写中文就会编程吗&#xff1f;这就像你以为会写汉字就会写出好文章一样。编程是和机器沟通&#xff0c;因此要用机器的语言而不是人类的语言。最早的程序就是0和1的数字&#xff0c;不是中文也不是英文。以前的程序员&#xff0c;…

高等数学公式大全_高中物理知识思维导图大全,赶紧收藏!

物理作为理综的重中之重&#xff0c;物理的学习一直是广大考生的难点。如何快捷高效的掌握物理知识点是高考复习的重点之一&#xff0c;根据高中物理三年知识点用思维导图的方式&#xff0c;来助大家掌握物理知识点。运动的描述 重力 基本相互作用 相互作用 牛顿运动定律 力的合…

go环境搭建_学习的golang第一步,搭建我们运行的环境,go! go! go

这是Golang教程系列中的第一个教程。本教程介绍了Go&#xff0c;并讨论了选择Go优于其他编程语言的优势。我们还将学习如何在Mac OS&#xff0c;Windows和Linux中安装Go。介绍Go也称为Golang是由Google开发的一种开源&#xff0c;编译和静态类型的编程语言。创造Go的关键人物是…

如何在Java中将数组转换为列表

你好朋友&#xff0c; 在本教程中&#xff0c;我们将学习将数组转换为List的各种方法。 package com.blogspot.javasolutionsguide;import com.google.common.collect.Lists; import org.apache.commons.collections4.CollectionUtils;import java.util.ArrayList; import ja…

html5网页制作代码_HTML5的网页设计教程

关注小编&#xff0c;教你如何制作网页HTML5是超文本标记语言(HyperText Markup Language)的第五代版本&#xff0c;它是书写网页代码的一种规范、一种标准。它通过标记符号来标记要显示的网页中的各个部分。浏览器根据这个标准显示其中的内容(如&#xff1a;文字如何处理&…

aop+注解 实现对实体类的字段校验_SpringBoot实现通用的接口参数校验

来自&#xff1a;掘金&#xff0c;作者&#xff1a;cipher链接&#xff1a;https://juejin.im/post/5af3c25b5188253064651c76原文链接&#xff1a;http://www.ciphermagic.cn/spring-boot-aop-param-check.html本文介绍基于Spring Boot和JDK8编写一个AOP&#xff0c;结合自定义…

java基础分享_一、java基础教程

1、java是一门比较纯粹的面向对象编程语言&#xff0c;所以java的所有代码都必须写在类的内部。1.1 java的可执行文件后缀名是".java"&#xff0c;例如HelloWorld.java&#xff0c;并且每个可执行文件内部&#xff0c;必须有且仅有一个public公共类/公共接口/公共抽象…

Spring Boot自定义横幅生成

每当我们启动Spring Boot应用程序时&#xff0c;都会显示如下所示的文本消息。 这称为横幅。 现在&#xff0c;如果我们可以创建一个特定于我们的Spring Boot应用程序的自定义横幅并使用它代替默认的Spring Boot横幅&#xff0c;那将不是一件很棒的事。 有很多方法可以生成和使…

java等待_Java学习:等待唤醒机制

等待唤醒机制线程的状态NEW 至今尚未启动的线程处于这种状态RUNNABLE 正在Java虚拟机中执行的线程处于这种状态BLOCKED 受阻塞并等待某个监视器锁的线程处于这种状态WAITING 无限期的等待另一个线程来执行某一待定操作的线程处于这种状态TIMED_WAITNG 等待另一个线程来执行取…

游戏ai 行为树_游戏AI –行为树简介

游戏ai 行为树游戏AI是一个非常广泛的主题&#xff0c;尽管有很多资料&#xff0c;但我找不到能以较慢且更易理解的速度缓慢介绍这些概念的东西。 本文将尝试解释如何基于行为树的概念来设计一个非常简单但可扩展的AI系统。 什么是AI&#xff1f; 人工智能是参与游戏的实体表现…

java构造器_Java构造器就是这么简单!

前言理解构造器之前&#xff0c;首先我们需要了解Java中为什么要引入构造器&#xff0c;以及构造器的作用。在很久之前&#xff0c;程序员们编写C程序总会忘记初始化变量&#xff08;这真的是一件琐碎但必须的事&#xff09;&#xff0c;C引入了 构造器(constructor) 的概念&am…

JavaFX技巧32:需要图标吗? 使用Ikonli!

动机 自2013年以来&#xff0c;我一直在编写JavaFX应用程序和库的代码&#xff0c;它们的共同点是&#xff0c;我需要找到可以用于它们的良好图标/图形。 作为前Swing开发人员&#xff0c;我首先使用图像文件&#xff0c;GIF或PNG。 通常&#xff0c;我会从IconExperience&…

java应用部署docker_Docker部署JavaWeb项目实战

摘要&#xff1a;本文主要讲了怎样在Ubuntu14.04 64位系统下来创建一个执行Java web应用程序的Docker容器。一、下载镜像、启动容器1、下载镜像先查看镜像docker images记住这个Image ID&#xff0c;下面我们启动容器须要用到它。假设看到以上输出&#xff0c;说明您能够使用“…

如何用Java创建不可变的Map

你好朋友&#xff0c; 在本教程中&#xff0c;我们将看到如何用Java创建不可变的Map。 –不可变的类或对象是什么意思&#xff1f; –什么是不可变地图&#xff1f; –如何在Java中创建不可变的Map&#xff1f; 不变的类或对象是什么意思&#xff1f; 不可变的类或对象是创…

quartz java 线程 不释放_java Quartz 内存泄漏

我用定时器启动应用的时候发现内存泄漏&#xff0c;具体报错如下&#xff1a;十月 30, 2015 2:30:12 下午 org.apache.catalina.startup.HostConfig undeploy信息: Undeploying context [/ChinaMoney Maven Webapp]十月 30, 2015 2:30:15 下午 org.apache.catalina.loader.Weba…

在ultraedit查找每行第二个单词_新手收藏!亚马逊关键字查找

亚马逊销售中最重要的是“排名”。而“关键字”对提高排名很重要。搜索结果对亚马逊的销售产生重大影响。要想让你的产品被显示在搜索结果的顶部&#xff0c;那你必须选择有效的关键字。搜索关键词排名一直上不去&#xff0c;你可能会这么想&#xff1a;“关键字不好吧......。…

java opencv磨皮算法_使用OPENCV简单实现具有肤质保留功能的磨皮增白算法

在一个美颜高手那里发现一个美颜算法&#xff0c;他写出了数学表达式&#xff0c;没有给出代码&#xff0c;正好在研究OPENCV&#xff0c;顺手实现之。具体过程就是一系列矩阵运算&#xff0c;据说是从一个PS高手那里研究 出来的&#xff0c;一并表示感谢。这是数学表达式&…

junit单元测试断言_简而言之,JUnit:单元测试断言

junit单元测试断言简而言之&#xff0c;本章涵盖了各种单元测试声明技术。 它详细说明了内置机制&#xff0c; Hamcrest匹配器和AssertJ断言的优缺点 。 正在进行的示例扩大了主题&#xff0c;并说明了如何创建和使用自定义匹配器/断言。 单元测试断言 信任但要验证 罗纳德里…