4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

BBc−1のアプリケーションプログラミング

Posted at

前回までは「BBc−1 version1.0っていうのをリリースしました」というタイトルだったのですが、ちょっと間が空いてしまい、いい加減そういう時期でもなくなったので、実態にあったタイトルにしました。

今回はBBc-1のプログラミングについて説明します。ひとまずノードは1台です。BBc-1のexamplesに含まれているfile_proof.pyというプログラムを例にとって、プログラミングの方法を解説していきます(概要はexamplesのREADMEにも書かれています)。また、プログラミングHowToがdocs/BBc1_programming_guide_v1.0_ja.mdにもありますのでご参考ください。なお、pipでbbc1がインストールされている前提での解説です。

今回はかなり長い記事になってしまいそうです。

file_pfoof.pyの使い方

まずはこのプログラムが何をするものかを説明します。このプログラムは、ファイルとその所有権をBBc-1に登録し、改ざんされていないかを検証したり、所有権を他人に移すことができます。ここでの所有権は、トランザクションに「XXXさんのファイルです」と書いてあるだけの簡単な表現になっています。なお、このプログラムは単なるサンプルなので、実用上足りない機能があると思います。

はじめに、別ターミナルでbbc_core.pyを起動しておきます。デフォルトではbbc_coreは許可されたbbc_app(今回の例ではfile_proof)だけと通信するようになっています(node_keyを持っているbbc_appだけが通信可能)。この鍵でbbc_coreとbbc_app間でやり取りされるメッセージを暗号化されるのですが、設定手順を省略するために、暗号化なしのモードで起動します。

bbc_core.py --no_nodekey

なお、-dオプションを付ければ、デーモンとしてバックグラウンド起動します。

次に、file_proof.pyを起動します。最初にdomainを作る必要があるので、setupコマンドを実行します。(最初の一回だけでいいです)

file_proof.py setup

つぎに、自分の秘密鍵、公開鍵のペアを作ります。本人を表す最も大事な情報です。

file_proof.py keypair

.private_keyと.public_keyというファイルが作成されたと思います。これらのファイルがfile_proof.pyを動かすディレクトリに存在している必要があります。

これで準備完了です。これで、ファイルの登録、検証、取得、譲渡ができるようになりました。

file_proof.py store <filename> -o <username>

というファイルをBBc-1にusernameという所有者名で登録します。ファイル本体をBBc-1のストレージに登録し、またトランザクションにはファイルのダイジェストと自分の所有物であるという記述が含まれます。

file_proof.pyを動かしたディレクトリに、.bbc_id_mappingsというファイルが出来ていると思います。これは、ファイル名とtransaction_idの対応付を記録したjsonファイルです。BBc-1は、ファイル名のようなアプリケーション固有の識別子は取り扱わないので、何らかの方法でtransaction_idとの紐付を持っておく必要があります。そのため、file_proof.pyには、store_id_mappings()、remove_id_mappings()、get_id_from_mappings()などのユーティリティを含んでいます。これらの関数は必ずしもこのような形でなくてもよく、例えば別のデータベースに記録しておいてもいいと思います。

file_proof.py verify <filename>

というファイルがBBc-1に登録されているものと同じかどうかを確認します。これは先程の. bbc_id_mappingsを使ってそのfilenameに対応するtransaction_idを入手し、そのtransaction_idでBBc-1を検索して署名を検証します。またfilenameのファイルのダイジェストを計算し、トランザクションに記載されているファイルダイジェストと同じかどうかを確認します。

file_proof.py get <filename>

登録されているというファイルをBBc-1から取得します。verifyと同じく. bbc_id_mappingsを使ってそのfilenameに対応するtransaction_idを入手し、そのtransaction_idでBBc-1を検索しています。transaction_idさえ入手できれば、他人の所有ファイルでも検索できます。実は、getとverifyはほとんど同じ処理を行っており、getはファイル本体をとりだして保存しています。

次はファイルの所有権譲渡です。これには2つのターミナルが必要です。

file_proof.py wait -o <username>

ファイルを譲渡してもらう側のユーザが、waitモードで待ち受けます。次に説明するsendコマンドが発行されると、受け取るか拒否するかを問われます。承諾すると所有権が自分に移ります。なお、ファイルそのものは受け取れないので、別途getする必要があります。

file_proof.py send <filename>

ファイルを譲渡します。実行するとプロンプトが表示され、誰に譲渡するかを聞かれますので、waitモードで待っているusernameを入力してください。相手が受け取りを承諾すると所有権が移転します(トランザクションにusernameの所有になったと記述されるだけです)

BBc-1アプリケーションのライフサイクル

BBc-1のアプリケーションプログラムの処理の流れを説明します。流れはとても単純で以下の4つのステップを実行するだけです。

  1. トランザクションを作成する
  • 合意し、保存したい情報をBBcAssetに格納し、この合意に関係あるトランザクションへのポインタを指定する
  1. トランザクションを確認してもらう
  • 当事者にトランザクションを確認してもらう。そのために、トランザクションそのものを相手に送る
  1. トランザクションに署名する
  • トランザクションの内容に合意するならトランザクションに署名する
  1. トランザクションをbbc_coreに登録する
  • 全員の署名がトランザクションに付与されたら、それを登録する

いくつかの点をあえて曖昧に書いています。それは、誰(どのクライアント)が1〜4の処理を実行するのかという点と、トランザクションの署名をどうやって集めるのかという点です。なぜそこを曖昧にしているかというと、BBc-1の本質としてはこれらの点を規定する必要がないからです。どういう方法でもいいので、署名が揃ったトランザクションをbbc_coreに登録すればそれでOKということです。とはいえ、それだといくらなんでも使いにくいだろうということで、いくつかのutilityメソッドやクラスが用意されています。

さてそれでは、file_proof.pyでどのようにコーディングされているかを見ていきます。ここで示したライフサイクルに入る前に、鍵の準備とbbc_coreへの接続という準備段階が必要です。

鍵の準備

まずは鍵の作成です。個々人の身分証明のようなものなのでとても大事です。特に秘密鍵は他人に知られてはなりません。

def create_keypair():
    keypair = bbclib.KeyPair()
    keypair.generate()
    with open(PRIVATE_KEY, "wb") as fout:
        fout.write(keypair.private_key)
    with open(PUBLIC_KEY, "wb") as fout:
        fout.write(keypair.public_key)
    print("created private_key and public_key : %s, %s" % (PRIVATE_KEY, PUBLIC_KEY))

bbclib.pyに含まれているKeyPairクラスを使います。BBc-1では楕円曲線暗号方式を用います。KeyPairはBBc-1で鍵を扱いやすいように保管します。keypair.generate()とするだけで鍵ペアを生成できます。private_keyやpublic_keyに鍵が直接記録されているので、それを保存しています(PEM形式でも保存可能)。

        with open(PRIVATE_KEY, "rb") as fin:
            private_key = fin.read()
        with open(PUBLIC_KEY, "rb") as fin:
            public_key = fin.read()
        global key_pair
        key_pair = bbclib.KeyPair(privkey=private_key, pubkey=public_key)

このように、KeyPairをインスタンス化するときに読み込ませることも出来ます。

bbc_coreとの接続

bbc_coreは、ドメインの管理、トランザクションの保存や検索要求への対応と、他のユーザへのメッセージングを行います。アプリケーションからbbc_coreの機能を利用するために、bbc_app.pyが提供するBBcAppClientクラスを使います。インスタンス化して使うのですが、独立したオブジェクトにしてもいいですし、アプリケーションのメインクラスにBBcAppClientクラスを継承させても構いません。file_proof.pyでは独立させています。

例えば、file_proof.pyのdomain_setup()というメソッドは、setupコマンドを呼んだときに実行されます。

def domain_setup():
    tmpclient = bbc_app.BBcAppClient(port=DEFAULT_CORE_PORT, multiq=False, loglevel="all")
    if os.path.exists("node_key.pem"):
        tmpclient.set_node_key("node_key.pem")
    tmpclient.domain_setup(domain_id)
    tmpclient.callback.synchronize()
    tmpclient.unregister_from_core()
    print("Domain %s is created." % (binascii.b2a_hex(domain_id[:4]).decode()))
    print("Setup is done.")

tmpclientがBBcAppClientオブジェクトでtmpclient.domain_setup(domain_id)のようにして使います。これで新しいドメインが接続先のbbc_core上に作られます。

bbc_coreからやってくるメッセージは非同期メッセージです。BBcAppClientは受信するメッセージ種別ごとにコールバック関数を設定できるのですが、単純な処理の場合は一つのメッセージキューにメッセージを格納する方法も提供しています。BBcAppClient()を初期化する中で、multiq=Falseとしているのがそれです。tmpclient.callback.synchronize()というところで、bbc_coreからの応答メッセージを待ち受けています。もしほかのメッセージも飛んでくる可能性がある場合はこの方法は使えませんが、この例は非常に単純なので、これで十分です。

特殊な方を先に説明してしまいました。もう一つ、setup以外のすべてのモードはこっちを使います。

def setup_bbc_client():
    bbc_app_client = bbc_app.BBcAppClient(port=DEFAULT_CORE_PORT, multiq=False, loglevel="all")
    bbc_app_client.set_user_id(user_id)
    bbc_app_client.set_domain_id(domain_id)
    bbc_app_client.set_callback(bbc_app.Callback())
    ret = bbc_app_client.register_to_core()
    assert ret
    return bbc_app_client

基本的な考え方は同じですが、bbc_app_clientオブジェクトにuser_idと接続先のdomain_idをセットしています。これをセットした状態で、bbc_app_client.register_to_core()とすることで、そのuser_id宛のメッセージを受け取ることができるようになります。また、bbc_coreからやってくるメッセージ種別ごとにコールバック関数を設定するところがbbc_app_client.set_callback(bbc_app.Callback())です。この行、デフォルトのコールバック関数をセットしているだけなので、実はなくても大丈夫です。なお、デフォルトのコールバックはキューにメッセージを貯め込むだけです。multiq=Trueにすると、request/responseを意識したマルチキューになります(requestに対して1つのキューができる)。詳細はこちらをご参考ください。

トランザクションの作成〜登録

BBc-1アプリケーションを実行するための最初の一歩は、鍵を準備する(KeyPair)とbbc_coreに接続する(BBcAppClientオブジェクトでregister_to_core()を呼ぶ)でした。あとはやりたいことに合わせてライフサイクルの通りに処理していくだけです。

file_proof.pyでは、store_file()とstore_proc()というメソッドがトランザクションの作成と登録を担っています。特に重要なポイントを説明します。

    store_transaction = bbclib.make_transaction(relation_num=1, witness=True)
    user_info = "Owner is %s" % user_name
    bbclib.add_relation_asset(store_transaction, relation_idx=0, asset_group_id=asset_group_id,
                              user_id=user_id, asset_body=user_info, asset_file=data)
    store_transaction.witness.add_witness(user_id)

ここで、トランザクションを作っています。bbclib.make_transaction(relation_num=1, witness=True)で、BBcRelationが1つとBBcWitnessを含んだトランザクションの雛形を作成し、bbclib.add_relation_asset(store_transaction,,,,)で実際のデータを書き込んでいきます。なお、asset_file=dataとすることで、ファイル本体をbbc_coreのストレージに分離して保管します。トランザクション本体にはSHA256ダイジェストだけが記載されます。

store_transaction.witness.add_witness(user_id)で、user_idのユーザの署名を付与することを宣言します。

次は、トランザクションの確認と過去の情報との関連付けです。

    if txid:
        bbc_app_client.search_transaction(txid)
        response_data = bbc_app_client.callback.synchronize()
        if response_data[KeyType.status] < ESUCCESS:
            print("ERROR: ", response_data[KeyType.reason].decode())
            sys.exit(0)
        prev_tx = bbclib.BBcTransaction(deserialize=response_data[KeyType.transaction_data])
        bbclib.add_relation_pointer(transaction=store_transaction, relation_idx=0,
                                    ref_transaction_id=prev_tx.transaction_id)

ここは、ファイルの新規登録ではなく更新の場合の処理になります。上記の中の、

        bbc_app_client.search_transaction(txid)
        response_data = bbc_app_client.callback.synchronize()

この2行で、指定したtransaction_id (=txid)のトランザクションを取得します。ここで取得するトランザクションは、更新前のファイルの登録情報です。

        prev_tx = bbclib.BBcTransaction(deserialize=response_data[KeyType.transaction_data])
        bbclib.add_relation_pointer(transaction=store_transaction, relation_idx=0,
                                    ref_transaction_id=prev_tx.transaction_id)

そして、BBcRelationの中にBBcPointerオブジェクトを作り、その中に更新前のファイルのtransaction_id (=prev_tx.transaction_id)を格納します。これによって、過去のファイルが更新されたものであると関連付けることが出来ます。

    sig = store_transaction.sign(key_type=bbclib.KeyType.ECDSA_SECP256k1,
                                 private_key=key_pair.private_key,
                                 public_key=key_pair.public_key)
    store_transaction.get_sig_index(user_id)
    store_transaction.add_signature(user_id=user_id, signature=sig)

最後に、トランザクションに署名します。署名オブジェクトsigをstore_transaction.sign()によって生成し、store_transaction.get_sig_index(user_id)によってトランザクションのsignature部に格納場所を確保します。確保された場所に、store_transaction.add_signature()で署名を書き込みます。

わざわざ、場所の確保→署名書き込みの2段階になっているのには理由があります。場所確保の操作を行うことで、BBcWitnessにuser_idと署名保存場所の配列要素番号の対応付けを書き込みます。このBBcWitnessは署名される情報の範囲内なので、「トランザクションに誰の署名が付与されているか」ということを改ざんできなくなります。つまり、署名部分だけ勝手に差し替えたり、削除したり、追加したりといった攻撃を検知できるようになっています。

    ret = bbc_app_client.insert_transaction(store_transaction)
    assert ret
    response_data = bbc_app_client.callback.synchronize()
    if response_data[KeyType.status] < ESUCCESS:
        print("ERROR: ", response_data[KeyType.reason].decode())
        sys.exit(0)

作成したトランザクションを、bbc_app_clientを使って登録します。

この例では、自分のファイルを扱うだけだったので自分の署名しか必要なく、単純な処理の流れでした。

署名検証

署名検証は簡単です。トランザクションを入手して、BBcSignatureオブジェクトのverifyメソッドを呼ぶだけです。file_proof.pyでは、verify_file()という関数でそれを行っています。以下、その抜粋です。

    ret = bbc_app_client.search_transaction_with_condition(asset_group_id=asset_group_id, asset_id=fileinfo["asset_id"])
    assert ret
    response_data = bbc_app_client.callback.synchronize()
    if response_data[KeyType.status] < ESUCCESS:
        print("ERROR: ", response_data[KeyType.reason].decode())
        sys.exit(0)

    transaction = bbclib.BBcTransaction(deserialize=response_data[KeyType.transactions][0])
    digest = transaction.digest()
    ret = transaction.signatures[0].verify(digest)

bbc_app_client.search_transaction_with_condition()というのはトランザクション検索関数の一つで、条件を指定して適合するトランザクション群を取得できます。返答されたデータはシリアライズされているので、以下のようにしてまずはBBcTransactionオブジェクトを取り出します。なお、このコードはちょっといい加減で、その返答の1番目のトランザクションだけをチェックしています。

transaction = bbclib.BBcTransaction(deserialize=response_data[KeyType.transactions][0])

そして、トランザクションのダイジェストを計算して(このダイジェストはtransaction_idと同値です)、verifyします。

    digest = transaction.digest()
    ret = transaction.signatures[0].verify(digest)

signatures[0]というのは、トランザクションのsignature部の1番目のBBcSignatureを指しています。

これで、少なくとも署名が正しいかどうかが検証できます。ただし、誰の署名がついているかなど全くこのアプリケーションでは確認していません。本来はBBcWitnessのuser_idを確認し、その公開鍵とBBcSignatureの公開鍵が一致しているかなども確認したほうがいいと思います。なお、file_proof.pyのuser_idは、storeコマンドで指定したusernameのSHA256ハッシュが設定されています。

※ ちなみに、bbc_coreは、トランザクションを登録したり検索したりする際に必ずすべての署名を検証しています。かなりしつこいぐらいに検証しているので、もしそれを信じるなら、アプリケーションでわざわざverifyする必要はありません(検証に失敗した場合、検索の応答でinvalidが返ってきます)。

ファイルの譲渡

ここが一番BBc-1らしい部分だと思います。ライフサイクルは同じなのですが、途中でトランザクションを相手に送って、署名だけ返してもらうという処理が入ります。

待受側

待受側は、enter_file_wait_mode()に処理フローが書かれており、その中で譲渡メッセージ(SIGN_RESUEST)を待ち受け、署名を返す処理が実行されます。

def enter_file_wait_mode():
    bbc_app_client = setup_bbc_client()

    recvdat = wait_for_transaction_msg(bbc_app_client=bbc_app_client)
    transaction, source_id = pick_valid_transaction_info(received_data=recvdat,
                                                         bbc_app_client=bbc_app_client)

    prompt_user_to_accept_the_file(bbc_app_client=bbc_app_client, source_id=source_id, transaction_id=transaction.transaction_id)
    signature = transaction.sign(keypair=key_pair)
    bbc_app_client.sendback_signature(source_id, transaction.transaction_id, -1, signature)

wait_for_transaction_msg()でメッセージを待ち受けます。ここで待ち受けるメッセージは、SIGN_REQUESTというメッセージです。このメッセージは、署名してほしいトランザクション全体が含まれているので、pick_valid_transaction_info()でトランザクションを検証します。中を見てみましょう。

def pick_valid_transaction_info(received_data=None, bbc_app_client=None):
    transaction = bbclib.BBcTransaction(deserialize=received_data[KeyType.transaction_data])
    asset_files = received_data[KeyType.all_asset_files]
    asset_id = transaction.relations[0].asset.asset_id
    if asset_id not in asset_files:
        print("**** No valid file is received...")
        print(received_data)
        bbc_app_client.sendback_denial_of_sign(received_data[KeyType.source_user_id],
                                               transaction.transaction_id,
                                               "No valid file is received.")
        sys.exit(1)

    file_to_obtain = asset_files[asset_id]
    file_digest = hashlib.sha256(file_to_obtain).digest()
    print("--------------------------")
    print("File digest written in the transaction data:  ",
          binascii.b2a_hex(transaction.relations[0].asset.asset_file_digest).decode())
    print("File digest calculated from the received file:", binascii.b2a_hex(file_digest).decode())
    print("--------------------------")
    return transaction, received_data[KeyType.source_user_id]

さきほどの検証のときと同じく、

transaction = bbclib.BBcTransaction(deserialize=received_data[KeyType.transaction_data])

でシリアライズされたトランザクションを元に戻しています。またbbc_coreではファイルをストレージに分離して管理していたので、そのファイルは

asset_files = received_data[KeyType.all_asset_files]

のようにして取得しています。あとはそのダイジェストを表示しているだけです。

prompt_user_to_accept_the_file()は、"Y/N"をプロンプトで出しているだけです。最後に、

    signature = transaction.sign(keypair=key_pair)
    bbc_app_client.sendback_signature(source_id, transaction.transaction_id, -1, signature)

で、署名を作成し、sendback_signature()で署名を送り返しています。sendback_signature()ではトランザクション全体を送るのではなく署名部分だけを送り返します。これは、BBcWitnessに予め「この人の署名を入れます」ということが記載されているので、その署名だけを送ってつけてもらえばいいだけだからです。この点については、次の送信側の説明でもう一度書きます。

送信側

送信側は、enter_file_send_mode()に処理フローが書かれています。ここでは、トランザクションを生成して、SIGN_REQUESTメッセージを送り、返答を受け取って、トランザクションを登録する処理が実施されます。

def enter_file_send_mode(filename):
    receiver_name, receiver_user_id = require_receiver_info_for(filename)
    with open(filename, "rb") as fin:
        file_data = fin.read()
    txid_for_reference = search_reference_txid_from_mappings(filename)

    bbc_app_client = setup_bbc_client()
    transfer_transaction = create_transaction_object_for_filedata(receiver_name,
                                                                  receiver_user_id,
                                                                  ref_txids=[txid_for_reference],
                                                                  file_data=file_data,
                                                                  bbc_app_client=bbc_app_client)
    insert_signed_transaction_to_bbc_core(transaction=transfer_transaction,
                                          bbc_app_client=bbc_app_client,
                                          file_name=filename)
    send_transaction_info_msg(bbc_app_client=bbc_app_client,
                              transaction=transfer_transaction,
                              file_name=filename,
                              receiver_user_id=receiver_user_id)
    print("Transfer is done.....")

require_receiver_info_for()で送信先のuser_idを取得します(usernameという文字列をSHA256ハッシュしています)。そして、create_transaction_object_for_filedata()で所有権譲渡のためのトランザクションを生成します。ここがキモなのでcreate_transaction_object_for_filedata()の中を見てみます。

    transaction = bbclib.make_transaction(relation_num=1, witness=True)

    user_info_msg = "Ownership is transfered from %s to %s" % (user_name, receiver_name)
    bbclib.add_relation_asset(transaction, relation_idx=0, asset_group_id=asset_group_id,
                              user_id=receiver_user_id, asset_body=user_info_msg, asset_file=file_data)
    transaction.witness.add_witness(user_id)
    transaction.witness.add_witness(receiver_user_id)

この部分で、トランザクションを生成します。bbclib.add_relation_asset()で、"Ownership is transfered from..."という文字列をasset_bodyに書き込んでいます。file_proof.pyではこれが所有権の移転を表します。

一番のポイントは、transaction.witness.add_witness(receiver_user_id) という部分です。receiver_user_idは譲渡先のユーザです。予めBBcWitnessにこれを登録しておくことがとても大切です。完成したトランザクションにはreceiver_user_idの署名も入っていなければならないということを宣言しているのです。

しかし当然ですが、自分でこの署名を作ることはできません。なので、SIGN_REQUESTメッセージを相手に送って、署名を送り返してもらいます。

    ret = bbc_app_client.gather_signatures(transaction, destinations=[receiver_user_id], asset_files=asset_files)
    if not ret:
        print("Failed to send sign request")
        sys.exit(0)
    response_data = bbc_app_client.callback.synchronize()
    if response_data[KeyType.status] < ESUCCESS:
        print("Rejected because ", response_data[KeyType.reason].decode(), "")
        sys.exit(0)
    result = response_data[KeyType.result]

    transaction.witness.add_signature(user_id=result[1], signature=result[2])

bbc_app_client.gather_signatures()でSIGN_REQUESTを相手に送ります。それで、response_data = bbc_app_client.callback.synchronize() で応答を待っています。file_proof.pyの所有権譲渡は送信先が1人なので、このような単純な待受処理でいいのですが、複数人に送る場合は工夫が必要です。

さきほど待受側のときに説明しましたが、応答は署名部分だけがやってきます。入手した署名を

transaction.witness.add_signature(user_id=result[1], signature=result[2])

でトランザクションに加えています。

あとは、これを登録するだけです。enter_file_send_mode()の最後に、send_transaction_info_msg()という記述がありますが、これはファイルの移転が完了した旨を通知するメッセージを送っています。BBc-1は、ユーザ間のメッセージングをサポートしています。SIGN_REQUESTもその一つですが、bbc_app_client.send_message()を使って自由なメッセージを送ることも出来ます。

SIGN_REQUESTはそれを送った人が署名を集めて最後に登録する、という前提にたったメッセージングの仕組みです。もし、そうではないスキームで署名を集めて登録したい場合は、SIGN_REQUESTを使わずに、send_message()を使うことになると思います。

まとめ

今回は、exampleに含まれているfile_proof.pyを用いて、BBc-1アプリケーションのプログラムの流れについて説明しました。file_proof.pyには、BBc-1がサポートする大半の要素が含まれています。

現在のBBc-1実装の良い点でもあり改善すべき点でもあるのですが、実装自由度が非常に大きいです。具体的に言うと、最も大事なのはbbclib.pyというライブラリであり、ここでトランザクションのデータ構造を定義しています。これを使ってトランザクションのデータを作り、署名を施しさえすればいいということです。あとは、他のユーザにメッセージを送ったり、トランザクション自体を保存するというbbc_coreの機能を利用するのみです。極端な話、ユーザ間のメッセージングはbbc_coreを使わずに、独自に実装しても何ら問題ありません。さらには、bbc_coreが提供するトランザクションの管理機能も、独自実装しても構いません。

自由度が高いというのはいい面ばかりではないので、もう少しexampleを増やして典型的なパターンを作らないといけないですね。

以前の記事

BBc−1 version1.0っていうのをリリースしました(5)
BBc−1 version1.0っていうのをリリースしました(4)
BBc−1 version1.0っていうのをリリースしました(3)
BBc−1 version1.0っていうのをリリースしました(2)
BBc−1 version1.0っていうのをリリースしました(1)

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?