forked from kscience/visionforge
Refactor server API
This commit is contained in:
parent
20b20a621f
commit
c8141c6338
@ -2,6 +2,7 @@ package space.kscience.visionforge.examples
|
||||
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.http.content.resources
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.html.*
|
||||
import space.kscience.dataforge.context.Global
|
||||
@ -10,6 +11,7 @@ import space.kscience.visionforge.VisionManager
|
||||
import space.kscience.visionforge.html.VisionPage
|
||||
import space.kscience.visionforge.html.formFragment
|
||||
import space.kscience.visionforge.onPropertyChange
|
||||
import space.kscience.visionforge.server.EngineConnectorConfig
|
||||
import space.kscience.visionforge.server.close
|
||||
import space.kscience.visionforge.server.openInBrowser
|
||||
import space.kscience.visionforge.server.visionPage
|
||||
@ -17,48 +19,54 @@ import space.kscience.visionforge.server.visionPage
|
||||
fun main() {
|
||||
val visionManager = Global.fetch(VisionManager)
|
||||
|
||||
val server = embeddedServer(CIO, 7777, "localhost") {
|
||||
|
||||
val connector = EngineConnectorConfig("localhost", 7777)
|
||||
|
||||
val server = embeddedServer(CIO, connector.port, connector.host) {
|
||||
|
||||
routing {
|
||||
visionPage(visionManager, VisionPage.scriptHeader("js/visionforge-playground.js")) {
|
||||
val form = formFragment("form") {
|
||||
label {
|
||||
htmlFor = "fname"
|
||||
+"First name:"
|
||||
}
|
||||
br()
|
||||
input {
|
||||
type = InputType.text
|
||||
id = "fname"
|
||||
name = "fname"
|
||||
value = "John"
|
||||
}
|
||||
br()
|
||||
label {
|
||||
htmlFor = "lname"
|
||||
+"Last name:"
|
||||
}
|
||||
br()
|
||||
input {
|
||||
type = InputType.text
|
||||
id = "lname"
|
||||
name = "lname"
|
||||
value = "Doe"
|
||||
}
|
||||
br()
|
||||
br()
|
||||
input {
|
||||
type = InputType.submit
|
||||
value = "Submit"
|
||||
}
|
||||
}
|
||||
resources()
|
||||
}
|
||||
|
||||
vision("form") { form }
|
||||
form.onPropertyChange {
|
||||
println(this)
|
||||
visionPage(connector, visionManager, VisionPage.scriptHeader("js/visionforge-playground.js")) {
|
||||
val form = formFragment("form") {
|
||||
label {
|
||||
htmlFor = "fname"
|
||||
+"First name:"
|
||||
}
|
||||
br()
|
||||
input {
|
||||
type = InputType.text
|
||||
id = "fname"
|
||||
name = "fname"
|
||||
value = "John"
|
||||
}
|
||||
br()
|
||||
label {
|
||||
htmlFor = "lname"
|
||||
+"Last name:"
|
||||
}
|
||||
br()
|
||||
input {
|
||||
type = InputType.text
|
||||
id = "lname"
|
||||
name = "lname"
|
||||
value = "Doe"
|
||||
}
|
||||
br()
|
||||
br()
|
||||
input {
|
||||
type = InputType.submit
|
||||
value = "Submit"
|
||||
}
|
||||
}
|
||||
|
||||
vision("form") { form }
|
||||
form.onPropertyChange {
|
||||
println(this)
|
||||
}
|
||||
}
|
||||
|
||||
}.start(false)
|
||||
|
||||
server.openInBrowser()
|
||||
|
@ -15,6 +15,7 @@ import space.kscience.dataforge.meta.Null
|
||||
import space.kscience.dataforge.names.Name
|
||||
import space.kscience.visionforge.Colors
|
||||
import space.kscience.visionforge.html.VisionPage
|
||||
import space.kscience.visionforge.server.EngineConnectorConfig
|
||||
import space.kscience.visionforge.server.close
|
||||
import space.kscience.visionforge.server.openInBrowser
|
||||
import space.kscience.visionforge.server.visionPage
|
||||
@ -36,21 +37,26 @@ fun main() {
|
||||
color.set(Colors.white)
|
||||
}
|
||||
}
|
||||
val connector = EngineConnectorConfig("localhost", 7777)
|
||||
|
||||
val server = embeddedServer(CIO,7777, "localhost") {
|
||||
val server = embeddedServer(CIO, connector.port, connector.host) {
|
||||
routing {
|
||||
|
||||
static {
|
||||
resources()
|
||||
}
|
||||
}
|
||||
|
||||
visionPage(solids.visionManager, VisionPage.threeJsHeader, VisionPage.styleSheetHeader("css/styles.css")) {
|
||||
div("flex-column") {
|
||||
h1 { +"Satellite detector demo" }
|
||||
vision { sat }
|
||||
}
|
||||
visionPage(
|
||||
connector,
|
||||
solids.visionManager, VisionPage.threeJsHeader,
|
||||
VisionPage.styleSheetHeader("css/styles.css")
|
||||
) {
|
||||
div("flex-column") {
|
||||
h1 { +"Satellite detector demo" }
|
||||
vision { sat }
|
||||
}
|
||||
}
|
||||
|
||||
}.start(false)
|
||||
|
||||
server.openInBrowser()
|
||||
|
@ -4,9 +4,8 @@ import io.ktor.http.URLProtocol
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.ApplicationEngine
|
||||
import io.ktor.server.engine.EngineConnectorConfig
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.routing.Routing
|
||||
import io.ktor.server.routing.route
|
||||
import io.ktor.server.util.url
|
||||
import io.ktor.server.websocket.WebSockets
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -26,8 +25,8 @@ import space.kscience.visionforge.html.HtmlFormFragment
|
||||
import space.kscience.visionforge.html.HtmlVisionFragment
|
||||
import space.kscience.visionforge.html.VisionCollector
|
||||
import space.kscience.visionforge.html.visionFragment
|
||||
import space.kscience.visionforge.server.VisionRouteConfiguration
|
||||
import space.kscience.visionforge.server.require
|
||||
import space.kscience.visionforge.server.EngineConnectorConfig
|
||||
import space.kscience.visionforge.server.VisionRoute
|
||||
import space.kscience.visionforge.server.serveVisionData
|
||||
import space.kscience.visionforge.visionManager
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
@ -48,8 +47,6 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout
|
||||
|
||||
public val visionManager: VisionManager = context.visionManager
|
||||
|
||||
private val configuration = VisionRouteConfiguration(visionManager)
|
||||
|
||||
private var counter = 0
|
||||
|
||||
private var engine: ApplicationEngine? = null
|
||||
@ -68,7 +65,7 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout
|
||||
|
||||
public fun startServer(
|
||||
host: String = context.properties["visionforge.host"].string ?: "localhost",
|
||||
port: Int = context.properties["visionforge.port"].int ?: VisionRouteConfiguration.DEFAULT_PORT,
|
||||
port: Int = context.properties["visionforge.port"].int ?: VisionRoute.DEFAULT_PORT,
|
||||
): MimeTypedResult = html {
|
||||
if (engine != null) {
|
||||
p {
|
||||
@ -77,6 +74,8 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout
|
||||
}
|
||||
}
|
||||
|
||||
val connector: EngineConnectorConfig = EngineConnectorConfig(host, port)
|
||||
|
||||
engine?.stop(1000, 2000)
|
||||
engine = context.embeddedServer(CIO, port, host) {
|
||||
install(WebSockets)
|
||||
@ -111,7 +110,7 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout
|
||||
val collector: VisionCollector = mutableMapOf()
|
||||
|
||||
val url = engine.environment.connectors.first().let {
|
||||
url{
|
||||
url {
|
||||
protocol = URLProtocol.WS
|
||||
host = it.host
|
||||
port = it.port
|
||||
@ -119,6 +118,8 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout
|
||||
}
|
||||
}
|
||||
|
||||
engine.application.serveVisionData(VisionRoute(cellRoute, visionManager), collector)
|
||||
|
||||
visionFragment(
|
||||
context,
|
||||
embedData = true,
|
||||
@ -126,13 +127,6 @@ public class VFForNotebook(override val context: Context) : ContextAware, Corout
|
||||
collector = collector,
|
||||
fragment = fragment
|
||||
)
|
||||
|
||||
engine.application.require(Routing) {
|
||||
route(cellRoute) {
|
||||
serveVisionData(TODO(), collector)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
//if not, use static rendering
|
||||
visionFragment(context, fragment = fragment)
|
||||
|
@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.html.*
|
||||
import kotlinx.html.stream.createHTML
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.ContextAware
|
||||
import space.kscience.dataforge.meta.*
|
||||
@ -30,33 +31,32 @@ import space.kscience.visionforge.VisionChange
|
||||
import space.kscience.visionforge.VisionManager
|
||||
import space.kscience.visionforge.flowChanges
|
||||
import space.kscience.visionforge.html.*
|
||||
import java.awt.Desktop
|
||||
import java.net.URI
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
public enum class DataServeMode {
|
||||
/**
|
||||
* Embed the initial state of the vision inside its html tag.
|
||||
*/
|
||||
EMBED,
|
||||
|
||||
/**
|
||||
* Fetch data on vision load. Do not embed data.
|
||||
*/
|
||||
FETCH,
|
||||
|
||||
/**
|
||||
* Connect to server to get pushes. The address of the server is embedded in the tag.
|
||||
*/
|
||||
UPDATE
|
||||
}
|
||||
|
||||
public class VisionRouteConfiguration(
|
||||
public class VisionRoute(
|
||||
public val route: String,
|
||||
public val visionManager: VisionManager,
|
||||
override val meta: ObservableMutableMeta = MutableMeta(),
|
||||
) : Configurable, ContextAware {
|
||||
|
||||
public enum class Mode {
|
||||
/**
|
||||
* Embed the initial state of the vision inside its html tag.
|
||||
*/
|
||||
EMBED,
|
||||
|
||||
/**
|
||||
* Fetch data on vision load. Do not embed data.
|
||||
*/
|
||||
FETCH,
|
||||
|
||||
/**
|
||||
* Connect to server to get pushes. The address of the server is embedded in the tag.
|
||||
*/
|
||||
UPDATE
|
||||
}
|
||||
|
||||
override val context: Context get() = visionManager.context
|
||||
|
||||
/**
|
||||
@ -64,7 +64,7 @@ public class VisionRouteConfiguration(
|
||||
*/
|
||||
public var updateInterval: Long by meta.long(300, key = UPDATE_INTERVAL_KEY)
|
||||
|
||||
public var dataMode: DataServeMode by meta.enum(DataServeMode.UPDATE)
|
||||
public var dataMode: Mode by meta.enum(Mode.UPDATE)
|
||||
|
||||
public companion object {
|
||||
public const val DEFAULT_PORT: Int = 7777
|
||||
@ -78,65 +78,74 @@ public class VisionRouteConfiguration(
|
||||
* Serve visions in a given [route] without providing a page template.
|
||||
* [visions] could be changed during the service.
|
||||
*/
|
||||
public fun Route.serveVisionData(
|
||||
configuration: VisionRouteConfiguration,
|
||||
public fun Application.serveVisionData(
|
||||
configuration: VisionRoute,
|
||||
resolveVision: (Name) -> Vision?,
|
||||
) {
|
||||
application.log.info("Serving visions at ${this@serveVisionData}")
|
||||
|
||||
//Update websocket
|
||||
webSocket("ws") {
|
||||
val name: String = call.request.queryParameters.getOrFail("name")
|
||||
application.log.debug("Opened server socket for $name")
|
||||
val vision: Vision = resolveVision(Name.parse(name)) ?: error("Plot with id='$name' not registered")
|
||||
|
||||
launch {
|
||||
incoming.consumeEach {
|
||||
val data = it.data.decodeToString()
|
||||
application.log.debug("Received update: \n$data")
|
||||
val change = configuration.visionManager.jsonFormat.decodeFromString(
|
||||
VisionChange.serializer(), data
|
||||
)
|
||||
vision.update(change)
|
||||
require(WebSockets)
|
||||
routing {
|
||||
route(configuration.route) {
|
||||
install(CORS) {
|
||||
anyHost()
|
||||
}
|
||||
}
|
||||
application.log.info("Serving visions at ${configuration.route}")
|
||||
|
||||
try {
|
||||
withContext(configuration.context.coroutineContext) {
|
||||
vision.flowChanges(configuration.updateInterval.milliseconds).onEach { update ->
|
||||
val json = configuration.visionManager.jsonFormat.encodeToString(
|
||||
VisionChange.serializer(),
|
||||
update
|
||||
//Update websocket
|
||||
webSocket("ws") {
|
||||
val name: String = call.request.queryParameters.getOrFail("name")
|
||||
application.log.debug("Opened server socket for $name")
|
||||
val vision: Vision = resolveVision(Name.parse(name)) ?: error("Plot with id='$name' not registered")
|
||||
|
||||
launch {
|
||||
incoming.consumeEach {
|
||||
val data = it.data.decodeToString()
|
||||
application.log.debug("Received update: \n$data")
|
||||
val change = configuration.visionManager.jsonFormat.decodeFromString(
|
||||
VisionChange.serializer(), data
|
||||
)
|
||||
vision.update(change)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
withContext(configuration.context.coroutineContext) {
|
||||
vision.flowChanges(configuration.updateInterval.milliseconds).onEach { update ->
|
||||
val json = configuration.visionManager.jsonFormat.encodeToString(
|
||||
VisionChange.serializer(),
|
||||
update
|
||||
)
|
||||
application.log.debug("Sending update: \n$json")
|
||||
outgoing.send(Frame.Text(json))
|
||||
}.collect()
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
this.application.log.info("WebSocket update channel for $name is closed with exception: $t")
|
||||
}
|
||||
}
|
||||
//Plots in their json representation
|
||||
get("data") {
|
||||
val name: String = call.request.queryParameters.getOrFail("name")
|
||||
|
||||
val vision: Vision? = resolveVision(Name.parse(name))
|
||||
if (vision == null) {
|
||||
call.respond(HttpStatusCode.NotFound, "Vision with name '$name' not found")
|
||||
} else {
|
||||
call.respondText(
|
||||
configuration.visionManager.encodeToString(vision),
|
||||
contentType = ContentType.Application.Json,
|
||||
status = HttpStatusCode.OK
|
||||
)
|
||||
application.log.debug("Sending update: \n$json")
|
||||
outgoing.send(Frame.Text(json))
|
||||
}.collect()
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
this.application.log.info("WebSocket update channel for $name is closed with exception: $t")
|
||||
}
|
||||
}
|
||||
//Plots in their json representation
|
||||
get("data") {
|
||||
val name: String = call.request.queryParameters.getOrFail("name")
|
||||
|
||||
val vision: Vision? = resolveVision(Name.parse(name))
|
||||
if (vision == null) {
|
||||
call.respond(HttpStatusCode.NotFound, "Vision with name '$name' not found")
|
||||
} else {
|
||||
call.respondText(
|
||||
configuration.visionManager.encodeToString(vision),
|
||||
contentType = ContentType.Application.Json,
|
||||
status = HttpStatusCode.OK
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public fun Route.serveVisionData(
|
||||
configuration: VisionRouteConfiguration,
|
||||
public fun Application.serveVisionData(
|
||||
configuration: VisionRoute,
|
||||
cache: VisionCollector,
|
||||
): Unit = serveVisionData(configuration) { cache[it]?.second }
|
||||
|
||||
//
|
||||
///**
|
||||
// * Compile a fragment to string and serve visions from it
|
||||
@ -161,91 +170,85 @@ public fun Route.serveVisionData(
|
||||
/**
|
||||
* Serve a page, potentially containing any number of visions at a given [route] with given [header].
|
||||
*/
|
||||
public fun Route.visionPage(
|
||||
configuration: VisionRouteConfiguration,
|
||||
public fun Application.visionPage(
|
||||
route: String,
|
||||
configuration: VisionRoute,
|
||||
connector: EngineConnectorConfig,
|
||||
headers: Collection<HtmlFragment>,
|
||||
visionFragment: HtmlVisionFragment,
|
||||
) {
|
||||
application.require(WebSockets)
|
||||
require(CORS) {
|
||||
anyHost()
|
||||
}
|
||||
require(WebSockets)
|
||||
|
||||
val visionCache: VisionCollector = mutableMapOf()
|
||||
serveVisionData(configuration, visionCache)
|
||||
val collector: VisionCollector = mutableMapOf()
|
||||
|
||||
//filled pages
|
||||
get {
|
||||
//re-create html and vision list on each call
|
||||
call.respondHtml {
|
||||
val callbackUrl = call.url()
|
||||
head {
|
||||
meta {
|
||||
charset = "utf-8"
|
||||
}
|
||||
headers.forEach { header ->
|
||||
consumer.header()
|
||||
}
|
||||
val html = createHTML().apply {
|
||||
head {
|
||||
meta {
|
||||
charset = "utf-8"
|
||||
}
|
||||
body {
|
||||
//Load the fragment and remember all loaded visions
|
||||
visionFragment(
|
||||
context = configuration.context,
|
||||
embedData = configuration.dataMode == DataServeMode.EMBED,
|
||||
fetchDataUrl = if (configuration.dataMode != DataServeMode.EMBED) {
|
||||
URLBuilder(callbackUrl).apply {
|
||||
pathSegments = pathSegments + "data"
|
||||
}.buildString()
|
||||
} else null,
|
||||
updatesUrl = if (configuration.dataMode == DataServeMode.UPDATE) {
|
||||
URLBuilder(callbackUrl).apply {
|
||||
protocol = URLProtocol.WS
|
||||
pathSegments = pathSegments + "ws"
|
||||
}.buildString()
|
||||
} else null,
|
||||
visionCache = visionCache,
|
||||
fragment = visionFragment
|
||||
)
|
||||
headers.forEach { header ->
|
||||
consumer.header()
|
||||
}
|
||||
}
|
||||
body {
|
||||
//Load the fragment and remember all loaded visions
|
||||
visionFragment(
|
||||
context = configuration.context,
|
||||
embedData = configuration.dataMode == VisionRoute.Mode.EMBED,
|
||||
fetchDataUrl = if (configuration.dataMode != VisionRoute.Mode.EMBED) {
|
||||
url {
|
||||
host = connector.host
|
||||
port = connector.port
|
||||
path(route, "data")
|
||||
}
|
||||
} else null,
|
||||
updatesUrl = if (configuration.dataMode == VisionRoute.Mode.UPDATE) {
|
||||
url {
|
||||
protocol = URLProtocol.WS
|
||||
host = connector.host
|
||||
port = connector.port
|
||||
path(route, "ws")
|
||||
}
|
||||
} else null,
|
||||
visionCache = collector,
|
||||
fragment = visionFragment
|
||||
)
|
||||
}
|
||||
}.finalize()
|
||||
|
||||
//serve data
|
||||
serveVisionData(configuration, collector)
|
||||
|
||||
//filled pages
|
||||
routing {
|
||||
get(route) {
|
||||
call.respondText(html, ContentType.Text.Html)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public fun Route.visionPage(
|
||||
public fun Application.visionPage(
|
||||
connector: EngineConnectorConfig,
|
||||
visionManager: VisionManager,
|
||||
vararg headers: HtmlFragment,
|
||||
configurationBuilder: VisionRouteConfiguration.() -> Unit = {},
|
||||
route: String = "/",
|
||||
configurationBuilder: VisionRoute.() -> Unit = {},
|
||||
visionFragment: HtmlVisionFragment,
|
||||
) {
|
||||
val configuration = VisionRouteConfiguration(visionManager).apply(configurationBuilder)
|
||||
visionPage(configuration, listOf(*headers), visionFragment)
|
||||
val configuration = VisionRoute(route, visionManager).apply(configurationBuilder)
|
||||
visionPage(route, configuration, connector, listOf(*headers), visionFragment)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render given [VisionPage] at server
|
||||
*/
|
||||
public fun Route.visionPage(page: VisionPage, configurationBuilder: VisionRouteConfiguration.() -> Unit = {}) {
|
||||
val configuration = VisionRouteConfiguration(page.visionManager).apply(configurationBuilder)
|
||||
visionPage(configuration, page.pageHeaders.values, visionFragment = page.content)
|
||||
public fun Application.visionPage(
|
||||
connector: EngineConnectorConfig,
|
||||
page: VisionPage,
|
||||
route: String = "/",
|
||||
configurationBuilder: VisionRoute.() -> Unit = {},
|
||||
) {
|
||||
val configuration = VisionRoute(route, page.visionManager).apply(configurationBuilder)
|
||||
visionPage(route, configuration, connector, page.pageHeaders.values, visionFragment = page.content)
|
||||
}
|
||||
|
||||
public fun <P : Pipeline<*, ApplicationCall>, B : Any, F : Any> P.require(
|
||||
plugin: Plugin<P, B, F>,
|
||||
configure: B.() -> Unit = {},
|
||||
): F = pluginOrNull(plugin) ?: install(plugin, configure)
|
||||
|
||||
|
||||
/**
|
||||
* Connect to a given Ktor server using browser
|
||||
*/
|
||||
public fun ApplicationEngine.openInBrowser() {
|
||||
val connector = environment.connectors.first()
|
||||
val uri = URI("http", null, connector.host, connector.port, null, null, null)
|
||||
Desktop.getDesktop().browse(uri)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server with default timeouts
|
||||
*/
|
||||
public fun ApplicationEngine.close(): Unit = stop(1000, 5000)
|
@ -0,0 +1,38 @@
|
||||
package space.kscience.visionforge.server
|
||||
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.application.Plugin
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.application.pluginOrNull
|
||||
import io.ktor.server.engine.ApplicationEngine
|
||||
import io.ktor.server.engine.EngineConnectorBuilder
|
||||
import io.ktor.server.engine.EngineConnectorConfig
|
||||
import io.ktor.util.pipeline.Pipeline
|
||||
import java.awt.Desktop
|
||||
import java.net.URI
|
||||
|
||||
|
||||
public fun <P : Pipeline<*, ApplicationCall>, B : Any, F : Any> P.require(
|
||||
plugin: Plugin<P, B, F>,
|
||||
): F = pluginOrNull(plugin) ?: install(plugin)
|
||||
|
||||
|
||||
/**
|
||||
* Connect to a given Ktor server using browser
|
||||
*/
|
||||
public fun ApplicationEngine.openInBrowser() {
|
||||
val connector = environment.connectors.first()
|
||||
val uri = URI("http", null, connector.host, connector.port, null, null, null)
|
||||
Desktop.getDesktop().browse(uri)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server with default timeouts
|
||||
*/
|
||||
public fun ApplicationEngine.close(): Unit = stop(1000, 5000)
|
||||
|
||||
|
||||
public fun EngineConnectorConfig(host: String, port: Int): EngineConnectorConfig = EngineConnectorBuilder().apply {
|
||||
this.host = host
|
||||
this.port = port
|
||||
}
|
Loading…
Reference in New Issue
Block a user