Odlično, pomoću Java Socket-a ručno napravih HTTP server
Kao Java backend programer, pružanje HTTP usluge se može reći da je jedna od osnovnih veština, ali da li zaista razumete HTTP protokol? Znate li kako da ručno napravite HTTP server? Kako Tomcat na donjem nivou podržava HTTP usluge? Šta je zapravo čuveni Servlet i kako se koristi?
Kada učite Javu kao početnik, Socket programiranje je poglavlje koje ne možete preskočiti; iako u stvarnim poslovnim projektima šansa da ovo koristite gotovo da ne postoji, to ne znači da ne treba da ga učite. Ovaj članak će uglavnom predstaviti kako koristiti Socket za implementaciju jednostavnog HTTP servera, pružanje podrške za uobičajene get/post zahteve, i tokom tog procesa upoznavanje sa HTTP protokolom.
I. HTTP server od 0 do 1
Pošto je naš cilj da pomoću Socket-a izgradimo HTTP server, prvo moramo da razjasnimo dve stvari: prvo, kako koristiti Socket; drugo, kako HTTP protokol raščlanjuje podatke; u nastavku su oba objašnjena redom.
1. Osnove Socket programiranja
Ovde uglavnom koristimo ServerSocket da vezemo port i pružimo TCP uslugu; osnovni način korišćenja je prilično jednostavan, a uobičajeni obrazac je:
- Kreiranje ServerSocket objekta i vezivanje port-a za slušanje
- Slušanje klijentskih zahteva putem
accept()metoda - Nakon uspostavljanja veze, čitanje podataka zahteva koje šalje klijent preko ulaznog toka
- Slanje informacija o odgovoru klijentu preko izlaznog toka
- Zatvaranje relevantnih resursa
Odgovarajući pseudokod je sledeći:
ServerSocket serverSocket = new ServerSocket(port, ip)
serverSocket.accept();
// Prihvatanje podataka zahteva
socket.getInputStream();
// Vraćanje podataka pošiljaocu zahteva
out = socket.getOutputStream()
out.print(xxx)
out.flush();;
// Zatvaranje veze
socket.close()Specifičan kod smo detaljno obradili u prethodnom poglavlju; pridošli čitaoci mogu kliknuti na link da ga nauče.
2. HTTP protokol
ServerSocket koristi TCP protokol, a HTTP protokol je sloj iznad TCP protokola.
TCP je transportni protokol orijentisan na vezu, pouzdan i zasnovan na toku bajtova. TCP uspostavlja pouzdan kanal komunikacije između dva mrežna čvora, osiguravajući da podaci tokom prenosa ne budu izgubljeni, duplirani ili u pogrešnom redosledu. TCP koristi proces rukovanja (handshake) za uspostavljanje veze, potvrđuje i retransmituje podatke radi pouzdanog prenosa, i koristi algoritme kontrole protoka i kontrole zagušenja radi optimizacije mrežnih performansi.
HTTP je aplikativni protokol koji se koristi za prenos hiperteksta, slika, videa i drugih medijskih resursa između Web pregledača i Web servera. HTTP koristi model zahtev-odgovor, odnosno klijent (obično Web pregledač) šalje zahtev serveru, server obrađuje zahtev i vraća odgovor. HTTP protokol definiše skup metoda (kao što su GET, POST, PUT, DELETE, itd.) kojima se određuje tip i svrha zahteva. Pored toga, HTTP protokol definiše i skup statusnih kodova (kao što su 200, 404, 500, itd.) koji predstavljaju rezultat odgovora.
HTTP protokol se oslanja na TCP protokol za prenos podataka. Kada Web pregledač pošalje HTTP zahtev Web serveru, on prvo koristi TCP protokol da uspostavi vezu sa serverom. Kada jednom veza bude uspostavljena, HTTP poruka zahteva se pakuje u TCP paket, a zatim šalje serveru preko TCP kanala. Nakon što server primi TCP paket, raspakuje ga, izdvaja HTTP poruku zahteva, obrađuje zahtev i generiše HTTP poruku odgovora. Na kraju, HTTP poruka odgovora se pakuje u TCP paket i šalje nazad klijentu kroz isti TCP kanal. Kada klijent primi TCP paket, raspakuje ga, izdvaja HTTP poruku odgovora i prikazuje je korisniku.

Ova slika prikazuje HTTP zahtev i odgovor između klijenta (Web pregledača) i servera (Web servera); oni prenose podatke preko pouzdane, na vezu orijentisane TCP konekcije.
Dobro, da se vratimo na HTTP server — najvažnije je da obratimo pažnju na dve stvari:
- Kako raščlaniti podatke zahteva prema HTTP protokolu
- Kako vratiti podatke prema HTTP protokolu
Zato moramo da znamo specifikaciju formata podataka.
Poruka zahteva
HTTP poruka zahteva se sastoji iz nekoliko delova: linija zahteva (Request Line), zaglavlja zahteva (Request Headers), prazna linija (Empty Line) i telo zahteva (Request Body, opciono).
①. Linija zahteva sadrži tri dela: HTTP metod (na primer GET, POST, PUT, DELETE, itd.), ciljni URL zahteva (obično relativni URL, ali može biti i apsolutni), i HTTP verzija (na primer HTTP/1.1 ili HTTP/2). Ovi delovi su razdvojeni razmakom, na primer:
GET /index.html HTTP/1.1②. Zaglavlja zahteva su niz metapodataka predstavljenih kao parovi ključ-vrednost, koji opisuju dodatne informacije o zahtevu. Svako zaglavlje zauzima jednu liniju, a ključ i vrednost su razdvojeni dvotačkom (😃. Zaglavlja zahteva sadrže informacije kao što su Host, User-Agent, Content-Type, Content-Length, Accept, itd. Na primer:
Host: www.tobebetterjavaer.com
User-Agent: Mozilla/5.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8③. Između zaglavlja zahteva i tela zahteva nalazi se prazna linija koja označava kraj zaglavlja zahteva.
④. Za neke HTTP metode (na primer POST, PUT, itd.), poruka zahteva može da sadrži i telo zahteva. Telo zahteva se koristi za prenos podataka koje treba poslati serveru. Format i sadržaj tela zahteva zavise od vrednosti zaglavlja Content-Type.
Na primer, prilikom slanja HTML forme, telo zahteva može izgledati ovako:
username=ChenmoWangEr&password=123456Kada se ovi delovi spoje, dobija se kompletna HTTP poruka zahteva:
POST /login HTTP/1.1
Host: Host: www.tobebetterjavaer.com
User-Agent: Mozilla/5.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 29
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
username=ChenmoWangEr&password=123456Da to predstavim pomoću jedne mentalne mape:

Poruka odgovora
Tipična HTTP poruka odgovora se sastoji iz tri dela: statusna linija (Status Line), zaglavlja odgovora (Response Headers) i telo odgovora (Response Body).

Gorenavedene dve slike vam mogu dati intuitivan utisak o HTTP zahtevu i odgovoru, a sada da pređemo na suštinu.
Bilo da je u pitanju poruka zahteva ili poruka odgovora, može se podeliti u tri dela, što nam znatno pojednostavljuje kasniju obradu.
- Prva linija: statusna linija
- Od druge linije do prve prazne linije: header (zaglavlje zahteva/zaglavlje odgovora)
- Sve ostalo: telo
3. Dizajn HTTP servera
Sada prelazimo na glavnu temu — kreiranje HTTP servera na osnovu Socket-a. Korišćenje Socket-a uglavnom nije veliki problem, ali moramo posebno obratiti pažnju na sledeće dve stvari:
- Raščlanjivanje podataka zahteva
- Pakovanje rezultata koji se vraćaju
a. Raščlanjivanje podataka zahteva
Uzimamo sve podatke iz Socket-a, a zatim ih raščlanjujemo u odgovarajući HTTP zahtev. Prvo definišemo Request objekat koji interno čuva osnovne HTTP informacije; zatim je ključni zadatak da izvučemo sve podatke iz Socket-a i pakujemo ih u request objekat.
Napomena 📢: ovaj kod se nalazi u klasi HttpMessageParser; kompletan kod će biti dat kasnije.
@Data
public static class Request {
/**
* Metod zahteva GET/POST/PUT/DELETE/OPTION...
*/
private String method;
/**
* URI zahteva
*/
private String uri;
/**
* HTTP verzija
*/
private String version;
/**
* Zaglavlja zahteva
*/
private Map<String, String> headers;
/**
* Podaci vezani za parametre zahteva
*/
private String message;
}Prema prethodnom uvodu u HTTP protokol, proces raščlanjivanja je sledeći — prvo da pogledamo proces raščlanjivanja linije zahteva.
Linija zahteva sadrži tri osnovna elementa: metod zahteva + URI + HTTP verzija, razdvojene razmacima, pa je kod za raščlanjivanje sledeći:
/**
* Raščlanjuje liniju zahteva prema standardnom HTTP protokolu
*
* @param reader
* @param request
*/
private static void decodeRequestLine(BufferedReader reader, Request request) throws IOException {
String[] strs = StringUtils.split(reader.readLine(), " ");
assert strs.length == 3;
request.setMethod(strs[0]);
request.setUri(strs[1]);
request.setVersion(strs[2]);
}Raščlanjivanje zaglavlja zahteva: od druge linije do prve prazne linije, svi podaci su zaglavlja zahteva; format zaglavlja je prilično jasan, oblika key:value, a konkretna implementacija je sledeća:
/**
* Raščlanjuje zaglavlja zahteva prema standardnom HTTP protokolu
*
* @param reader BufferedReader objekat koji čita zaglavlja zahteva
* @param request Request objekat koji čuva informacije o zahtevu
* @throws IOException baca se kada dođe do I/O izuzetka pri čitanju zaglavlja zahteva
*/
private static void decodeRequestHeader(BufferedReader reader, Request request) throws IOException {
// Kreiranje Map objekta za čuvanje informacija zaglavlja zahteva
Map<String, String> headers = new HashMap<>(16);
// Čitanje zaglavlja zahteva; svaka linija je par ključ-vrednost, a završava se praznom linijom
String line = reader.readLine();
String[] kv;
while (!"".equals(line)) {
// Razdvajanje svake linije zaglavlja po dvotački, pa se koristi kao ključ i vrednost u Map-i
kv = StringUtils.split(line, ":");
assert kv.length == 2;
headers.put(kv[0].trim(), kv[1].trim());
line = reader.readLine();
}
// Smještanje raščlanjenih zaglavlja zahteva u Request objekat
request.setHeaders(headers);
}Konačno, raščlanjivanje tela: ovde treba obratiti pažnju na to da telo može biti prazno ili može sadržati podatke; kada ima podataka, kako da izvučemo sve podatke?
Prvo pogledajmo konkretnu implementaciju:
/**
* Raščlanjuje telo zahteva prema standardnom HTTP protokolu
*
* @param reader čitač ulaznog toka, koristi se za čitanje podataka iz zahteva
* @param request Request objekat koji predstavlja HTTP zahtev
* @throws IOException baca se kada dođe do I/O greške
*/
private static void decodeRequestMessage(BufferedReader reader, Request request) throws IOException {
// Preuzimanje Content-Length iz zaglavlja zahteva; ako ne postoji, podrazumevano je 0
int contentLen = Integer.parseInt(request.getHeaders().getOrDefault("Content-Length", "0"));
// Ako je Content-Length 0, to znači da nema tela zahteva, pa se odmah vraća.
// Na primer, GET i OPTIONS zahtevi obično ne sadrže telo zahteva
if (contentLen == 0) {
return;
}
// Kreiranje niza karaktera prema Content-Length-u radi čuvanja tela zahteva
char[] message = new char[contentLen];
// Čitanje tela zahteva pomoću BufferedReader-a
reader.read(message);
// Konvertovanje niza karaktera u string i postavljanje kao message Request objekta
request.setMessage(new String(message));
}Obratite pažnju na moj način korišćenja gore: prvo, na osnovu vrednosti Content-Type u zaglavlju zahteva dobijamo veličinu podataka tela, pa je naš način čitanja da kreiramo char[] niz te veličine i učitamo sve podatke iz toka; ako je naš niz manji od stvarnog, nećemo pročitati sve; ako je veći, u nizu će ostati neki prazni podaci.
Na kraju, da zapakujemo gore navedena raščlanjivanja, čime se kompletira raščlanjivanje request-a:
/**
* HTTP zahtev se može podeliti u tri dela:
* 1. Linija zahteva: uključuje metod zahteva, URI i verziju HTTP protokola
* 2. Zaglavlja zahteva: od druge linije do prve prazne linije
* 3. Telo poruke: sav sadržaj nakon prazne linije, dužinu određuje Content-Length iz zaglavlja zahteva
*
* Ovaj metod raščlanjuje podatke HTTP zahteva iz InputStream-a u Request objekat
*
* @param reqStream ulazni tok koji sadrži podatke HTTP zahteva
* @return Request objekat koji predstavlja HTTP zahtev
* @throws IOException baca se kada dođe do I/O greške
*/
public static Request parse2request(InputStream reqStream) throws IOException {
// Čitanje podataka iz ulaznog toka pomoću BufferedReader-a i InputStreamReader-a
BufferedReader httpReader = new BufferedReader(new InputStreamReader(reqStream, "UTF-8"));
// Kreiranje novog Request objekta
Request httpRequest = new Request();
// Raščlanjivanje linije zahteva i postavljanje u Request objekat
decodeRequestLine(httpReader, httpRequest);
// Raščlanjivanje zaglavlja zahteva i postavljanje u Request objekat
decodeRequestHeader(httpReader, httpRequest);
// Raščlanjivanje tela poruke i postavljanje u Request objekat
decodeRequestMessage(httpReader, httpRequest);
// Vraćanje raščlanjenog Request objekta
return httpRequest;
}Zatim, sledi pakovanje rezultata zahteva — da damo jednostavan primer za demonstraciju:
/**
* Klasa Response predstavlja HTTP odgovor, uključujući verziju, statusni kod, statusnu poruku, zaglavlja odgovora i telo odgovora.
*/
@Data
public static class Response {
private String version;
private int code;
private String status;
private Map<String, String> headers;
private String message;
}
/**
* Gradi HTTP odgovor na osnovu datog Request objekta i stringa odgovora.
*
* @param request Request objekat na osnovu kojeg se gradi odgovor
* @param response string odgovora
* @return string koji predstavlja HTTP odgovor
*/
public static String buildResponse(Request request, String response) {
// Kreiranje novog Response objekta i postavljanje verzije, statusnog koda i statusne poruke
Response httpResponse = new Response();
httpResponse.setCode(200);
httpResponse.setStatus("ok");
httpResponse.setVersion(request.getVersion());
// Postavljanje zaglavlja odgovora
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/json");
headers.put("Content-Length", String.valueOf(response.getBytes().length));
httpResponse.setHeaders(headers);
// Postavljanje tela odgovora
httpResponse.setMessage(response);
// Gradnja stringa odgovora
StringBuilder builder = new StringBuilder();
buildResponseLine(httpResponse, builder);
buildResponseHeaders(httpResponse, builder);
buildResponseMessage(httpResponse, builder);
return builder.toString();
}
/**
* Gradi liniju odgovora, uključujući verziju, statusni kod i statusnu poruku.
*
* @param response Response objekat na osnovu kojeg se gradi linija odgovora
* @param stringBuilder StringBuilder objekat za nadovezivanje stringa odgovora
*/
private static void buildResponseLine(Response response, StringBuilder stringBuilder) {
stringBuilder.append(response.getVersion()).append(" ").append(response.getCode()).append(" ")
.append(response.getStatus()).append("\n");
}
/**
* Gradi zaglavlja odgovora.
*
* @param response Response objekat na osnovu kojeg se grade zaglavlja odgovora
* @param stringBuilder StringBuilder objekat za nadovezivanje stringa odgovora
*/
private static void buildResponseHeaders(Response response, StringBuilder stringBuilder) {
for (Map.Entry<String, String> entry : response.getHeaders().entrySet()) {
stringBuilder.append(entry.getKey()).append(":").append(entry.getValue()).append("\n");
}
stringBuilder.append("\n");
}
/**
* Gradi telo odgovora.
*
* @param response Response objekat na osnovu kojeg se gradi telo odgovora
* @param stringBuilder StringBuilder objekat za nadovezivanje stringa odgovora
*/
private static void buildResponseMessage(Response response, StringBuilder stringBuilder) {
stringBuilder.append(response.getMessage());
}b. Zadatak zahteva HttpTask
Za svaki zahtev se pojedinačno dodeljuje zadatak koji to obavlja, kako bi se podržala konkurentnost. Za ServerSocket, kada primi zahtev, kreira HttpTask zadatak da realizuje HTTP komunikaciju.
Šta onda radi ovaj httptask?
- Izvlačenje podataka iz zahteva
- Odgovaranje na zahtev
- Pakovanje rezultata i vraćanje
/**
* Klasa HttpTask implementira Runnable interfejs i koristi se za obradu jednog HTTP zahteva.
* Kada se izvršava u niti, ovaj zadatak obrađuje HTTP zahtev na jednoj Socket konekciji
* i šalje poruku odgovora.
*/
public class HttpTask implements Runnable {
// Socket za obradu HTTP zahteva
private Socket socket;
/**
* Konstruiše novi HttpTask za obradu navedene Socket konekcije.
*
* @param socket Socket za obradu HTTP zahteva
*/
public HttpTask(Socket socket) {
this.socket = socket;
}
/**
* Implementacija run metoda iz Runnable interfejsa; služi za obradu HTTP zahteva i slanje poruke odgovora.
*/
@Override
public void run() {
// Provera da li je socket null; ako jeste, baca se izuzetak
if (socket == null) {
throw new IllegalArgumentException("socket can't be null.");
}
try {
// Preuzimanje izlaznog toka Socket-a i kreiranje PrintWriter objekta
OutputStream outputStream = socket.getOutputStream();
PrintWriter out = new PrintWriter(outputStream);
// Raščlanjivanje HTTP zahteva iz ulaznog toka Socket-a
HttpMessageParser.Request httpRequest = HttpMessageParser.parse2request(socket.getInputStream());
try {
// Odgovor na osnovu rezultata zahteva; povratna vrednost je izostavljena
String result = null;
// Gradnja HTTP odgovora na osnovu zahteva i rezultata
String httpRes = HttpMessageParser.buildResponse(httpRequest, result);
// Slanje HTTP odgovora klijentu
out.print(httpRes);
} catch (Exception e) {
// Ako dođe do izuzetka, gradi se HTTP odgovor koji sadrži informaciju o izuzetku
String httpRes = HttpMessageParser.buildResponse(httpRequest, e.toString());
out.print(httpRes);
}
// Ispiranje (flush) izlaznog toka radi obezbeđivanja slanja poruke odgovora
out.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
// Zatvaranje Socket konekcije
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}c. Izgradnja HTTP servera
Prethodno smo uglavnom završili sve što je trebalo uraditi, pa je preostalo jednostavno — kreiramo ServerSocket, vežemo port i primamo zahteve; ovaj HTTP server pokrećemo u bazenu niti (thread pool).
public class BasicHttpServer {
// Kreiranje izvršioca sa jednom nit radi pokretanja HTTP servera
private static ExecutorService bootstrapExecutor = Executors.newSingleThreadExecutor();
// Kreiranje bazena niti za obradu HTTP zahteva klijenata
private static ExecutorService taskExecutor;
// Podešavanje broja porta-a koji server sluša
private static int PORT = 8999;
// Metod za pokretanje HTTP servera
static void startHttpServer() {
// Preuzimanje broja dostupnih jezgara procesora, za određivanje veličine bazena niti
int nThreads = Runtime.getRuntime().availableProcessors();
// Inicijalizacija bazena niti: veličina bazena, veličina reda i politika odbacivanja
taskExecutor =
new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(100),
new ThreadPoolExecutor.DiscardPolicy());
// Petlja koja pokušava da pokrene server; ako pokretanje ne uspe, čeka 10 sekundi pa pokušava ponovo
while (true) {
try {
ServerSocket serverSocket = new ServerSocket(PORT);
bootstrapExecutor.submit(new ServerThread(serverSocket));
break;
} catch (Exception e) {
try {
// Pokušaj ponovo, čeka 10 sekundi
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
}
// Gašenje izvršioca za pokretanje
bootstrapExecutor.shutdown();
}
// Glavna klasa zadatka HTTP servera
private static class ServerThread implements Runnable {
// Čuva instancu ServerSocket-a prosleđenu konstruktoru
private ServerSocket serverSocket;
// Konstruktor
public ServerThread(ServerSocket s) throws IOException {
this.serverSocket = s;
}
// Glavna metoda zadatka
@Override
public void run() {
while (true) {
try {
// Čekanje na konekciju klijenta
Socket socket = this.serverSocket.accept();
// Kreiranje instance HttpTask-a, sa instancom Socket-a kao parametrom
HttpTask eventTask = new HttpTask(socket);
// Predaja HttpTask-a taskExecutor-u na izvršenje
taskExecutor.submit(eventTask);
} catch (Exception e) {
e.printStackTrace();
try {
// Ako dođe do izuzetka, čeka 1 sekund pa nastavlja pokušaje
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
}
}
}
}Ovaj kod je jednostavna implementacija HTTP servera. Sledeći detaljni opis se odnosi na glavne komponente i funkcije ovog HTTP servera:
bootstrapExecutor: jednonitni ExecutorService koji se koristi za izvršavanje zadatka pokretanja HTTP servera.
taskExecutor: bazen niti koji se koristi za obradu HTTP zahteva klijenata. Veličina bazena niti je jednaka broju dostupnih jezgara procesora, veličina reda je 100, i koristi politiku odbacivanja DiscardPolicy.
PORT: broj porta koji server sluša, podrazumevano 8999.
Metod startHttpServer():
- a. Kreira bazen niti taskExecutor za obradu HTTP zahteva.
- b. U petlji pokušava da kreira instancu ServerSocket-a i veže je na zadati port. Ako ne uspe, čeka 10 sekundi pa pokušava ponovo.
- c. Kada instanca ServerSocket-a bude uspešno kreirana, prosleđuje se bootstrapExecutor-u kao parametar radi izvršavanja ServerThread zadatka.
- d. Gasiti bootstrapExecutor.
Klasa ServerThread implementira Runnable interfejs i ona je glavni zadatak HTTP servera:
- a. Članska promenljiva serverSocket: čuva instancu ServerSocket-a prosleđenu konstruktoru.
- b. Metod run():
- U beskonačnoj petlji poziva metod serverSocket.accept() i čeka na konekciju klijenta.
- Kada primi novu konekciju klijenta, kreira instancu HttpTask-a, sa instancom Socket-a kao parametrom.
- Predaje HttpTask taskExecutor-u na izvršenje.
Glavna logika ovog HTTP servera je: koristi jednu nit za slušanje klijentskih konekcija; kada stigne nova konekcija klijenta, kreira HttpTask za obradu HTTP zahteva klijenta i predaje taj zadatak na izvršenje u bazen niti taskExecutor. Na ovaj način se omogućava konkurentna obrada više klijentskih zahteva.
Ovde je HTTP server zasnovan na Socket-u uglavnom izgrađen, pa možemo preći na testiranje.
4. Testiranje
Pravljenje ovog servera je uglavnom proisteklo iz projekta quick-fix; taj projekat prvenstveno služi za rešavanje pristupa internim uslugama aplikacije i ispravku podataka, pa ćemo na osnovu tog projekta sprovesti test.
Jedan kompletan post zahtev izgleda ovako:

Zatim da pogledamo situaciju kada se ispišu zaglavlja odgovora.

II. Ostalo
0. Izvorni kod projekta
- quick-fix
- Relevantan kod:
- com.git.hui.fix.core.endpoint.BasicHttpServer
- com.git.hui.fix.core.endpoint.HttpMessageParser
- com.git.hui.fix.core.endpoint.HttpTask
Autor: Yi Hui, obradio: Chenmo Wang Er, tim, tehnička frakcija
