มาเรียนรู้ Blockchain โดยใช้ Corda กันเถอะ

สวัสดีครับหลังจากผมห่างหายจากการเขียนบทความไปนาน วันนี้ผมกลับมาพร้อมกับบทความใหม่ ซึ่งต้องยอมรับว่าเรื่องนี้ค่อนข้างใหม่สำหรับผม ในโลกปัจจุบัน คงไม่มีใครปฏิเสธว่า blockchain กำลังจะเข้ามามีบทบาทสำคัญกับชีวิตเรา ดังนั้นเลยเริ่มทำการเรียนรู้เกี่ยวกับมัน แต่ออกตัวก่อนเลยว่าผมคงไม่ได้ไปยุ่งกะ algotithm หรือกระบวนการทำงาน ผมคงแค่เอามาใช้เท่านั้น

Corda เป็นระบบ Distributed Ledger Platform ที่ทำการกระจายขอมูลไปยัง node ต่างๆ โดยอาศัย concept ของ blockchain ซึ่งแตกต่างกับระบบของ bitcoin ที่ทุกคนสามารถมองเห็นข้อมูลได้หมด แต่ corda จะมีการจำกัดสิทธิ์ในการเข้าถึงข้อมูลด้วย ผมว่ามันเหมาะกับธุรกิจมากกว่ากว่านะเพราะธุกิจเราคงไม่อยากแชร์ทุกข้อมูลให้คนอื่นๆ เห็นทั้งหมดหรอกจริงไหม!!

ปัจจุบันระบบของ corda ถูกนำไปใช้ในองค์กรและสถาบันทางการเงินต่างๆ มากมาย

10 ปากว่าไม่เท่าลงมือทำ * *

ผมว่าหลายๆ คนคงอยากลองทำดู(แต่ถ้ายังไม่อยากก็ลองๆ ทำดูเล่นๆ ก็ได้) แต่ก็อาจจะติดตรงไม่รู้จะเริ่มต้นจากตรงไหนใช่ไหมครับ เดี๋ยวลอง follow ตามนี้ดูนะครับ เพื่อจะได้ idea ไปประยุกต์ใช้ในขั้น advance ต่อๆ ไป

เอาล่ะมาลงมือทำกันเลยดีกว่าครับ…

สิ่งที่เราต้องมี
  1. Java SE (http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html)
  2. IntelliJ Editor (https://www.jetbrains.com/idea/download/)
  3. Download corda template (https://github.com/corda/cordapp-template-kotlin)

ทำการ install java และ editor กันก่อนเลยครับ หลังจากนั้นก็ open template project ใน IntelliJ ครับ

อย่าลืมเลื่อกให้มัน auto import นะครับ

จากนั้นก็รอมัน download สักครู่ (นั่งฟังเพลงเล่นสักเพลง)

การเตรียมการเป็นอันเสร็จสิ้น !!!

Design First

สิ่งที่ผมจะทำตอนนี้คือ ผมจะสร้างระบบการ share ข้อมูลสมาชิก (แบบง่ายๆ) โดย

เริ่มต้นเราจะเริ่มจากการ design database schema ของ members กันก่อน

linear_id | title | first_name | last_name | creator | viewer

ผมทำการ design ง่ายๆ ตามนี้นะครับ

ขั้นตอนต่อไปเรามาวาง structure folder กันก่อน โดยผมจะวาง folder ตามรูปครับ

Note: folder corda ต้องมองเห็น folder cordapp-contracts-states นะครับ ซึ่งกำหนดได้ใน file build.gradle

cordapp project(“:cordapp-contracts-states”)

ได้เวลาลง code กันแล้ว

1. Define the State

ในขั้นตอนนี้มีสิ่งที่เราต้องทำ 3 อย่าง

1.1 สร้าง file member state (states/Member) ซึ่งสิ่งนี้จะทำการเก็บข้อมูลที่ส่งกันไปมาในแต่ละ node นะครับ

สร้าง data class Member ขึ้นมา define field

อย่าลืม override function !!!

  • supportedSchemas (schema ที่ support)
  • generateMappedObject (ไว้ map state เป็น schema เพื่อเก็บลง db จ้า)
  • participants (list ของ node ที่เกี่ยวข้องจ้า)
package states

import net.corda.core.contracts.LinearState
import net.corda.core.contracts.UniqueIdentifier
import net.corda.core.identity.AbstractParty
import net.corda.core.identity.Party
import net.corda.core.schemas.MappedSchema
import net.corda.core.schemas.PersistentState
import net.corda.core.schemas.QueryableState
import schemas.MemberSchemaV1
data class Member(
val creator: Party,
val viewer: Party,
val title: String?,
val firstName: String?,
val lastName: String?,
override val linearId: UniqueIdentifier = UniqueIdentifier()): LinearState, QueryableState {
override fun supportedSchemas(): Iterable<MappedSchema> = listOf(MemberSchemaV1)

override fun generateMappedObject(schema: MappedSchema): PersistentState {
return when(schema) {
is MemberSchemaV1 -> MemberSchemaV1.MemberEntity(
linearId = this.linearId.id.toString(),
creator = this.creator.name.toString(),
viewer = this.viewer.name.toString(),
title = this.title,
firstName = this.firstName,
lastName = this.lastName
)
else -> throw IllegalArgumentException("Unrecognised schema $schema")
}
}

override val participants: List<AbstractParty> = listOf(creator, viewer)

}
1.2 สร้าง schema ครับ (schemas/MemberSchema)
package schemas

import net.corda.core.schemas.MappedSchema
import net.corda.core.schemas.PersistentState
import net.corda.core.serialization.CordaSerializable
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Table
object MemberSchema

@CordaSerializable
object MemberSchemaV1 : MappedSchema(schemaFamily = MemberSchema.javaClass,
version = 1,
mappedTypes = listOf(MemberEntity::class.java)) {

@Entity
@Table(name = "member_states")
class MemberEntity(

@Column(name = "linear_id")
var linearId: String? = null,

@Column(name = "creator")
var creator: String? = null,

@Column(name = "viewer")
var viewer: String? = null,

@Column(name = "title")
var title: String? = null,

@Column(name = "first_name")
var firstName: String? = null,

@Column(name = "last_name")
var lastName: String? = null

) : PersistentState()

}
1.3 สร้าง models (models/MemberModel) เอาไว้ใช้ map input ตอนสร้าง member นะครับ
package models

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonInclude
import net.corda.core.serialization.CordaSerializable
@JsonIgnoreProperties(ignoreUnknown = true)
@CordaSerializable
@JsonInclude(JsonInclude.Include.NON_NULL)
data class MemberModel(
val creator: String?= null,
val viewer: String?= null,
val title: String? = null,
val firstName: String? = null,
val lastName: String? = null)

2. Define the Contract

สร้าง contract (contracts/MemberContract)

package contracts

import net.corda.core.contracts.*
import net.corda.core.transactions.LedgerTransaction
import net.corda.core.utilities.loggerFor
import states.Member
import java.security.PublicKey

class MemberContract : Contract {

companion object {
@JvmStatic
val MEMBER_CONTRACT_ID = "contracts.MemberContract"
val logger
= loggerFor<MemberContract>()
}

interface Commands : CommandData {
class Issue : TypeOnlyCommandData(), Commands

}

override fun verify(tx: LedgerTransaction) {

val command = tx.commands.requireSingleCommand<Commands>()
val setOfSigners = command.signers.toSet()
when (command.value) {
is Commands.Issue -> verifyIssue(tx, setOfSigners)
else -> throw IllegalArgumentException("Unrecognised command.")
}
}

private fun verifyIssue(tx: LedgerTransaction, ofSigners: Set<PublicKey>) = requireThat {
val
memberOuts = tx.outputsOfType<Member>()
"At least one output must be produced when verifyIssue member." using (memberOuts.isNotEmpty())

"Only one member can create in the same time." using (memberOuts.size == 1)
val memberOut = memberOuts.single()

"All participants signs together only may sign when create member." using (ofSigners == (keysFromParticipants(memberOut)))
}

private fun
keysFromParticipants(state: Member): Set<PublicKey> {
return state.participants.map {
it
.owningKey
}
.toSet()
}
}

อธิบายกันหน่อยสำหรับ file นี้

Contract เอาไว้กำหนด rule ที่เกี่ยวกับ member ครับ ซึ่งมันจะคอย verify ข้อมูล member ที่วิ่งเล่นอยู่ใน network ว่าต้องผ่าน rule ทั้งหมดก่อนนะครับ ไม่ใช่ใครนึกจะ put อะไรใส่ network ก็ได้

MEMBER_CONTRACT_ID : เป็น id นอง contract ที่เราสร้างครับผม

Commands : เราต้องทำการ define command ที่จะใช้ใน contract นี้กันก่อนนะ โดยในตัวอย่างนี้ผมสร้างมาเป็นตัวอย่างแค่ตัวเดียวครับ

Issue เป็นคำสั่งที่ใช้ในการสร้าง member ซึ่ง command นี้จะถูกเอาไปผูกกะ transaction ใน flow นะครับ

จากตัวอย่างจะเห็นว่าเมื่อเราเรียก issue member เราจะ verify กฎ 3 ข้อ

“At least one output must be produced when verifyIssue member.” => หลังจากสร้างแล้วต้องมี member record เกิดขึ้นด้วยนา
“Only one member can create in the same time.” => member จะถูกสร้างได้ทีละ record เท่านั้นนะครับ
“All participants signs together only may sign when create member.” => ผู้เกี่ยวข้องทุกคนต้องลงชื่อ confirm ข้อมูลว่าถูกต้องนะ

ถึงจุดนี้ต้องเสริมนิดนึงเพราะก่อนหน้านี้ผมก็เคยสงสัยว่าใครกันที่จะกำหนดกฎเหล่านี้

คำตอบคือ Business and Developer (ยินดีด้วยครับ คุณได้รับสิทธิ์นั้น) ซึ่งหมายความว่าคุณจำเป็นที่จะต้องจะเข้าใจสิ่งที่คุณจะสร้างก่อนเลย จะมาตีมึนนี่ยากแล้ว clear requirement ให้ดีนะครับ!!

3. Define the Flow

สร้าง flow การทำงานตอนสร้าง member กันเถอะ (flows/CreateMember) flow นี้อยู่กันคนละ folder กับ contract model schema ที่กล่าวมาก่อนหน้านะครับ

มาเริ่มต้นจากการกำหนด step การทำงานของ flow กันก่อนโดยในตัวอย่างผมกำหนดเป็น 5 ขั้น

  1. INITIALISING : หลังจากตรวจสอบ input เราจะทำการสร้าง member data กันที่ขั้นตอนนี้
  2. BUILDING : สร้าง transaction ที่จะเกิดขึ้น จุดนี้แหละครับที่เราต้องใส่ input (ตัวอย่างนี้ไม่ต้องมี input ครับ แต่ถ้าอัพเดต ต้องมีครับผม) output และ command ที่ต้องใช้ (จำได้ไหมที่อยู่ใน contracts ไง!!)
  3. SIGNING : เซ็นต์ชื่อซะ (ตัวเองนะ)
  4. COLLECTING : ตามล่ารายเซ็นต์จากผู้ที่เกี่ยวข้องให้ครบ
  5. FINALISING: Job done

ผ่านทุกขั้นก็ถือว่าจบ flow จ้า

อธิบายเพิ่มเติมก่อนเข้า step 1 เราสามารถ validate input หรือข้อมูลอื่นๆ ที่เกี่ยวข้องได้นะครับใน function inspect ในตัวอย่างนี้ผม validate 3 อย่าง title firstName และ lastName ห้ามว่างครับ

private fun inspect() {
requireThat {
"The title cannot be empty"
using (memberModel.title.isNullOrEmpty().not())
"The first name cannot be empty" using (memberModel.firstName.isNullOrEmpty().not())
"The last name cannot be empty" using (memberModel.lastName.isNullOrEmpty().not())
}
}

อีกอย่างที่ต้องเข้าใจสำหรับ flow ครับ จะเห็นว่าใน flow นี้เรามี 2 class อันแรกคือ Initiator อีกอันคือ responder ซึ่ง 2 อันนี้ทำงานต่างที่กันนะ

สมมุติผม เป็น A ซึ่งเป็นคนสร้าง member ได้ และคุณเป็น B ซึ่งรอใช้ข้อมูล member จากผมอย่างเดียว (ห้ามมาสร้างนะ!!)

จากข้อมูลข้างต้น ผมจะเป็นคน initiate flow นี้ขึ้นแล้วค่อยส่งให้ คุณหมายความว่าผมควรจะทำงานใน class Initiator แล้วกระจาย flow นี้ไปยังคนอื่นซึ่งจะทำงานใน class responder

check(members.all { resolveIdentity(it.creator)!!.name.organisation == "PartyA" }) {"Only Party A can create the member."}

จากใน code จะสังเกตุว่าผมได้ทำการตรวจสอบว่าข้อมูลว่าผู้สร้าง member ต้องเป็น PartyA เท่านั้นนะใน class responder ซึ่งหมายความว่าถ้าคุณพยายามที่จะมาสร้างแทนผม คุณจะเป็นคน initate flow แล้วผมจะรับ flow นั้นต่อมาจากคุณซึ่งผมจะทำงานใน class responder แทน ผมจะ validate เจอว่าคุณไม่ใช่ PartyA ซึ่งผมจะ reject transaction นี้ (ไม่ sign ให้หรอก) transaction นี้ก็จะไม่เกิดขึ้นครับ

คุณต้องกำหนด ชื่อ node (PartyA) ใน file config ตอนคุณ deploy ขึ้น server ครับ ซึ่งในบทความนี้จะไม่กล่าวถึงการ deploy ลง server จริง แต่ผมจะบอกจุดที่คุณสามารถ set ชื่อ node (legalName) นี้เพื่อใช้ทดสอบใน local นะครับ

มันอยู่ที่ build.gradle ที่ main folder เลยจ้า แถวๆ task deployNodes
name “O=PartyA,L=London,C=GB”

…….

ใกล้เสร็จแล้วหล่ะครับขาดอีกนิดคือ ทำทางเข้า (API)

สร้าง file Member ใน folder apis ก่อนเลย

Class MemberApi มีการ pass parameter CordaRPCOps เข้ามาให้ (dependency injection) ใช้ query data และ ใช้ call flow จ้า

Note : กำหนด group เป็น members ถ้าก้องการ uri ต่อท้ายให้ลึกไปอีกก็ไปกำหนดในแต่ api โดยไม่ต้องใส่ members ซ้ำแล้วนะ

@Path(“members”)

ผมทำตัวอย่างไว้สอง api ครับ

@GET
@Produces(MediaType.APPLICATION_JSON)
fun getMember(
@QueryParam(value = "title") title: String?,
@QueryParam(value = "firstName") firstName: String?,
@QueryParam(value = "lastName") lastName: String?): Response {

//Create search model from query parameters
val searchModel = MemberSearchModel(
title = title,
firstName = firstName,
lastName = lastName
)

//generate criteria from search model
val criteria = generateCriteria(searchModel)

val invoicePageFound = services.vaultQueryBy<Member>(criteria)

val members = invoicePageFound.states.map { it.state.data }

return
Response.ok(members).build()
}
  1. Get members : ทำการ query ข้อมูลมาจาก vault (เอามาจาก db แหละ) แล้ว response ออกมาให้
@POST
@Produces(MediaType.APPLICATION_JSON)
fun postMember(member: MemberModel): Response {

logger.info("MemberApi.postMember POST members/ : $member ")

// since we can do a bulk creation atomically, we have to do one by one invoice.
val (responseStatus, responseMessage) = try {
val flowHandle = services.startTrackedFlowDynamic(CreateMember.Initiator::class.java, member)
flowHandle.progress.subscribe({ logger.info("MemberApi.postMember: $member") })
val stx = flowHandle.use { it.returnValue.getOrThrow() }

// Create a invoice model pertaining to all information of the states created
val output = stx.tx.outputsOfType<Member>()

val http = HttpStatus.CREATED_201
http to output

} catch (ex: Exception) {
logger.error("Exception during invoice: ", ex)
val error = ex.message ?: ex.rootCause.toString()
val http = HttpStatus.INTERNAL_SERVER_ERROR_500
http to error
}

return Response.status(responseStatus).entity(responseMessage).build()
}

2. Post members: ทำการสร้าง member ขึ้นมาครับ ที่ method จะมีการเรียก flow CreateMember ที่เราสร้างไปก่อนหน้าครับ

OK ครับสร้างทุกอย่างครบได้เวลา deploy ไป test กันละ

  1. Clean build แล้วต่อด้วย deploy
$ ./gradlew clean && ./gradlew build && ./gradlew deployNodes
  1. Run node

ไปที่ folder build/nods

$ cd build/nods

สั่ง run

$ ./runnodes

ควรเห็น ผลลัพธ์แบบนี้นะครับ ถ้าไม่ได้สั่ง runnodes ใหม่ (อย่าลืมปิด terminal ก่อน run ใหม่ทุกครั้งเน้อ)

  1. สร้าง member ผ่าน postman กัน
Host : http://localhost:10007/api/members
Method : POST
Param (body) :
{
    “viewer”: “O=PartyB,L=New York,C=US”,
    “title”: “Mr”,
    “firstName”: “TeaterA”,
    “lastName”: “TeaterA”
}

Response ตามรูปครับ

ลอง get ดูต่อเลย

Host : http://localhost: 10010/api/members
Method : GET
Param : title = Mr

Response คือ member ที่เราพึ่งจะ add ไปครับ

ลอง create ที่ port 10010 ดูจะไม่ได้ครับ!!!

ลอง create ที่ port 10007 แต่ไม่ใส่ title (ผิดกฎ) ดูก็จะไม่ได้เช่นกันครับ!!!

Link code กับ collection postman

Collection postman

Example code

Corda document

แถมท้าย

  1. ถ้าเรามี server จริงมี db จริง (ถ้า test ใน local ใช้ H2) ลองเข้าไปเช็คดูจะเห็น table member_state ที่เราสร้างขึ้นครับแต่บอกเลยว่าแก้ไขไปก็เท่านั้น เพราะข้อมูลจริงหาได้เก็บในนี้ไม่!! อันนี้ใช้ให้ human อ่านได้เท่านั้น ข้อมูลจริง จะถูก serialize ให้อ่านไม่ออกแล้วเก็บใน blob type หมายความว่าเราไม่สามารถได้ แก้ไขข้อมูลต่างๆ ได้ง่ายๆ นะ hacker เข้า db มาก็แก้ยาก (รวมทั้ง developer ด้วย T T)
  2. ข้อมูลต่างๆ จะถูก store เป็น new record ตลอดนะครับ โดยอันเก่าจะถูก stamp เป็น comsumed ไป (ของใหม่เป็น unconsume) หมายความว่าถ้าเราสร้างแล้วแก้ไข จะเกิด 2 records อันแรกตอนสร้าง อันที่สองตอน edit แล้วจะไปแก้ status อันแรกให้เป็น comsumed
  3. ในบทความนี้ผมข้ามส่วนที่เป็น unit test ไปเพราะกลัวว่าจะยาวไปจนคนอ่านเบื่อ แต่บอกเลยนะครับว่าส่วนนี้ก็สำคัญแล้วควรมีเป็นอย่างยิ่ง ซึ่งมันจะช่วยไม่ให้คุณต้อง depoly ซ้ำเพราะคุณสร้าง bug ครับ ซึ่งโดยหลักๆ เราจะ test สองส่วนคือ contract (เอาให้ครอบคลุมทุก rule ที่คุณสร้างมานะ) อีกอันคือ flow ครับ