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.
--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
.
@Controller
annotation@ GET
annotation (only GET
is supported this time)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
.
This is the configuration.
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)
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.
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)
.
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.
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
.
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)
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 ...
came.
--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