diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..95d14e1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = crlf +insert_final_newline = true + +[*.java] +indent_style = space +indent_size = 4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd26a8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea/ +build/ +out/ +.gradle/ +*.iml diff --git a/README.md b/README.md index 0fa9bf5..7f44475 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,61 @@ -# http-nio-server -Simple HTTP server built with Java NIO & NIO.2 +# Java NIO simple HTTP server + +## Overview + +A very simple HTTP server that is able to receive GET requests and serve local files. +Only HTTP/1.1 is supported. + +This project is an educational project. + +Built for Java 7+ without any external dependencies. +Works in a non-blocking fashion and uses Java NIO and NIO.2 (combines reactor and +proactor patterns). + +## Build + +To build the server run: + +``` +gradlew build +``` + +The built `.jar` file will be located under `build/libs/`. + +## Run + +To start the server with default configuration run: + +``` +java -jar http-nio-server-1.0.0.jar +``` + +You also can override default configuration by specifying the path as +a command line argument: + +``` +java -jar http-nio-server-1.0.0.jar "/home/settings.properties" +``` + +## Server configuration + +By default tries to read configuration from `settings.properties` file located in the +folder where server executable was run. +Falls back to default configuration if the file was not found. + +Configuration file must have properties format and must have the following settings: + +* `port` (default: 8080) - server port +* `www_root` (default: ) - path to the folder containing files that +the server will be serving; empty or missing value means the folder where +server executable was run +* `session_timeout_secs` (default: 30) - maximum time in seconds that +any connection will be served; connection will be closed when the timeout +is reached +* `max_connections` (default: 10000) - maximum number of simultaneous +connections that will be served + +## License + +Copyright 2018 puzpuzpuz + +Licensed under MIT License. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..144c880 --- /dev/null +++ b/build.gradle @@ -0,0 +1,24 @@ +version '1.0.0' + +apply plugin: 'application' + +sourceCompatibility = 1.7 +targetCompatibility = 1.7 + +mainClassName = 'ru.puzpuzpuz.http.Launcher' + +jar { + manifest { + attributes( + 'Main-Class': mainClassName + ) + } +} + +repositories { + // no external dependencies +} + +dependencies { + testCompile group: 'junit', name: 'junit', version: '4.12' +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..ccd6041 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..0d35f27 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Apr 27 13:00:39 MSK 2018 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.0-all.zip diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f955316 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..9e3283e --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'http-nio-server' diff --git a/settings.properties.sample b/settings.properties.sample new file mode 100644 index 0000000..b638faf --- /dev/null +++ b/settings.properties.sample @@ -0,0 +1,4 @@ +port=8080 +www_root=/home +session_timeout_secs=30 +max_connections=10000 diff --git a/src/main/java/ru/puzpuzpuz/http/HttpServer.java b/src/main/java/ru/puzpuzpuz/http/HttpServer.java new file mode 100644 index 0000000..69b5602 --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/HttpServer.java @@ -0,0 +1,205 @@ +package ru.puzpuzpuz.http; + +import ru.puzpuzpuz.http.handler.HttpRequestHandler; +import ru.puzpuzpuz.http.util.Logger; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.nio.channels.ServerSocketChannel; +import java.nio.channels.SocketChannel; +import java.util.Iterator; +import java.util.Set; + +import static ru.puzpuzpuz.http.util.Constants.SHUTDOWN_TIMEOUT_MILLIS; + +/** + * The main class of the HTTP server. Uses Java NIO for non-blocking HTTP request handling. + * Runs an event loop and orchestrates all other modules internally. + *

+ * Warning: this and all corresponding classes are not thread-safe. The server is supposed to be run in + * a single thread as a {@link Runnable}. Stopping the server with {@link Thread#interrupt()} is not guaranteed to + * be properly working. + *

+ * Also supports a simple graceful shutdown mode for the server with a fixed timeout. During the timeout + * in this mode the server won't be accepting new connections. + */ +public final class HttpServer implements Runnable { + + private static final Logger LOGGER = new Logger(HttpServer.class.getName()); + + private final ServerSettings settings; + + private ServerSocketChannel serverChannel; + private Selector selector; + + private int connectionsNum; + private volatile long shutdownSignalTime = -1L; + + public HttpServer(ServerSettings settings) { + this.settings = settings; + } + + @Override + public void run() { + try { + init(); + startLoop(); + } catch (Exception e) { + LOGGER.error("Unexpected error occurred. Stopping server", e); + } finally { + stop(); + } + } + + private void init() throws IOException { + selector = Selector.open(); + serverChannel = ServerSocketChannel.open(); + serverChannel.configureBlocking(false); + serverChannel.socket().bind(new InetSocketAddress((InetAddress) null, settings.getPort())); + serverChannel.register(selector, SelectionKey.OP_ACCEPT); + + // register a simple graceful shutdown hook + Runtime.getRuntime().addShutdownHook(new Thread() { + @Override + public void start() { + try { + LOGGER.info("Got shutdown signal. Switching to shutdown mode"); + // can't use Thread#interrupt here because this affects Channel#write + HttpServer.this.shutdownSignalTime = System.currentTimeMillis(); + Thread.sleep(SHUTDOWN_TIMEOUT_MILLIS); + } catch (Exception e) { + LOGGER.warn("Error during shutdown. Forced shutdown", e); + } finally { + LOGGER.info("Stopped"); + Logger.resetFinally(); + } + } + }); + + LOGGER.info("Server is now listening on port: " + settings.getPort()); + } + + private void stop() { + try { + LOGGER.info("Stopping server"); + selector.close(); + serverChannel.close(); + } catch (IOException e) { + LOGGER.warn("Error during stopping server. Ignoring", e); + } + } + + private void startLoop() throws IOException { + boolean needToStop = false; + while (!needToStop) { + boolean shutdownMode = shutdownSignalTime > 0; + if (shutdownMode) { + needToStop = System.currentTimeMillis() - shutdownSignalTime >= SHUTDOWN_TIMEOUT_MILLIS; + } + handleLoopTick(shutdownMode); + } + } + + private void handleLoopTick(boolean shutdownMode) throws IOException { + selector.select(); + Set keys = selector.selectedKeys(); + + Iterator keyIterator = keys.iterator(); + while (keyIterator.hasNext()) { + SelectionKey key = keyIterator.next(); + keyIterator.remove(); + try { + if (!key.isValid()) { + continue; + } + + if (key.isAcceptable()) { + if (shutdownMode) { + continue; + } + accept(); + } else if (key.isReadable()) { + if (shutdownMode) { + continue; + } + read(key); + } else if (key.isWritable()) { + write(key); + } + } catch (Exception e) { + LOGGER.error("Closing channel: error while handling selection key. Channel: " + key.channel(), e); + closeChannelSilently(key); + } + } + } + + private void accept() throws IOException { + SocketChannel clientChannel = serverChannel.accept(); + if (clientChannel == null) { + LOGGER.warn("No connection is available. Skipping selection key"); + return; + } + + clientChannel.configureBlocking(false); + clientChannel.register(selector, SelectionKey.OP_READ); + + } + + private void read(SelectionKey key) throws IOException { + SocketChannel clientChannel = (SocketChannel) key.channel(); + + HttpRequestHandler handler = (HttpRequestHandler) key.attachment(); + if (handler == null) { + connectionsNum++; + LOGGER.info("Got new connection handler for channel: " + clientChannel + + ", connection #: " + connectionsNum); + handler = HttpRequestHandler.build(settings, connectionsNum); + key.attach(handler); + return; + } + + handler.read(clientChannel); + + // switch to write mode + key.interestOps(SelectionKey.OP_WRITE); + } + + private void write(SelectionKey key) throws IOException { + HttpRequestHandler handler = (HttpRequestHandler) key.attachment(); + if (handler == null) { + throw new IOException("Handler is missing for the channel: " + key.channel()); + } + + SocketChannel clientChannel = (SocketChannel) key.channel(); + handler.write(clientChannel); + + if (handler.hasNothingToWrite()) { + closeChannelSilently(key); + } else { + // keep writing + key.interestOps(SelectionKey.OP_WRITE); + } + } + + private void closeChannelSilently(SelectionKey key) { + connectionsNum--; + + SocketChannel channel = (SocketChannel) key.channel(); + key.cancel(); + LOGGER.info("Closing connection for channel: " + channel + ", active connections: " + connectionsNum); + + HttpRequestHandler handler = (HttpRequestHandler) key.attachment(); + if (handler != null) { + handler.releaseSilently(); + } + + try { + channel.close(); + } catch (IOException e) { + LOGGER.warn("Error during closing channel: " + channel, e); + } + } +} diff --git a/src/main/java/ru/puzpuzpuz/http/Launcher.java b/src/main/java/ru/puzpuzpuz/http/Launcher.java new file mode 100644 index 0000000..c4b1e2a --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/Launcher.java @@ -0,0 +1,28 @@ +package ru.puzpuzpuz.http; + +import ru.puzpuzpuz.http.util.Logger; + +import static ru.puzpuzpuz.http.util.Constants.SETTINGS_FILE_DEFAULT; + +/** + * The launcher class for the HTTP server. Loads settings and starts the {@link HttpServer}. + */ +public class Launcher { + + private static final Logger LOGGER = new Logger(Launcher.class.getName()); + + public static void main(String[] args) { + String settingsPath = SETTINGS_FILE_DEFAULT; + if (args.length > 0) { + settingsPath = args[0]; + LOGGER.info("Found path for settings file in arguments: " + settingsPath); + } + ServerSettings settings = new ServerSettingsLoader().load(settingsPath); + LOGGER.info("Loaded settings: " + settings); + + LOGGER.info("Starting server"); + HttpServer server = new HttpServer(settings); + new Thread(server, "http-server").start(); + } + +} diff --git a/src/main/java/ru/puzpuzpuz/http/ServerSettings.java b/src/main/java/ru/puzpuzpuz/http/ServerSettings.java new file mode 100644 index 0000000..fb63b70 --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/ServerSettings.java @@ -0,0 +1,46 @@ +package ru.puzpuzpuz.http; + +/** + * A POJO for server settings. + */ +public final class ServerSettings { + + private final int port; + private final String wwwRoot; + private final int sessionTimeoutSecs; + private final int maxConnections; + + public ServerSettings(int port, String wwwRoot, int sessionTimeoutSecs, int maxConnections) { + this.port = port; + this.wwwRoot = wwwRoot; + this.sessionTimeoutSecs = sessionTimeoutSecs; + this.maxConnections = maxConnections; + } + + public int getPort() { + return port; + } + + public String getWwwRoot() { + return wwwRoot; + } + + public int getSessionTimeoutSecs() { + return sessionTimeoutSecs; + } + + public int getMaxConnections() { + return maxConnections; + } + + @Override + public String toString() { + return "ServerSettings{" + + "port=" + port + + ", wwwRoot=" + wwwRoot + + ", sessionTimeoutSecs=" + sessionTimeoutSecs + + ", maxConnections=" + maxConnections + + '}'; + } + +} diff --git a/src/main/java/ru/puzpuzpuz/http/ServerSettingsLoader.java b/src/main/java/ru/puzpuzpuz/http/ServerSettingsLoader.java new file mode 100644 index 0000000..716ab33 --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/ServerSettingsLoader.java @@ -0,0 +1,58 @@ +package ru.puzpuzpuz.http; + +import ru.puzpuzpuz.http.util.Logger; +import ru.puzpuzpuz.http.util.PropertiesReader; + +import java.io.File; +import java.io.IOException; + +import static ru.puzpuzpuz.http.util.Constants.*; + +/** + * A loader class for server settings. Loads settings from the given file with a fallback to default values. + */ +class ServerSettingsLoader { + + private static final Logger LOGGER = new Logger(Launcher.class.getName()); + + public ServerSettings load(String settingsPath) { + int port = SETTINGS_PORT_DEFAULT; + int sessionTimeoutSecs = SETTINGS_SESSION_TIMEOUT_SECS_DEFAULT; + int maxConnections = SETTINGS_MAX_CONNECTIONS_DEFAULT; + String wwwRoot = SETTINGS_WWW_ROOT_DEFAULT; + + try { + PropertiesReader propertiesReader = PropertiesReader.init(settingsPath); + + Integer portSetting = propertiesReader.readIntKey(SETTINGS_PORT); + if (portSetting != null) { + port = portSetting; + } + + Integer sessionTimeoutSecsSetting = propertiesReader.readIntKey(SETTINGS_SESSION_TIMEOUT_SECS); + if (sessionTimeoutSecsSetting != null) { + sessionTimeoutSecs = sessionTimeoutSecsSetting; + } + + Integer maxConnectionsSetting = propertiesReader.readIntKey(SETTINGS_MAX_CONNECTIONS); + if (maxConnectionsSetting != null) { + maxConnections = maxConnectionsSetting; + } + + String wwwRootSetting = propertiesReader.readStringKey(SETTINGS_WWW_ROOT); + if (wwwRootSetting != null) { + wwwRoot = wwwRootSetting; + } + } catch (IOException e) { + LOGGER.warn("Failed to settings from file. Falling back to defaults"); + } + + // use local folder if www root is not specified + if (wwwRoot == null || wwwRoot.trim().isEmpty()) { + wwwRoot = new File(".").getAbsolutePath(); + } + + return new ServerSettings(port, wwwRoot, sessionTimeoutSecs, maxConnections); + } + +} diff --git a/src/main/java/ru/puzpuzpuz/http/fs/AsyncFileReader.java b/src/main/java/ru/puzpuzpuz/http/fs/AsyncFileReader.java new file mode 100644 index 0000000..8134829 --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/fs/AsyncFileReader.java @@ -0,0 +1,35 @@ +package ru.puzpuzpuz.http.fs; + +import java.io.File; +import java.io.IOException; + +/** + * A simple wrapper on top of {@link java.nio.channels.AsynchronousFileChannel} (with default thread pool + * configuration). Reads the file sequentially in async fashion. Reading the next chunk of file data can be + * scheduled by calling {@link #readNextChunk}. + *

+ * Note that only a single read can be pending simultaneously. + */ +public abstract class AsyncFileReader { + + public static AsyncFileReader open(String root, String path) throws IOException { + String filePath = (root + path).replace("/", File.separator); + return new AsyncFileReaderImpl(filePath); + } + + public abstract void readNextChunk(ReadHandler handler); + + public abstract FileMetadata getMetadata(); + + public abstract void closeSilently(); + + public interface ReadHandler { + + void onRead(byte[] data); + + void onComplete(); + + void onError(Throwable e); + + } +} diff --git a/src/main/java/ru/puzpuzpuz/http/fs/AsyncFileReaderImpl.java b/src/main/java/ru/puzpuzpuz/http/fs/AsyncFileReaderImpl.java new file mode 100644 index 0000000..688de6a --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/fs/AsyncFileReaderImpl.java @@ -0,0 +1,86 @@ +package ru.puzpuzpuz.http.fs; + +import ru.puzpuzpuz.http.util.Logger; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.AsynchronousFileChannel; +import java.nio.channels.CompletionHandler; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; + +import static ru.puzpuzpuz.http.util.Constants.FILE_READ_BUFFER_SIZE_BYTES; + +class AsyncFileReaderImpl extends AsyncFileReader { + + private static final Logger LOGGER = new Logger(AsyncFileReaderImpl.class.getName()); + + private final AsynchronousFileChannel fileChannel; + private final FileMetadata metadata; + + private final ByteBuffer readBuffer = ByteBuffer.allocate(FILE_READ_BUFFER_SIZE_BYTES); + private volatile int readPos; + + private volatile boolean reading; + + AsyncFileReaderImpl(String filePath) throws IOException { + fileChannel = AsynchronousFileChannel.open(Paths.get(filePath), StandardOpenOption.READ); + metadata = new FileMetadata(fileChannel.size(), filePath); + } + + @Override + public void readNextChunk(final ReadHandler handler) { + // this method is called from a single thread, so volatile field is enough here + if (reading) { + return; + } + reading = true; + + fileChannel.read(readBuffer, readPos, null, + new CompletionHandler() { + @Override + public void completed(Integer result, Void attachment) { + if (result == -1) { + handler.onComplete(); + return; + } + + readPos += result; + readBuffer.flip(); + byte[] data = new byte[result]; + System.arraycopy(readBuffer.array(), 0, data, 0, result); + readBuffer.clear(); + + handler.onRead(data); + + if (readPos == metadata.getSize()) { + handler.onComplete(); + return; + } + + reading = false; + } + + @Override + public void failed(Throwable e, Void attachment) { + handler.onError(e); + reading = false; + } + } + ); + } + + @Override + public FileMetadata getMetadata() { + return metadata; + } + + @Override + public void closeSilently() { + try { + fileChannel.close(); + } catch (IOException e) { + LOGGER.warn("Error during closing file channel", e); + } + } +} diff --git a/src/main/java/ru/puzpuzpuz/http/fs/FileMetadata.java b/src/main/java/ru/puzpuzpuz/http/fs/FileMetadata.java new file mode 100644 index 0000000..d295599 --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/fs/FileMetadata.java @@ -0,0 +1,31 @@ +package ru.puzpuzpuz.http.fs; + +/** + * A POJO for file metadata. + */ +public final class FileMetadata { + + private final long size; + private final String filePath; + + public FileMetadata(long size, String filePath) { + this.size = size; + this.filePath = filePath; + } + + public long getSize() { + return size; + } + + public String getFilePath() { + return filePath; + } + + @Override + public String toString() { + return "FileMetadata{" + + "size=" + size + + ", filePath='" + filePath + '\'' + + '}'; + } +} diff --git a/src/main/java/ru/puzpuzpuz/http/handler/HttpRequestHandler.java b/src/main/java/ru/puzpuzpuz/http/handler/HttpRequestHandler.java new file mode 100644 index 0000000..629eba1 --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/handler/HttpRequestHandler.java @@ -0,0 +1,31 @@ +package ru.puzpuzpuz.http.handler; + +import ru.puzpuzpuz.http.ServerSettings; +import ru.puzpuzpuz.http.fs.AsyncFileReader; + +import java.io.IOException; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.WritableByteChannel; + +/** + * The main class responsible for HTTP communication. Handles the incoming data, parses it as a HTTP request + * and sends the response. + *

+ * When serving files it first flushes headers, then registers file read callbacks with an internal instance of + * {@link AsyncFileReader}. As those callbacks are run on separate threads, necessary concurrency control takes place. + */ +public abstract class HttpRequestHandler { + + public abstract void read(ReadableByteChannel channel) throws IOException; + + public abstract void write(WritableByteChannel channel) throws IOException; + + public abstract boolean hasNothingToWrite(); + + public abstract void releaseSilently(); + + public static HttpRequestHandler build(ServerSettings settings, int connectionNum) { + return new HttpRequestHandlerImpl(settings, connectionNum); + } + +} diff --git a/src/main/java/ru/puzpuzpuz/http/handler/HttpRequestHandlerImpl.java b/src/main/java/ru/puzpuzpuz/http/handler/HttpRequestHandlerImpl.java new file mode 100644 index 0000000..ba4d84d --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/handler/HttpRequestHandlerImpl.java @@ -0,0 +1,178 @@ +package ru.puzpuzpuz.http.handler; + +import ru.puzpuzpuz.http.ServerSettings; +import ru.puzpuzpuz.http.fs.AsyncFileReader; +import ru.puzpuzpuz.http.request.HttpRequest; +import ru.puzpuzpuz.http.request.HttpRequestParser; +import ru.puzpuzpuz.http.request.HttpRequestValidator; +import ru.puzpuzpuz.http.request.RawRequestReader; +import ru.puzpuzpuz.http.response.HttpResponse; +import ru.puzpuzpuz.http.response.HttpResponseFactory; +import ru.puzpuzpuz.http.response.HttpResponseWriter; +import ru.puzpuzpuz.http.util.Logger; +import ru.puzpuzpuz.http.util.OptimisticLock; + +import java.io.IOException; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.WritableByteChannel; + +class HttpRequestHandlerImpl extends HttpRequestHandler { + + private static final Logger LOGGER = new Logger(HttpRequestHandlerImpl.class.getName()); + + private HttpRequest request; + private HttpResponse response; + + private final HttpResponseWriter responseWriter = new HttpResponseWriter(); + private final OptimisticLock writeLock = new OptimisticLock(); + private AsyncFileReader fileReader; + private FileReadHandler fileReadHandler; + + private final ServerSettings settings; + private final int sessionTimeoutMillis; + private final long creationTimeMillis; + private final long connectionNum; + + HttpRequestHandlerImpl(ServerSettings settings, int connectionNum) { + this.settings = settings; + this.sessionTimeoutMillis = settings.getSessionTimeoutSecs() * 1000; + this.creationTimeMillis = System.currentTimeMillis(); + this.connectionNum = connectionNum; + } + + @Override + public void read(ReadableByteChannel channel) throws IOException { + String raw = new RawRequestReader().readRaw(channel); + request = new HttpRequestParser().parse(raw); + LOGGER.info("Parsed incoming HTTP request: " + request); + + HttpRequestValidator validator = new HttpRequestValidator(sessionTimeoutMillis, creationTimeMillis, + connectionNum, settings.getMaxConnections()); + response = validator.validate(request); + if (response != null) { + LOGGER.warn("Invalid incoming HTTP request: " + request + ", response: " + response); + } + } + + @Override + public void write(WritableByteChannel channel) throws IOException { + if (request == null) { + throw new IllegalStateException("Request is not initialized"); + } + + initFileResponse(); + responseWriter.writeHeaders(channel, response); + validateSessionTimeout(); + writePendingContent(channel); + scheduleFileForRead(); + } + + private void initFileResponse() { + // an error response may be already there + if (response != null) { + return; + } + + HttpResponseFactory httpResponseFactory = new HttpResponseFactory(); + try { + fileReader = AsyncFileReader.open(settings.getWwwRoot(), request.getPath()); + fileReadHandler = new FileReadHandler(); + response = httpResponseFactory.buildFileResponse(fileReader.getMetadata()); + LOGGER.info("Started reading file for request: " + request); + } catch (IOException e) { + LOGGER.warn("Could not read file for request: " + request); + response = httpResponseFactory.buildNotFound("Could not read file"); + } + } + + private void validateSessionTimeout() { + long time = System.currentTimeMillis(); + if (time - creationTimeMillis > sessionTimeoutMillis) { + writeLock.lock(); + + try { + LOGGER.warn("Session timeout exceeded for request: " + request); + response.markAsComplete(); + // get rid of buffered pending data + response.flushPendingContent(); + } finally { + writeLock.unlock(); + } + } + } + + private void writePendingContent(WritableByteChannel channel) throws IOException { + // write only if there is an opportunity, otherwise - write on next tick + boolean responseLocked = writeLock.tryLock(); + if (responseLocked) { + try { + responseWriter.writeContent(channel, response); + } finally { + writeLock.unlock(); + } + } + } + + private void scheduleFileForRead() { + if (fileReader == null) { + return; + } + fileReader.readNextChunk(fileReadHandler); + } + + @Override + public boolean hasNothingToWrite() { + boolean responseLocked = writeLock.tryLock(); + if (responseLocked) { + try { + return response.isComplete() && !response.hasPendingContent(); + } finally { + writeLock.unlock(); + } + } + return false; + } + + @Override + public void releaseSilently() { + if (fileReader != null) { + fileReader.closeSilently(); + } + } + + private class FileReadHandler implements AsyncFileReader.ReadHandler { + @Override + public void onRead(byte[] data) { + writeLock.lock(); + try { + if (!response.isComplete()) { + response.addContentChunk(data); + } + } finally { + writeLock.unlock(); + } + } + + @Override + public void onComplete() { + LOGGER.info("Finished reading file for request: " + request); + writeLock.lock(); + try { + response.markAsComplete(); + } finally { + writeLock.unlock(); + } + } + + @Override + public void onError(Throwable e) { + LOGGER.error("Error during reading file for request: " + request, e); + writeLock.lock(); + try { + response.markAsComplete(); + } finally { + writeLock.unlock(); + } + } + } +} diff --git a/src/main/java/ru/puzpuzpuz/http/request/HttpRequest.java b/src/main/java/ru/puzpuzpuz/http/request/HttpRequest.java new file mode 100644 index 0000000..df968b0 --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/request/HttpRequest.java @@ -0,0 +1,38 @@ +package ru.puzpuzpuz.http.request; + +/** + * A POJO that contains HTTP request data that is necessary for the server. + */ +public final class HttpRequest { + + private final String method; + private final String path; + private final String version; + + public HttpRequest(String method, String path, String version) { + this.method = method; + this.path = path; + this.version = version; + } + + public String getMethod() { + return method; + } + + public String getPath() { + return path; + } + + public String getVersion() { + return version; + } + + @Override + public String toString() { + return "HttpRequest{" + + "method='" + method + '\'' + + ", path='" + path + '\'' + + ", version='" + version + '\'' + + '}'; + } +} diff --git a/src/main/java/ru/puzpuzpuz/http/request/HttpRequestParser.java b/src/main/java/ru/puzpuzpuz/http/request/HttpRequestParser.java new file mode 100644 index 0000000..c437929 --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/request/HttpRequestParser.java @@ -0,0 +1,25 @@ +package ru.puzpuzpuz.http.request; + +import java.io.IOException; +import java.util.StringTokenizer; + +/** + * A simple parser for HTTP requests. During parsing it tries to tokenize incoming data and + * parses it as an HTTP request. + */ +public final class HttpRequestParser { + + public HttpRequest parse(String raw) throws IOException { + try { + StringTokenizer tokenizer = new StringTokenizer(raw); + String method = tokenizer.nextToken().toUpperCase(); + String path = tokenizer.nextToken(); + String version = tokenizer.nextToken(); + + return new HttpRequest(method, path, version); + } catch (Exception e) { + throw new IOException("Malformed request", e); + } + } + +} diff --git a/src/main/java/ru/puzpuzpuz/http/request/HttpRequestValidator.java b/src/main/java/ru/puzpuzpuz/http/request/HttpRequestValidator.java new file mode 100644 index 0000000..8f505a7 --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/request/HttpRequestValidator.java @@ -0,0 +1,60 @@ +package ru.puzpuzpuz.http.request; + +import ru.puzpuzpuz.http.response.HttpResponse; +import ru.puzpuzpuz.http.response.HttpResponseFactory; + +import static ru.puzpuzpuz.http.util.Constants.SUPPORTED_HTTP_METHOD; +import static ru.puzpuzpuz.http.util.Constants.SUPPORTED_HTTP_VERSION; + +/** + * A simple validator for {@link HttpRequest}. Validates the request against supported HTTP version and methods, + * also checks session timeout and connections limit. + */ +public final class HttpRequestValidator { + + private final int sessionTimeoutMillis; + private final long creationTimeMillis; + private final long connectionNum; + private final long maxConnectionsNum; + + private final HttpResponseFactory httpResponseFactory = new HttpResponseFactory(); + + public HttpRequestValidator(int sessionTimeoutMillis, long creationTimeMillis, + long connectionNum, long maxConnectionsNum) { + this.sessionTimeoutMillis = sessionTimeoutMillis; + this.creationTimeMillis = creationTimeMillis; + this.connectionNum = connectionNum; + this.maxConnectionsNum = maxConnectionsNum; + } + + public HttpResponse validate(HttpRequest request) { + // validateSupported request (method, etc.) + String invalidReason = validateSupported(request); + if (invalidReason != null) { + return httpResponseFactory.buildBadRequest(invalidReason); + } + // check session timeout before starting write + long time = System.currentTimeMillis(); + if (time - creationTimeMillis > sessionTimeoutMillis) { + return httpResponseFactory.buildRequestTimeout(); + } + // check if connection number exceeds the limit + if (connectionNum > maxConnectionsNum) { + return httpResponseFactory.buildTooManyRequests(); + } + return null; + } + + private String validateSupported(HttpRequest request) { + String method = request.getMethod(); + if (method == null || !method.equals(SUPPORTED_HTTP_METHOD)) { + return "Unsupported method"; + } + String version = request.getVersion(); + if (version == null || !version.equals(SUPPORTED_HTTP_VERSION)) { + return "Unsupported HTTP version"; + } + return null; + } + +} diff --git a/src/main/java/ru/puzpuzpuz/http/request/RawRequestReader.java b/src/main/java/ru/puzpuzpuz/http/request/RawRequestReader.java new file mode 100644 index 0000000..f87c0c6 --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/request/RawRequestReader.java @@ -0,0 +1,42 @@ +package ru.puzpuzpuz.http.request; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.ReadableByteChannel; + +import static ru.puzpuzpuz.http.util.Constants.SOCKET_READ_BUFFER_SIZE_BYTES; +import static ru.puzpuzpuz.http.util.Constants.SOCKET_READ_DATA_LIMIT_BYTES; + +/** + * A reader for data incoming over the socket. Does bufferized reading of the data and converts it to text. + */ +public final class RawRequestReader { + + private final ByteBuffer readBuffer = ByteBuffer.allocate(SOCKET_READ_BUFFER_SIZE_BYTES); + + public String readRaw(ReadableByteChannel channel) throws IOException { + StringBuilder sb = new StringBuilder(); + readBuffer.clear(); + int read; + int totalRead = 0; + while ((read = channel.read(readBuffer)) > 0) { + totalRead += read; + if (totalRead > SOCKET_READ_DATA_LIMIT_BYTES) { + throw new IOException("Request data limit exceeded"); + } + + readBuffer.flip(); + byte[] bytes = new byte[readBuffer.limit()]; + readBuffer.get(bytes); + sb.append(new String(bytes)); + readBuffer.clear(); + } + + if (read < 0) { + throw new IOException("End of input stream. Connection is closed by the client"); + } + + return sb.toString(); + } + +} diff --git a/src/main/java/ru/puzpuzpuz/http/response/HttpResponse.java b/src/main/java/ru/puzpuzpuz/http/response/HttpResponse.java new file mode 100644 index 0000000..a53eb2a --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/response/HttpResponse.java @@ -0,0 +1,106 @@ +package ru.puzpuzpuz.http.response; + +import java.text.SimpleDateFormat; +import java.util.*; + +import static ru.puzpuzpuz.http.util.Constants.SUPPORTED_HTTP_VERSION; + +/** + * A POJO that contains all data that will be sent in a HTTP response. Also contains fields that indicate + * state of transitioning the response over the wire. + */ +public final class HttpResponse { + + private static final SimpleDateFormat dateFormat = new SimpleDateFormat( + "EEE, dd MMM yyyy HH:mm:ss z", Locale.US); + + static { + dateFormat.setTimeZone(TimeZone.getTimeZone("GMT")); + } + + private int code; + private String reason; + private final Map headers = new HashMap<>(); + + private List pendingContent = new LinkedList<>(); + private int pendingContentLength; + private long contentLength; + + private boolean complete; + private boolean wroteHeaders; + + public void addDefaultHeaders() { + Calendar calendar = Calendar.getInstance(); + this.headers.put("Date", dateFormat.format(calendar.getTime())); + this.headers.put("Server", "Simple NIO HTTP Server v1.0.0"); + this.headers.put("Connection", "closeSilently"); + this.headers.put("Content-Length", Long.toString(contentLength)); + } + + public String generatePrefix() { + return SUPPORTED_HTTP_VERSION + " " + code + " " + reason; + } + + public Map getHeaders() { + return headers; + } + + public boolean hasPendingContent() { + return pendingContentLength > 0; + } + + public byte[] flushPendingContent() { + byte[] result = new byte[pendingContentLength]; + int pos = 0; + for (byte[] chunk : pendingContent) { + System.arraycopy(chunk, 0, result, pos, chunk.length); + pos += chunk.length; + } + + pendingContent = new LinkedList<>(); + pendingContentLength = 0; + + return result; + } + + public void addContentChunk(byte[] chunk) { + pendingContent.add(chunk); + pendingContentLength += chunk.length; + } + + public void setContentLength(long contentLength) { + this.contentLength = contentLength; + } + + public boolean isComplete() { + return complete; + } + + public void markAsComplete() { + this.complete = true; + } + + public boolean isWroteHeaders() { + return wroteHeaders; + } + + public void markAsWroteHeaders() { + this.wroteHeaders = true; + } + + public void setCode(int code) { + this.code = code; + } + + public void setReason(String reason) { + this.reason = reason; + } + + @Override + public String toString() { + return "HttpResponse{" + + "code=" + code + + ", reason='" + reason + '\'' + + '}'; + } +} diff --git a/src/main/java/ru/puzpuzpuz/http/response/HttpResponseFactory.java b/src/main/java/ru/puzpuzpuz/http/response/HttpResponseFactory.java new file mode 100644 index 0000000..0350213 --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/response/HttpResponseFactory.java @@ -0,0 +1,50 @@ +package ru.puzpuzpuz.http.response; + +import ru.puzpuzpuz.http.fs.FileMetadata; +import ru.puzpuzpuz.http.util.Constants.HttpStatus; + +/** + * A simple factory for {@link HttpResponse}. + */ +public final class HttpResponseFactory { + + public HttpResponse buildBadRequest(String msg) { + return buildImmediateResponse(HttpStatus.BAD_REQUEST, msg); + } + + public HttpResponse buildRequestTimeout() { + return buildImmediateResponse(HttpStatus.REQUEST_TIMEOUT, + "Session timeout exceeded"); + } + + public HttpResponse buildTooManyRequests() { + return buildImmediateResponse(HttpStatus.TOO_MANY_REQUESTS, + "Connections number exceeded the limit"); + } + + public HttpResponse buildNotFound(String msg) { + return buildImmediateResponse(HttpStatus.NOT_FOUND, msg); + } + + private HttpResponse buildImmediateResponse(HttpStatus status, String msg) { + HttpResponse response = new HttpResponse(); + response.setCode(status.code); + response.setReason(status.reason); + byte[] content = msg.getBytes(); + response.addContentChunk(content); + response.setContentLength(content.length); + response.markAsComplete(); + response.addDefaultHeaders(); + return response; + } + + public HttpResponse buildFileResponse(FileMetadata metadata) { + HttpResponse response = new HttpResponse(); + response.setCode(HttpStatus.SUCCESS.code); + response.setReason(HttpStatus.SUCCESS.reason); + response.setContentLength(metadata.getSize()); + response.addDefaultHeaders(); + return response; + } + +} diff --git a/src/main/java/ru/puzpuzpuz/http/response/HttpResponseWriter.java b/src/main/java/ru/puzpuzpuz/http/response/HttpResponseWriter.java new file mode 100644 index 0000000..5fbee4b --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/response/HttpResponseWriter.java @@ -0,0 +1,53 @@ +package ru.puzpuzpuz.http.response; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.channels.WritableByteChannel; +import java.nio.charset.Charset; +import java.nio.charset.CharsetEncoder; +import java.util.Map; +import java.util.Set; + +/** + * A simple class that is able to write {@link HttpResponse} over the socket. + */ +public final class HttpResponseWriter { + + private final Charset charset = Charset.forName("UTF-8"); + private final CharsetEncoder encoder = charset.newEncoder(); + + public void writeHeaders(WritableByteChannel channel, HttpResponse response) throws IOException { + if (response.isWroteHeaders()) { + return; + } + + String prefix = response.generatePrefix(); + writeLine(channel, prefix); + + Set> headers = response.getHeaders().entrySet(); + for (Map.Entry header : headers) { + writeLine(channel, header.getKey() + ": " + header.getValue()); + } + writeLine(channel, ""); + + response.markAsWroteHeaders(); + } + + public void writeContent(WritableByteChannel channel, HttpResponse response) throws IOException { + if (!response.hasPendingContent()) { + return; + } + + byte[] content = response.flushPendingContent(); + ByteBuffer byteBuffer = ByteBuffer.wrap(content); + channel.write(byteBuffer); + } + + private void writeLine(WritableByteChannel channel, String line) throws IOException { + CharBuffer charBuffer = CharBuffer.wrap(line + "\r\n"); + ByteBuffer byteBuffer = encoder.encode(charBuffer); + channel.write(byteBuffer); + } + +} diff --git a/src/main/java/ru/puzpuzpuz/http/util/Constants.java b/src/main/java/ru/puzpuzpuz/http/util/Constants.java new file mode 100644 index 0000000..8ee9586 --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/util/Constants.java @@ -0,0 +1,47 @@ +package ru.puzpuzpuz.http.util; + +/** + * A simple container for various constants. + */ +public interface Constants { + + String SUPPORTED_HTTP_METHOD = "GET"; + String SUPPORTED_HTTP_VERSION = "HTTP/1.1"; + + long SHUTDOWN_TIMEOUT_MILLIS = 10000L; + + int SOCKET_READ_DATA_LIMIT_BYTES = 32768; + int SOCKET_READ_BUFFER_SIZE_BYTES = 8192; + int FILE_READ_BUFFER_SIZE_BYTES = 8192; + + String SETTINGS_FILE_DEFAULT = "settings.properties"; + + String SETTINGS_PORT = "port"; + String SETTINGS_WWW_ROOT = "www_root"; + String SETTINGS_SESSION_TIMEOUT_SECS = "session_timeout_secs"; + String SETTINGS_MAX_CONNECTIONS = "max_connections"; + + int SETTINGS_PORT_DEFAULT = 8080; + String SETTINGS_WWW_ROOT_DEFAULT = ""; + int SETTINGS_SESSION_TIMEOUT_SECS_DEFAULT = 30; + int SETTINGS_MAX_CONNECTIONS_DEFAULT = 10000; + + enum HttpStatus { + + SUCCESS(200, "OK"), + BAD_REQUEST(400, "Bad Request"), + REQUEST_TIMEOUT(408, "Request Timeout"), + TOO_MANY_REQUESTS(429, "Too Many Requests"), + NOT_FOUND(404, "Not Found"); + + public final int code; + public final String reason; + + HttpStatus(int code, String reason) { + this.code = code; + this.reason = reason; + } + + } + +} diff --git a/src/main/java/ru/puzpuzpuz/http/util/Logger.java b/src/main/java/ru/puzpuzpuz/http/util/Logger.java new file mode 100644 index 0000000..5313340 --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/util/Logger.java @@ -0,0 +1,68 @@ +package ru.puzpuzpuz.http.util; + +import java.util.logging.Level; +import java.util.logging.LogManager; + +/** + * A very primitive wrapper for JUL logger. Registers it's own shutdown-friendly {@link LogManager} that keeps + * outputting after shutdown signal. + */ +public class Logger { + + static { + // must be called before any Logger method is used + System.setProperty("java.util.logging.manager", ShutdownFriendlyLogManager.class.getName()); + } + + private final java.util.logging.Logger _logger; + + public Logger(String name) { + this._logger = java.util.logging.Logger.getLogger(name); + } + + public void info(String msg) { + _logger.log(Level.INFO, msg); + } + + public void warn(String msg) { + _logger.log(Level.WARNING, msg); + } + + public void warn(String msg, Throwable throwable) { + _logger.log(Level.WARNING, msg, throwable); + } + + public void error(String msg, Throwable throwable) { + _logger.log(Level.SEVERE, msg, throwable); + } + + /** + * Resets all settings of JUL LogManager. + */ + public static void resetFinally() { + ShutdownFriendlyLogManager.resetFinally(); + } + + public static class ShutdownFriendlyLogManager extends LogManager { + + static ShutdownFriendlyLogManager instance; + + public ShutdownFriendlyLogManager() { + instance = this; + } + + @Override + public void reset() { + // don't reset yet + } + + private void reset0() { + super.reset(); + } + + static void resetFinally() { + instance.reset0(); + } + } + +} diff --git a/src/main/java/ru/puzpuzpuz/http/util/OptimisticLock.java b/src/main/java/ru/puzpuzpuz/http/util/OptimisticLock.java new file mode 100644 index 0000000..a2d905c --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/util/OptimisticLock.java @@ -0,0 +1,28 @@ +package ru.puzpuzpuz.http.util; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A simple lock with optimistic locking support. Based on {@link AtomicBoolean}. + *

+ * Note: StampedLock is not used as it's only available since Java 8. + */ +public final class OptimisticLock { + + private final AtomicBoolean lockMarker = new AtomicBoolean(false); + + public void lock() { + while (!tryLock()) { + // keep the loop + } + } + + public boolean tryLock() { + return lockMarker.compareAndSet(false, true); + } + + public void unlock() { + lockMarker.set(false); + } + +} diff --git a/src/main/java/ru/puzpuzpuz/http/util/PropertiesReader.java b/src/main/java/ru/puzpuzpuz/http/util/PropertiesReader.java new file mode 100644 index 0000000..c7edbba --- /dev/null +++ b/src/main/java/ru/puzpuzpuz/http/util/PropertiesReader.java @@ -0,0 +1,39 @@ +package ru.puzpuzpuz.http.util; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +/** + * A reader for files in .properties format. + */ +public class PropertiesReader { + + private final Properties properties; + + private PropertiesReader(Properties properties) { + this.properties = properties; + } + + public static PropertiesReader init(String settingsPath) throws IOException { + try ( + InputStream inputStream = new FileInputStream(new File(settingsPath)) + ) { + Properties properties = new Properties(); + properties.load(inputStream); + return new PropertiesReader(properties); + } + } + + public Integer readIntKey(String key) { + String keyVal = properties.getProperty(key); + return keyVal != null ? Integer.valueOf(keyVal) : null; + } + + public String readStringKey(String key) { + return properties.getProperty(key); + } + +}