Kotlin Void vs Unit vs Nothing | Kotlin Void vs Unit vs Nothing
쟈 미
728x90
Void
자바의 void
java.lang 패키지안에 있는 Void 클래스 : java의 primitive type인 void를 래핑하는 객체이다. (int wrapper인 Integer과 같다고 보면 된다.)
자바에서는 void 말고 Void를 리턴해야하는 경우가 많지 않다. : 제네릭에서 Void를 사용하는 정도의 용례
package java.lang;
/**
* The {@code Void} class is an uninstantiable placeholder class to hold a
* reference to the {@code Class} object representing the Java keyword
* void.
*
* @author unascribed
* @since 1.1
*/
public final
class Void {
/**
* The {@code Class} object representing the pseudo-type corresponding to
* the keyword {@code void}.
*/
@SuppressWarnings("unchecked")
public static final Class<Void> TYPE = (Class<Void>) Class.getPrimitiveClass("void");
/*
* The Void class cannot be instantiated.
*/
private Void() {}
}
Void를 코틀린에서 사용할 때
fun returnTypeAsVoidAttempt1() : Void {
println("Trying with Void return type")
}
이때 컴파일이 되지 않는다.
Error: Kotlin: A 'return' expression required in a function with a block body ('{...}')
그래서 코틀린에서 Void 객체를 만들어서 return 해야하나. 위의 Void 클래스 정의와 같이 private constructor로 인스턴트화가 막혀있다.
따라서 위 경우에는 어쩔수 없이 Void를 nullable로 만들고 Void? null을 리턴해야한다.
fun returnTypeAsVoidAttempt1() : Void? {
println("Trying with Void return type")
return null
}
작동하는 솔루션이 있긴하나. java의 void와 같이 동일한 결과를 낼 수 있는 방법으로 Unit 타입이 있다. (의미 있는 것을 반환하지 않는 함수의 반환 유형)
반환타입이 Unit일 경우네는, return Unit;. return; 모두 선택적으로 작성해도 된다.
fun printHello(name: String?): Unit{
if (name != null) {
println("hello $name")
} else {
println("Hi")
}
}
java에서의 void와 대응한다. 그러나 자바에서 void는 반환 값이 없음을 의미하는 특수 타입이지만, Unit은 class로 정의된 일반타입이다.
Unit은 기본 반환 유형이므로 그리고 return 타입 명시를 안했을 때도 함수가 작동한다.
// Unit.kt
package kotlin
/**
* The type with only one value: the `Unit` object. This type corresponds to the `void` type in Java.
*/
public object Unit {
override fun toString() = "kotlin.Unit"
}
따라서 Unit 타입을 반환하는 함수는 return을 생략해도 암묵적으로 Unit 타입 객체를 리턴한다 (싱글턴 객체이므로 객체 생성은 하지 않는다.)
즉 기원적으로 Void는 Java를 사용할 때 만들어진 클래스, Unit은 Kotlin을 사용할 때 만들어진 클래스인 듯
Nothing
kotlin에서는 throw가 expression 이다. 그래서 이때 throw의 타입이 Nothing 이다.
이 타입은 값이 없으며, 도달할 수 없는 코드 위치를 표한하는데 사용된다. Nothing을 사용하면, 도달할 수 없는 코드의 위치를 컴파일단에서 체크가 가능하다.
fun fail(message: String): Nothing {
throw IllegalArgumentException(message)
}
컴파일단 체크 덕분에 잠재적인 버그와 좋지 않은 코드로부터 확인이 가능하다. 반환 유형이 Nothing인 함수가 호출되면 이 함수 호출 이상으로 실행되지 않고, 컴파일러에서 경고를 내보낸다.
fun invokeANothingOnlyFunction() {
fail("nothing")
println("hello") // Unreachable code
}
또한 Nothing은 type inference (타입추론)에도 사용이 가능하다.
null을 사용하여 초기화된 값일 때의 타입추론
구체적인 타입을 결정하는데 사용할 수 없는 경우에서의 타입추론
val x = null // "type : Nothing?"
val l = listOf(null) // "type : List<Nothing?>"
Nothing은 java에서 대응되는 개념이 없으며, 자바에서는 주로 throw 처리를 할 때 void를 사용했었다.
// Nothing.kt
package kotlin
/**
* Nothing has no instances. You can use Nothing to represent "a value that never exists": for example,
* if a function has the return type of Nothing, it means that it never returns (always throws an exception).
*/
public class Nothing private constructor()
마찬가지로 Nothing도 객체를 생성할 수 없다. (값을 가지지 않는다.)
따라서 Nothing이 값을 가질 수 있는 경우는 Nothing? 에서 null 이 할당되었을 때 뿐이다.
Void
Java's void
The Void class in the java.lang package: It's an object that wraps Java's primitive type void. (Think of it like Integer being the wrapper for int.)
In Java, there aren't many cases where you need to return Void instead of void — it's mostly used in generics.
package java.lang;
/**
* The {@code Void} class is an uninstantiable placeholder class to hold a
* reference to the {@code Class} object representing the Java keyword
* void.
*
* @author unascribed
* @since 1.1
*/
public final
class Void {
/**
* The {@code Class} object representing the pseudo-type corresponding to
* the keyword {@code void}.
*/
@SuppressWarnings("unchecked")
public static final Class<Void> TYPE = (Class<Void>) Class.getPrimitiveClass("void");
/*
* The Void class cannot be instantiated.
*/
private Void() {}
}
Using Void in Kotlin
fun returnTypeAsVoidAttempt1() : Void {
println("Trying with Void return type")
}
This won't compile.
Error: Kotlin: A 'return' expression required in a function with a block body ('{...}')
So should we create a Void object and return it in Kotlin? Well, as you can see from the Void class definition above, instantiation is blocked by a private constructor.
So in this case, you have no choice but to make Void nullable as Void? and return null.
fun returnTypeAsVoidAttempt1() : Void? {
println("Trying with Void return type")
return null
}
While this is a working solution, there's a better way to achieve the same result as Java's void — the Unit type. (It's the return type for functions that don't return anything meaningful.)
When you don't need a return value, you use Unit as the function's return type.
When the return type is Unit, both return Unit; and return; are optional — you can write either or omit them entirely.
fun printHello(name: String?): Unit{
if (name != null) {
println("hello $name")
} else {
println("Hi")
}
}
It corresponds to void in Java. However, while void in Java is a special type meaning "no return value," Unit is a regular type defined as a class.
Since Unit is the default return type, functions work even when you don't explicitly specify a return type.
// Unit.kt
package kotlin
/**
* The type with only one value: the `Unit` object. This type corresponds to the `void` type in Java.
*/
public object Unit {
override fun toString() = "kotlin.Unit"
}
So a function that returns Unit implicitly returns the Unit object even if you omit the return statement. (Since it's a singleton object, no new object is created.)
In other words, it seems like Void is a class that originated from Java, while Unit is a class that originated from Kotlin.
Nothing
In Kotlin, throw is an expression. And the type of that throw expression is Nothing.
This type has no value and is used to mark code locations that can never be reached. By using Nothing, unreachable code locations can be checked at compile time.
fun fail(message: String): Nothing {
throw IllegalArgumentException(message)
}
Thanks to compile-time checks, you can catch potential bugs and bad code. When a function with a return type of Nothing is called, execution never continues beyond that function call, and the compiler emits a warning.
fun invokeANothingOnlyFunction() {
fail("nothing")
println("hello") // Unreachable code
}
Nothing can also be used in type inference.
Type inference for values initialized with null
Type inference when a concrete type cannot be determined
val x = null // "type : Nothing?"
val l = listOf(null) // "type : List<Nothing?>"
Nothing has no corresponding concept in Java. In Java, void was typically used when handling throw.
// Nothing.kt
package kotlin
/**
* Nothing has no instances. You can use Nothing to represent "a value that never exists": for example,
* if a function has the return type of Nothing, it means that it never returns (always throws an exception).
*/
public class Nothing private constructor()
Likewise, Nothing cannot be instantiated either. (It holds no value.)
Therefore, the only case where Nothing can hold a value is when null is assigned to Nothing?.
위와같이 간단한 코드만 짜서 서버 RUN을 시키면? 서버 실행 속도가 이렇게까지 빨라도 되는건가.. spring안쓰고 간단하게 서버 만들고 싶을 때 쓰면 좋을 것 같다.
b. engineMain (HOCON 포맷)
위 예제는 embeddedServer로 짜는 방법을 적었는데, 만약 EngineMain 방식으로 ktor 서버를 짜고 싶다면 조금 다른 방식이다.
// main.kotlin.com.jyami
fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)
fun Application.module() {
configureRouting()
}
// main.kotlin.com.jyami.plugins
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Hello World!")
}
}
}
// resource
ktor {
deployment {
port = 8080
}
application {
modules = [ com.jyami.ApplicationKt.module ]
}
}
HOCON 포맷이라고한다. resources 폴더안에 application.conf 파일을 이용해서 실행시킬 포트나, entry point에대한 애플리케이션 설정을 작성한다. application.conf 관련 여러 설정들 : https://ktor.io/docs/configurations.html
get("{id}") {
val id = call.parameters["id"] ?: return@get call.respondText(
"Missing or malformed id",
status = HttpStatusCode.BadRequest
)
val customer = customerStorage.find {it.id == id} ?: return@get call.respondText (
"No customer with id $id",
status = HttpStatusCode.NotFound
)
call.respond(customer)
}
인덱스 접근자 함수 : call.parameters["myParamName"] 요청 Path를 기본적으로 string으로 가져오게 된다.
2. request body
post {
val customer = call.receive<Customer>()
customerStorage.add(customer)
call.respondText ("Customer stored correctly", status = HttpStatusCode.Created)
}
call.recevie<T> : 제네릭 변수를 사용하여 호출한 request body를 자동으로 코틀린 객체로 역직렬화 한다.
동시에 여러 요청이 접근될 때 문제는 이 실습의 범위를 벗어난다 : 동시에 요청/스레드에서 액세스 할 수 있는 데이터 구조 혹은 코드를 작성하면 될 것
3. response body
get {
if (customerStorage.isNotEmpty()) {
call.respond(customerStorage)
} else {
call.respondText("No customers found", status = HttpStatusCode.NotFound)
}
}
call.respond() : kotlin 객체를 가져와 해당 객체가 지정된 형식으로 직렬화하여 http 응답을 반환한다.
call.respondText() : Text를 응답으로 보낼 때의 respond를 간단하게 구현해둔 함수이다. 문자열 응답을 반환한다.
Routing
Route 확장함수를 이용하여 경로를 정의하였다. 이렇게 할 경우 아무래도 라우팅만 역할을 빼서 코드를 관리할 수 있어서 좋아보인다. Application.module에 있는 라우팅 블록 내부에 각 경로를 직접 추가할 수 있긴하지만, 파일 경로들을 그룹화하여 관리하는 것이 유지보수에 더 좋다.
Route 확장함수만을 사용하여 경로를 정의할 경우 실제 Ktor 서버에는 적용되지 않는데, 맨 처음 ktor 시작하기에 했던 것처럼, route를 Application에 등록해주는 과정이 필요하다. 즉, Route.module에 등록된 경로들을 Application.module에 등록하는 방식으로 관리하자.
Application의 확장함수를 사용함으로써 application에 등록할 path를 route dsl을 이용하여 등록할 수 있으며, 이 dsl에는 사실상 Route 모듈에 등록된 경로를 Application에 경로를 등록하도록 도와주는 것으로 보인다.
fun Route.customerRouting() {
route("/customer") {
get{}
}
}
fun Application.registerCustomerRoutes() {
routing {
customerRouting()
}
}
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
registerCustomerRoutes()
install(ContentNegotiation){
json()
}
}.start(wait = true)
}
4. Ktor 테스트
ktor-server-test-host : netty를 시작하지 않고도 endpoint 테스트가 가능하다. 테스트 request를 실행하기 위한 몇가지 헬퍼 메서드가 프레임워크에서 제공되며, 그중 하나가 TestApplication 이다.
다만 embeddedServer 방식으로 withTestApplication을 작성하는 경우에는, main() 함수안에 직접 선언이 되어있어 애플리케이션에 대한 모든 테스트는 불가능하고 위에서 내가 정의한 configureRouting(), registerCustomRouting(), registerOrderRouting()과 같이 Application 확장함수 단위로 테스트가 가능하다.
다만 여기서 주의해야할 점은 configureRouting은 단일하게 넣어도 테스트가 가능하나, registerOrderRouting, registerCustomRouting의 경우에는 불가능하다 왜냐하면 이것들을 직접 넣고 돌리게되면, Application에 등록해둔 ContentNegotiation이 빠지게 될것이고, serialize과정에 문제가 생겨서 테스트가 실패하게 될 것이다. 따라서 테스트시 두개의 configuration을 함께 등록하여 테스트해야한다.
위와같이 테스트가 가능하게 되면서, ktor을 사용할 경우에는 모듈별로, 설정별로 테스트가 손쉽게 될 것으로 보인다. 사실, embeddedServer나 HOCON이나 하나의 Application 확장함수를 만들어서 테스트하는건 같아서 입맛대로 사용하도록하자.
This is the basic skeleton for running a server. The routing part is typically extracted into a plugin-style pattern like configureXXX as shown below, separating concerns. (Using Kotlin's extension functions)
If you write just this simple code and RUN the server? Is it even okay for the server startup to be this fast.. Seems like it would be great for quickly spinning up a server without using Spring.
b. engineMain (HOCON Format)
The example above showed how to write it using embeddedServer. If you want to build a ktor server using the EngineMain approach, it's a slightly different method.
// main.kotlin.com.jyami
fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)
fun Application.module() {
configureRouting()
}
// main.kotlin.com.jyami.plugins
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Hello World!")
}
}
}
// resource
ktor {
deployment {
port = 8080
}
application {
modules = [ com.jyami.ApplicationKt.module ]
}
}
It's called the HOCON format. You write the application configuration — such as the port to run on and the entry point — in an application.conf file inside the resources folder. Various settings for application.conf: https://ktor.io/docs/configurations.html
It provides the ability to create executable JVM applications. It makes it easy to start the application during development and also supports application packaging like TAR and ZIP.
The application plugin has both the java and distribution plugins built in. Each seems to be a plugin that provides source sets for main and packaging functionality for distribution, respectively.
When using the application plugin, specifying the main class (i.e. entry point) in the configuration is mandatory.
get("{id}") {
val id = call.parameters["id"] ?: return@get call.respondText(
"Missing or malformed id",
status = HttpStatusCode.BadRequest
)
val customer = customerStorage.find {it.id == id} ?: return@get call.respondText (
"No customer with id $id",
status = HttpStatusCode.NotFound
)
call.respond(customer)
}
Index accessor function: call.parameters["myParamName"] It retrieves the request path as a string by default.
2. request body
post {
val customer = call.receive<Customer>()
customerStorage.add(customer)
call.respondText ("Customer stored correctly", status = HttpStatusCode.Created)
}
call.receive<T>: Uses a generic type parameter to automatically deserialize the request body into a Kotlin object.
Handling concurrent request access is beyond the scope of this tutorial — you'd just need to write data structures or code that can handle simultaneous requests/threads.
3. response body
get {
if (customerStorage.isNotEmpty()) {
call.respond(customerStorage)
} else {
call.respondText("No customers found", status = HttpStatusCode.NotFound)
}
}
call.respond(): Takes a Kotlin object, serializes it into the specified format, and returns it as an HTTP response.
call.respondText(): A convenience function for sending text as a response. Returns a string response.
Routing
Routes are defined using Route extension functions. This approach is nice because it lets you separate and manage just the routing logic on its own. While you could add each route directly inside the routing block in Application.module, grouping file paths together is better for maintainability.
If you only define routes using Route extension functions, they won't actually be applied to the Ktor server. As we did in the initial ktor setup, you need to register the routes with the Application. In other words, manage it by registering the routes defined in Route.module into Application.module.
By using Application extension functions, you can register paths to the application using the route DSL. This DSL essentially helps register the routes defined in the Route module into the Application.
fun Route.customerRouting() {
route("/customer") {
get{}
}
}
fun Application.registerCustomerRoutes() {
routing {
customerRouting()
}
}
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
registerCustomerRoutes()
install(ContentNegotiation){
json()
}
}.start(wait = true)
}
4. Testing in Ktor
ktor-server-test-host: Allows endpoint testing without starting Netty. The framework provides several helper methods for executing test requests, and one of them is TestApplication.
HOCON approach: A structure where all configurations can be registered at once
This appears to be usable with the HOCON format. (For the embeddedServer approach, it seems you'd use a direct call method with httpClient instead: https://ktor.io/docs/testing.html#end-to-end)
The key part is withTestApplication, which injects the application you want to run for testing. (It looks like any method in the form of Application.module() can be injected.)
With the HOCON format, since the Application extension function is defined outside of main before being put into main, all configurations for that application are registered and can be tested. So testing with module() as shown above works just fine.
embeddedServer approach: A structure where you can register only the needed modules in the configuration for testing
However, when writing withTestApplication with the embeddedServer approach, since everything is declared directly inside the main() function, you can't test the entire application at once. Instead, you can test at the Application extension function level — like configureRouting(), registerCustomRouting(), and registerOrderRouting() that I defined above.
One thing to watch out for here: configureRouting can be tested on its own, but registerOrderRouting and registerCustomRouting cannot. That's because if you plug them in directly and run them, the ContentNegotiation registered in the Application will be missing, causing issues with the serialization process and making the tests fail. So you need to register both configurations together when testing.
With testing set up like this, using ktor makes it look easy to test by module and by configuration. Honestly, whether you use embeddedServer or HOCON, it's the same in that you create one Application extension function and test with it, so just use whichever you prefer.
git 종종 사용하지만 까먹는 명령어들 | git commands I occasionally use but keep forgetting
쟈 미
728x90
+ 조금씩 추가할 예정
이미 commit한 메세지 author 변경
git rebase -i HEAD~N # 원하는 수정 커밋 범위 설정
# 원하는 커밋 pick > e 로 수정 후
git commit --amend --author="jyami-kim <mor2222@naver.com>" # --amend로 author 변경
git rebase --continue # 다음 rebase 진행
Changing the author of an already committed message
git rebase -i HEAD~N # 원하는 수정 커밋 범위 설정
# 원하는 커밋 pick > e 로 수정 후
git commit --amend --author="jyami-kim <mor2222@naver.com>" # --amend로 author 변경
git rebase --continue # 다음 rebase 진행
규모 확장 시스템 설계 기본 | Fundamentals of Designing Systems for Scale
쟈 미
728x90
1. 단일 서버 DNS : 도메인 이름을 이용해 웹사이트에 접속한다. DNS에 질의하여 IP로 변환하는 과정이 필요하다. http 요청을 보내고 클라이언트는 응답을 받는다
2. 데이터베이스 데이터베이스 : 웹/모바일 트래픽 처리 서버 (웹 계층)와 데이터베이스 서버 (데이터 계층) 분리를 시도한다.
3. 로드밸런서 로드밸런서 : 부하 분산 집합에 속한 웹서버들에게 트래픽 부하를 고르게 분산한다 데이터베이스 : 다중화로 성능과 안정성을 보장한다 (master는 쓰기, slave는 읽기)
4. 캐시 캐시 : 캐시를 이용해 서버의 요청이 보다 빨리 처리될 수 있게 한다. SOPF가 되지 않게 분산한다
5. 콘텐츠 전송 네트워크 (CDN) CND : 정적 콘텐츠(이미지, 비디오, CSS, JavaScript)는 웹 서버대신 CDN으로 성능을 보장한다
6. 무상태(stateless) 웹 계층 웹서버 : 무상태 웹 계층을 갖게 함으로써 자동 규모 확장(autoScaling)이 가능하다 공유 저장소 : 웹서버를 무상태 웹 계층으로 전환하면서, 필요한 상태정보들은 공유 저장소에 저장한다.
7. 데이터 센터 로드밸런서 : 데이터 센터를 이용해 가용성을 높이고, 전 세계 어디서도 쾌적하게 사용이 가능하다. 지리적 라우팅을 이용하여 사용자의 위치에 따라 가장 가까운 위치의 데이터 센터로 안내한다.
8. 메시지 큐 메시지 큐 : 서버간 결합을 느슨하게 하여 (loosely coupled) 규모 확장성이 보장되는 안정된 애플리케이션 구성이 가능하게 한다.
9. 로그, 메트릭 그리고 자동화 도구 : 로그, 모니터링, 메트링, 자동화는 규모가 큰 서비스 관리에 용이하다
10. 데이터베이스의 규모 확장 데이터베이스 : 샤딩으로(수평적 확장 = 서버증설) DB부하를 줄인다.
관련 책 : 가상 면접 사례로 배우는 대규모 시스템 설계 기초
1. Single Server DNS : You access a website using a domain name. A process of querying DNS to resolve it into an IP address is needed. You send an HTTP request and the client receives a response.
2. Database Database : We separate the server that handles web/mobile traffic (web tier) from the database server (data tier).
3. Load Balancer Load Balancer : Evenly distributes traffic load across web servers in the load-balanced set. Database : Replication ensures performance and reliability (master handles writes, slave handles reads).
4. Cache Cache : Uses cache so that server requests can be processed faster. Distribute caches to avoid becoming a SPOF.
5. Content Delivery Network (CDN) CDN : Static content (images, videos, CSS, JavaScript) is served through a CDN instead of web servers to ensure performance.
6. Stateless Web Tier Web Server : By making the web tier stateless, auto-scaling becomes possible. Shared Storage : When converting web servers to a stateless web tier, the necessary state information is stored in shared storage.
7. Data Centers Load Balancer : Using data centers improves availability and enables a smooth experience from anywhere in the world. GeoDNS routing directs users to the nearest data center based on their location.
8. Message Queue Message Queue : By loosely coupling servers, it enables building stable applications with guaranteed scalability.
9. Logging, Metrics, and Automation Tools : Logging, monitoring, metrics, and automation make it easier to manage large-scale services.
10. Database Scaling Database : Sharding (horizontal scaling = adding more servers) reduces the database load.
Related Book: System Design Interview – An Insider's Guide
이 챕터의 목표 : Reacitve Stream 패턴의 이해와 reactive application의 설계 방법의 전반적인 이해를 위함.
4.4.1 Motivation
비동기(Asynchronous)와 리액티브 방법론 (reactive methodolgies)는 네트워크나 디스크 IO로 인한 쓰레드의 대기시간 낭비 대신 시스템의 리소스를 좀 더 효율적으로 사용하도록 해준다.
이런 스타일의 프로그래밍을 용이하게 하기위한 광법위한 기술이 존재하는데, java.util.concurrent.Future 부터 Akka와 같이 완전한 라이브러리들이 있다 (?)
Project Reactor는 매우 방대한 셋의 asynchronous workflow를 위한 연산자들이 있고, 이것들은 더이상 프레임워크에 의존하지 않으며 매우 많은 Reactive Streams model을 지원한다.
4.4.2 Understanding Reactive Streams
Reactive Stream은 처음에는 표준의 non-blocking back pressure(이게 뭐지) 을 기반으로 한 asynchronous stream의 표준을 제공하기 위해 만들어졌다. 여기에는 런타임 환경 (JVM and Javascript)뿐만 아니라 네트워크 프로토콜도 포함하려하는 목표가 있었다.
Reactive Streams의 범위는 목표를 달성하는 데 필요한 작업이나 엔터티를 설명하는 최소한의 인터페이스, 메서드 및 프로토콜 집합을 찾는 것이다. 그 목표는 non-blocking back pressure 이 있는 asynchronous streams data이다. (?)
그것은 어플리케이션 코드에서 라이브러리를 연결할 필요 없이, 상호자용을 할 수 있도록 허용해주는 multiple reactive composition 라이브러리들 사이의 운영 표준이다.
Reactive Stream의 통합은 주로 Publicsher<T> 나 Subscriber<T> 타입으로 복잡성을 숨기는 composition library (컴포지션 라이브러리)로 제공된다. Lettuce는 publisher를 Mono, Flux로 사용하는 Project Reactor를 사용한다.
Aynchronouse processing은 IO작업이나 계산과 같은 작업을 호추한 스레드에서 분리한다. handle이 그 return 타입이며, 주로 java.util.concurrent.Future와 동일하거나 비슷하다. 유사하게 single object나 collection, exception을 return 한다. asynchronously하게 fetch 한 결과를 가져온 결과를 기억하는 것은 주로 한 프로우의 끝이 아니다. 한번 데이터가 확보되면 항상 혹은 조건부로 추가 요청을 발행할 수 있다. Java 8이나 Promise 패턴을 사용하면 futuers의 chaining은 연속적으로 그 이후의 asynchronous한 request가 발생하도록 할 수 있다. 한번 조건처리가 필요하면 asynchronous한 flow을 interrupted 시키고 synchronized 해야한다. 이런 접근 방식은 가능하지만 asynchronous의 장점을 완전히 활용하지는 않는다.
반면 Publisher<T> 객체는 다양하고 asynchronous한 질문에 다른방향으로 대답한다 : Pull pattern을 Push patter으로 변환한다.
push : 데이터 변경시 변경이 발새된 곳에서 데이터를 보내주는 방식 (Mono, Flux) pull : 변경된 데이터가 있는지 질의 후 가져오는 방식 (클라이언트 요청 -> 서버 -> 응답)
Publisher<T>는 Futures처럼 single scalar value에 대한 emission 뿐만 아니라 emission sequences 심지어 infinite streams도 지원한다. 당신이 stream 작업을 시작하기만 하면 이 사실을 감사하게 여길 것이다. Project Reactor는 두개의 타입의 publisher가 사용된다 : Mono 와 Flux
Mono : 0부터 1까지의 event를 emit 한다. Flux : 0부터 N까지의 event를 emit 한다.
Publisher<T> 는 concurrency(동시성)나 asynchronicity(비동기성)의 특정 소스나 코드가 실행되는 방식에 편향되지 않는다. synchronous, asynchronous 둘다 ThreadPool 내에서 실행된다. Publisher<T>의 consumer는 실제 구현을 supplier에게 맡겨 나중에 supplier의 코드를 수정하지 않고도 변경이 가능하다.
Publisher<T>의 마지막 키포인트는 Publisher<T>를 가져올 때 처리가 되는 것이 아니라, Publisher<T>에 대한 observer가 subscribe하거나 신호가 보내지는 순간 프로세스가 실행된다는 것이다. 이것은 java.util.concurrent.Future와의 중요한 차이점이다. (Future는 created/obtained 가되는 순간 프로세스가 시작되기 때문이다.) 따라서 어떤 옵저버가 Publisher<T>를 subscribe 하지 않으면 아무일도 일어나지 않는다.
4.4.4. A word on the lettuce Reacitve API
모든 커맨드는 Flux<T>, Mono<T>, Mono<Void> 를 return 한다. 이것들은 Subscriber가 subscribe할 수 있다. 이 구독자는 Publisher <T>가 emit하는 아이템 또는 아이템의 시퀀스에 반응한다. 이 패턴은 Publisher<T>가 객체를 emit 할때까지 기다리는 동안 차단을 할 필요가 없어 동시작업(concurrent operations)에 용이하다. 대신, Publisher<T>가 어떤 futuer time에든 적절하게 반응하도록 준비가 되어있는 Subscriber 형태로 sentry를 만든다.
4.4.5 Consuming Publisher<T>
publisher를 사용할 때 가장먼저 고려할 일은 그들을 consume하는 것이다. Consuming a publisher의 의미는 subscribing을 의미한다.
@Test
@DisplayName("Consuming Publisher 예제 : emit 된 모든 항목을 subscribe 하고 print 한다.")
void consumingPublisher() {
Flux.just("jyami", "java", "javabom").subscribe(new Subscriber<String>() {
@Override
public void onSubscribe(Subscription s) {
s.request(3);
}
@Override
public void onNext(String s) {
System.out.println("Hello " + s + "!");
}
@Override
public void onError(Throwable t) {
t.printStackTrace();
}
@Override
public void onComplete() {
System.out.println("Complete consuming!");
}
});
}
모든 구독자 또는 관찰자 (Subscriber or Observer)가 모든 이벤트에 대한 알림을 받고 완료된 이벤트도 받은걸을 확인 할 수 있다.한개의 Publisher<T>는 exception이 발생하거나, 그 Publisher<T>가 종료되었다고 onComplete()를 호출할 때까지 아이템을 내 보낸다. 그 이후에는 더이상 element가 emit되지 않는다.
subscribe의 호출로 한개의 Subscription이 등록되고 이것은 cancel을 허용하므로 더이상 이벤트를 수진하지 않는다. Publisher는 한번 Publisher<T>가 unsubscribed하면 구독 취소및 리소스 할당 해제(free resource)를 상호 운용할 수 있다. (?)
좀더 간단한 포맷의 Subscriber<T> 구현
@Test
@DisplayName("Subscriber<T> 의 더 간단한 구현")
void subscriberSimpleImpl() {
Flux.just("Ben", "Michael", "Mark").doOnNext(new Consumer<String>() {
public void accept(String s) {
System.out.println("Hello " + s + "!");
}
}).doOnComplete(new Runnable() {
public void run() {
System.out.println("Completed");
}
}).subscribe();
}
@Test
@DisplayName("Subscriber<T> 의 더 간단한 구현 - 람다사용")
void subscriberSimpleImplWithLambda() {
Flux.just("Ben", "Michael", "Mark")
.doOnNext(s -> System.out.println("Hello " + s + "!"))
.doOnComplete(() -> System.out.println("Completed")).subscribe();
}
Subscriber의 여러 연산자를 이용해서 element를 제어할 수 있다. take() 연산자는 관심이 있는 처음 N개 요소까지로 emit되는 수를 제한한다.
Hello Ben!
Hello Michael!
왜 여기서 Completed가 같이 출력이 안되는지를 모르겠다.
take 연산자는 기대하는 요소 수를() emit한 후 Publisher<T>에서 암시적으로 subscription을 취소한다.
Publisher<T> 에 대한 subscription은 다른 Flux나 Subscriber가 수행 할 수도 있다. 커스텀한 Publisher를 구현하지 않는한 항상 Subscriber를 사용하라.
그리고 항상 error handler를 올바르게 구현하는 것을 권장한다. 특정시점에서 일이 잘못될 수 있다 (스택트레이스가 쌓여서) 완벽하게 구현이 된 subscriber는 이벤트에 반응할 수 있게 onCompleted, onError 메서드를 모두 선언하자
4.4.6. From push to pull
위에서 햇던 예제는 publisher가 blocking, non-blocking execution에 대해서 별다른 의견없이(추가 없이) 설정되어있는 방법을 기술하였다. Flux<T>는 명시적으로 Iterable<T>로 변환하거나 block() 메서드를 이용해서 synchronized(동기화) 할 수 있다. block() 호출은 피하자. block()을 호출하면 애플리케이션에 대한 reactive chain의 모든 non-blocking 적인 장점이 사라진다.
@Test
@DisplayName("block() 연산자 : async -> sycn")
void blockOperator() {
String last = Flux.just("Ben", "Michael", "Mark").last().block();
System.out.println(last); // Mark
}
blocking 호출은 publisher의 체인을 synchronize로 사용되게 할 수 있고, 평범하고 잘 알려진 Pull 패턴으로 돌아가는 방법을 찾는다.
toList 연산자는 모든 emmited된 요소들을 모으고(collect), 그리고 그 리스트를 BlockingPublisher<T> 타입으로 패스한다.
4.4.7 Creating Flux and Mono using Lettuce
publisher를 설계하는 방법은 많이 있다. 당신은 이미 just(), take(), collectList()를 이미 보았다. Project Reactor documentation 의 참조에 따르면 Flux와 Mono를 만드는데 사용 할 수 있는 더 많은 메서드가 있다.
Lettuce publisher는 초기화나 체이닝 작업에 사용할 수 있다. Lettuce publisher를 사용하면 non-blocking 동작을 확인 할 수 있다. 이는 모든 I/O 및 커맨드 처리가 netty의 EventLoop를 사용해 aynchronously(비동기적)으로 처리되기 때문이다.
key에서 value를 가져오려면 GET 연산이 필요하다. (subscribe자리에 Consumer를 넣었다.)
@Test
@DisplayName("lettuce publisher를 사용하고, 여기서 get 연산자를 통해 redis의 key에 따른 value를 가져올 수 있다. ")
void LettucePublisher() {
commands.get("key")
.subscribe(System.out::println);
}
이것의 실행은 asynchronously(비동기적으로) 처리되며, Netty EventLoop Thread에서 작업이 완료되는 동안 호출하는 스레드(invoking thread)는 프로세스내의 다른 일을 처리할 수 있다. 분리된 특성으로 인해 호출한 메서드(calling method)는 Publisher<T>의 실행이 완료되기 전에 그대로 둘 수 있다 (?).
Lettuce의 publichser는 연결된 컨텍스트(context of chaining) 내에서 여러개의 키들을 asynchronously(비동기적으로) 로드하는데 사용될 수 있다.
public interface UserRepositoryCustom {
Optional<User> findByUserId(Long userId);
}
@RequiredArgsConstructor
public class UserRepositoryImpl implements UserRepositoryCustom {
private final JPAQueryFactory jpaQueryFactory;
@Override
public Optional<User> findByUserId(Long userId) {
User user = jpaQueryFactory.selectFrom(QUser.user)
.where(QUser.user.userId.eq(userId))
.fetchOne();
return Optional.ofNullable(user);
}
}
이렇게 짠 Querydsl 코드를 테스트하기 위한 테스트코드를 짤 때 @SpringBootTest를 하면 모든 빈이 주입되기 때문에 상관이 없지만,
아래 코드와 같이 DataJpaTest와 같은 슬라이싱 테스트를 하고싶을 때 문제가 발생한다.
@DataJpaTest
@ActiveProfiles("test")
class UserRepositoryTest {
@Autowired
private EntityManager entityManager;
@Autowired
private UserRepository userRepository;
private User settingUser() {
User settingUser = User.builder()
.email("jyami@ewhain.net")
.name("jyami")
.build();
return userRepository.save(settingUser);
}
@Test
void test() {
settingUser();
entityManager.clear();
User user = userRepository.findByUserId(1L)
.orElseThrow(() -> new ResourceNotFoundException("user", "userId", 1L));
}
}
Error creating bean with name 'userRepositoryImpl' defined in file [/Users/jyami/Documents/commiters/commiters-ewha/commiters-ewha-api/build/classes/java/main/com/jyami/commitersewha/domain/user/UserRepositoryImpl.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.querydsl.jpa.impl.JPAQueryFactory'
결국 이유는 JpaQueryFactory가 persistenceLayer가 아니어서 빈등록이 되지않아 발생하는 문제인데, 이때 테스트 시 특정부분의 빈만 등록해주는 방법이 있었다!
@TestConfiguration
public class TestConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
테스트에서만 사용할 용도의 @TestConfiguration을 이용해 JPAQueryFactory 만 Bean으로 생성해준다.
@DataJpaTest
@ActiveProfiles("test")
@Import(TestConfig.class)
public class UserRepositoryTest {
}
이후 @Import 어노테이션을 사용해 해당 테스트용 빈을 주입해주면, JpaQueryFactory에 대한 빈도 생성되므로, Querydsl의 슬라이싱 테스트가 가능해진다 :)
public interface UserRepositoryCustom {
Optional<User> findByUserId(Long userId);
}
@RequiredArgsConstructor
public class UserRepositoryImpl implements UserRepositoryCustom {
private final JPAQueryFactory jpaQueryFactory;
@Override
public Optional<User> findByUserId(Long userId) {
User user = jpaQueryFactory.selectFrom(QUser.user)
.where(QUser.user.userId.eq(userId))
.fetchOne();
return Optional.ofNullable(user);
}
}
When writing test code for Querydsl code like this, using @SpringBootTest would be fine since all beans get injected,
but a problem occurs when you want to do a slicing test like @DataJpaTest as shown in the code below.
@DataJpaTest
@ActiveProfiles("test")
class UserRepositoryTest {
@Autowired
private EntityManager entityManager;
@Autowired
private UserRepository userRepository;
private User settingUser() {
User settingUser = User.builder()
.email("jyami@ewhain.net")
.name("jyami")
.build();
return userRepository.save(settingUser);
}
@Test
void test() {
settingUser();
entityManager.clear();
User user = userRepository.findByUserId(1L)
.orElseThrow(() -> new ResourceNotFoundException("user", "userId", 1L));
}
}
Error creating bean with name 'userRepositoryImpl' defined in file [/Users/jyami/Documents/commiters/commiters-ewha/commiters-ewha-api/build/classes/java/main/com/jyami/commitersewha/domain/user/UserRepositoryImpl.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.querydsl.jpa.impl.JPAQueryFactory'
The reason is that JpaQueryFactory isn't part of the persistence layer, so it doesn't get registered as a bean. But there's a way to register only specific beans during testing!
@TestConfiguration
public class TestConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
Using @TestConfiguration, which is meant only for tests, we create just the JPAQueryFactory as a Bean.
@DataJpaTest
@ActiveProfiles("test")
@Import(TestConfig.class)
public class UserRepositoryTest {
}
Then, by using the @Import annotation to inject the test-specific bean, the JpaQueryFactory bean also gets created, making slicing tests with Querydsl possible :)
redirect_uri : OAuth2 provider가 성공적으로 인증을 완료했을 때 redirect 할 URI를 지정한다. (OAuth2의 redirectUri 와는 다르다)
2. endpoint로 인증 요청을 받으면, Spring Security의 OAuth2 클라이언트는 user를 provider가 제공하는 AuthorizationUrl로 redirect 한다. Authorization request와 관련된 state는authorizationRequestRepository에 저장된다 (Security Config에 정의함) provider에서 제공한 AutorizationUrl에서 허용/거부가 정해진다.
이때 만약 유저가 앱에 대한 권한을 모두 허용하면 provider는 사용자를 callback url로 redirect한다. (http://localhost:8080/oauth2/callback/{provider}) 그리고 이때 사용자 인증코드 (authroization code) 도 함께 갖고있다.
만약 거부하면 callbackUrl로 똑같이 redirect 하지만 error가 발생한다.
3. Oauth2 에서의 콜백 결과가 에러이면 Spring Security는oAuth2AuthenticationFailureHanlder를 호출한다. (Security Config에 정의함)
4. Oauth2 에서의 콜백 결과가 성공이고 사용자 인증코드 (authorization code)도 포함하고 있다면 Spring Security는 access_token 에 대한 authroization code를 교환하고, customOAuth2UserService 를 호출한다 (Security Config에 정의함)
5. customOAuth2UserService 는 인증된 사용자의 세부사항을 검색한 후에 데이터베이스에 Create를 하거나 동일 Email로 Update 하는 로직을 작성한다.
6. 마지막으로 oAuth2AuthenticationSuccessHandler 이 불리고 그것이 JWT authentication token을 만들고 queryString에서의 redirect_uri로 간다 (1번에서 client가 정의한 ) 이때 JWT token과 함께!
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(
securedEnabled = true,
jsr250Enabled = true,
prePostEnabled = true
)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService customUserDetailsService;
@Autowired
private CustomOAuth2UserService customOAuth2UserService;
@Autowired
private OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
@Autowired
private OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;
@Autowired
private HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Bean
public TokenAuthenticationFilter tokenAuthenticationFilter() {
return new TokenAuthenticationFilter();
}
/*
By default, Spring OAuth2 uses HttpSessionOAuth2AuthorizationRequestRepository to save
the authorization request. But, since our service is stateless, we can't save it in
the session. We'll save the request in a Base64 encoded cookie instead.
*/
@Bean
public HttpCookieOAuth2AuthorizationRequestRepository cookieAuthorizationRequestRepository() {
return new HttpCookieOAuth2AuthorizationRequestRepository();
}
@Override
public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
authenticationManagerBuilder
.userDetailsService(customUserDetailsService)
.passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean(BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.csrf()
.disable()
.formLogin()
.disable()
.httpBasic()
.disable()
.exceptionHandling()
.authenticationEntryPoint(new RestAuthenticationEntryPoint())
.and()
.authorizeRequests()
.antMatchers("/",
"/error",
"/favicon.ico",
"/**/*.png",
"/**/*.gif",
"/**/*.svg",
"/**/*.jpg",
"/**/*.html",
"/**/*.css",
"/**/*.js")
.permitAll()
.antMatchers("/auth/**", "/oauth2/**")
.permitAll()
.anyRequest()
.authenticated()
.and()
.oauth2Login()
.authorizationEndpoint()
.baseUri("/oauth2/authorize")
.authorizationRequestRepository(cookieAuthorizationRequestRepository())
.and()
.redirectionEndpoint()
.baseUri("/oauth2/callback/*")
.and()
.userInfoEndpoint()
.userService(customOAuth2UserService)
.and()
.successHandler(oAuth2AuthenticationSuccessHandler)
.failureHandler(oAuth2AuthenticationFailureHandler);
// Add our custom Token based authentication filter
http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
redirect_uri: Specifies the URI to redirect to when the OAuth2 provider successfully completes authentication. (This is different from OAuth2's redirectUri)
2. When the authentication request is received at the endpoint, Spring Security's OAuth2 client redirects the user to the AuthorizationUrl provided by the provider. The state related to the authorization request is stored in the authorizationRequestRepository (defined in Security Config). The allow/deny decision is made at the AuthorizationUrl provided by the provider.
If the user grants all permissions to the app, the provider redirects the user to the callback URL (http://localhost:8080/oauth2/callback/{provider}) along with the user's authorization code.
If the user denies, they are redirected to the same callbackUrl, but with an error.
3. If the OAuth2 callback result is an error, Spring Security invokes the oAuth2AuthenticationFailureHanlder (defined in Security Config).
4. If the OAuth2 callback result is successful and includes the authorization code, Spring Security exchanges the authorization code for an access_token and invokes the customOAuth2UserService (defined in Security Config).
5. The customOAuth2UserService retrieves the authenticated user's details and then either creates a new entry in the database or updates the existing one with the same email.
6. Finally, the oAuth2AuthenticationSuccessHandler is called, which creates a JWT authentication token and redirects to the redirect_uri from the queryString (the one defined by the client in step 1) — along with the JWT token!
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(
securedEnabled = true,
jsr250Enabled = true,
prePostEnabled = true
)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService customUserDetailsService;
@Autowired
private CustomOAuth2UserService customOAuth2UserService;
@Autowired
private OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
@Autowired
private OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;
@Autowired
private HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Bean
public TokenAuthenticationFilter tokenAuthenticationFilter() {
return new TokenAuthenticationFilter();
}
/*
By default, Spring OAuth2 uses HttpSessionOAuth2AuthorizationRequestRepository to save
the authorization request. But, since our service is stateless, we can't save it in
the session. We'll save the request in a Base64 encoded cookie instead.
*/
@Bean
public HttpCookieOAuth2AuthorizationRequestRepository cookieAuthorizationRequestRepository() {
return new HttpCookieOAuth2AuthorizationRequestRepository();
}
@Override
public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
authenticationManagerBuilder
.userDetailsService(customUserDetailsService)
.passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean(BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.csrf()
.disable()
.formLogin()
.disable()
.httpBasic()
.disable()
.exceptionHandling()
.authenticationEntryPoint(new RestAuthenticationEntryPoint())
.and()
.authorizeRequests()
.antMatchers("/",
"/error",
"/favicon.ico",
"/**/*.png",
"/**/*.gif",
"/**/*.svg",
"/**/*.jpg",
"/**/*.html",
"/**/*.css",
"/**/*.js")
.permitAll()
.antMatchers("/auth/**", "/oauth2/**")
.permitAll()
.anyRequest()
.authenticated()
.and()
.oauth2Login()
.authorizationEndpoint()
.baseUri("/oauth2/authorize")
.authorizationRequestRepository(cookieAuthorizationRequestRepository())
.and()
.redirectionEndpoint()
.baseUri("/oauth2/callback/*")
.and()
.userInfoEndpoint()
.userService(customOAuth2UserService)
.and()
.successHandler(oAuth2AuthenticationSuccessHandler)
.failureHandler(oAuth2AuthenticationFailureHandler);
// Add our custom Token based authentication filter
http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
Notes from my struggles trying to connect MySQL to a GCP compute engine.
1. Installing MySQL on Compute Engine
sudo apt-get update
sudo apt-get install mysql-server
sudo mysql_secure_installation // mysql 설정
Log in using the password you set during mysql_secure_installation.
How to connect: Log in as the root user with a password
sudo mysql -u root -p
2. Opening Firewall Ports for External IP Access
Go to VPC network > firewall rules menu
traffic : ingress
protocols and port : 3306
Port 3306 will be opened in the firewall.
Customize the source filters and targets as you need. I set it to all instances for general-purpose use.
3. Changing the Daemon Address
To allow MySQL access via IP, the MySQL daemon needs to be listening on 0.0.0.0 (the default is 127.0.0.1) To do this, you need to modify the conf file in the compute engine's shell.
cd /ect/mysql/mariadb.conf.d
sudo vi 50-server.cnf
The config file is located at this path. Go in and change the bind-address.
# bind-address = 127.0.0.1
bind-address = 0.0.0.0
After making this change, you need to restart the daemon.
You could use service restart, but since it wasn't installed on the VM, I used a different method.
sudo /etc/init.d/mysql restart
4. Granting User Permissions
Earlier, we connected to the DB using sudo privileges. So let's add a dedicated user for external access and grant permissions to that user.
Check User List
SELECT user, host FROM mysql.user;
Add a User Account
CREATE USER 'jyami'@'%' IDENTIFIED BY 'password';
Username: jyami
Access: % - allows access from external sources
Password: password
User Access Scope
localhost
Allows access only from local
%
Allows access from all IPs
xxx.xxx.xxx.xxx
Allows access only from a specific IP
xxx.xxx.%
Allows access from a specific IP range
Grant DB Privileges
GRANT ALL PRIVILEGES ON *.* TO 'jyami'@'%' WITH GRANT OPTION;
This grants all privileges to the user jyami connecting from anywhere.
*.*
Privileges to do everything
[database_name].*
Privileges to manage a specific DB
[database_name].[tablename]
Privileges to manage a specific table in a specific DB
View Granted Privileges
SHOW GRANTS FOR jyami@%
5. Connecting from Spring Boot
When I accessed the GCE, I definitely installed MySQL following the commands above, but when I actually connected to MySQL, it showed MariaDB [DB]>. In this case, the MySQL connection didn't work, but the MariaDB connection did.
댓글
Comments