Search code examples
javajsonmavenintellij-ideajavac

JSON ObjectMapper with javac "-parameters" behaves when run via maven, not via InteliJ IDEA


As you can probably gather from the title, this is a somewhat complicated issue.

First of all, my goal:

  • I am trying to achieve conversion of my java classes to and from JSON without having to add any json-specific annotations to them.

  • My java classes include immutables, which must initialize their members from parameters passed to the constructor, so I have to have multi-parameter constructors that work without @JsonCreator and without @JsonParameter.

  • I am using the jackson ObjectMapper. If there is another ObjectMapper that I can use that works without the problem described herein, I'd be happy to use it, but it would have to be equally reputable as the jackson ObjectMapper. (So, I am not willing to download Jim's ObjectMapper from his GitHub.)

My understanding as to how this can actually be achieved, in case I am wrong somewhere:

Java used to make method (and constructor) parameter types discoverable via reflection, but not parameter names. That's why the @JsonCreator and @JsonParameter annotations used to be necessary: to tell the json ObjectMapper which constructor parameter corresponds to which property. With Java 8, the compiler will emit method (and constructor) parameter names into the bytecode if you supply the new -parameters argument, and will make them available via reflection, and recent versions of the jackson ObjectMapper support this, so it should now be possible to have json object mapping without any json-specific annotations.

I have this pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>test</groupId>
    <artifactId>test.json</artifactId>
    <version>1.0-SNAPSHOT</version>
    <name>Json Test</name>
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
    <build>
        <sourceDirectory>main</sourceDirectory>
        <testSourceDirectory>test</testSourceDirectory>
        <plugins>
            <plugin>
                <!--<groupId>org.apache.maven.plugins</groupId>-->
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.5</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                    <!--<compilerArgument>-parameters</compilerArgument>-->
                    <!--<fork>true</fork>-->
                    <compilerArgs>
                        <arg>-parameters</arg>
                    </compilerArgs>
                </configuration>
            </plugin>
        </plugins>
    </build>
    <dependencies>
        <dependency>
            <groupId>com.fasterxml.jackson.jaxrs</groupId>
            <artifactId>jackson-jaxrs-json-provider</artifactId>
            <version>2.7.2</version>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.module</groupId>
            <artifactId>jackson-module-parameter-names</artifactId>
            <version>2.7.2</version>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.11</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

And I use it to compile and run the following little self-contained program:

package jsontest;

import com.fasterxml.jackson.annotation.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;

import java.io.IOException;
import java.lang.reflect.*;

public final class MyMain
{
    public static void main( String[] args ) throws IOException, NoSuchMethodException
    {
        Method m = MyMain.class.getMethod("main", String[].class);
        Parameter mp = m.getParameters()[0];
        if( !mp.isNamePresent() || !mp.getName().equals("args") )
            throw new RuntimeException();
        Constructor<MyMain> c = MyMain.class.getConstructor(String.class,String.class);
        Parameter m2p0 = c.getParameters()[0];
        if( !m2p0.isNamePresent() || !m2p0.getName().equals("s1") )
            throw new RuntimeException();
        Parameter m2p1 = c.getParameters()[1];
        if( !m2p1.isNamePresent() || !m2p1.getName().equals("s2") )
            throw new RuntimeException();

        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule( new ParameterNamesModule() ); // "-parameters" option must be passed to the java compiler for this to work.
        mapper.configure( DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true );
        mapper.configure( SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true );
        mapper.setSerializationInclusion( JsonInclude.Include.ALWAYS );
        mapper.setVisibility( PropertyAccessor.ALL, JsonAutoDetect.Visibility.PUBLIC_ONLY );
        mapper.enableDefaultTyping( ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY );

        MyMain t = new MyMain( "1", "2" );
        String json = mapper.writeValueAsString( t );
        /*
         * Exception in thread "main" com.fasterxml.jackson.databind.JsonMappingException: No suitable constructor found for type [simple type, class saganaki.Test]: can not
         * instantiate from JSON object (missing default constructor or creator, or perhaps need to add/enable type information?)
         */
        t = mapper.readValue( json, MyMain.class );
        if( !t.s1.equals( "1" ) || !t.s2.equals( "2" ) )
            throw new RuntimeException();
        System.out.println( "Success!" );
    }

    public final String s1;
    public final String s2;

    public MyMain( String s1, String s2 )
    {
        this.s1 = s1;
        this.s2 = s2;
    }
}

Here is what happens:

  • If I compile the program using mvn clean compile and then run or debug it from within Idea, it works fine and it displays "Success!".

  • If I do "Rebuild Project" from within Intellij Idea and then I run/debug, it fails with a JsonMappingException that "No suitable constructor found for simple type jsontest.MyMain".

  • The strange thing (to me) is, the code before the instantiation of the ObjectMapper checks to make sure that constructor parameter names are present and valid, effectively ensuring that the "-parameters" argument has been successfully passed to the compiler, and these checks always pass!

  • If I edit my "debug configuration" in Idea and in the "Before launch" section I remove "Make" and I replace it with "Run maven goal" compile then I can successfully run my program from within Idea, but I do not want to do have to do this. (Also, it does not even work very well, I guess I must be doing something wrong: quite often I run and it fails with the same exception as above, and the next time I run it succeeds.)

So, here are my questions:

  • Why does my program behave differently when compiled by maven than when compiled with Idea?

    • More specifically: what is ObjectMapper's problem given that my assertions prove that the "-parameters" argument was passed to the compiler, and arguments do have names?
  • What can I do to make Idea compile my program the same way as maven (at least with respect to the problem at hand) without replacing Idea's "Make"?

  • Why does it not work consistently when I replace the default "Make" with "Run maven goal" compile in Idea's debug configuration? (What am I doing wrong?)

EDIT

My apologies, the assertions were not necessarily proving anything, since they were not necessarily enabled with -enableassertions. I replaced them with if() throw RuntimeException() to avoid confusion.


Solution

  • As far as I can see in the IntelliJ Community edition sources, IntelliJ is not doing anything with the compilerArgs you're specifying.

    In MavenProject.java, there are two places where the compilerArgs are being read:

    Element compilerArguments = compilerConfiguration.getChild("compilerArgs");
    if (compilerArguments != null) {
      for (Element element : compilerArguments.getChildren()) {
        String arg = element.getValue();
        if ("-proc:none".equals(arg)) {
          return ProcMode.NONE;
        }
        if ("-proc:only".equals(arg)) {
          return ProcMode.ONLY;
        }
      }
    }
    

    and

    Element compilerArgs = compilerConfig.getChild("compilerArgs");
    if (compilerArgs != null) {
      for (Element e : compilerArgs.getChildren()) {
        if (!StringUtil.equals(e.getName(), "arg")) continue;
        String arg = e.getTextTrim();
        addAnnotationProcessorOption(arg, res);
      }
    }
    

    The first code block is only looking at the -proc: argument, so this block can be ignored. The second one is passing the values of the arg element (which you are specifying) to the addAnnotationProcessorOption method.

    private static void addAnnotationProcessorOption(String compilerArg, Map<String, String> optionsMap) {
      if (compilerArg == null || compilerArg.trim().isEmpty()) return;
    
      if (compilerArg.startsWith("-A")) {
        int idx = compilerArg.indexOf('=', 3);
        if (idx >= 0) {
          optionsMap.put(compilerArg.substring(2, idx), compilerArg.substring(idx + 1));
        } else {
          optionsMap.put(compilerArg.substring(2), "");
        }
      }
    }
    

    This method is only processing arguments which start with -A, which are used to pass options to the annotation processors. Other arguments are ignored.

    Currently, the only ways to get your sources to run from within IntelliJ are to enable the flag yourself in the "Additional command line parameters" field of the compiler settings (which isn't portable), or by compiling with maven as a pre-make step in your run configuration. You probably have to file an issue with Jetbrains if you want this to be possible in IntelliJ automatically.