[JAVA] Rails was difficult, so I made something like a controller of Spring Framework to take a break

This article is the 19th day of Back freee Developers Advent Calender 2018.

Hello. I'm tamura shingo doing GYOMU hack on freee. I use Ruby at work, but I'm going to write about Java because it's behind the scenes.

Motivation

--Ruby on Rails was difficult to route --Spring Framework is still good ~ ――But how much did you understand the Spring Framework?

So I decided to make something like a controller.

Spring-like guy → Before Spring → Before Spring → New Year? → I'm so happy, let's boil the seven herbs So it's Nanakusagayu Framework.

https://github.com/tamurashingo/NanakusagayuFramework

Since it is not registered with Maven, you can use it by downloading and mvn install.

procedure

  1. Read all the class files under a certain package
  2. Find the @Controller annotation
  3. Find the @ GET annotation (only GET is supported this time)
  4. Create a mapping between the path and the method to call
  5. Jetty launch
  6. When there is access, start the method according to the mapping in 4.

Annotation for the time being

Controller.java


@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Controller {
    String value() default "";
}

GET.java


@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface GET {
    String value() default "";
}

Leave it as RetentionPolicy.RUNTIME.

Get the class file under the package

This is the configuration.

image.png

Initialization main

Initializer.java


/**
 *Get a list of classes under the package to which the reference class belongs
 *
 */
public class Initializer {

    /**
     *Specify the reference class and get the class list.
     *
     * @param className Base class name
     * @return class list
     * @throws InitializerException Failed to get class list
     */
    public List<String> getClassList(String className) throws InitializerException {
        return getClassnames(className);
    }

    /**
     *Get the class file name from the class information
     * @param cls class information
     * @return file name
     */
    private String getPathnameFromClass(Class<?> cls) {
        return cls.getCanonicalName().replace(".", "/") + ".class";
    }

    private List<String> getClassnames(String className) throws InitializerException {
        try {
            //Get the location of the class from the class name
            Class<?> baseClass = Class.forName(className);
            ClassLoader cl = baseClass.getClassLoader();
            String pathname = getPathnameFromClass(baseClass);
            URL url = cl.getResource(pathname);

            if (url == null) {
                throw new InitializerException("not found class:" + baseClass.getCanonicalName());
            }

            //Get the class name crawler from the class and its location
            AbstractClassnameCrawler parser = ClassnameCrawlerFactory.create(baseClass, url);
            //Get a list of class names
            return parser.getClassnameList();
        } catch (ClassNotFoundException ex) {
            throw new InitializerException("Initialization failure", ex);
        }
    }
}

If you pass a class that is the basis for loading a package, you will get a list of classes under that package. The reading method is slightly different depending on whether the standard class is in the jar file or in a directory like classes, so I created ʻAbstractClassnameClawer` and processed it there.

Next, let's look at the implementation when it is in a jar file. (The file is almost the same)

Get the full class name from the Jar

ClassnameCrawlerFromJar.java


public class ClassnameCrawlerFromJar extends AbstractClassnameCrawler {

    /**The package to which the reference class belongs*/
    private String basePackageName;

    public ClassnameCrawlerFromJar(Class<?> baseClass, URL baseUrl) {
        super(baseClass, baseUrl);
        this.basePackageName = baseClass.getPackage().getName();
    }

    /**
     *File and extension.class or not
     */
    Predicate<JarEntry> isClassfile = jarFile -> !jarFile.isDirectory() && jarFile.getName().endsWith(".class");

    /**
     *Whether the package (subordinate) to which the standard class belongs
     */
    Predicate<JarEntry> hasPackage = jarFile -> jarFile.getName().replace("/", ".").startsWith(basePackageName);

    /**
     * JarEntry(com/github/xxxx/xxx/XXXX.class)The class name(com.github.xxxx.xxx.XXXX)Convert to
     */
    Function<JarEntry, String> convertFilename = jarFile -> {
        String filename = jarFile.getName();
        // com/github/xxxx/xxx/XXXX.class -> com/github/xxxx/xxx/XXXX
        filename = filename.substring(0, filename.lastIndexOf(".class"));
        // com/github/xxxx/xxx/XXXX -> com.github.xxxx.xxx.XXXX
        return filename.replace("/", ".");
    };

    @Override
    public List<String> getClassnameList() throws InitializerException {
        String path = baseUrl.getPath(); // file:/path/to/jarfile!/path/to/class
        String jarPath = path.substring(5, path.indexOf("!")); // /path/to/jarfile
        try (JarFile jar = new JarFile(URLDecoder.decode(jarPath, StandardCharsets.UTF_8.name()))) {
            Enumeration<JarEntry> entries = jar.entries();
            return Collections.list(entries).stream()
                    .filter(isClassfile)
                    .filter(hasPackage)
                    .map(convertFilename)
                    .collect(Collectors.toList());
        } catch (IOException ex) {
            throw new InitializerException("File read error:" + jarPath, ex);
        }
    }
}

Let's take a look at getClassnameList.

I get the actual file name of the jar (/ path / to / jarfile) from the URL of the jar (file: / path / to / jarfile! / Path / to / class) and create a JarFile object.

Collections.list(entries).stream()
    .filter(isClassfile)
    .filter(hasPackage)
    .map(convertFilename)
    .collect(Collectors.toList());

--The one with the extension .class --A directory delimiter (/) with a dot (.) Starting with the base package name --A fully qualified name generated from the class name --Collect in a list

I am doing that.

Get controller

Now that you have a list of file names, get the controller from this list. It's a little long, but I'll put it all at once.

ControllerScanner.java


public class ControllerScanner implements ComponentScanner {

    /**
     *Rooting information
     * String: /path
     * Object[0]: Controller instance
     * Object[1]: method instance
     */
    private Map<String, Object[]> pathMethodMap = new HashMap<>();

    public Map<String, Object[]> getRoute() {
        return this.pathMethodMap;
    }

    @Override
    public void componentScan(Class<?> cls) throws InitializerException {
        Controller controller = cls.getAnnotation(Controller.class);
        if (controller == null) {
            return;
        } else {
            createController(cls, controller);
        }
    }

    /**
     *Create routing information
     *
     * @param cls
     * @param controller
     * @param <T>
     * @throws InitializerException
     */
    private <T> void createController(Class<?> cls, Controller controller) throws InitializerException {
        T inst = createInst(cls);
        getPathAndMethod(inst, controller.value());
    }

    /**
     *Instantiate a class.
     * (now)Only the default constructor is supported.
     *
     * @param cls class information
     * @param <T>Dummy parameters
     * @return instance
     * @throws InitializerException Instantiation failed
     */
    private <T> T createInst(Class<?> cls) throws InitializerException {
        try {
            return (T)cls.getDeclaredConstructor().newInstance();
        } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException ex) {
            throw new InitializerException("Controller creation failed:" + cls.getCanonicalName(), ex);
        }
    }

    /**
     *Get the GET method and create the routing
     * @Instance of param inst Controller
     * @path defined by param basePath Controller
     * @param <T>Dummy information
     * @throws InitializerException
     */
    private <T> void getPathAndMethod(T inst, final String basePath) throws InitializerException {
        Class<?> cls = inst.getClass();

        for (Method method: cls.getDeclaredMethods()) {
            GET get = method.getAnnotation(GET.class);
            if (get == null) {
                continue;
            }
            String path = get.value();

            StringBuilder buf = new StringBuilder();
            if (basePath.isEmpty()) {
                if (path.isEmpty()) {
                    buf.append("/");
                } else if (!path.startsWith("/")) {
                    buf.append("/").append(path);
                } else {
                    buf.append(path);
                }

            } else {
                if (!basePath.startsWith("/")) {
                    buf.append("/");
                }
                buf.append(basePath);
                if (!path.isEmpty()) {
                    if (!path.startsWith("/")) {
                        buf.append("/");
                    }
                    buf.append(path);
                }
            }

            pathMethodMap.put(buf.toString(), new Object[]{ inst, method });
        }
    }
}

Let's take a closer look.

componentScan

@Override
public void componentScan(Class<?> cls) throws InitializerException {
    Controller controller = cls.getAnnotation(Controller.class);
    if (controller == null) {
       return;
    } else {
       createController(cls, controller);
    }
}

Bring the Controller annotation, if any, incls.getAnnotation (Controller.class).

createController and createInst

private <T> void createController(Class<?> cls, Controller controller) throws InitializerException {
    T inst = createInst(cls);
    getPathAndMethod(inst, controller.value());
}

/**
 *Instantiate a class.
 * (now)Only the default constructor is supported.
 *
 * @param cls class information
 * @param <T>Dummy parameters
 * @return instance
 * @throws InitializerException Instantiation failed
 */
private <T> T createInst(Class<?> cls) throws InitializerException {
    try {
        return (T)cls.getDeclaredConstructor().newInstance();
    } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException ex) {
        throw new InitializerException("Controller creation failed:" + cls.getCanonicalName(), ex);
    }
}

The method has <T> at the beginning, but it is a dummy to deceive the compiler. I'm getting the default constructor and instantiating it.

It seems that it is recommended to DI with the constructor in the recent Spring Framework, but for that purpose it is difficult for the head family because it is necessary to investigate various types of constructors.

getPathAndMethod

/**
 *Get the GET method and create the routing
 * @Instance of param inst Controller
 * @path defined by param basePath Controller
 * @param <T>Dummy information
 * @throws InitializerException
 */
private <T> void getPathAndMethod(T inst, final String basePath) throws InitializerException {
    Class<?> cls = inst.getClass();

    for (Method method: cls.getDeclaredMethods()) {
        GET get = method.getAnnotation(GET.class);
        if (get == null) {
            continue;
        }
        String path = get.value();

        StringBuilder buf = new StringBuilder();
        if (basePath.isEmpty()) {
            if (path.isEmpty()) {
                buf.append("/");
            } else if (!path.startsWith("/")) {
                buf.append("/").append(path);
            } else {
                buf.append(path);
            }

        } else {
            if (!basePath.startsWith("/")) {
                buf.append("/");
            }
            buf.append(basePath);
            if (!path.isEmpty()) {
                if (!path.startsWith("/")) {
                    buf.append("/");
                }
                buf.append(path);
            }
        }

        pathMethodMap.put(buf.toString(), new Object[]{ inst, method });
    }
}

First, get the list of methods with the GET annotation. After that, the actual path is generated by combining the path defined in Controller and the path defined in GET. Store the path and the instance and method to start when the path is last accessed in map.

Router

The one who receives the request and calls the method of the appropriate instance. I am making it to work with Jetty.

Rotuer.java


public class Router extends AbstractHandler {

    /**
     *Routing information
     * String: /path
     * Object[0]: Controller instance
     * Object[1]: method instance
     */
    private Map<String, Object[]> routing;

    public Router(Map<String, Object[]> routing) {
        this.routing = routing;
    }

    @Override
    public void handle(String s, Request request, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws IOException, ServletException {
        if (!routing.containsKey(s)) {
            throw new ServletException("page not found");
        }
        Object[] inst = routing.get(s);

        try {
            Method method = (Method) inst[1];
            httpServletResponse.setStatus(HttpServletResponse.SC_OK);
            method.invoke(inst[0], httpServletRequest, httpServletResponse);
            request.setHandled(true);
        } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException ex) {
            throw new ServletException(ex);
        }
    }
}

I won't explain it at all, but with Jetty, the path comes as the first argument, so it's easy not to look into HttpServletRequest.

Starting point

This is the so-called SpringApplication.run (xxx.class, args) part.

NanakusagayuApplication.java


public class NanakusagayuApplication {

    private static ControllerScanner controllerScanner = new ControllerScanner();

    public static void run(Class<?> cls, String[] args) throws Exception {
        Initializer init = new Initializer();

        //Get class list
        List<String> classList = init.getClassList(cls.getCanonicalName());
        init(classList);

        startServer();
    }

    private static void init(List<String> classList) throws InitializerException {
        ComponentScanner[] scannerList = new ComponentScanner[] {
                controllerScanner
        };

        try {
            for (String clsName : classList) {
                Class<?> cls = Class.forName(clsName);
                for (ComponentScanner scanner : scannerList) {
                    scanner.componentScan(cls);
                }
            }
        } catch (ClassNotFoundException ex) {
            throw new InitializerException(ex);
        }
    }

    private static void startServer() throws Exception {
        Server server = new Server(3344);
        server.setHandler(new Router(controllerScanner.getRoute()));
        server.start();
        server.join();
    }
}

We have prepared some ComponentScanners so that we can handle other than controllers. (But it is undeniable that it is a little omission)

Run

Make a controller ...

TestController.java


@Controller("/test")
public class TestController {

    @GET("/say")
    public void hello(HttpServletRequest req, HttpServletResponse res) throws IOException {
        res.setContentType("text/html; charset=UTF-8");
        PrintWriter out = res.getWriter();
        out.println("<h1>Hello</h1>");
    }
}

Make the main

Main.java


public class Main {
    public static void main(String...args) throws Exception {
        NanakusagayuApplication.run(Main.class, args);
    }
}

If you run it and access http: // localhsot: 3344 / test / say ...

待望のhello world

came.

in conclusion

--Controller constructor allows only default constructor --Spring Framework also allows constructor injection --Controller method arguments are fixed to HttpServletRequest, HttpServletResponse --Spring Framework can also convert and pass requests in advance such as beans and path parameters --Only supports GET --Spring Framework is also available for POST and PUT

Despite the above restrictions, it was quite difficult. However, I felt that I could do this with standard Java functions, rather than playing with bytecode.

I really wanted to make an executable FatJar with mvn package, but I gave up because I couldn't make it by the Advent calendar.

Tomorrow 20th, kei-0226 will talk about confronting difficult domains. I'm looking forward to it.

Recommended Posts

Rails was difficult, so I made something like a controller of Spring Framework to take a break
I made a SPA with Rails + Nuxt.js for half a year of self-study, so please take a look.
I was a little addicted to the S3 Checksum comparison, so I made a note.
[Updating] It was difficult to upgrade from httpclient 3.x series to 4.5, so I will write a summary of changes
I made a function to register images with API in Spring Framework. Part 1 (API edition)
I made a function to register images with API in Spring Framework. Part 2 (Client Edition)
I made a Japanese version of Rails / devise automatic email
A story I was addicted to in Rails validation settings
I want to know the Method of the Controller where the Exception was thrown in the ExceptionHandler of Spring Boot
I tried to implement Ajax processing of like function in Rails
I made a gem to post the text of org-mode to qiita
I made a tool to output the difference of CSV file
[Rails] I want to send data of different models in a form
I want to display images with REST Controller of Java and Spring!
I was in trouble at work, so I made a plugin for IntelliJ
I was addicted to a simple test of Jedis (Java-> Redis library)
I was a little addicted to running old Ruby environment and old Rails
[CircleCI] I was addicted to the automatic test of CircleCI (rails + mysql) [Memo]
I was able to obtain OCJP Silver SE 11 safely, so a summary
[Rails / JavaScript / Ajax] I tried to create a like function in two ways.
Rails6 I want to make an array of values with a check box
[Spring Boot] If you use Spring Boot, it was convenient to use a lot of util.
I tried to clone a web application full of bugs with Spring Boot
I made a sample of how to write delegate in SwiftUI 2.0 using MapKit