[Из песочницы] Web MVC приложение без фреймворков и сервлетов

Напишем небольшое веб приложение, без использование Web-фреймворков, внешних библиотек, и сервера приложений.

Цель данной статьи показать общую суть происходящего под капотом веб-сервиса, на примере Java. Итак, поехали. Мы не должны использовать сторонние библиотеки, а также сервлет. Поэтому проект соберем Maven-ом, но без зависимостей.

Что происходит когда пользователь вводит некий ip-адрес (ну или dns который превращается в ip-адрес) в адресной строке браузера? Происходит запрос к ServerSocket указанного host-a, на указанный порт.

Организуем на нашем localhost, socket на случайном свободном порту (например 9001).

public class HttpRequestSocket {
    private static volatile Socket socket;

    private HttpRequestSocket() {
    }

    public static Socket getInstance() throws IOException {
        if (socket == null) {
            synchronized (HttpRequestSocket.class) {
                if (socket == null) {
                    socket = new ServerSocket(9001).accept();
                }
            }
        }

        return socket;
    }
}


Не забываем, что слушатель на порту, как объект, нам желателен в единственном экземпляре, поэтому singleton (не обязательно double-check, но можно и так).

Теперь на нашем host-e (localhost) на порту 9001, есть слушатель, который получает то что вводит пользователь, в виде потока байт.

Если вычитать byte[] из socket-а, в DataInputStream и преобразовать в строку то получится примерно это:

GET /index HTTP/1.1
Host: localhost:9001
Connection: keep-alive
Cache-Control: no-cache
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36
Postman-Token: 838f4680-a363-731d-aa74-10ee46b9a87a
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7


Стандартный Http-запрос со всеми необходимыми заголовками.

Для парсинга сделаем небольшой util-интерфейс с default-методами, на мой взгляд довольно удобно для подобных целей, (к тому же если это все таки Spring то сокращаем число зависимостей в классе).

public interface InputStringUtil {

    default String parseRequestMapping(final String inputData) {
        return inputData.split((" "))[1];
    }

    default RequestType parseRequestType(final String source) {
        return valueOf(source.split(("/"))[0].trim());
    }

    default Map parseRequestParameter(final String source) {
        if (parseRequestType(source) == GET) {
            return parseGetRequestParameter(source);
        } else {
            return parsePostRequestParameter(source);
        }
    }

    @SuppressWarnings("unused")
    class ParameterParser {
        static Map parseGetRequestParameter(final String source) {
            final Map parameterMap = new HashMap<>();
            if(source.contains("?")){
                final String parameterBlock = source.substring(source.indexOf("?") + 1, source.indexOf("HTTP")).trim();
                for (final String s : parameterBlock.split(Pattern.quote("&"))) {
                    parameterMap.put(s.split(Pattern.quote("="))[0], s.split(Pattern.quote("="))[1]);
                }

            }
            return parameterMap;
        }

        static Map parsePostRequestParameter(final String source) {
            //todo task #2
            return new HashMap<>();
        }
    }
}


Данный util умеет парсить типа запроса, url, и список параметров, как для GET, так и для POST запросов.

В процессе парсинга формируем модель request, с целевым url и Map с параметрами запроса.

Контроллер для нашего сервиса представляет собой небольшую абстракцию на библиотеку, в которой мы можем добавлять книги (в данной реализации просто в List), удалять книги и возвращать список всех книг.

1. Controller

public class BookController {
    private static volatile BookController bookController;

    private BookController() {
    }

    public static BookController getInstance() {
        if (bookController == null) {
            synchronized (BookController.class) {
                if (bookController == null) {
                    bookController = new BookController();
                }
            }
        }
        return bookController;
    }

    @RequestMapping(path = "/index")
    @SuppressWarnings("unused")
    public void index(final Map paramMap) {
        final Map> map = new HashMap<>();
        map.put("book", DefaultBookService.getInstance().getCollection());
        HtmlMarker.getInstance().makeTemplate("index", map);
    }

    @RequestMapping(path = "/add")
    @SuppressWarnings("unused")
    public void addBook(final Map paramMap) {
        DefaultBookService.getInstance().addBook(paramMap);
        final Map> map = new HashMap<>();
        map.put("book", DefaultBookService.getInstance().getCollection());
        HtmlMarker.getInstance().makeTemplate("index", map);
    }
}


Контроллер у нас также singleton.

Прописываем RequestMapping. Стоп мы же делаем без фреймворка, какой RequestMapping? Придется написать самим эту аннотацию.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RequestMapping {
    String path() default "/";
}


Также можно было добавить аннотацию Controller над классом и при старте приложения собирать все классы помеченные этой аннотацией, и их методы, и добавлять в некую Map-ку c маппингов url. Но в текущей реализации ограничимся одним контроллером.

Перед контроллером, у нас будет некий PreProcessor, который будет формировать понятную программе модель request и осуществлять мэппинг к методам контроллера.

public class HttpRequestPreProcessor implements InputStringUtil {

    private final byte[] BYTE_BUFFER = new byte[1024];

    public void doRequest() {
        try {
            while (true) {
                System.out.println("Socket open");
                final Socket socket = HttpRequestSocket.getInstance();
                final DataInputStream in = new DataInputStream(new BufferedInputStream(socket.getInputStream()));
                final String inputUrl = new String(BYTE_BUFFER, 0, in.read(BYTE_BUFFER));
                processRequest(inputUrl);
                System.out.println("send request " + inputUrl);
            }
        } catch (final IOException e) {
            e.printStackTrace();
        }
    }

    private void processRequest(final String inputData) {
        final String urlMapping = parseRequestMapping(inputData);
        final Map paramMap = parseRequestParameter(inputData);
        final Method[] methods = BookController.getInstance().getClass().getMethods();
        for (final Method method : methods) {
            if (method.isAnnotationPresent(RequestMapping.class) && urlMapping.contains(method.getAnnotation(RequestMapping.class).path())) {
                try {
                    method.invoke(BookController.getInstance(), paramMap);
                    return;
                } catch (IllegalAccessException | InvocationTargetException e) {
                    e.printStackTrace();
                }
            }
        }
        HtmlMarker.getInstance().makeTemplate("error", emptyMap());
    }


2. Model

В качестве модели у нас будет класс Book

public class DomainBook {
    private String id;

    private String author;

    private String title;

    public DomainBook(String id, String author, String title) {
        this.id = id;
        this.author = author;
        this.title = title;
    }

    public String getId() {
        return id;
    }

    public String getAuthor() {
        return author;
    }

    public String getTitle() {
        return title;
    }

    @Override
    public String toString() {
        return "id=" + id +
                " author='" + author + '\'' +
                " title='" + title + '\'';
    }
}


и service

public class DefaultBookService implements BookService {
    private static volatile BookService bookService;

    private List bookList = new ArrayList<>();

    private DefaultBookService() {
    }

    public static BookService getInstance() {
        if (bookService == null) {
            synchronized (DefaultBookService.class) {
                if (bookService == null) {
                    bookService = new DefaultBookService();
                }
            }
        }
        return bookService;
    }

    @Override
    public List getCollection() {
        System.out.println("get collection " + bookList);
        return bookList;
    }

    @Override
    public void addBook(Map paramMap) {
        final DomainBook domainBook = new DomainBook(paramMap.get("id"), paramMap.get("author"), paramMap.get("title"));
        bookList.add(domainBook);
        System.out.println("add book " + domainBook);
    }

    @Override
    public void deleteBookById(long id) {
        //todo #1
    }
}


который будет собирать коллекцию книг, и класть в Model (некую Map) данные полученные из service.

3. View

В качестве View, мы сделаем html шаблон, и разместим его в отдельной директории resources/pages, обосабливая уровень представления.



    Example


${book.id}${book.author}${book.title}



Number

Author

Title


Пишем свой шаблонизатор, класс должен уметь оценить полученный от сервиса ответ, и сформировать нужный http заголовок (в нашем случае OK или BAD REQUEST), заменить в HTML документе необходимые переменные значениями из Модели и отрисовать в итоге полноценную HTML, понятную браузеру и пользователю.

public class HtmlMarker {
    private static volatile HtmlMarker htmlMarker;

    private HtmlMarker() {
    }

    public static HtmlMarker getInstance() {
        if (htmlMarker == null) {
            synchronized (HtmlMarker.class) {
                if (htmlMarker == null) {
                    htmlMarker = new HtmlMarker();
                }
            }
        }

        return htmlMarker;
    }

    public void makeTemplate(final String fileName, Map> param) {
        try {
            final BufferedWriter bufferedWriter =
                    new BufferedWriter(
                            new OutputStreamWriter(
                                    new BufferedOutputStream(HttpRequestSocket.getInstance().getOutputStream()), StandardCharsets.UTF_8));
            if (fileName.equals("error")) {
                bufferedWriter.write(ERROR + ERROR_MESSAGE.length() + OUTPUT_END_OF_HEADERS + readFile(fileName, param));
                bufferedWriter.flush();
            } else {
                bufferedWriter.write(SUCCESS + readFile(fileName, param).length() + OUTPUT_END_OF_HEADERS + readFile(fileName, param));
                bufferedWriter.flush();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

    private String readFile(final String fileName, Map> param) {
        final StringBuilder builder = new StringBuilder();
        final String path = "src\\resources\\pages\\" + fileName + ".html";
        try (BufferedReader br = Files.newBufferedReader(Paths.get(path))) {
            String line;
            while ((line = br.readLine()) != null) {
                if (line.contains("${")) {
                    final String key = line.substring(line.indexOf("{") + 1, line.indexOf("}"));
                    final String keyPrefix = key.split(Pattern.quote("."))[0];
                    for (final DomainBook domainBook : param.get(keyPrefix)) {
                        builder.append("");
                        builder.append(
                                line.replace("${book.id}", domainBook.getId())
                                        .replace("${book.author}", domainBook.getAuthor())
                                        .replace("${book.title}", domainBook.getTitle())
                        ).append("");
                    }
                    if(param.get(keyPrefix).isEmpty()){
                        builder.append(line.replace("${book.id}${book.author}${book.title}", "

library is EMPTY

")); } continue; } builder.append(line).append("\n"); } return builder.toString(); } catch (IOException e) { e.printStackTrace(); } return ""; } }


В качестве тестирования приложения на работоспособность добавим пару книг в наше приложение:

image

Спасибо что дочитали до конца, статья носит ознакомительный характер, надеюсь что она была немного интересна и чуточку полезна.

© Habrahabr.ru