How do you stop dependencies from one subproject leaking into another?


(Trejkaz (pen name)) #1

If I have a build structured like this:

build.gradle:

buildscript {
  repositories {
    jcenter()
  }
}

subprojects {
  apply plugin: 'java'

  ext.libraries = [
    log4j: [
      'log4j:log4j:1.2.17'
    ],
  ]

  repositories {
    jcenter()
  }
}

settings.gradle:

include 'project1'
include 'project2'

project1/build.gradle:

dependencies {
  compile libraries.log4j
}

project2/build.gradle:

dependencies {
  compile project(':project1')
}

project2/src/main/java/org/example/Test.java:

import org.apache.log4j.Logger;

public class Test {
    public static void main(String[] args) {
        org.apache.log4j.Logger.getLogger(Test.class).error("log something");
    }
}

Basically, project2 depends on project1. project1 depends on log4j. project2 does not depend on log4j and thus should not be able to use it.

When I run this build, the build somehow works. log4j is added as a compile-time dependency for project2 even though I did not want it.

This has happened in our real project. Many modules have dependencies which they should not have and this appears to be the cause.

How do I stop Gradle doing that?

For contrast, notice how buildr does the right thing by default.

buildfile:

repositories.remote << 'http://jcenter.bintray.com'

LOG4J = 'log4j:log4j:jar:1.2.17'

define 'dependency_leak' do
  define 'project1' do
    compile.with LOG4J
  end

  define 'project2' do
    compile.with project(:project1)
  end
end

If I now run buildr, the build downloads log4j and then fails because project2 is trying to compile and can’t find log4j, which is exactly what you would expect to happen with this dependency tree.

I don’t know why this isn’t the default behaviour, but in any case, is there a way to get the sane behaviour?


(Gary Hale) #2

Dependencies are transitive by default. You can turn off transitivity at the individual dependency, for all dependencies in a configuration, or for all configurations. To turn it off for all configurations, you can do configurations.all { transitive = false }.


(Trejkaz (pen name)) #3

We’ll definitely be doing that.

I wouldn’t mind knowing why the default behaviour was chosen to be the exact opposite of the setting which makes builds work sensibly. But a lot of things in Gradle have been like that so far and currently our build file is something like 3 times the equivalent build written in buildr.


(Trejkaz (pen name)) #4

Update on this, I have found a case where marking compile as transient has not worked.

Clone from https://github.com/trejkaz/gradle_dependency_leak_2/ for quicker reproduction.

In the main build:

configurations {
  compile.transitive = false
}

In common/build.gradle:

dependencies {
  //...
  compile libraries.icu4j
  //...
}

In util/build.gradle:

dependencies {
  //...
  compile libraries.icu4j
  //...
}

Now, we still have widespread issues where people are putting in dubious dependencies, so I have written a script which will go through your build.gradle file, deleting dependencies to see whether the build still works. The script looks like this:

#!/usr/bin/ruby

def build
  system("gradle clean compileJava > build.log 2>&1")
  #more thorough: system("gradle clean build -x test > build.log 2>&1")
end

def collect_records
  results = []
  Dir.glob('**/build.gradle') do |file|
    original_contents = File.read(file)
    matches = original_contents.scan(/^\s*(compile\s*libraries\.\S+)\s*$/m)
    results += matches.map { |match| [file] + match }
  end
  results
end

def main
  records = collect_records

  puts "Performing initial build to make sure things work before making any changes."
  t0 = Time.new
  if !build
    $stderr.puts "Can't build even before making changes, aborting."
    exit 1
  end
  t1 = Time.new
  dt = t1 - t0
  puts "It built in #{dt} seconds."
  puts "There are #{records.size} lines to check, so this should take about #{records.size * dt} seconds to run."

  records.each do |record|
    file, line = record

    original_contents = File.read(file)
    modified_contents = original_contents.gsub(line, "// #{line}")
    File.write(file, modified_contents)

    if build
      puts "Dependency line in #{file} is INCORRECT: #{line} - REMOVED FROM FILE"
    else
      puts "Dependency line in #{file} is correct: #{line}"
      File.write(file, original_contents)
    end

  end
end

if $0 == __FILE__
  main
end

So essentially, it will comment out a dependency line in the build, and try to clean and build. If the build doesn’t work, it will revert it. If the build does work, it will leave it commented out. Then at the end of the run, you have a build.gradle file which only includes the dependencies which you actually use.

But I find that when running this script inside util, it comments out the compile line for icu4j even though classes in that jar are used in util. And indeed, with this line commented out, the util build somehow still passes, even though it is importing ICU4J classes in the code. So clearly the files from ICU4J are leaking from the common module into the util module, even though we specified transitive = true.

I would like a way for the dependencies we specify to actually be honoured exactly as we specified them. Really I think that this should be the default and continue to question why people would want a build tool that doesn’t do what they want.

This was occurring against Gradle 2.12 but we just updated to 2.14 and it’s misbehaving in the same way.