How to support different versions of main (and test) source sets for different Java versions (6, 7, 8)
Asked Answered
L

8

8

I have a library project in Java. I want to implement, test, and probably release several versions of the project, intented to be used with different Java versions: 6, 7, 8.

The simpliest way is just to copy-paste project and support several source trees, but I want to avoid this because It's tedious and error-prone.

Another possible way is to factor "base" project, and several Java version specific projects depending. Versions differs very slightly, but I don't what to reflect this technical development issue in class hierarchy.

So I'm looking for

  • a kind of precompilers
  • and/or standard Maven options
  • and/or Maven plugins

which could help to support several Java version-specific versions of the library from a single source tree, and transparently for the lib users.

Luca answered 4/10, 2014 at 17:24 Comment(12)
what are the specific cases you need different versions for? If they are few can use System.getProperty("java.version") at run time and instantiate differnt sub clases as need be.Styx
@Styx sometimes people need that: search.maven.org/#search%7Cga%7C1%7Cjdk Runtime checks are not always applicable, because I might need to change not only method bodies, but signatures also.Luca
i was not arguing if anyone would need or not. wanted you to give a sample in case it makes a reader think of some alternate solution. like in log4j2 i remeber reading they have some code that is based on newer versions of java but they commit that out via a constant. maybe the constant is set by a script during compile based on the target environment of compile. then java will ignore some codeStyx
see igor link 3rd answer #188050 might helpStyx
@Styx looks very similar to what I expected to discover. make an answer from your comment.Luca
gradle may have some facilities for dealing with thisIsolda
@RayTayek yes, I already do this with Gradle using my own precompiler. But I'm looking for Maven toolLuca
You should think to a runtime compilation using com.sun.tools.javac.Main.compile()Aventine
leventov - check my updated answer below on how to set different dependencies on each buildOkechuku
Why not choose an answer as right? see faq on whyStyx
@Styx I upvoted 6 answers in this thread, but doesn't feel any one comprehensively answers my question, to be choosen as right.Luca
alright. i saw a bounty so i thought that one solved itStyx
O
8

You can generate one jar for each Java version (6, 7, 8) from a single pom.xml file.

All the relevant work takes place in the maven-compiler-plugin:compile mojo.

The trick is to execute the mojo 3 times, each time writing the resulting file to a different outputFileName. This will cause the compiler to run multiple times, each time using different versions and spitting an appropriately named file.

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.1</version>
    <executions>
        <execution>
            <id>compile-1.6</id>
            <goals>
                <id>compile</id>
            </goals>
            <phase>compile</phase>
            <configuration>
                <source>1.6</source>
                <target>1.6</target>
                <executable>${env.JAVA_HOME_6}/bin/javac</executable>
                <outputFileName>mylib-1.6.jar</outputFileName>
            </configuration>
<!-- START EDIT -->
            <dependencies>
                <dependency>
                    <groupId>com.mycompany</groupId>
                    <artifactId>ABC</artifactId>
                    <version>1.0</version>
                </dependency>
            </dependencies>
<!-- END EDIT -->
        </execution>
        <execution>
            <id>compile-1.7</id>
            <phase>compile</phase>
            <goals>
                <id>compile</id>
            </goals>
            <configuration>
                <source>1.7</source>
                <target>1.7</target>
                <executable>${env.JAVA_HOME_7}/bin/javac</executable>
                <outputFileName>mylib-1.7.jar</outputFileName>
            </configuration>
<!-- START EDIT -->
            <dependencies>
                <dependency>
                    <groupId>com.mycompany</groupId>
                    <artifactId>XYZ</artifactId>
                    <version>2.0</version>
                </dependency>
            </dependencies>
<!-- END EDIT -->
        </execution>

        <!-- one more execution for 1.8, elided to save space -->

    </executions>
</plugin>

Hope that helps.

EDIT

RE: additional requirement that each run compile against different sources.

See edits to pom snippet above.

Each execution can define its own dependencies library list.

So JDK6 build depends on ABC.jar but JDK7 depends on XYZ.jar.

Okechuku answered 10/10, 2014 at 9:45 Comment(2)
Thanks this is helpful. However the main issue about precompiling the sources remains - classes for different Java versions should be compiled from slightly different sources.Luca
@Luca please check my updated answer on how to set different dependencies on each buildOkechuku
T
4

You can conditionally include some source directories by creating separate profiles for each java version. Than you can run maven with profile name which will define which sources for which version you would like to use. In this case all common sources remain in src/main/java, while java version dependant files are placed in /src/main/java-X.X directories.

<profiles>
    <profile>
        <id>java-6</id>
        <build>
            <plugins>
                <plugin>
                    <groupId>org.codehaus.mojo</groupId>
                    <artifactId>build-helper-maven-plugin</artifactId>
                    <version>1.5</version>
                    <executions>
                        <execution>
                            <id>add-sources</id>
                            <phase>generate-sources</phase>
                            <goals>
                                <goal>add-source</goal>
                            </goals>
                            <configuration>
                                <sources>
                                    <source>${basedir}/src/main/java-1.6</source>
                                </sources>
                            </configuration>
                        </execution>
                    </executions>
                </plugin>
            </plugins>
        </build>
    </profile>
    <profile>
        <id>java-7</id>
        (...)
        <source>${basedir}/src/main/java-1.7</source>
    </profile>
    <profile>
        <id>java-8</id>
        (...)
        <source>${basedir}/src/main/java-1.8</source>
    </profile>
</profiles>

You can probably do this even more dynamic by replacing hardcoded java-X.X by property which you will pass to maven together with profile. This would be something like:

    <profile>
        <id>conditional-java</id>
        (...)
        <source>${basedir}/src/main/java-${my.java.version}</source>
    </profile>
</profiles> 

And later when you run it you just pass mvn -Pconditional-java -Dmy.java.version=1.6.

This requires you to put java version dependant files in separate directories. In your IDE when you develop against specific version of java simply mark directory relevant to your java version as source folder (because by default IDEs will only recognize src/main/java as source dir).

The same way you can pass the compiler level to maven compiler plugin:

<project>
  [...]
  <build>
    [...]
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.1</version>
        <configuration>
          <source>${my.java.version}</source>
          <target>${my.java.version}</target>
        </configuration>
      </plugin>
    </plugins>
    [...]
  </build>
  [...]
</project>
Tautomerism answered 15/10, 2014 at 14:49 Comment(0)
S
3

One way is embedded in code : From log4j2 source code :

   //  JDK 1.1 doesn't support readResolve necessary for the assertion 
   if (!System.getProperty("java.version").startsWith("1.1.")) {
        assertTrue(obj == Level.INFO);
    }

You could also use https://github.com/raydac/java-comment-preprocessor and set variables based on java version to change code. Though would do this in as few places as it will be difficult to debug. Or at least print a log before the dynamic code is run so you know which version / real line has issue.

Styx answered 5/10, 2014 at 7:54 Comment(0)
E
3

Why don't you collect the methods that are supposed to be different in each version of java, wrap them in a "utility" project, make your own api that your main code can call, and on distribution time add whichever utility jar you want?

something like:

util-api.jar (methods that your main project calls)

util-1.6.jar (whichever implementation applies, even "no operation" if needed when nothing is to be done)

I've successfully done this many times with similar problems as the one you have now.

Erythrocyte answered 10/10, 2014 at 12:4 Comment(3)
If this could be the solution, tgkprog's runtime suggestion could also be. Even not "internal" util, the API of my library itself slightly differs between versions of Java. Read the comments to the question.Luca
man - if even your library's API is different, then I would use a different approach. I would have 3 (as many as java versions I mean) different projects, unit-test them for the specifics of THAT version, and keep only that specific code on each project. Then have a "commons" project with what is the same, and unit-test the commons using the different java versions to always check it's good for all of them. I understand I'm preaching to the choir here, and you want some advanced tools stuff, but seems to me a KISS approach would save you lots of head scratching in the future.Erythrocyte
I described why don't like this approach in the question body. However if I won't find good tools I will stick with it.Luca
B
3

The guys working on Hibernate wrestled with this very problem for some time before deciding to migrate to a Gradle based build.

Have a look at Gradle: why? for more information.

Bike answered 16/10, 2014 at 5:33 Comment(0)
C
2

For something like this, I would

  • move JVM-version dependent files into their own package
  • alternatively, use a naming convention to identify JVM-version specific files
  • use Ant, instead of Maven, for compilation. Ant makes it easy to exclude source files based on name or location. You can also easily create several targets with Ant.
  • if the library uses the same API for all JVM versions, I would create interfaces for the JVM-specific classes and then instantiate the appropriate implementations at runtime depending on JVM version.
Cleocleobulus answered 15/10, 2014 at 14:59 Comment(5)
No, the API is different. Example. Java 6: Api.forEach(org.mylib.Consumer). Java 8: org.mylib.Consumer extends java.util.function.Consumer {}; Api.forEach(j.u.f.Consumer)Luca
Then I would use one source directory for common files and one per JVM version for the JVM-specific files. And ant to compile this.Cleocleobulus
All files are common. As you see, the difference is negligable, API is changed, but the implementation code could be completely the same. That is why I don't want to copy-paste files and support these versions separately, as I wrote in the question body from the very beginning.Luca
Why is your API different? The whole point of an API is that it's the interface that is same regardless of the underlying implementation. In the example you listed, where in Java 6 the signature is Api.forEach(org.mylib.Consumer) and in Java 8 it's Api.forEach(j.u.f.Consumer) is wrong. The API's method is still Api.forEach(org.mylib.Consumer). Api.forEach(j.u.f.Consumer) is a DIFFERENT method, though it might do nothing more than construct an org.mylib.Consumer instance and call the other method...Complacent
@SteveK because 1) I must have Api.forEach(j.u.f.Consumer) because Api implements Iterable. 2) If I leave both methods separately, there will be ambiguity and user will be required always to cast lambda to specific interface, that is very inconvenient.Luca
C
1

What you need to do is define a basic API, and then add the capabilities available to the new versions of Java as extra methods (which can be called from your original API methods). This allows the basic integrity of your API to remain the same from the old implementation to the new.

For example:

public void forEach(Consumer<T> c);

is your original API method, where Consumer is your org.mylib.Consumer. You can do two things with it in Java 8. You can keep your old implementation and add a new method as a convenience method for that one, or you can add your new implementation and add a defender method which calls the new one. Either way, legacy code implementing your API will remain valid.

Convenience method example:

// No need to change the old Java 6 style implementation
public void forEach(Consumer<T> c); 

default void forEach(java.util.function.Consumer<T> c){
    // Your Consumer needs a constructor for j.u.f.C
    Consumer<T> myC = new Consumer<T>(c);
    forEach(myC);
}

Defender method example:

// Consumer extends j.u.f.C so this is more specific and will override
default void forEach(Consumer<T> c){ 
    // All we need to call the other method is an explicit cast
    forEach((java.util.function.Consumer<T>) c);
}

//All new Java 8 style implementation here
public void forEach(java.util.function.Consumer<T> c);

Obviously for the Java 7 version you can't define the default methods on the Interface, so you'll have to implement those methods in abstract base classes instead, but the concept is pretty much the same. Theoretically, in Java 8 you wouldn't need the defender, because the call can be exactly the same, since any valid instance of your Consumer is a valid instance of j.u.f.C.

Java 8 for the first time means it's perfectly valid to add new methods to an existing interface. You really shouldn't have differing API signatures between Java 6 and 7 though.

For Java 6 and 7, simply avoid the following things in your source code and you can compile to both:

  • The diamond operator <>. It's convenient, but not using it means one codebase for J6 and J7
  • switch statements (In nearly all cases, if you have a switch statement, you probably should have multiple classes and a factory for them instead).
  • try-with-resources. It's one of my favorite features of J7, but finally blocks are still perfectly valid.
  • multi-exception catch.
  • The J7 Path api - the old File API is better for legacy coding
  • Fork and Join API

I am not really aware of any code written for J6 that will not run on a J8 JVM if compiled for J8, but I understand that you probably wish for the newer versions of the library to take advantage of the J8 improvements like streams and lambdas.

You might also want to look into the Java Services provider api from Java 7. It would allow you to define your API in a base module and install the implementations as Java Services plugin jar files which can be detected by the JVM so long as they're in the classpath of your application. Then you can simply define new service plugin jars as new features become available. Of course this doesn't help your Java 6 implementation, but you could use your J6 API as the base, add the services add-on capability for your J7 API, and just keep adding and replacing services as the JVM is updated.

Complacent answered 17/10, 2014 at 2:54 Comment(1)
Having both Api#forEach(org.mylib.Consumer) and Api#forEach(j.u.f.Consumer) is not viable, because each time the user writes api.forEach(System.out::println), he will get a compilation error. He should write api.forEach((j.u.f.Consumer) System.out::println) that is ugly.Luca
C
1

I added another answer here in response to Leventov's last comment about requiring an explicit cast, and am offering this advice. It may or may not be bad practice, but I have found it very useful for defining my own interfaces in some cases where I wanted to inject some pre-or-post processing or provide my own layer of abstraction overtop of somebody else's (for instance, we defined one like this for Hibernate's Work classes so that if Hibernate's API changes in the future, our implementations don't have to - only the default method in our Interface), and it might make life a bit easier with the two versions of the same interface.

Consider this: The beauty of the functional interface is that it has only one abstract method, so you can pass a lambda as that method's implementation.

But when you extend an interface, which you still want to have that same functionality (pass a lambda, and work in all cases), another beauty of Java 8 comes into play: Defender methods. Nothing anywhere says that you have to leave the SAME method abstract as the parent class did. You can extend an interface as a sort of interceptor interface. So you can define YOUR MyConsumer like this:

public interface MyConsumer<T> extends Consumer<T> {
    default void accept(T t){ // Formerly the abstract functional method
        getConsumer().accept(t);
    }
    public Consumer<T> getConsumer(); //Our new abstract functional method
}

Our interface, instead of defining accept(T) as an abstract method, implements accept(T), and defines an abstract method getConsumer(). This makes the lambda to instantiate a MyConsumer different from the one required to instantiate a j.u.f.Consumer, and removes the compiler's class conflict. Then you can define your class that implements Iterable to instead implement your own custom interface extending Iterable.

public interface MyIterable<T> extends Iterable<T> {    
    default void each(MyConsumer<? super T> action){
        //Iterable.super.forEach((Consumer<? super T>) action);
        //
        // Or whatever else we need to do for our special
        // class processing
    }       
    default void each(Consumer<? super T> action){
        if (action instanceof MyConsumer){
            each((MyConsumer<? super T>) action);
        } else {
            Iterable.super.forEach(action);
        }
    }
    @Override
    default void forEach(Consumer<? super T> action){
        each(action);   
    }
}

It still has a forEach method, allowing it to comply to the iterable iterface, but all of your code can call each() instead of forEach() in all versions of YOUR api. This way you also partially future-proof your code - if the underlying Java api were to be deprecated years down the line, you could modify your default each() method to do things the new way, but in every other place all your existing implementation code will still be functionally correct.

Thus, when you call api.each, instead of requiring an explicit cast, you simply pass the lambda for the other method... in MyConsumer, the method returns a consumer, so your lambda is really simple, you just add the lambda zero-arg constructor to your previous statement. The accept() method in Consumer takes one argument and returns a void, so if you define it with no arguments, Java knows that it wants an interface that has an abstract method which takes no arguments, and this lambda instantiates MyConsumer.

api.each(()->System.out::println);

while this one instantiates j.u.f.Consumer

api.each(System.out::println);

Because the original abstract method (accept) is there, and implemented, it's still a valid instance of Consumer, and will work in all cases, but because we called the 0-arg constructor, we've explicitly made it an instance of our custom interface. This way, we still fulfill the interface contract of Consumer, but we can differentiate our interface's signature from Consumer's.

Complacent answered 20/10, 2014 at 1:45 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.