Cet article concerne la création d'un serveur HTTP prenant en charge les méthodes GET et POST.
Cela signifie aussi étudier HTTP, mais quand j'essaie de le faire moi-même, je pense souvent que c'est surprenant et que je l'apprécie, alors j'écris un article que je veux recommander à diverses personnes.
--Personnes impliquées dans le développement WEB
L'article est assez cassé, donc si vous voulez voir le tout, veuillez cliquer ici. https://github.com/ksugimori/SimpleHttpServer
Il a un peu moins de fonctionnalités que la version Java, mais je fais aussi une version Ruby. https://github.com/ksugimori/simple_http_server
Comme il est créé uniquement pour étudier HTTP, il peut afficher une page statique.
--Correspondant à la méthode GET --Peut afficher des pages statiques
Lors de l'affichage d'une page WEB, le traitement suivant est effectué de manière très simple.
Dans ce qui suit, nous allons l'implémenter selon ces 5 étapes.
J'ai dit "suivez 5 étapes ...", mais avant cela, créez une classe qui représente le message de demande / réponse qui est la base de HTTP.
Vérifiez d'abord les spécifications → RFC7230
Formater le message entier
HTTP-message = start-line
*( header-field CRLF )
CRLF
[ message-body ]
start-line = request-line / status-line
request-line/status-format de ligne
request-line = Method SP Request-URI SP HTTP-Version CRLF
status-line = HTTP-version SP status-code SP reason-phrase CRLF
request-line/status-exemple de ligne
GET /path/to/something HTTP/1.1
HTTP/1.1 200 OK
La structure est la même sauf pour Start Line, donc créons une classe de base abstraite et héritons-en.
AbstractHttpMessage.java
public abstract class AbstractHttpMessage {
protected Map<String, String> headers;
protected byte[] body;
public AbstractHttpMessage() {
this.headers = new HashMap<>();
this.body = new byte[0];
}
public void addHeaderField(String name, String value) {
this.headers.put(name, value);
}
public Map<String, String> getHeaders() {
return headers;
}
public void setBody(byte[] body) {
this.body = body;
}
public byte[] getBody() {
return body;
}
protected abstract String getStartLine();
@Override
public String toString() {
return getStartLine() + " headers: " + headers + " body: " + new String(body, StandardCharsets.UTF_8);
}
}
Request.java
public class Request extends AbstractHttpMessage {
Method method;
String target;
String version;
public Request(Method method, String target, String version) {
super();
this.method = method;
this.target = target;
this.version = version;
}
public Method getMethod() {
return method;
}
public String getTarget() {
return target;
}
public String getVersion() {
return version;
}
@Override
public String getStartLine() {
return method.toString() + " " + target + " " + version;
}
}
Response.java
public class Response extends AbstractHttpMessage {
String version;
Status status;
public Response(String version, Status status) {
super();
this.version = version;
this.status = status;
}
public String getVersion() {
return version;
}
public int getStatusCode() {
return status.getCode();
}
public String getReasonPhrase() {
return status.getReasonPhrase();
}
@Override
public String getStartLine() {
return version + " " + getStatusCode() + " " + getReasonPhrase();
}
}
Bien qu'il s'agisse d'un serveur HTTP, il n'effectue en fait qu'une communication socket normale. La méthode principale attend juste la connexion, et lorsque la connexion est établie, elle est traitée par un autre thread.
python
public static void main(String[] args) {
ServerSocket server = new ServerSocket(8080);
ExecutorService executor = Executors.newCachedThreadPool();
while (true) {
Socket socket = server.accept();
//Passez l'objet socket et traitez chaque demande dans un thread distinct
executor.submit( new WorkerThread(socket) );
}
}
Request-Line
format
request-line = Method SP Request-URI SP HTTP-Version CRLF
Puisqu'il s'agit d'une chaîne de caractères séparés par des espaces, la correspondance de modèle est effectuée avec une expression régulière et la méthode HTTP, l'URI et la version HTTP sont extraites.
Request-Perspective de ligne
InputStream in = socket.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(in));
String requestLine = br.readLine();
Pattern requestLinePattern
= Pattern.compile("^(?<method>\\S+) (?<target>\\S+) (?<version>\\S+)$");
Matcher matcher = requestLinePattern.matcher(requestLine);
Method method = Method.valueOf(matcher.group("method"));
String target = matcher.group("target");
String version = matcher.group("version");
Request request = new Request(method, target, version);
format
header-field = field-name ":" OWS field-value OWS
Puisque l'en-tête est un ensemble de nom et de valeur de champ séparés par «:», il est également extrait avec une expression régulière. Même si le corps est de 0 octet, il y a toujours une ligne vide qui sépare l'en-tête du corps, alors lisez-le comme un en-tête jusqu'à ce que la ligne vide soit rencontrée. [^ 1]
Pattern headerPattern = Pattern.compile("^(?<name>\\S+):[ \\t]?(?<value>.+)[ \\t]?$");
while ( true ) {
String headerField = br.readLine();
if ( EMPTY.equals(headerField.trim()) ) break; //Lire jusqu'au délimiteur entre l'en-tête et le corps
Matcher matcher = headerPattern.matcher(headerField);
if (matcher.matches()) {
request.addHeaderField(matcher.group("name"), matcher.group("value"));
} else {
throw new ParseException(headerField);
}
}
L'en-tête "Content-Length" ou "Transfer-Encoding" est toujours spécifié si le corps est présent. En d'autres termes, il devrait être possible de créer une branche conditionnelle en fonction de l'en-tête de demande et de prendre en charge les trois modèles suivants.
Puisqu'il s'agit d'un format simple de message-body = * OCTET
, il est correct de simplement stocker le contenu envoyé dans un tableau de type octet
.
Le nombre d'octets à lire est déterminé en fonction de "Content-Length".
Integer contentLength = Integer.valueOf(request.getHeaders().get("Content-Length"));
char[] body = new char[contentLength];
bufferedReader.read(body, 0, contentLength);
request.setBody((new String(body)).getBytes());
Si Content-Length
est requis, le client ne peut pas envoyer la demande tant que le corps de la demande n'est pas créé, ce qui est inefficace. Par conséquent, HTTP / 1.1 permet d'envoyer des corps de requête fragmentés comme suit. [^ 3]
python
POST /hoge HTTP/1.1
Host: example.jp
Transfer-Encoding: chunked
Connection: Keep-Alive
a
This is a
a
test messa
3
ge.
0
Format du corps en morceaux
Taille de bloc (octets, hexadécimal) CRLF
Données fragmentées CRLF
Même si vous ne connaissez pas le nombre d'octets pour le corps entier, c'est une stratégie pour ajouter le nombre d'octets dans l'ordre à partir de la partie préparée et l'envoyer. Le client doit enfin envoyer un bloc de 0 octet car il n'y a pas de Content-Length
et la fin du corps entier ne peut pas être déterminée côté serveur.
String transferEncoding = request.getHeaders().get("Transfer-Encoding");
// "Transfer-Encoding: gzip, chunked"Il est possible de spécifier plus d'un comme, mais cette fois, seul le bloc est pris en charge
if (transferEncoding.equals("chunked")) {
int length = 0;
ByteArrayOutputStream body = new ByteArrayOutputStream();
String chunkSizeHex = br.readLine().replaceFirst(" .*$", ""); // ignore chunk-ext
int chunkSize = Integer.parseInt(chunkSizeHex, 16);
while (chunkSize > 0) {
char[] chunk = new char[chunkSize];
br.read(chunk, 0, chunkSize);
br.skip(2); //Minutes CRLF
body.write((new String(chunk)).getBytes());
length += chunkSize;
chunkSizeHex = br.readLine().replaceFirst(" .*$", "");
chunkSize = Integer.parseInt(chunkSizeHex, 16);
}
request.addHeaderField("Content-Length", Integer.toString(length));
request.getHeaders().remove("Transfer-Encoding");
request.setBody(body.toByteArray());
}
Dans ce cas, le corps de la requête ne doit pas exister, alors ne faites rien
Lisez simplement le fichier demandé sous forme de tableau d'octets avec Files # readAllBytes
et c'est OK.
//Connectez-vous à la racine du document sur le chemin du fichier réel
Path target = Paths.get(SimpleHttpServer.getDocumentRoot(), request.getTarget()).normalize();
//Rendez-le accessible uniquement sous la racine du document
if (!target.startsWith(SimpleHttpServer.getDocumentRoot())) {
return new Response(protocolVersion, Status.BAD_REQUEST);
}
if (Files.isDirectory(target)) {
target = target.resolve("index.html");
}
try {
response = new Response("HTTP/1.1", Status.OK);
response.setBody(Files.readAllBytes(target)); //Le fichier est stocké sous forme de tableau d'octets
} catch (IOException e) {
//Définissez un code d'erreur s'il n'existe pas et lisez le fichier HTML de la page d'erreur
response = new Response("HTTP/1.1", Status.NOT_FOUND);
response.setBody(SimpleHttpServer.readErrorPage(Status.NOT_FOUND));
}
Dans le cas de POST, sortie vers la sortie standard pour vérifier si le corps est lu correctement pour le moment. Le code de réponse doit être 204: No Content pour signaler la réussite.
System.out.println("POST body: " + new String(request.getBody(), StandardCharsets.UTF_8));
Response response = new Response(protocolVersion, Status.NO_CONTENT);
Si vous spécifiez Content-Type
dans l'en-tête en fonction du format de fichier, le côté client se chargera du reste. [^ 2]
response = new Response(protocolVersion, Status.OK);
response.setBody(Files.readAllBytes(target));
Map<String, String> mimeTypes = new HashMap<>();
mimeTypes.put("html", "text/html");
mimeTypes.put("css", "text/css");
mimeTypes.put("js", "application/js");
mimeTypes.put("png", "image/png");
String ext = StringUtils.getFileExtension(target.getFileName().toString());
String contentType = mimeTypes.getOrDefault(ext, "");
response.addHeaderField("Content-Type", contentType);
Le format du message de réponse est
HTTP-version SP status-code SP reason-phrase CRLF
*( header-field CRLF )
CRLF
[ message-body ]
Donc, je l'écris honnêtement sur la socket en fonction du format.
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out));
String statusLine
= resp.getVersion() + SP + resp.getStatusCode() + SP + resp.getReasonPhrase() + CRLF;
writer.write(statusLine);
for (Map.Entry<String, String> field : response.getHeaders().entrySet()) {
writer.write(field.getKey() + ":" + SP + field.getValue() + CRLF);
}
writer.write(CRLF); //Ligne vide requise pour séparer l'en-tête et le corps
writer.flush();
out.write(response.getBody()); //Le corps écrit le tableau d'octets lu dans le fichier tel quel
Dans ce programme, le message de réponse est envoyé au côté client une fois terminé, il ne vaut donc pas le format de bloc, mais je vais essayer de le faire.
Format du corps en morceaux
Taille de bloc (octets, hexadécimal) CRLF
Données fragmentées CRLF
Divisez le tableau de types d'octets qui représente le corps entier par CHUNK_SIZE
et formatez-le dans le format ci-dessus.
byte[] CRLF = new byte[] {0x0D, 0x0A};
byte[] body = response.getBody();
ByteArrayOutputStream out = new ByteArrayOutputStream();
for (int offset = 0; offset < body.length; offset += CHUNK_SIZE) {
byte[] chunk = Arrays.copyOfRange(body, offset, offset + CHUNK_SIZE);
String lengthHex = Integer.toHexString(chunk.length);
out.write(lengthHex.getBytes());
out.write(CRLF);
out.write(chunk);
out.write(CRLF);
}
out.write("0".getBytes());
out.write(CRLF); //Fin de ligne de taille de morceau
out.write(CRLF); //Données de bloc de taille 0
J'utilise l'objet Request
et l'objet Response
pour générer un journal d'accès de type Apache juste avant de renvoyer une réponse au client.
Comme il est écrit sur la sortie standard sans rien faire de particulier, l'espace demi-largeur est codé en pourcentage en +
, mais vous pouvez voir que le contenu du formulaire est reçu.
Le code d'état est 404, mais comme j'ai mis le fichier HTML de la page d'erreur dans le corps, je peux passer à l'écran d'erreur.
Il a été confirmé que le corps était divisé en 20 octets (14 en hexadécimal).
$ curl localhost:8080/chunked/sample.txt --trace-ascii /dev/stdout
== Info: Trying ::1...
== Info: TCP_NODELAY set
== Info: Connected to localhost (::1) port 8080 (#0)
=> Send header, 96 bytes (0x60)
0000: GET /chunked/sample.txt HTTP/1.1
0022: Host: localhost:8080
0038: User-Agent: curl/7.54.0
0051: Accept: */*
005e:
<= Recv header, 17 bytes (0x11)
0000: HTTP/1.1 200 OK
<= Recv header, 28 bytes (0x1c)
0000: Transfer-Encoding: chunked
<= Recv header, 26 bytes (0x1a)
0000: Content-Type: text/plain
<= Recv header, 2 bytes (0x2)
0000:
<= Recv data, 109 bytes (0x6d)
0000: 14
0004: This is a sample tex
001a: 14
001e: t..Response is chunk
0034: 14
0038: ed in every 20 bytes
004e: 14
0052: ....................
0068: 0
006b:
This is a sample text.
Response is chunked in every 20 bytes.
Transfer-Encoding: chunked
et Content-Type: text / plain
, il téléchargera automatiquement le fichier une fois ouvert dans le navigateur. Je pensais que c'était juste une application / octat-stream`.RFC 7230 [Comment apprendre un nouveau langage de programmation Java, Scala, Clojure à apprendre en créant un serveur HTTP](https://speakerdeck.com/todokr/xin-siihurokuraminkuyan-yu-falsexue-hifang-httpsahawozuo-tutexue-hu-java-scala- clojure) : arrow_up: Cette diapositive m'a inspiré pour créer un serveur HTTP.
[^ 1]: Avec ça, si le client lance une requête étrange, je suis accro à une boucle infinie, donc je ne pense pas que ce soit cool. .. [^ 2]: Il devrait être ajouté à moins qu'il n'y ait une raison particulière, mais il semble qu'il ne soit pas nécessaire de l'ajouter car il est "DEVRAIT" au lieu de "DOIT" → 3.1.1.5. Content-Type /specs/rfc7231.html#header.content-type) [^ 3]: Un peu simplifié. Cliquez ici pour plus de détails → 4.1. Codage de transfert par blocs
Recommended Posts