NaaN日記

やったこと、覚えたことを発信する場

sqlxでSUMを使う方法が分からなかった(わかった)

はじめに結論

SUMなどの集計関数を使うなら、モデルのタグに、`db:"SUM(hoge)"`とSUMも書きましょう

何故動かないのかわからなかった

例えば、次のようなデータのテーブル(sampleUser)があるとして、

name score
A 10
B 20
B 30

次の形のデータが欲しかったんですね。

name score
A 10
B 50

SQLを叩くとこうなるわけですが、このクエリをsqlxで実行することに躓いてしまいました。

SELECT name, SUM(score) FROM sampleUser GROUP BY name;
// 実行しようとしたもの
nameScore := []SampleUser{} // 結果を格納する構造体のスライス
err := sqlx.SelectContext(
	ctx,
	r.db,
	&nameScore,
	`
	SELECT name, SUM(score) FROM sampleUser
		GROUP BY name
	`,
)

// "missing destination name SUM(score) in *[]SampleUser"と怒られる(とても親切なメッセージ)

結論を言うと、モデルのタグが不適切でした。

type SampleUser struct {
	Name string `db:"name"`
	Score uint32 `db:"score"`
}

このような形で、nameとscoreを定義していたのですが、`db:"score"`と定義してしまっていました。
返ってくる値はscoreではなく、SUM(score)なので、`db:"SUM(score)"`とする必要がありました。
(この記事の最初から二つ目の表は間違っている)

mysql > SELECT name, SUM(score) FROM sampleUser GROUP BY name;
+------+------------+
| name | SUM(score) |
+------+------------+
| A    |         10 |
| B    |         50 |
+------+------------+

次のようにタグを変更することで、集計関数を含んだクエリを、sqlx.SelectContextで実行することができました。

type SampleUser struct {
	Name string `db:"name"`
	Score uint32 `db:"SUM(score)"`
}


もちろん、タグを変更せず、AS句を使ってSQLの方を変更しても動きますね。

type SampleUser struct {
	Name string `db:"name"`
	Score uint32 `db:"score"`
}

nameScore := []SampleUser{}
err := sqlx.SelectContext(
	ctx,
	r.db,
	&nameScore,
	`
	SELECT name, SUM(score) AS score FROM sampleUser
		GROUP BY name
	`,
)

ブクマ記事のタイトルを使ってワード・クラウドを作成する

はじめに

こんにちは!はやいもので、もう12月ですね!

これは、SLP KBITアドベントカレンダーの4日目の記事です。他の部員の記事は以下からご覧ください!
adventar.org

今回は、タイトルにもある通り、はてなブックマークで、自身がブクマしている記事のタイトルからキーワードを抽出し、ワード・クラウドの画像データを作成します。

ワード・クラウドから、自分はこういうキーワードに興味を持っているというものを見ることが出来れば、面白いなと思っています!

ワード・クラウドとは

ワード・クラウドとは、タグ・クラウドの応用形で、文章ベースのコンテンツを視覚化して魅力的な文字空間を構成する、情報可視化の手法の一つです*1
ワード・クラウドを構成する語句のサイズは、出現頻度から決まります。

f:id:CNaan:20201204231213p:plain

開発環境

  • Windows10 Pro 20H2
  • Docker 19.03.13
使用する主なライブラリ
  • feedparser
  • MeCab
  • Matplotlib
  • WordCloud

今回使用したプログラムは、GitHubで公開しています。
実行方法などは、READMEを参考にしてください。
github.com

私はDocker上でアプリケーションを動かしましたが、Python3を動かせる環境があれば、以下に記述するプログラムは動作すると思います。
コンテナを使わない方は、プログラムに記述してあるファイルパスを適宜変更するようにしてください。

tree
.
├── Dockerfile
├── images
│  └── image-CNaan.png // ワードクラウド画像
├── requirements.txt
├── src
│  ├── bookmark.py
│  └── main.py
└── userdic
   └── myDic.csv

ブクマ記事のタイトルを取得する

ブクマ記事のタイトルを取得するために、RSSを利用します。
RSSの詳しい仕様はこちらを参照してください。↓
はてなブックマークフィード仕様 - Hatena Developer Center

RSSは、https://b.hatena.ne.jp/${ユーザ名}/bookmark.rssから確認できます。
私のユーザ名はCNaanですので、次の通りです。
https://b.hatena.ne.jp/CNaan/bookmark.rss

上記のRSSからタイトルを取得するために、Pythonのライブラリであるfeedparserを使います。

Pythonでプログラム(bookmark.py)を作成しました。

import feedparser
import re
import sys
from typing import List

class Bookmark:
    hatena_id = ""
    def __init__(self, hatena_id: str) :
        self.hatena_id = hatena_id

    # 公開しているブックマークの数を求める
    def count_bookmark(self) -> int:
        d = feedparser.parse('https://b.hatena.ne.jp/{}/rss'.format(self.hatena_id))
        content = d['feed']['subtitle'] # 'Userのはてなブックマーク (num)'
        match = re.search(r"(はてなブックマーク \()(.*?)\)", content)
        num = match.group(2).replace(',', '') # 公開しているブックマーク数
        if not num.isdecimal():
            print('Error: num is string', file=sys.stderr)
            return 0
        return int(num)

    def get_title(self) -> List[str]:
        # 1ページに20件のデータがある。ページ数を求める
        bookmark_num = self.count_bookmark()
        max_page = (bookmark_num//20) + int((bookmark_num%20) > 0)

        titles = []

        for i in range(max_page):
            d = feedparser.parse('https://b.hatena.ne.jp/{}/rss?page={}'.format(self.hatena_id, i+1))
            entries = d['entries']
            for entry in entries:
                titles.append(entry['title'])
        return titles

bookmark = Bookmark("CNaan")
titles = bookmark.get_title()
print(titles)

インスタンス生成時に、はてなIDの"CNaan"を引数に入れ、初期化しました。
関数count_bookmarkでは、公開しているブックマークの数を求めています。(軽くRSSを見て、取得方法がわからなかったので、正規表現で文字列から取得しました。)
正規表現で取得しましたが、' , '区切りの数となるので、注意が必要です(コンマを取り除かないと、1000を超える人のブックマーク数が取得できません)。

関数get_titleは、タイトルを取得し、リストを作成します。
実行すると、タイトルの一覧のリストが出力されると思います。

ワード・クラウドの作成

日本語フォントの用意

日本語を出力するためには日本語フォントが必要です。
今回は、予めホストで持っているフォントファイルをコンテナ内にCOPYすることで対応しました。
今回はGoogle Noto Fontsを利用しました。
How to install fonts – Google Noto Fonts
ダウンロードし、.fontsというディレクトリの中にNotoSansCJKjp-Regular.otfを用意しました。

プログラム
from bookmark import Bookmark
import MeCab
import os
from wordcloud import WordCloud

#ワードクラウドの作成
def create_wordcloud(titles: str):
    fontpath = '/work/.fonts/NotoSansCJKjp-Regular.otf'

    tagger = MeCab.Tagger(
        '-d /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd'
    )
    tagger.parse('')
    word_list = []
    for title in titles:
        node = tagger.parseToNode(title)

        while node:
            word_type = node.feature.split(',')[0]
            word_surf = node.surface.split(',')[0]
            if word_type == '名詞':
                if len(set(["副詞可能", "数", "非自立", "代名詞", "接尾"]) \
                        & set(node.feature.split(",")[1:4])) == 0:
                    word_list.append(node.surface)
            node = node.next

    word_chain = ' '.join(word_list)
    wordcloud = WordCloud(background_color=None,
                          mode="RGBA",
                          font_path=fontpath,
                          width=900,
                          height=500,
                          relative_scaling=0.5 # フォントサイズの相対的な単語頻度の重要性
                         ).generate(word_chain)

    #ファイルの作成
    wordcloud.to_file("/work/images/image-" + os.environ['HATENAID'] + ".png")

def main():
    hatena_id = os.environ['HATENAID']
    bookmark = Bookmark(hatena_id)
    titles = bookmark.get_title()

    create_wordcloud(titles)

if __name__ == "__main__":
    main()

システム辞書として、mecab-ipadic-neologdを使用しました。
GitHub - neologd/mecab-ipadic-neologd: Neologism dictionary based on the language resources on the Web for mecab-ipadic

mecab-ipadic-NEologdは、新語や固有表現に強い辞書です。週に2回以上更新されています(すごい)。

このプログラムでは、キーワードとして名詞を抽出するようにしています。なお、名詞の中の("副詞可能", "数", "非自立", "代名詞", "接尾")については除外するようにしています。
WordCloudのパラメータの、background_color=Noneと、mode="RGBA"を組み合わせることで、透過画像となります。また、relative_scalingの値(0から1)によって、文字の出現頻度に対する文字サイズの比率を設定することができます。
その他のWordCloudのパラメータについては以下を参照ください。
wordcloud.WordCloud — wordcloud 1.8.1 documentation

出力してみる

画像を作成してみると、次のような感じになりました。
Qiitaが大きいですね。
今回は、自分の趣向が見たいなーと思っているので、Qiitaの記事のタイトルは必要ですが、サービス名である、"Qiita"という単語自体は、今回はそこまで重要ではありません。よって、ここからQiitaを除外したいと思います。
また、noteやSpeaker Deckも除外します。

f:id:CNaan:20201203214534p:plain

stop wordsの追加

除外する単語を格納したリスト、stop_wordsを作成します。

from bookmark import Bookmark
import MeCab
import os
from wordcloud import WordCloud

#ワードクラウドの作成
def create_wordcloud(titles: str):
    fontpath = '/work/.fonts/NotoSansCJKjp-Regular.otf'
    stop_words = ['Qiita', 'note', 'Speaker Deck']

    tagger = MeCab.Tagger(
        '-d /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd'
    )
    tagger.parse('')
    word_list = []
    for title in titles:
        node = tagger.parseToNode(title)

        while node:
            word_type = node.feature.split(',')[0]
            word_surf = node.surface.split(',')[0]
            if word_type == '名詞':
                if (node.surface not in stop_words) and \
                    len(set(["副詞可能", "数", "非自立", "代名詞", "接尾"]) \
                        & set(node.feature.split(",")[1:4])) == 0:
                    word_list.append(node.surface)
            node = node.next

    word_chain = ' '.join(word_list)
    wordcloud = WordCloud(background_color=None,
                          mode="RGBA",
                          font_path=fontpath,
                          width=900,
                          height=500,
                          relative_scaling=0.5 # フォントサイズの相対的な単語頻度の重要性
                         ).generate(word_chain)

    #ファイルの作成
    wordcloud.to_file("/work/images/image-" + os.environ['HATENAID'] + ".png")

f:id:CNaan:20201204032927p:plain

ユーザ辞書の作成

ストップワードとしてSpeaker Deckを記述したにも関わらず、画像にはSpeaker Deckが表示されていることがわかります。これは、下のように、Speaker と Deck が別の単語として認識されているためです。

Speaker	名詞,固有名詞,組織,*,*,*,*
Deck	名詞,一般,*,*,*,*,*

また、Speaker と Deckは、辞書に登録されていない単語で、MeCabが品詞を推定しています。

Speaker Deckを、一つの単語として認識させるために、ユーザ辞書を作成します。CSVファイル(userdic/myDic.csv)を作成しました。

ユーザ辞書の追加は、こちらを参考にしました。
blog.apar.jp


このとき、下の例のように、システムの品詞体系に沿わない辞書登録を行うと、エラーとなります。

Speaker Deck,,,1,名詞,固有名詞,*,*,*,*,Speaker Deck,,,
$ /usr/lib/mecab/mecab-dict-index -d /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd -u /work/userdic/myDic.dic -f utf-8 -t utf-8 /work/userdic/myDic.csv
reading /work/userdic/myDic.csv ... context_id.cpp(96) [it != left_.end()] cannot find LEFT-ID  for 名詞,固有名詞,*,*,*,*,*

固有名詞を、さらに分ける必要があります。
MeCabユーザ辞書作成時の陥りがち?なミス(エラー: cannot find LEFT-ID) - Qiita

Speaker Deck,,,1,名詞,固有名詞,組織,*,*,*,Speaker Deck,,,

"組織" を追加してみると、以下のように成功します。

$ /usr/lib/mecab/mecab-dict-index -d /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd -u /work/userdic/myDic.dic -f utf-8 -t utf-8 /work/userdic/myDic.csv
reading /work/userdic/myDic.csv ... 1
emitting double-array: 100% |###########################################| 

done!

また、mecabrcに作成したユーザ辞書の情報( userdic = /work/userdic/myDic.dic )を追加します。

echo userdic = /work/userdic/myDic.dic >> /usr/local/etc/mecabrc

出力結果を確認すると、ユーザ辞書が適用されていることがわかります。

>>> import MeCab
>>> tagger = MeCab.Tagger()
>>> print(tagger.parse('Speaker Deck'))
Speaker Deck    名詞,固有名詞,組織,*,*,*,Speaker Deck,,,

結果

docker runをする際、環境変数はてなIDを設定し、また、画像出力用のディレクトリをコンテナ内のディレクトリとマウントします。

$ docker build -t myword-cloud:1.0 .
$ docker run -it --rm --name myword-cloud -e HATENAID=CNaan -v $(pwd)/images:/work/images myword-cloud:1.0
f:id:CNaan:20201204230008p:plain
CNaanのブックマークから作成したワード・クラウド

おわりに

今回は、CNaanがはてなブックマークしている記事のタイトルを使って、ワードクラウドを作成しました。
最後の画像を見てみると、GoやWeb、TypeScriptやCSSなどに興味を持っていそうですね*2

ワードクラウドは、見た目が色鮮やかで、見ていて楽しい気持ちになるので、
タイトル以外の情報に対しても、ワードクラウドを作ってみたいなあと思いました!

*1:Bella Martin, Bruce Hanington(2013) 『Research & Design Method Index -リサーチデザイン、新・100の法則』 郷司 陽子 (訳) 株式会社ビー・エヌ・エヌ新社

*2:blogはstop_wordsに入れるか悩ましいところでした。今回は結局入れませんでしたが

はてなサマーインターン2020に自宅から参加しました!!

はじめに

はてなサマーインターン2020に参加しました。
タイトルの通り、オンラインだったので家インターンです。

オンラインインターンでは、PCなどの環境は自分で用意する必要がありました。
Ubuntu 20.04/CPU 3.60 GHz 8コア Intel Corei7 9700K/メモリ 32GBで参加しました。

参加まで

はてなインターンとの出会い

はてなインターンの存在は、2年前の先輩(id:guni1192)の参加報告で知りました!!
guni1192.hatenablog.com
developer.hatenastaff.com
id:guni1192は、大規模システム開発コースに参加していて、私がやっていることとは分野が違うのですが、当時からはてなはいいぞという布教を受けていました。

そして、今年度自分は学部3年生で、id:guni1192が参加した時と同じ年齢になり、今年は応募するぞーー!!と応募しました。
はてなという会社については、うごメモはてなから始まりはてなダイアリーはてなブログはてなブックマークなど、いつもお世話になっています。
ちなみにブログははてな記法で書く派です。

選考

課題は、dockerコマンドを実行し、表示される問題に回答する、というものでした。

$ docker run --rm -it hatena/apply-for-internship-2020:latest

https://github.com/hatena/apply-for-internship-2020

質問に回答すると、トークンが発行され、そのトークンを応募フォームにペーストすれば、課題達成です!

この後の面接で、id:cockscomb さんとお話したのですが、この課題にはやさしさが詰め込まれていました。面接はGoogle Meetを使ってオンラインでしました。

選考について、詳しくはこちら↓
hatenacorp.jp

事前課題

事前課題は、hatena/Hatena-Intern-2020-Templateのテンプレートリポジトリを基にして、プライベートリポジトリの作成、およびREADMEを参考に環境構築を行うというものでした。
環境構築にとても時間がかかるということはなく、負担はなかったです。

事前交流会

インターン生と、はてなの方で、事前交流会を行いました。
Zoomでid:onishiさんから歓迎の言葉を頂いたり、三つのグループに分かれてテーマに沿った話をしました。
社員の方や他のインターン生と気楽に話すことができ、とても楽しかったです!!
飲み物については、お酒を家で一人で飲む習慣がなく、水を飲んでました。酒を準備している方もいて、酒を飲んでも良かったかなと思いました。社員の方は大体の方が酒を用意していた印象があります(違ったらすみません)。
ちなみに水はすごくおいしいので好きです。
最後には、id:motemenさんから激励の言葉を頂きました。

はてなサマーインターン2020開始!!

コミュニケーションについて

はじめに、インターン期間中のコミュニケーションについて説明しておきます。
メインで使用したツールはDiscordとScrapboxです。講義や交流会ではZoomを使いました。
事前課題やインターン参加中に質問などあればDiscordのチャットで質問をしました。業務連絡や雑談など、連絡はDiscordで行っていました(重要な連絡など一部の連絡は一部メールで)。
ちなみに事前交流会でポケモンGoが好きだという話をしたら、Discordにpokemongoチャンネルができてました。
初日の講義はZoomを用いて行い、二日目以降はDiscordのボイスチャンネルにミュートでイン、会話する時にミュート解除という感じでした。
マイクは、bluetoothイヤホンを使っていたのですが、充電の残量が時々気になりました。結果としては、ミュート中や休憩中に充電をしていたので、充電が切れることはなかったです。
また、常にカメラオンではないという状態は、とても楽でした。
課題に取り組む際は、メンター一人とメンティー二人のグループに分かれて活動するのですが、私のグループでは、質問やミーティングの際は、カメラをオンにしていました。

また、Discordと併用してScrapbox(https://scrapbox.io/)に定期的に手動で作業ログを吐くことで交流しました。
この記事も、Scrapboxのログを参考に書きました。記録残しておいてよかった……!!

作業ログ
作業ログ

1日目

講義

初日は講義デーでした。
講義動画は、後々公開されるらしいです。
hatenacorp.jp
↑ こちらのページや、Twitterの@hatenatech、「Hatena Developer Blog」で新着情報を待ちましょう。

45分の講義が講義1から講義5までありました。

  1. Web API
  2. コンテナ
  3. Kubernetes
  4. マイクロサービス
  5. 課題説明

どの回もとても面白い内容でした。
コンテナの回では、Docker Quizがあり、とても面白かったです。
Q6までは解答できたのですが、Q7は解答できませんでした。
後でじっくり解説を見返したいと思います。

Kubernetesの回では、オートスケールの設定などを行いました。
Apache Benchを使って負荷テストを行いました。

$ kubectl autoscale deployment blog --cpu-percent=50 --min=1 --max=10

目標となるPod全体の平均CPU使用率を50%、オートスケールする際のPod数の下限を1、上限を10としていました。
以下の画像のように、大量にリクエストを投げたところ、Pod数の増加を確認できました。
Podが増えたとき、「増えたーー!!」となりましたね。嬉しい😆

f:id:CNaan:20200829210658p:plain
オートスケールの様子

残念ながら、スケールインの様子は確認できませんでした。
もっとたくさんリクエストを投げて、Podを増やしてみるべきだったかなと思っています。

課題

課題説明では、課題内容に加え、テストを書くこと、コミットの粒度を意識することの説明を受けました。
課題は、

  • ブログ記事に記法を実装すること
  • タイトルの自動取得サービスを作成すること

加えて、発展課題がありました。

記法というのは、見出し記法やリンク記法、リスト記法といったもののことです。
タイトルの自動取得サービスについては、ブログに以下のような記法で入力されたとき、

[](https://hatenablog.com) # リンク記法で、[]内のタイトルが省略されている

https://hatenablog.comのページからタイトルを取得し、
[はてなブログ](https://hatenablog.com)となるようにするというものです。

課題開始

講義が終わって17時くらいからは、メンターが一人、メンティーが二人のグループで活動しました。
2日目から4日目は、主にこのグループで活動することになります。
メンターは id:yigarashi さんでした。
id:cordx56 さんと一緒にyigarashi部屋での活動を始めました。
id:cordx56さんはTweet generatorでバズった人です。インターン3日目くらいに知りました。すごい。

歓迎会

夜は歓迎会で、自己紹介をしたりわいわい夕飯を食べました。
id:namachan10777にカレー屋の紹介をされました。今度食べに行こうと思います。

2日目

この日は、見出し記法、リンク記法、リスト記法の実装を行いました。
Go言語を使って実装しました。推奨されていた、goldmarkというMarkdownライブラリを使用しました。何らかのMarkdownライブラリを利用している人がほとんどでしたが、id:cha-shu00さんはパーサモジュールを自作していました。すごい……。記事の最後に他のインターン生の記事を載せているので、気になる方はid:cha-shu00さんの記事を読みましょう。

ちなみにこの日、見出し記法で'##'>'###'>'#'の順にフォントサイズが大きいという問題にぶつかりました。

f:id:CNaan:20200829221256p:plain
見出し記法のフォントサイズ

原因っぽいものは調べると出てきたのですが、h1タグの仕様っぽいです。
h1タグ、articleやsectionでネストされるとフォントサイズが小さくなるらしいです。知らなかった……。
この時、articleやsectionで結構ネストされていたので、h1がh5くらいのサイズになっていたのかなと思いました。
https://stackoverflow.com/questions/6851833/h1-is-smaller-than-h2-in-firefox-and-chrome

3日目

2日目で記法を実装したのですが、これに加えて、独自記法というものを実装していきます。
この日の午前中は、独自記法の途中に改行を入れるとInternal Server Errorとなる問題に取り組んでいました。id:yigarashiさんに手伝っていただきました。比較的直ぐに、Go言語のプログラムで、範囲外参照をしていることがわかりました。
goldmarkのモジュールの拡張方法など、理解するのに時間がかかってしまっていたので、その辺りを理解する力を、もっとつけていきたいなあと思いました。

ランチタイム

ランチ時間(13:00 ~ 14:00)は、Discordのランチルームでわいわいして良いという雰囲気でした。
この日は、id:chris4403さんやid:onishiさんがいらっしゃり、楽しく雑談をしながら食事をしました。
例えば、好きな映画の話をしました。
魔女の宅急便」のどんなシーンが好き?といった話をしました。ちなみにid:cordx56さんは魔女の宅急便が好きです。
私は、昔見たとき、「キキが飛べなくなった」ことが悲しかったという話をしました。昔はデッキブラシの見た目がかっこよくないと思っていたんですね。
社員の方は、「お父さんがキキを見送るシーンが感動する」と言ってました😢

4日目

この日は、タイトルの自動取得をできるようにしました。
Markdown記法をHTMLに変換する作業は、renderer-goというサービスが行うのですが、URLからタイトルを取得するのは、fetcherサービスという別のサービスが行います。今回の課題では、fetcherサービスを作るところから行いました。
タイトルの自動取得は、ASTの各ノードをvisitし、タイトルが未指定の場所に、fetcherサービスで取得したタイトルを差し込むという方法で行いました。
ここで、タイトルを差し込む部分について、どのようにすればテストを書きやすい設計になるかわからず、id:yigarashiさんにテストの書きやすい設計について教わりました……。理解に時間がかかり、自分はテストやプログラムを書きなれていないと感じました。今後の目標は、テストを書き慣れることです。
また、5日目には成果発表を行うので、その資料作りをしました。

プロフィール画像の変更

突然ですがこの日の深夜にプロフィール画像を変更しました。
Amakuchiからcnaan仕様に変更しました。1日目の自己紹介で、インターン中にプロフィール画像を変更する、と言っていたので、無事有言実行できてよかったです。
自己紹介などでモンメンが好きだと、沢山アピールしていたので、モンメンを添えました。
モンメンはもふもふ感とかがとても好きです。ちなみにポケモンのゲームはホワイトを買ってました。つまりモンメンはいません。
最近ポケモンGoにもモンメンが実装されたので、みなさんモンメンと遊んでください。

cnaanのプロフィール画像
cnaanの新しいプロフィール画像

5日目

成果発表会

一人5分以内で、課題について取り組んだ内容をプレゼンテーションする、というものでした。
PCのフロントカメラが前々から調子の悪い時があったのですが、当日再起動しても途中で切れそうだったので、画面共有用のPCと、カメラ用のiPadの2台からZoomにログインさせていただきました。
私は、基本課題である、記法(見出し記法、リンク記法、リスト記法)の実装、独自記法の追加、タイトルの自動取得に取り組んだので、それを発表しました。

Scrapboxで、成果発表会会場が用意されていたので、そちらに発表へのコメントを書き込んだり、はてなスターのように、良かった!!と他の人の発表に自分のアイコン画像を沢山ぺたぺたしました。いっぱいつけるの楽しいですね、もっといっぱいつければよかった(´・ω・`)

ところで、3日目に独自記法を追加したと言ったのですが、どのような記法かは述べていませんでした。
私は、独自記法として、以下略記法を追加しました。
以下略記法は、(ryを使った記法です。
(ry(かっこあーるわい)の説明は、こちらを参考にしてください→(ryとは ウェブの人気・最新記事を集めました - はてな
恐らく、昔うごくメモ帳をやったり、ブログを書いたりしていた人は、言葉の末尾に(ryを付ける文化をご存じなのでは?と思っています。私は(ryを多用してました。

f:id:CNaan:20200829230602p:plain
以下略記法の実装

以下略記法は、detailsタグとsummaryタグを使った表記となっています。次の1文は、detailsタグを使っています。

表示されているよ省略されているよ

以下略記法について、面白いと言っていただけたので嬉しいです!

以下略記法への反応
以下略記法への反応

そういえば発表の時にあまり説明をしなかったなと思ったのですが、「(ryと省略してるけど、実は続きを書いている」という優しさを詰め込んだ表現です。

発表会終わり

発表会が終わった後は、アンケートを書いたり、面談したり、このブログを書いたり、19時から表彰式&送別会に出席したりしました。
面談では、PCのフロントカメラの調子が悪いからとiPadから参加したら、Google Meetに「他のアプリを開いているとカメラ使えません」と言う感じに怒られました。最後の最後に自分の顔を見せられなくて悲しくなりましたね……。
送別会では、初日の歓迎会同様、わいわい楽しんで話すことができました。
ところで無人島って、誰でも上陸できるんですね……。
ちなみに、この日も酒を買い忘れたので水を飲みました。水は美味しい。
主食はどん兵衛きつねうどんです。四国なので多分西です。

5日間を振り返って

はてなの人、褒めるのが上手いなと思いました。
参加前の面接では、id:okikukun さん及び id:cockscomb さんとお話したのですが、
なんでも肯定から入ってくださるという印象があり、素敵な方々だなと思いました……!!
インターンの選考をされるという経験が初めてで、「一体どんな角度から攻めてくるんだ……!?」と若干ドキドキしていたのですが、
面接では、id:okikukunさんとid:cockscombさんの暖かさに包まれました……。
インターンに参加前からとても親切だったので、なんと素敵なんだ……😇と感動しました。
結果も早く伝えていただいたので、結果をドキドキ待つ時間が少なくて心臓にやさしかったですね。

インターン参加中も、メンターのid:yigarashiさんに、「惜しいところまで行ってます!」というように、やる気を削がないアドバイスをたくさん頂いて、凄いなあと思いました……!!人と話すときは、はてなの方々の暖かさを真似していきたいと思いました。

また、お気づきの方もいらっしゃるかと思いますが、はてなブログPROになってます。
インターン参加報酬にいただきました。やったーー!!
最近ブログをあまり書いていなかったので、モチベーションがあがりますね💪

長々と書きましたが、今回のインターンで感じたことを箇条書きすると、次のような感じです。

  • はてなの方々は優しくて暖かい
  • テストをしっかり書こう
  • オンラインランチ会やその他交流会が楽しかった

落ち着いてきたら、オフィスランチを食べてみたいですね……。

他のインターン生の記事にもありましたが、これだけ学ばせていただいて3万円分のAmazonギフト券を頂けるの、すごい。
参加前は、「3万か~~へ~~」くらいに思っていたのですが、インターンが終わった今となっては、お金貰えるとかおかしいのでは????と感じてきました(そのくらい良いインターンでした)。

最後に、
はてなサマーインターン2020最高でした!!!!

その他

id:cordx56さんに広告費を貢いだ。

f:id:CNaan:20200829230313p:plain
我が家のモンメン

久々に家にあるぬいぐるみを出した。かわいい。

GoogleのOAuth 2.0で受け取ったトークンをrevokeする

はじめに

GoogleのOAuth 2.0を使ったアプリケーションで、認証によって得たユーザの情報を用いて、条件に合ったユーザにのみ、サービスの利用を許可したい、という場合があります。

  • 例) 学生用メールアドレス(~ac.jp)を持つユーザにのみサービスを提供したい

このブログでは、この例の場合を想定して、話を進めていきます。

トークンを取り消すこと

Google OAuth 2.0では、認証された際に、アクセストークンが発行されます。このトークンを取り消す方法には、ユーザが自分で行う方法と、アプリケーション側で行う方法があります。

  1. ユーザがAccount Settingsにアクセスして、認証を取り消す
  2. アプリケーション側でリクエストを送信し、認証を取り消す

トークンをrevokeすることは、実際のところ、実装の必要性は低いかもしれません。今回は、以下の理由から、revokeを行います。

  1. サービスの利用を許可しないアカウントに対して、トーク*1を残しておく理由はない
  2. 認証後に、OAuth認証用のURL(例 : http://localhost/auth/login/google)へアクセスすると、Googleにログインしているアカウントが一つしかないとき*2、認証済みアカウントに対してトークンが再発行される。

2の場合について、例を思い出します。

例では、OAuth 2.0 を用いた認証後、認証に用いたGoogleのメールアドレスを確認し、条件に合わないユーザであれば、サービスの利用を許可せず、ユーザに通知します。

許可されなかったユーザのうち、学生用メールアドレスを持っているが、異なるアカウントでログインを試みてしまったというようなユーザは、学生アカウントで再認証を行います。

再認証を行うのですが、Googleにログイン中のアカウントが一つだけのとき、アカウントを追加し、切り替えることは少し面倒です。

OAuth 2.0 による認証後に、条件に合っているかどうかを判断しているということは、OAuth 2.0による認証は通っているということです*3

つまり、アプリケーションがサービスの利用を許可していない場合でも、認証を取り消さない間は、期限が切れるまで、認証されている状態になっています。

Google OAuth 2.0の仕様により、ログイン画面にアクセスすると、新しいアカウントを追加する間もなく、以前認証されたものは、自動で認証されます*4

この場合、ユーザが取る行動には、Googleのトップページからアカウントを追加するAccount Settingsから認証を取り消すなどがあります。


また、自動で認証が通るという仕様について、認可画面をスキップしないようにする方法があります。

OAuth 2.0のエンドポイント(https://accounts.google.com/o/oauth2/auth)に対し、
approval_prompt=force のクエリパラメータを付加することです。

しかし、この方法では、全ての(アカウントを一つしか持たない)ユーザが認可画面を毎回見る事になります。そのため、認可画面をスキップするか、しないか、どちらが良いかは状況によると思います。

次に、アプリケーション側でトークンを削除する方法について、書いていきます。

環境

  • Go 1.13.0
  • Echo 3.3.10 (Go web framework)
  • MySQL 8.0.17
  • Docker 19.03.5-ce
  • Docker Compose 1.25.0

認証には、Gomniauthを、データの処理のためにobjxを利用しています。

また、本ブログでは、説明に最低限必要な部分のみ記述しています。認証部分のソースコードは、Go言語によるWebアプリケーション開発を参考にしています。
www.oreilly.co.jp

トークンを取り消す方法

プログラムでトークンを取り消すには、パラメータにトークンを付加した、次の形式のリクエストを送信します。

https://oauth2.googleapis.com/revoke?token={token}

アクセストークンの取得

認証後に呼び出される関数CallbackHandler*5の中で、accessTokenを取得します。

/*
import(
	"net/http"

	"github.com/labstack/echo"
	"github.com/stretchr/gomniauth"
	"github.com/stretchr/objx"
)
*/

// CallbackHandler -- Provider called this handler after login
func (u *userHandler) CallbackHandler(c echo.Context) error {
	provider, err := gomniauth.Provider(c.Param("provider"))
	if err != nil {
		return err
	}

	omap, err := objx.FromURLQuery(c.QueryString())
	if err != nil {
		return err
	}

	creds, err := provider.CompleteAuth(omap)
	if err != nil {
		return err
	}

	accessToken := creds.Get("access_token").Str()

	return c.Redirect(http.StatusTemporaryRedirect, "/")
}

トークンを取り消す関数

取得したアクセストークンを引数に受けとる、以下の関数を作成しました。

/*
import (
	"io"
	"io/ioutil"
	"net/url"
	"net/http"
)
*/

func revokeToken(accessToken string) error {
	const googleRevokeURL = "https://accounts.google.com/o/oauth2/revoke"
	u, err := url.Parse(googleRevokeURL)
	if err != nil {
		return err
	}

	q := u.Query()
	q.Set("token", accessToken)
	u.RawQuery = q.Encode()
	resp, err := http.Get(u.String())
	if err != nil {
		return err
	}

	defer func() {
		io.Copy(ioutil.Discard, resp.Body)
		resp.Body.Close()
	}()

	return err
}

関数CallbackHandler内で取得したaccessTokenを、関数revokeTokenに渡すことで、トークンを取り消すことができました。
これにより、Googleにログインしているアカウントが一つであったとしても、認証用URLにアクセスすると、認可画面が表示されます。
また、条件に合わないユーザが認証してきたら、関数revokeTokenを呼び出す、というような利用もできます。
たとえ、認証後にログインページにリダイレクトするようなコードを書いてしまったとしても、無限ループにはならないと思います(そういう実装は良くないと思いますが……)。


ありがとうございました。

*1:有効期限はあるけれど

*2:アカウントが複数あるとき、認証するアカウントを選択する画面(認可画面)が表示される

*3:この言い回し、いつか話題になった構文のようですね

*4:認可画面をスキップしない方法はある

*5:詳しくは本を読むか、検索をお願いします

閉包テーブルに触れてみる

はじめに

これは、SLPアドベントカレンダー最終日の記事となっています。毎日異なる部員が執筆を行ってきました。私を含んだ、25人の記事をお楽しみください。

adventar.org


SQLアンチパターンを読み始めました。まだ読んでいる途中ですが、良い本です。
さて、この本の2章では、ナイーブツリーの話題が登場します。
そこでは、階層的なデータ設計が比較されます。

  • 隣接リスト
  • 再帰クエリ
  • 経路列挙
  • 入れ子集合
  • 閉包テーブル


今回、この中から閉包テーブルを選択して、コメント管理機能を作成しました。その中から部分的にプログラムを載せています。
閉包テーブルは、ツリー全体のパスを格納する方法です。特徴には、ノードが複数のツリーへ所属することが挙げられます。メインのテーブルとは別に、木構造のパスを記憶するテーブルを持ちます。

開発環境

  • ホストOS : Windows10 Pro 1909
  • ゲストOS : ArchLinux ( Kernel 4.19.88-1-lts x86_64)
  • Golang : 1.13.0
  • MySQL : 8.0.17
  • Echo 3.3.10 (Go web framework)
  • jmoiron/sqlx (database/sqlのラッパー)

テーブルの作成

CREATE TABLE comments (
    id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
    body TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP DEFAULT NULL,
    user_id INT NOT NULL REFERENCES user(id),
    question_id INT NOT NULL REFERENCES question(id)
);

CREATE TABLE tree_paths (
    ancestor BIGINT UNSIGNED NOT NULL REFERENCES comments(id),
    descendant BIGINT UNSIGNED NOT NULL REFERENCES comments(id),
    PRIMARY KEY (ancestor, descendant)
);
comments
属性* 概要*
id コメントのid。コメントを投稿すると、1から自動的に割り振られる
body コメントの内容
created_at コメントの作成時間
updated_at コメントの編集時間
deleted_at コメントの削除時間
user_id コメントを投稿した人のid
question_id 質問した話題のid
tree_paths
属性* 概要*
ancestor そのコメントの先祖となるコメントid
descendant そのコメントの子孫となるコメントid

ancestorとdescendantは、組み合わせが主キーとなっています。
自分自身を示すためには、両方の属性が同じidを持ちます。

コメントの登録

コメントを登録するには、2つの手順が必要です。

  1. コメントの挿入
  2. コメントのパスの挿入

プログラム内で、構造体は下のように定義しました。

type Comment struct {
	ID        uint64     `db:"id"`
	Body      string     `db:"body"`
	CreatedAt *time.Time `db:"created_at"`
	UpdatedAt *time.Time `db:"updated_at"`
	DeletedAt *time.Time `db:"deleted_at"`
	UID       uint64     `db:"user_id"`
	QID       uint64     `db:"question_id"`
}

/questions/:id へPOSTリクエストが行われると、ハンドラが呼び出されます。

func (comment *commentHandler) PostCommentHandler(c echo.Context) error {
        uid := getUserID()
	id := c.QueryParam("cid")
	body := c.FormValue("comment")
	param := c.Param("id")
	qid, err := strconv.ParseUint(param, 10, 64)
	if err != nil {
		return err
	}
	
	cid, err := strconv.ParseUint(id, 10, 64)
	if err != nil {
		return err
	}

	message := model.Comment{Body: body, UID: uid, QID: qid}
	fmt.Println(uid, qid, cid, body)
	err = comment.commentModel.CreateComment(&message, cid)
	if err != nil {
		return err
	}

	route := "/questions/" + param
	fmt.Println(route)
	return c.Redirect(http.StatusSeeOther, route)
}

コメントは、CreateCommentで作成されます。
CreateCommentの引数のparent_idは、コメントの親のIDです。
先祖を持たないコメントのとき、parent_idは0としています。
SELECT LAST_INSERT_ID()は、MySQL情報関数です。
最後にINSERTされたコメントのIDを取得するために用いています。

type CommentModel struct {
	db *sqlx.DB
}
// CreateComment -- Insert comment data
func (c *CommentModel) CreateComment(comment *model.Comment, parent_id uint64) error {
	var id uint64
	_, err := c.db.Exec(`INSERT INTO comments (body, user_id, question_id) VALUES (?, ?, ?)`, comment.Body, comment.UID, comment.QID)
	if err != nil {
		return err
	}
	err = c.db.Get(&id, "SELECT LAST_INSERT_ID()")
        if err != nil {
		return err
	}
	if parent_id == 0 {
		parent_id = id
	}
	_, err = c.db.Exec(`INSERT INTO tree_paths (ancestor, descendant) SELECT tree_paths.ancestor, ? FROM tree_paths WHERE tree_paths.descendant = ? UNION ALL SELECT ?, ?`, id, parent_id, id, id)
	return err
}

上のプログラムから、TreePathへのINSERT文を抽出しました。

INSERT INTO tree_paths (ancestor, descendant)
    SELECT tree_paths.ancestor, id FROM tree_paths
    WHERE tree_paths.descendant = parent_id
    UNION ALL SELECT id, id;

理解を深めるために、実際に挿入しながら考えていきます。


ここに、おすすめのテキストエディタを尋ねる質問があります。
CommentIDが7の人はVS Codeを、8の人はVimを勧めました。
ここで、8の人に対して学生R(CommentID: 9)がEmacsを勧めると、どうなるでしょうか。
図で書くと、このようになるはずです。

属性には、先祖となるコメントIDと、子孫となるコメントIDを持ちます。
そのため、tree_pathsには、(8, 9), (9. 9)の二つがINSERTされるはずです。

mysql> SELECT tree_paths.ancestor, 9 FROM tree_paths
    -> WHERE tree_paths.descendant = 8
    -> UNION ALL SELECT 9, 9;

+----------+---+
| ancestor | 9 |
+----------+---+
|        8 | 9 |
|        9 | 9 |
+----------+---+

> SELECT tree_paths.ancestor, 9
1列目にtree_paths.ancestor、2列目に9を射影
> FROM tree_paths WHERE tree_paths.descendant = 8
子孫のIDが、挿入するIDの親と等しいtree_pathsのテーブルを選択
> UNION ALL SELECT 9, 9;
自分自身を指す9, 9の行と結合

コメント情報の取得

質問に対しての全てのコメントをデータベースから取得するのなら、質問番号で選択すれば良いです。

func (c *CommentModel) All(question_id uint64) ([]model.Comment, error) {
	comments := []model.Comment{}
	err := c.db.Select(&comments, "SELECT * FROM comments WHERE question_id = ?", question_id)
	if err != nil {
		return nil, err
	}

	return comments, nil
}

次の関数では、指定されたコメントIDの子孫を全て取得します。tree_pathsで、指定されたコメントIDが先祖となっている行を選択します。

func (c *CommentModel) GetDescendant(id uint64) ([]model.Comment, error) {
	comments := []model.Comment{}
	err := c.db.Select(&comments, "SELECT comments.* FROM comments INNER JOIN tree_paths ON comments.id = tree_paths.descendant WHERE tree_paths.ancestor = ?", id)
	if err != nil {
		return nil, err
	}

	return comments, nil
}

先ほどのコメントに対し、学生Oと学生Sが参加しました。


学生Iの子孫を表示

学生Rの子孫を表示


おわりに

二つのテーブルを用いているため、どちらか一方のみを間違えないよう、一つのテーブルを用いるとき以上に気を使う必要があると感じました。コメントを登録するCreateComment関数では、データを二度INSERTします。しかし、現時点では、二番目のINSERTが失敗した際の、ロールバックを行う処理は未実装です。そのため、優先度高めで対応したいと思います。
また、tree_pathsに深さの属性を追加することで、直近の親子関係を簡潔に調べることができます。今回、その部分の設計は行わなかったので、今後、その部分の設計が必要かどうか、考えたいです。
今回、プログラムの全てをここに記述したわけではありません。またリファクタリングを行った際、記事にする予定です。

VMを使ってUSB経由でNFCを読み取る話

こんにちは。

現在、私の加入しているサークルでは、チーム開発の一つとして、
PaSoRiを使った出席管理システムを作成中です。

このシステムは、Python3で開発しています。
学生証をPaSoRiにタッチすると、サークルの活動に参加したことになり、その日の議事録に名前が載るシステムです(※開発中)。
NFCタグの読み取りのために、nfcpyのモジュールを使用しています。

現状、開発環境はWindows10となっています。
しかし、Windows10での開発は、正直言って面倒です。

今回、開発環境の構築までの手間を比較するために、
VirtualBox仮想マシンを作成し、PaSoRiを使用するための設定を行いました。

Windowsでの開発

Windowsで開発を行うためには、下の作業が必要です。

  • WinUSB(またはZadig)とlibusbの手動インストール
    • PaSoRiのドライバをWinUSBドライバに置き換える
    • libusbをインストールしたら、DLLファイルを指定されたディレクトリにCopyする
  • nfcpyをpipでインストール

今回、私のチームでは、Zadigとlibusbを手動インストールしました。
ダウンロードしてきたDLLファイルを、手動で C:\Windows\System32 や、C:\Windows\SysWOW64 にCopyすることは、置き間違えなどのリスクがあります。

VirtualBoxでの開発

  • Linuxでは、libusbは通常インストール済み
  • VM上でUSBデバイスを使うための設定を行う必要がある
  • 権限や、ドライバーの設定のために、少しコマンドを叩く必要がある
  • nfcpyをpipでインストール

Windows版と違い、手動でCopyするようなものはありません。
仮想マシンでUSBを認識するための設定や、一般ユーザが使用する際の権限の設定などをする必要があります。

環境

今回、動作確認のために用意したものは以下の通りです。

今回、新しいものが使いたいという理由と、約一年前に友人から布教を受けたこともあり、ArchLinuxを使いましたが、Ubuntuなどでも動くと思います。

設定の流れ

設定の流れは、次のようになります。

  1. 仮想マシンの用意
  2. VirtualBox仮想マシンの設定でUSBの設定
  3. PaSoRiの認識確認
  4. nfcpyのインストール
  5. PaSoRiの接続設定

ここでは、2以降の手順を記述します。

VirtualBox仮想マシンのUSB設定

まず、手元のVirtualBoxに設定済みの仮想マシンを用意します。
そして、仮想マシンの状態が電源オフであることを確認します。次に、PaSoRiをUSBポートに挿します。

仮想マシンのUSBデバイスフィルターの設定
VirtualBox仮想マシンに対するUSBデバイスフィルターの設定

使用する仮想マシンの設定から、USBを選択、USB2.0コントローラーを有効にします。次に、右にある新規のUSBフィルターの追加から、目的の物(今回の場合はSONY RC-S380/P)を選択します。

PaSoRiを認識しているかの確認

次に、仮想マシンを立ち上げ、PaSoRiが認識されているかを確認します。認識を確認する方法はいくつかありますが、ここでは二つ紹介します。

1. lsusbコマンドを使用

$ lsusb
Bus 002 Device 003: ID 054c:06c3 Sony Corp. RC-S380

このように表示されたら認識されています。

2. dmesgコマンドを使用

$ dmesg | grep SONY # または grep NFC

それっぽいものが表示されたら、認識されています。

nfcpyのインストール

venvを使用し、仮想環境内にインストールします。

venvを使用する理由

venvとは、軽量な仮想環境の作成をサポートしてくれるものです。Python3の標準の機能の一つです。

メリット
  • 仮想環境の中に、パッケージ群を独立してインストールできる
    • 仮想環境ごとに、使用するパッケージ群のバージョンを分けられる
  • それぞれの仮想環境は独立しているため、仮想環境間では競合しない

nfcpyは、現在のプロジェクトでのみ使用し、頻繁に使用するものではないため、仮想環境の中で使用します。

nfcpyのインストールの流れ

$ python -V
Python 3.8.0
$ cd [WORKDIR] # 作業ディレクトリへ移動
$ python -m venv [ENV_DIR] # 環境の作成 (ENV_DIRをvenvとする例が多いらしい)
$ source [ENV_DIR]/bin/activate
(venv)$ pip install nfcpy
(venv)$ pip freeze # インストールしたパッケージの確認

PaSoRiの接続設定

接続確認のために、下のコマンドを実行します。

(venv)$ python -m nfc

いくつかエラーが出ることがあるので、確認したものについて順に対処していきます。

This is the 1.0.3 version of nfcpy run in Python 3.8.0
on Linux-5.4.2-arch1-1-x86_64-with-glibc2.2.5
I'm now searching your system for contactless devices
** found usb:054c:06c3 at usb:002:004 but access is denied
-- the device is owned by 'root' but you are 'amakuchi'
-- also members of the 'root' group would be permitted
-- you could use 'sudo' but this is not recommended
-- it's better to adjust the device permissions
   sudo sh -c 'echo SUBSYSTEM==\"usb\", ACTION==\"add\", ATTRS{idVendor}==\"054c\", ATTRS{idProduct}==\"06c3\", MODE=\"0666\" >> /etc/udev/rules.d/nfcdev.rules'
   sudo udevadm control -R # then re-attach device
I'm not trying serial devices because you haven't told me
-- add the option '--search-tty' to have me looking
-- but beware that this may break other serial devs
Sorry, but I couldn't find any contactless device

このように出ることがあります。
要するに、デバイスを "plugdev" というグループへ追加することを推奨しています。
言われた通りに作業を行います。

$ sudo sh -c 'echo SUBSYSTEM==\"usb\", ACTION==\"add\", ATTRS{idVendor}==\"054c\", ATTRS{idProduct}==\"06c3\", MODE=\"0666\" >> /etc/udev/rules.d/nfcdev.rules'

$ sudo udevadm control -R

PaSoRiの再接続を行い、もう一度、実行します。

(venv)$ python -m nfc
This is the 1.0.3 version of nfcpy run in Python 3.8.0
on Linux-5.4.2-arch1-1-x86_64-with-glibc2.2.5
I'm now searching your system for contactless devices
** found usb:054c:06c3 at usb:002:005 but it's already used
-- scan sysfs entry at '/sys/bus/usb/devices/2-2:1.0/'
-- the device is used by the 'port100' kernel driver
-- this kernel driver belongs to the linux nfc subsystem
-- you can remove it to free the device for this session
   sudo modprobe -r port100
-- and blacklist the driver to prevent loading next time
   sudo sh -c 'echo blacklist port100 >> /etc/modprobe.d/blacklist-nfc.conf'
I'm not trying serial devices because you haven't told me
-- add the option '--search-tty' to have me looking
-- but beware that this may break other serial devs
Sorry, but I couldn't find any contactless device

今度は、デバイスのport100は、既に使われているというメッセージが出ました。

そのため、port100のカーネルドライバを取り外します。

$ sudo modprobe -r port100

また、今後も停止させ続けておきたい場合は、ブラックリストに入れます。

$ sudo sh -c 'echo blacklist port100 >> /etc/modprobe.d/blacklist-nfc.conf'

もう一度、実行します。

(venv)$ python -m nfc
This is the 1.0.3 version of nfcpy run in Python 3.8.0
on Linux-4.19.88-1-lts-x86_64-with-glibc2.2.5
I'm now searching your system for contactless devices
** found SONY RC-S380/P NFC Port-100 v1.11 at usb:002:003
I'm not trying serial devices because you haven't told me
-- add the option '--search-tty' to have me looking
-- but beware that this may break other serial devs

このように出たら、成功です!最後に、きちんと終了させます。

(venv)$ deactivate # 仮想環境から出る

おわり!

仮想マシンのUSBデバイスフィルターの設定は、GUIでしましたが、操作ミスの起こりにくい場所であり、ファイルの手動コピーよりは心理的安全性がありました。また、仮想マシン内での操作は、コマンドをぽちぽち貼り付けるだけなので、個人的には、Windows版でやるより、仮想マシンの中でやるほうが楽だと感じました。
それから、やはりWindowsの方のドライバはあまり触りたくないなーという気持ちがあります。libusbの導入と、Zadigのインストールをしなくて良いというだけで、仮想マシンでの開発を選ぶメリットがある気がします。

なお、今回一番時間がかかったのは、ArchLinuxの設定です……。しばらく環境を壊さないように気を付けたいです……。また、ArchLinuxのデスクトップ環境は作っていません。Webアプリケーション以外のGUIアプリケーションを作る予定がない間は、デスクトップ環境は作らないと思います。作ったらまたブログに書くと思います。
それから、昨年の誕生日プレゼントにPaSoRiをくださった先輩、ありがとうございました☺