Цель данной статьи показать общую суть происходящего под капотом веб-сервиса, на примере 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<String, String> parseRequestParameter(final String source) {
if (parseRequestType(source) == GET) {
return parseGetRequestParameter(source);
} else {
return parsePostRequestParameter(source);
}
}
@SuppressWarnings("unused")
class ParameterParser {
static Map<String, String> parseGetRequestParameter(final String source) {
final Map<String, String> 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<String, String> 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<String, String> paramMap) {
final Map<String, List<DomainBook>> map = new HashMap<>();
map.put("book", DefaultBookService.getInstance().getCollection());
HtmlMarker.getInstance().makeTemplate("index", map);
}
@RequestMapping(path = "/add")
@SuppressWarnings("unused")
public void addBook(final Map<String, String> paramMap) {
DefaultBookService.getInstance().addBook(paramMap);
final Map<String, List<DomainBook>> 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<String, String> 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<DomainBook> 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<DomainBook> getCollection() {
System.out.println("get collection " + bookList);
return bookList;
}
@Override
public void addBook(Map<String, String> 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, обосабливая уровень представления.
<html>
<head>
<title>Example</title>
</head>
<br>
<table>
<td>${book.id}</td><td>${book.author}</td><td>${book.title}</td>
</table>
</br>
</br>
</br>
<form method="get" action="/add">
<p>Number<input type="text" name="id"></p>
<p>Author<input type="text" name="author"></p>
<p>Title<input type="text" name="title"></p>
<p><input type="submit" value="Send"></p>
</form>
</body>
</html>
Пишем свой шаблонизатор, класс должен уметь оценить полученный от сервиса ответ, и сформировать нужный 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<String, List<DomainBook>> 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<String, List<DomainBook>> 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("<tr>");
builder.append(
line.replace("${book.id}", domainBook.getId())
.replace("${book.author}", domainBook.getAuthor())
.replace("${book.title}", domainBook.getTitle())
).append("</tr>");
}
if(param.get(keyPrefix).isEmpty()){
builder.append(line.replace("${book.id}</td><td>${book.author}</td><td>${book.title}", "<p>library is EMPTY</p>"));
}
continue;
}
builder.append(line).append("\n");
}
return builder.toString();
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
}
В качестве тестирования приложения на работоспособность добавим пару книг в наше приложение:
Спасибо что дочитали до конца, статья носит ознакомительный характер, надеюсь что она была немного интересна и чуточку полезна.
Комментарии (16)
igormich88
31.07.2019 00:53+1Я правильно понимаю, что
urlMapping.contains(method.getAnnotation(RequestMapping.class).path())
будет возвращать true для метода с @RequestMapping(path = "/") и urlMapping равного "/hello.html"?mypanacea87 Автор
31.07.2019 04:35-1если делать по уму то там должен быть некий хитрый RegExp, который будет парсить url, отделять от него параметры (для GET), здесь ознакомительный контроллер, с 2 @RequestMapping отличными от @RequestMapping(path = "/"). Парсинг примитивно сделан
igormich88
31.07.2019 14:45+1И еще вопрос если я запрашиваю у вас адрес вида "/post.html?postid=461965#habracut" то что вернёт parseRequestParameter?
igormich88
01.08.2019 12:51-1В догонку, так как вы выложили полную версию кода мне сложно об этом говорить, но кажется что всесто
Должно бытьbufferedWriter.write(ERROR + ERROR_MESSAGE.length() + OUTPUT_END_OF_HEADERS + readFile(fileName, param));
bufferedWriter.write(ERROR + ERROR_MESSAGE.length() + OUTPUT_END_OF_HEADERS + ERROR_MESSAGE)
mypanacea87 Автор
01.08.2019 14:06вам кажется, в проекте есть шаблон error.html который и будет загружен, про полную версию кода вы придумали сами, я такого не писал, я привел примеры ключевых файлов, в которых некоторые методы todo, чтоб показать общую тенденцию происходящего, Вы же во первых это не заметили, во вторых не прочитали поясняющий комментарий на свой первый вопрос. Вы не поняли о чем эта статья(что может быть как виной автора, так и виной читающего).
igormich88
01.08.2019 14:22-1Ок, а можно узнать что тогда лежит в ERROR_MESSAGE? И зачем берется длина этой строки?
И можно уточнить о чём эта статья? Написать веб приложение не соответствующее стандартам?
PS многие вопросы бы отпали если бы была ссылка на полную версию кода.
puyol_dev2
31.07.2019 04:41Публикация из разряда — я изучал MVC и сделал сам. Хоть и не джавист, но вижу, что в разработке опыта ещё немного. Есть склонность к усложнению, а не к упрощению
lasteran
31.07.2019 08:58Я так понимаю, это статья начального уровня, тогда для вхождения не хватает ссылок на то, куда читать, чтоб это запустить на сервере и как этот сервер настроить, чтоб все работало. А еще хоть один скриншот того, что получилось снаружи…
usharik
31.07.2019 14:41+1Я бы вам вот эту книжку рекомендовал How Tomcat Works: A Guide to Developing Your Own Java Servlet Container . Наверняка можно ее где-то и в виде pdf найти.
usharik
31.07.2019 14:45+1Насчет вашего синглтона HttpRequestSocket. Я правильно понимаю, что у вас предполагается одно соединение с одним клиентом на все время работы сервера?
mypanacea87 Автор
31.07.2019 15:19да, все так, предложение однопоточное, так как задача была показать жизненный цикл WEB-MVC сервиса, от запроса в браузере до странице в браузере, не вдаваясь в тонкости многопользовательского приложения, можно было из препроцессора дергать задачи в отдельном TaskExecutor, но. повторюсь, цель была другая.
usharik
31.07.2019 15:57Даже в рамках модельного примера одиночка ClientSocket смотрится очень странно. Вот одиночка (singleton) ServerSocket — совсем другое дело. И никакой многопоточности не нужно. Просто в бесконечном цикле вызываем accept(), дожидаемся запроса обрабатываем его и возвращаемся на ожидание. Это один из типовых способов работы с ServerSocket. Пример такого цикла можете глянуть у меня вот в этой статье habr.com/ru/post/441150
roswell
Господи, за что?..