Как JaCoCo рассчитывает покрытие кода

исходный код

вводить

Адрес официального сайта:www.eclemma.org/jacoco/

JaCoCo — очень часто используемый инструмент для расчета покрытия кода.Достигаемый эффект заключается в анализе того, какие коды были выполнены и какие коды никогда не выполнялись в определенный момент времени, когда код запущен, чтобы понять покрытие кода. степень испытаний. Поддерживает статистику покрытия на уровне классов, методов и строк, а также статистику на уровне ветвей.

На картинке ниже скриншот официального сайта, зеленый означает выполнено, красный означает не реализовано, желтый означает часть реализации, а ниже коэффициент охвата класса и пакета Очень интуитивно понятно.

Принцип реализации

Если мы получим это требование, как мы будем его реализовывать? Один из самых простых способов — сделать отметку на каждой строке кода, чтобы отметить, выполняется ли эта строка кода. Если эта отметка выполняется, это доказывает, что предыдущая строка кода код будет выполняться Он выполняется На самом деле принцип JaCoCo почти такой же О том, куда ставится метка, что вставляется и как посчитать покрытие по метке — в центре внимания этой статьи.

Как JaCoCo модифицирует код

Есть два способа изменить код jacoco.

  • одинon-the-fly, то есть модифицировать код в режиме реального времени, принцип заключается в использованииjava agentтехнологии, находится в центре внимания этого введения.

  • одинoffline, то есть на лету нельзя использовать по особым причинам, например, среда не поддерживает использование java-агента и т. д.

Что вставляет JaCoCo?

Ниже приведен пример.Для следующего кода, что делает JaCoCo?Давайте декомпилируем в соответствии с измененным байт-кодом JaCoCo, чтобы увидеть, что было изменено

public class JacocoTest {

    public static void main(String[] args) {
        int a = 10;
        a = a+20;
        System.out.println();
        if (a > 10) {
            test1();
        } else {
            test2();
        }
        System.out.println();
    }

    public static void test1() {
        System.out.println("");
    }

    public static void test2() {
        System.out.println("");
        throw new RuntimeException("");
    }
}

Код, обработанный JaCoCo, может выводить модифицированный файл, изменяя исходный код JaCoCo, и инструменты декомпиляции, такие какCFRДекомпилируйте, чтобы получить следующее:

public class JacocoTest {
    private static transient /* synthetic */ boolean[] $jacocoData;

    public JacocoTest() {
        boolean[] arrbl = JacocoTest.$jacocoInit();
        arrbl[0] = true;
    }

    public static void main(String[] arrstring) {
        boolean[] arrbl = JacocoTest.$jacocoInit();
        int a = 10;
        ++a;
        arrbl[1] = true;
        System.out.println();
        if (++a > 10) {
            arrbl[2] = true;
            JacocoTest.test1();
            arrbl[3] = true;
        } else {
            JacocoTest.test2();
            arrbl[4] = true;
        }
        System.out.println();
        arrbl[5] = true;
    }

    public static void test1() {
        boolean[] arrbl = JacocoTest.$jacocoInit();
        System.out.println("");
        arrbl[6] = true;
    }

    public static void test2() {
        boolean[] arrbl = JacocoTest.$jacocoInit();
        System.out.println("");
        arrbl[7] = true;
        arrbl[8] = true;
        throw new RuntimeException("");
    }

    private static /* synthetic */ boolean[] $jacocoInit() {
        boolean[] arrbl = $jacocoData;
        boolean[] arrbl2 = arrbl;
        if (arrbl != null) return arrbl2;
        Object[] arrobject = new Object[]{4473305039327547984L, "com/xin/test/JacocoTest", 9};
        UnknownError.$jacocoAccess.equals(arrobject);
        arrbl2 = $jacocoData = (boolean[])arrobject[0];
        return arrbl2;
    }
}


На первый взгляд, операция JaCoCo аналогична прогнозированию.Тег использует логический массив.Пока выполняется соответствующий путь, логический массив назначается, и, наконец, коэффициент покрытия может быть получен путем подсчета логического значения.Это тег официально имеет названиеЗонд

Но вот вопрос: почему после всех исполняемых операторов не стоит зонд? Это включает в себя стратегию введения зонда,официальная документацияЕсли есть введение, эта статья также представит его.

Стратегия введения зонда

Как вставлять зонды для измерения охвата? Стратегию внедрения можно разделить на следующие три вопроса.

  • Как посчитать, сработал ли метод
  • Как считать выполнение разных веток
  • Если засчитывается выполнение исполняемого блока кода

срабатывает ли метод

С этим проще справиться, просто добавьте его в заголовок или конец метода.

  • Добавьте в конце метода: Этот вид обработки более проблематичен, и может быть несколько возвратов или бросков.Он может показать, что метод был выполнен, и он показывает, что метод над зондом был выполнен, а также показывает, что следующий оператор готов .
  • Добавление заголовка метода: Обработка очень проста, но она может только показать, что метод был введен.

Важно, выполняется зонд или нет, поэтому JaCoCo выбирает подсчет в конце метода.

Исполнение разных веток

Различные ветки относятся к операторам суждения if, операторам суждения, while, switch и т. д., и они будут переходить к разным блокам кода для выполнения, и часть кода может быть пропущена в середине.Поскольку JaCoCo работает с байт-кодом, такого рода переход. Байт-код, соответствующий инструкции передачи,GOTO, IFx, TABLESWITCH or LOOKUPSWITCH, вместе именуемыеПрыжок типа

Этот тип JUMP также имеет два разных случая, один из которых заключается в том, что условный переход не требуется, а другой - условный переход.

  • безусловный прыжок (goto), который обычно появляется непосредственно в continue, break и jumps. Это не обязательно должно охватывать ветки без перехода и операторы перехода. JaCoCo добавит зонд перед переходом, который фактически запускает описанный выше метод «Facing». относительно близко, можно увидеть, что goto — это конец метода.

  • условный переход (ifxx), что часто встречается в операторах условного перехода, таких как if.Обычно есть две ветви, которые необходимо охватить.Поток общего байт-кода ветви if, вероятно, такой, потому что байт-код выполняется последовательно, поэтому мне все еще нужна помощь гото.
function() {
    指令1
    if (){
       指令3
    } else {
       指令4
    }
    指令5
}

На рисунке ниже показана ситуация с введением зонда, зонд 1 и зонд 2 находятся в разных местах.

На самом деле, есть еще один частный случай условного перехода следующим образом.Особенность в том, что без else инструкция 3 может быть выполнена или нет.Но даже если условие ложно, это путь, который нужно считать.Но потому что условие ложно, перейти непосредственно к зонду 5, поэтому после добавления зонда 2 синий путь должен добавить переход, чтобы пропустить зонд 2. Такая фактическая обработка будет более проблематичной.

function() {
    指令1
    if (条件){
       指令3
    }
    指令5
}

JaCoCo использует лучшее решение для добавления зонда 2. То есть инвертировать условие, поставитьifизменить наifnotНа логику кода не влияет, но очень удобно добавлять щупы и goto.

Считать выполнение исполняемых блоков кода

Это относительно просто, пока зонд вставляется перед каждой строкой кода, но будет проблема.То есть проблема с производительностью, и нужно вставить большое количество зондов.Есть ли способ оптимизировать это ? Если несколько строк кода выполняются последовательно, то ставить зонды нужно только перед сегментом кода и после сегмента кода, но проблемы все равно будут, что делать, если строка кода выдает исключение? JaCoCo считает, что инструкции, не являющиеся вызовами методов, обычно имеют низкую вероятность исключений, поэтому для инструкций, не являющихся вызовами методов, пробы не вставляются, а пробы вставляются перед каждым вызовом метода. Конечно, будут проблемы, напримерNullPointerExceptionorArrayIndexOutOfBoundsExceptionВозникновение исключений может вызвать исключения в покрытии смежных инструкций, не связанных с вызовом метода.

На следующем рисунке показано исключение, вызванное a/0, но проверка выше test1() может зафиксировать int a = 10; кроме этого оператора, невозможно определить, выполнять его или нет.

Как реализовать уровень кода jacoco

В основном используется asm для модификации классов, требуется некоторое знание asm

Модификации кода

Глядя на приведенный выше декомпилированный пример, вы можете увидеть, что были внесены три конкретных изменения.

  1. Класс добавляет свойство $jacocoData
  2. В начале каждого метода добавляется локальная переменная логического массива, а для присваивания вызывается $jacocoInit.
  3. Класс добавляет метод $jacocoInit
  4. Измените элементы логического массива для оператора в методе.

Введение в классы, участвующие в модификации кода

Модификация класса реализации в основном сосредоточена в следующих классах (диаграмма взаимодействия — это только выделенный класс, и многие детали опущены)

CoverageTransformer:Это класс, который подключается к Java-агенту, который наследуетjava.lang.instrument.ClassFileTransformer, является типичным использованием агента Java.

Instrumenter:Подобно фасаду, он предоставляет метод модификации класса без особой логики реализации.Вывод модифицированного файла JaCoCo также изменяет код этого класса.

IProbeArrayStrategy:Это класс стратегии генерации логического массива. Он используется для реализации вышеуказанного свойства 1 $ jacocoData, 2 (добавить логический массив и присвоить значение) и 3 метода $ jacocoInit. Поскольку он предназначен для работы с классом и методом, поэтому в два класса обработки Его можно увидеть внутри.

Поскольку разные свойства и методы генерируются для разных ситуаций, таких как номер версии jdk класса, является ли он интерфейсом или общим классом, является ли он внутренним классом и т. д., существуют разные реализации, которые создаются следующую фабрику ProbeArrayStrategyFactory.

ProbeArrayStrategyFactory:Фабрика, отвечающая за создание IProbeArrayStrategy.

В конце также есть несколько классов, которые являются ключевыми классами для вставки зондов.

ClassProbesAdapter:Это переходник из названия, и логики особой нет.Лично дизайн здесь какой-то неразумный. Причина в том, что режим адаптера больше подходит для тех, у кого нет связи между вызывающим классом и вызываемым классом, и он может вызывать вызываемый класс только через зависимости, но хочет отделить вызываемый класс, поэтому адаптер используется в качестве посредник для защиты вызывающего класса от вызываемого класса. Зависимости классов. Но ClassProbesAdapter и вызываемый класс такие же, как родительский, и оба зависят от ClassVisitor, но есть некоторые различия между обработкой внутренних классов и обычных классов, и адаптер делает не имеет своего уникального процесса, поэтому целесообразнее использовать шаблонный режим, но и читабельность лучше.

ClassInstrumenter:Это класс агента упомянутого выше ClassProbesAdapter.Конкретная логика обработки здесь есть, но логики не так много, потому что IProbeArrayStrategy уже сделал вещи на уровне класса, и ClassInstrumenter может его вызвать.А также создать метод процессор. ClassInstrumenter — это фактически конкретная реализация, наследующая ClassProbesVisitor, а другая реализация — ProbeCounter, которая подсчитывает количество всех зондов, но не делает никакой обработки, в ProbeArrayStrategyFactory отвечает за генерацию разных классов реализации после подсчета. зондов равно 0, используйте NoneProbeArrayStategy.

MethodProbesAdapter:Он же является адаптером, который используется для поиска тех инструкций, которые нужно вставить в зонд, а затем вызвать MethodInstrumenter для его вставки.

MethodInstrumenter:Это должно решить проблему, как вставить зонд.В большинстве случаев его можно вставить напрямую, но в некоторых случаях для его вставки требуется дополнительная обработка.

ProbeInserter:Этот код отвечает за генерацию пробы вставки, такой как вставка arrbl[2] = true; и поскольку в заголовок метода добавляется локальная переменная, ему также необходимо иметь дело с некоторыми вещами уровня модификации файла класса, такими как оставшийся код все ссылки на локальные переменные должны быть изменены To +1, StackSize и т. д. Это требует некоторых базовых знаний о формате файла класса и байт-коде.

Вставьте конкретную реализацию для метода

Для упомянутой выше стратегии введения зонда мы в основном вводим реализацию нескольких пунктов:

  1. Зонд для введения хвоста метода
  2. Вставьте зонд перед goto и вставьте зонд после ifxx (все относятся к прыжку и соедините их вместе)
  3. Зонды вставляются перед вызовами методов, а зонды не вставляются для вызовов, не являющихся методами.

Зонд для введения хвоста метода

На уровне байт-кода есть две инструкции, указывающие на конец метода, то естьxRETURN or THROW, это самый простой способ вставить.

MethodProbesAdapter

@Override
	public void visitInsn(final int opcode) {
		switch (opcode) {
		case Opcodes.IRETURN:
		case Opcodes.LRETURN:
		case Opcodes.FRETURN:
		case Opcodes.DRETURN:
		case Opcodes.ARETURN:
		case Opcodes.RETURN:
		case Opcodes.ATHROW:
			probesVisitor.visitInsnWithProbe(opcode, idGenerator.nextId());
			break;
		default:
			probesVisitor.visitInsn(opcode);
			break;
		}
	}

MethodInstrumenter

	@Override
	public void visitInsnWithProbe(final int opcode, final int probeId) {
		probeInserter.insertProbe(probeId);
		mv.visitInsn(opcode);
	}

Вставьте зонд перед goto, вставьте зонд после ifxx

MethodProbesAdapter

@Override
	public void visitJumpInsn(final int opcode, final Label label) {
		if (LabelInfo.isMultiTarget(label)) {
			probesVisitor.visitJumpInsnWithProbe(opcode, label,
					idGenerator.nextId(), frame(jumpPopCount(opcode)));
		} else {
			probesVisitor.visitJumpInsn(opcode, label);
		}
	}

LabelInfo.isMultiTarget(label)Этот метод немного особенный, и он также показывает, что не ко всем прыжкам нужно добавлять зонды, это тоже небольшая оптимизация. Перед обработкой метода выполняется анализ потока управления для метода, и конкретная логика находится в разработке.org.jacoco.agent.rt.internal_43f5073.core.internal.flow.LabelFlowAnalyzerПробы требуются только для инструкций, к которым можно получить доступ несколькими путями (включая обычное последовательное выполнение или переходы). Иногда компилятор выполняет некоторые оптимизации, приводящие к добавлению перехода, например выполнения

boolean b = a > 10;

Скомпилированный код

         L6 {
             iload1
             bipush 10
             if_icmple L7
             iconst_1 //推1 到栈帧
             goto L8
         }
         L7 {
             iconst_0 //推0 到栈帧
         }
         L8 {
             istore2 //栈帧出栈并把值保存在变量中
         }

goto L8Добавлять щуп к этому goto бессмысленно, потому что сегмент L8 идет только из этой инструкции и из других мест не придет. Пробник добавляется для того, чтобы различать разные ветки. Но нет ветвления от goto L8 к сегменту L8. Поэтому ,нет необходимости добавлять Probe.Конечно,не все gotos не нужно добавлять probes.Есть и другие пути для присоединения к сегменту L8,так что это должно быть из какой ветки.На самом деле это пункт статистики jacoco, выполнение ветки - это не только Просто покрытие кода Я могу охватить весь код, но не обязательно все ветки.

MethodInstrumenter

	@Override
	public void visitJumpInsnWithProbe(final int opcode, final Label label,
			final int probeId, final IFrame frame) {
		if (opcode == Opcodes.GOTO) {
            //如果是goto则在goto前插入
			probeInserter.insertProbe(probeId);
			mv.visitJumpInsn(Opcodes.GOTO, label);
		} else {
           //如果是其他跳转语句则需要翻转if 且加入探针和goto.
			final Label intermediate = new Label();
			mv.visitJumpInsn(getInverted(opcode), intermediate);
			probeInserter.insertProbe(probeId);
			mv.visitJumpInsn(Opcodes.GOTO, label);
			mv.visitLabel(intermediate);
			frame.accept(mv);
		}
	}

Вставляйте зонды перед вызовами методов, вызовы без методов не вставляют зонды

через тот жеLabelFlowAnalyzerКакой сегмент инструкции помечен как вызов метода после анализа

LabelFlowAnalyzer

	@Override
	public void visitInvokeDynamicInsn(final String name, final String desc,
			final Handle bsm, final Object... bsmArgs) {
		successor = true;
		first = false;
		markMethodInvocationLine();
	}

	private void markMethodInvocationLine() {
		if (lineStart != null) {
			LabelInfo.setMethodInvocationLine(lineStart);
		}
	}

Пока вы знаете, что отметили его, с ним легко иметь дело.

MethodProbesAdapter

	@Override
	public void visitLabel(final Label label) {
		if (LabelInfo.needsProbe(label)) {
			if (tryCatchProbeLabels.containsKey(label)) {
				probesVisitor.visitLabel(tryCatchProbeLabels.get(label));
			}
			probesVisitor.visitProbe(idGenerator.nextId());
		}
		probesVisitor.visitLabel(label);
	}

LabelInfo

	public static boolean needsProbe(final Label label) {
		final LabelInfo info = get(label);
		return info != null && info.successor
				&& (info.multiTarget || info.methodInvocationLine);
	}

Анализируется только часть реализации ядра, а обработку trycatch, switch и т. д. можно изучить самостоятельно.

влияние на производительность

В документации JaCoCo есть введение

The control flow analysis and probe insertion strategy described in this document allows to efficiently record instruction and branch coverage. In total classes instrumented with JaCoCo increase their size by about 30%. Due to the fact that probe execution does not require any method calls, only local instructions, the observed execution time overhead for instrumented applications typically is less than 10%.

Анализ потока управления и стратегия вставки зонда, упомянутые в этой статье, могут эффективно фиксировать покрытие инструкций и ветвей.В случае, когда все классы внедряются JaCoCo, размер увеличится примерно на 30%, поскольку выполнение зонда не требует никаких методов. Вызов выполняет только локальные инструкции, поэтому затраты времени на выполнение внедряемого приложения обычно составляют менее 10 %.