LoginSignup
43
43

More than 1 year has passed since last update.

Django REST frameworkで学んだことをまとめてみた

Last updated at Posted at 2019-10-15

はじめに

rayaと申します。
プログラミング初心者であるため、内容に誤りがあるかもしれません。
もし、誤りがあれば修正するのでどんどん指摘してください。

今回の内容

DjangoのREST frameworkで学んだことをまとめてみました。

DjangoのREST frameworkとは

・Django REST framework
Djangoと組み合わせて、簡単にAPIを作るための設計ルール

・API
外部から呼び出せる機能の塊。
Google mapのAPIを自分のサイトに組み込めば自分のサイトに地図を読み込める

準備

詳細は公式サイトをご覧ください。
djangoがインストールしてある仮想環境にRest frameworkをインストールします。

pip install djangorestframework

以下のように、setting.pyファイルにRest frameworkを使用することを伝えます。今回はtutorialプロジェクトの中にsnippetesアプリケーションを作成します。

INSTALLED_APPS = [
    ...
    'rest_framework',
    'snippets.apps.SnippetsConfig',
]

Serialization

snippets/serializer.pyにSerializerを作成します。
Serializerとは、DjangoのFormに似た働きをし、Modelに対してデータを適切な形にして入力、出力するものです。
Serializerにはシリアライズとデシリアライズという働きがあり、シリアライズとは、オブジェクトからデータを読み出すことで、デシリアライズとは、データを元にオブジェクトを作成したり、データを適切な形に変換することです。

models.pyにSnippetモデルを作成した上で、SnippetSerializerを以下のように作成します。

class SnippetSerializer(serializers.Serializer):
    id = serializers.IntegerField(read_only=True)
    title = serializers.CharField(required=False, allow_blank=True, max_length=100)
    code = serializers.CharField(style={'base_template': 'textarea.html'})
    linenos = serializers.BooleanField(required=False)

serializers.SerializerはSerializerの1つで、オブジェクトならばなんでもシリアライズできます。
Fieldの引数に指定されているのはCommon argsで、SerializerのFieldに対して指定でき、以下のようなものがあります。
・read_only
読み込み(出力)専用のフィールドかどうかを指定します。Trueなら読み込みのみ可能です。
・required = False
値を入力しなければならないか否かを指定します。デフォルトだとTrueです。

{'base_template': 'textarea.html'}はDjango Formのwidget=widgets.Textareaに相当します。
widgetはDjangoでHTMLの要素を表現する方法で、Textareaなら<textarea></textarea>を表現しています。

上記のSnippetSerializerクラス内に以下のメソッドを定義します。
これらのメソッドはそれぞれ、serializer.save()が呼び出された時にインスタンスを作成、更新します(デシリアライズします)。
それぞれの使い分けはメソッド定義の形を見ればわかります。
第一引数が指定されていればupdateメソッドが、指定されていなければcreateメソッドが実行されます。
updateメソッドの定義にはinstance引数が指定されており、これが第1引数になります。

def create(self, validated_data):
        return Snippet.objects.create(**validated_data)

def update(self, instance, validated_data):
        instance.title = validated_data.get('title', instance.title)
       ...
        instance.save()
        return instance

validated_dataには、バリデーションを通過したデータが格納されます。
バリデーションの通過は、shell上で確認でき、適切なデータだったらTrueになり、validated_dataに格納されます。

snippet = Snippet(code='print("hello, world")\n')

snippet.save()

serializer = SnippetSerializer(snippet)

serializer.is_valid()
# True #適切なデータだったらTrueになり、validated_dataに格納される

次は、先ほどとは異なるserializers.ModelSerializerを見てみます。
このSerializerでも同様なことがより簡単にできます。
これはModelに紐づいたFieldを自動でSerializerのFieldに定義してくれます。
DjangoのModelFormに似ています。
serializers.Serializerはどんなオブジェクトもシリアライズできましたが、serializers.ModelSerializerはDjangoモデルのみシリアライズできます。

class SnippetSerializer(serializers.ModelSerializer):
    class Meta:
        model = Snippet
        fields = ['id', 'title', 'code', 'linenos']

これだけで、SnippetモデルのFieldに基づいたSerializerを作成できます。

Requests and Responses

REST frameworkにはいくつか中核を担う機能があります。
・Request objects
DjangoのHttpRequestを拡張し、より柔軟になった機能です。
request.data属性により、POST、PUT、PATCHで受け取ったデータを処理できます。

・Response objects
return Response(data)でリクエストに応じて適切な形でデータを出力します。

・Status codes
数値のHTTPステータスコードではなく、HTTP_400_BAD_REQUESTのような、そのステータスコードの意味を捉えやすい識別子を提供しています。

・Wrapping API views
API Viewの作成に使える2種類のラッパーがあります。
1. @api_view : デコレーター。関数ベースのViewの前に記述する。
2. APIView : クラス。クラスベースのViewに継承する。
これらはViewに対してRequestインスタンスを受け取り、Responseオブジェクトに最適なコンテキストを追加します。

という訳で、これらを使ってViewを作ってみます。

from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response
from snippets.models import Snippet
from snippets.serializers import SnippetSerializer

@api_view(['GET', 'POST'])
def snippet_list(request):
    if request.method == 'GET':
        snippets = Snippet.objects.all()
        serializer = SnippetSerializer(snippets, many=True)
        return Response(serializer.data)

    elif request.method == 'POST':
        serializer = SnippetSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

statusResponseのStatus codesを示します。
CREATED, REQUESTといった識別子が記述されていて、意味が明快です。
request.dataResponseの記述でわかるように、何というかざっくり記述できるようになりました。
Responseが柔軟にデータを出力するようにできたことで、URLに色々な指定ができます。
まず、Viewにformat引数を指定します。
このformat引数に指定した値を接尾子とするURLが提供されるようになります。
.json.htmlをURLに指定できます。
ここではNoneなので特に指定していません。

def snippet_list(request, format=None):

次にsnippets/urls.pyファイルにformat_suffix_patternsを追加します。
これにより先ほどのformat引数の値で各Viewにより表示されるページのURLを指定できます。

from django.urls import path
from rest_framework.urlpatterns import format_suffix_patterns
from snippets import views

urlpatterns = [
    path('snippets/', views.snippet_list),
    path('snippets/<int:pk>', views.snippet_detail),
]

urlpatterns = format_suffix_patterns(urlpatterns)

Class-based Views

ここまでは関数ベースのViewを使ってきましたが、クラスベースのViewに置き換えてみます。
views.pyを以下のようにします。
@api_viewデコレーターではなく、APIViewクラスを使っています。
クラスベースのViewでは、HTTPメソッドをそのままクラスのインスタンスメソッドとして設定します。
このインスタンスメソッドはハンドラメソッドと呼ばれます。

from snippets.models import Snippet
from snippets.serializers import SnippetSerializer
from django.http import Http404
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status

class SnippetList(APIView):

    def get(self, request, format=None):
        snippets = Snippet.objects.all()
        serializer = SnippetSerializer(snippets, many=True)
        return Response(serializer.data)

    def post(self, request, format=None):
        serializer = SnippetSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

クラスベースのViewを使う場合は、urls.pyのViewの記述をviews.クラス名.as_view()とします。

urlpatterns = [
    path('snippets/', views.SnippetList.as_view()),
    path('snippets/<int:pk>/', views.SnippetDetail.as_view()),
]

さらに簡潔にするためにMixinが使えます。
Mixinとは機能をまとめたクラスのことです。
単体では動作しないため他のクラスに継承して機能を提供する働きをします。
DjangoのREST frameworkのMixinには、作成・取得・更新・削除といった基本的な機能が実装されているので、Mixinを使うことでこれらの機能を簡単に呼び出すことができます。
まず、GenericAPIViewListModelMixinCreateModelMixinを追加します。
このGeneric〜に関しては後述します。
これらのMixinクラスにより.list().create()が使えるようになります。

from snippets.models import Snippet
from snippets.serializers import SnippetSerializer
from rest_framework import mixins
from rest_framework import generics

class SnippetList(mixins.ListModelMixin,
                  mixins.CreateModelMixin,
                  generics.GenericAPIView):
    queryset = Snippet.objects.all()
    serializer_class = SnippetSerializer

    def get(self, request, *args, **kwargs):
        return self.list(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        return self.create(request, *args, **kwargs)

*args : tupleで複数の値をとる引数
**kwargs : dictionaryでkeyとvalue2つの値をとる引数

実はさらに簡潔に記述できます。
REST frameworkにはデフォルトでGenericviewが導入されています。
GenericViewは先ほどのGenericAPIViewを基底クラスとし、Mixinを継承しています。
GenericViewクラスに対応した特定のクラスを継承させることで、それに紐づいたアクションメソッドを提供します。
アクションメソッドとはMixinで提供されるハンドラメソッド(上述)のようなものです。
先ほどのViewは以下のように記述できます。

from snippets.models import Snippet
from snippets.serializers import SnippetSerializer
from rest_framework import generics

class SnippetList(generics.ListCreateAPIView):
    queryset = Snippet.objects.all()
    serializer_class = SnippetSerializer

Authentication & Permissions

現状、このAPIは誰もがデータを編集、削除できます。
よって、以下の機能を実装します。
・スニペットとその作成者が関連付けられる。
・認証されたユーザーのみスニペットを作成できる。
・スニペットの作成者のみがそれを更新・削除できる。
・認証されていないリクエストは読み取りのみになる

まず、Userモデルに対する設定をしたいのでUserモデルのSerializerを作ります。
ModelSerializerのところで行ったようにUserモデルと関連づけてSerializerを作成します。

from django.contrib.auth.models import User

class UserSerializer(serializers.ModelSerializer):
    snippets = serializers.PrimaryKeyRelatedField(many=True, queryset=Snippet.objects.all())

    class Meta:
        model = User
        fields = ['id', 'username', 'snippets']

また、GenericViewを使い、Viewを設定します。

from django.contrib.auth.models import User
from snippets.serializers import UserSerializer

class UserList(generics.ListAPIView):
    queryset = User.objects.all()
    serializer_class = UserSerializer


class UserDetail(generics.RetrieveAPIView):
    queryset = User.objects.all()
    serializer_class = UserSerializer

snippets/urls.pyにもURLを追加します。

path('users/', views.UserList.as_view()),
path('users/<int:pk>/', views.UserDetail.as_view()),

ここからSnippetモデルとUserモデルを関連付けます。
models.pyのSnippetモデルに以下のFieldを追加します。

owner = models.ForeignKey('auth.User', related_name='snippets', on_delete=models.CASCADE)

さらにSnippetListViewクラスに以下のメソッドを追加します。

def perform_create(self, serializer):
    serializer.save(owner=self.request.user)

これで.perform_create()メソッドによりリクエストのあったユーザーの情報をownerに代入し保存できます。

先ほどはmodels.pyにownerFieldを追加しましたが、次はserializers.pyのSnippetSerializerにownerフィールドを追加します。
この時、class Meta : にもownerを追加します。

owner = serializers.ReadOnlyField(source='owner.username')

ここで、source引数は、Fieldの引数に指定され、そのFieldに入るシリアライズされたデータの値に属性を指定できます。
この場合は、ownerFieldにownerのusernameが入ります。
ReadOnlyFieldはserializerに設定できるフィールドで読み取り専用であることを示します。

次は、認証されたユーザーのみがスニペットを作成し、それらを更新・削除できるようにします。
そのためにViewにPermissionを追加します。
views.pyにPermissionクラスをインポートし、Permissionを追加したいViewにそのPermissionクラスを指定します。

from rest_framework import permissions

#SnippetList Viewに追記
permission_classes = [permissions.IsAuthenticatedOrReadOnly]

IsAuthenticatedOrReadOnlyは認証されたリクエストを受け取った場合、読み取りと書き込み権限を与え、認証されなければ読み取り権限しか与えないというPermissionです。

ユーザーを認証するるためにはログイン、ログアウト機能を実装し、ユーザー名とパスワードを送ってもらいます。
まず、tutorial/urls.pyに以下の内容を記述します。

from django.conf.urls import include

#ファイルの最後に記述
urlpatterns += [
    path('api-auth/', include('rest_framework.urls')),
]

'api-auth/'は任意の値で大丈夫です。
これだけでログイン、ログアウト機能が実装できます。

ここまでは、認証されれば(ログインできれば)、他のユーザーのスニペットを含め、全てのスニペットを編集できるように設定しています。
しかし、スニペットの編集は自分が作成したもののみにしたいですよね。
そこで、次は各オブジェクトに対するパーミッションを設定し、自分の投稿したオブジェクトのみ編集ができるようにします。
そのためにはCustom Permissionを実装する必要があるので、snippet/permissions.pyファイルを作成して以下のように記述します。

from rest_framework import permissions

class IsOwnerOrReadOnly(permissions.BasePermission):
    def has_object_permission(self, request, view, obj):
        if request.method in permissions.SAFE_METHODS:
            return True
        return obj.owner == request.user

Basepermissionhas_object_permissionによってCustom Permissionが実装され、そのオブジェクトの作成者のみ編集権限を得られるようになります。
has_object_permission(self, request, view, obj)メソッドでは、permissionが設定されたViewかつ特定のオブジェクトに対して権限のあるユーザーからのアクセスと保証されるならTrueを返します。
同じような機能のhas_permission(self, request, view)メソッドでは、permissionが設定されたView全てで呼び出され、権限があるユーザーからのリクエストならばTrueを返しますが、オブジェクトを指定しません。
SAFE_METHODSは、GET, OPTIONS, HEADを含むタプルです。
要は読み取りのパーミッションを示しています。
よって、if request.method in permissions.SAFE_METHODS以降は、読み取りのリクエストの場合常にTrueになり、読み取りだけなら常に許可します。
それ以外のリクエスト(書き込み)ならば、return obj.owner == request.userとしてオブジェクトの所有者とリクエストを送ったユーザーが同じならば書き込みを許可します。
このCustom Permissionをviews.pyに設定します。


from snippets.permissions import IsOwnerOrReadOnly

#SnippetDetailクラスに追記(この記事では表記していないので公式サイトでご確認ください)
permission_classes = [permissions.IsAuthenticatedOrReadOnly,
                      IsOwnerOrReadOnly]

Relationships & HyperlinkedAPIs

今のところ、usersとsnippetへのエンドポイントはありますが、API自身のエントリポイントがありません。
そこで、snippet/views.pyに以下の内容を追記してエントリポイントを作ります。

※エントリポイントとエンドポイントとは共に特定のリソースに与えられた一意なURI(web上の住所(URL)とweb上での名前(URN)の総称)で、同じ場所を示します。
その違いは、そのURIにアクセスする視点です。
サービスを提供する側からの視点に立つと、そのURIはプログラムを始める地点という意味でエントリポイントになり、サービスを利用する側からの視点に立つと、そのURIはアクセスさえすればあとはAPIが勝手にプログラムを開始し終了させてくれるという意味でエンドポイントになります。
例えば、Google mapのAPIを自分のサイトで使いたくて、あるURIにアクセスする場合、Google mapから見ればそのURIはエントリポイント、自分から見るとエンドポイントになります。

from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework.reverse import reverse


@api_view(['GET'])
def api_root(request, format=None):
    return Response({
        'users': reverse('user-list', request=request, format=format),
        'snippets': reverse('snippet-list', request=request, format=format)
    })

ここで、reverse関数は、URLを完全な形にします。
例えば、/foobarではなくhttp://example.com/foobarで返します。

次に入力した内容のhtml表現をハイライトするコードを導入します。
Restframeworkが提供しているHTML Rendererには2種類あります。
1つは、TemplateHTMLRendererで、templateに基づいたHTMLをレンダリングします。
もう1つはStaticHTMLRendererで、あらかじめレンダリングされたHTMLを処理します。
今回は後者を扱います。
StaticHTMLRendererを使用する際は、APIがこれまでと異なる挙動をするので、GenericViewのメソッドを使わずに、別でViewのメソッドを定義する必要があります。
今回はsnippets/views.pyにget()メソッドを定義していきます。

from rest_framework import renderers
from rest_framework.response import Response

class SnippetHighlight(generics.GenericAPIView):
    queryset = Snippet.objects.all()
    renderer_classes = [renderers.StaticHTMLRenderer]

    def get(self, request, *args, **kwargs):
        snippet = self.get_object()
        return Response(snippet.highlighted)

snippets/urls.pyにここまでに定義したViewのURLを追記します。

path('', views.api_root),
path('snippets/<int:pk>/highlight/', views.SnippetHighlight.as_view()),

ここからは、HyperLinkedModelSerializerを使って、entity間をHyperlinkで繋げます。
entityとは、テキストにリンクをつけるための情報です。
HyperLinkedModelSerializerModelSerializerとはidフィールドがデフォルトで存在しない点、HyperlinkedIdentityFieldを用いたurlフイールドが存在する点、HyperLinkedRelatedFieldでentity間を繋げる点が異なります。
snippets/serializers.pyに以下の内容を追記します。

class SnippetSerializer(serializers.HyperlinkedModelSerializer):
    owner = serializers.ReadOnlyField(source='owner.username')
    highlight = serializers.HyperlinkedIdentityField(view_name='snippet-highlight', format='html')

    class Meta:
        model = Snippet
        fields = ['url', 'id', 'highlight', 'owner',
                  'title', 'code', 'linenos']

class UserSerializer(serializers.HyperlinkedModelSerializer):
    snippets = serializers.HyperlinkedRelatedField(many=True, view_name='snippet-detail', read_only=True)
    ##SnippetDetailはこの記事で表記していないので公式サイトでご確認ください

    class Meta:
        model = User
        fields = ['url', 'id', 'username', 'snippets']

ここで新たに定義されているhighlight Fieldにはformat引数でhtmlを指定する必要がある点は注意してください。
view_nameにはurls.pyで設定したURLの名前が入ります。

UserとSnippetモデルは大量のオブジェクトを返しています。
そこで、1つのページあたりに表示できるオブジェクトの数を制限します。
このことをPaginationと言います。
tutorial/settings.pyに以下の内容を追記します。

REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 10
}

Viewsets & Routers

REST frameworkにはURLの構築を自動で行ってくれるViewsetという仕組みがあります。
Viewsetクラスは、Viewクラスとほとんど同じですが、GET,PUTなどのHTTPメソッドではなく、read,updateなどのCRUDでデータをサーバーとやりとりします。
※厳密には、Viewsetクラスがインスタンス化される瞬間にHTTPメソッドは使われます。(後述)

ここまで作ってきたViewをViewsetに置き換えます。
UserListクラスとUserDetailクラスを1つのUserViewsetクラスにまとめます。
ReadOnlyModelViewSetクラスによって「読み取り機能」が設定され、list, detailというアクションが自動で定義されます。

from rest_framework import viewsets

class UserViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer

次は、SnippetList,SnippetDetail, SnippetHighlightクラスをSnippetViewsetにまとめます。

from rest_framework.decorators import action
from rest_framework.response import Response

class SnippetViewSet(viewsets.ModelViewSet):
    queryset = Snippet.objects.all()
    serializer_class = SnippetSerializer
    permission_classes = [permissions.IsAuthenticatedOrReadOnly,
                          IsOwnerOrReadOnly]

    @action(detail=True, renderer_classes=[renderers.StaticHTMLRenderer])
    def highlight(self, request, *args, **kwargs):
        snippet = self.get_object()
        return Response(snippet.highlighted)

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)

ここではModelViewsetによって「読み書き機能」が設定され、list, create, retrieve, update, destroyが自動で定義されます。
また、CRUD以外のアクションを追加したい時は、@actionの後に記述でき、highlight, perform_createアクションが定義されています。
一応、これらのアクションにはGETメソッドがデフォルトで割り当てられます。
他のHTTPメソッドにも対応させたい場合は、method引数で指定できます。

detail引数について間違ってたらコメントお願いします!
これはかなり自分なりの解釈ですが、
detail=はTrueならpkの設定が必要になり、データ1つあたりに対してアクションを起こしたい場合に使用され、Falseの場合はpkが不要でデータ一覧に対してアクションを起こしたい場合に使用されます。

次にViewsetとURLを繋げます。
具体的には、Viewsetの各アクションをHTTPメソッドへどう割り当てるかをsnippets/urls.pyで定めます。


from snippets.views import SnippetViewSet, UserViewSet, api_root
from rest_framework import renderers

snippet_list = SnippetViewSet.as_view({
    'get': 'list',
    'post': 'create'
})
snippet_detail = SnippetViewSet.as_view({
    'get': 'retrieve',
    'put': 'update',
    'patch': 'partial_update',
    'delete': 'destroy'
})
snippet_highlight = SnippetViewSet.as_view({
    'get': 'highlight'
}, renderer_classes=[renderers.StaticHTMLRenderer])
user_list = UserViewSet.as_view({
    'get': 'list'
})
user_detail = UserViewSet.as_view({
    'get': 'retrieve'
})

その後、URLconf(tutorial/urls.py)で実際にURLを設定します。

urlpatterns = format_suffix_patterns([
    path('', api_root),
    path('snippets/', snippet_list, name='snippet-list'),
    path('snippets/<int:pk>/', snippet_detail, name='snippet-detail'),
    path('snippets/<int:pk>/highlight/', snippet_highlight, name='snippet-highlight'),
    path('users/', user_list, name='user-list'),
    path('users/<int:pk>/', user_detail, name='user-detail')
])

しかし、最初に言ったように、実際はViewsetクラスを使っているため、自分でURLを設定する必要はありません。
それはRouterクラスが自動で行ってくれます。
Routerクラスが機能するようにsnippets/urls.pyを書き換えると以下のようになります。

from django.urls import path, include
from rest_framework.routers import DefaultRouter
from snippets import views

#Routerクラスを作り、Viewsetクラスを登録する
router = DefaultRouter()
router.register(r'snippets', views.SnippetViewSet)
router.register(r'users', views.UserViewSet)

#URLが自動で割り当てられる
urlpatterns = [
    path('', include(router.urls)),
]

DefaultRouterクラスは自動でAPIルートビューを作るので、Relationships & HyperlinkedAPIsのところで作成したViewsモジュールからapi_rootメソッドを削除できます。

メモ

Tutorialの中で気になって調べたことを一部まとめています。
メモ程度に書いているのでご了承ください。

・参照と逆参照

class Food(models.Model):
    name = models.CharField(max_length=100)

class Person(models.Model):
    name = models.CharField(max_length=100)
    favorite_food = models.ForeignKey(Food, on_delete="CASCADE", null=True)

foreignkeyフィールドを設定してあるモデルからその関連するモデルのカラムを取得するときは参照。

person1 = Person.objects.get(name="ジョブス")
person1.favorite_food
インスタンス.フィールド名

foreignkeyフィールドの設定してない方のモデルからそれち関連したforeignkeyフィールドの設定したあるモデルのフォールドを参照する場合は逆参照

food1 = Food.objects.get(name="寿司")
food1.person_set.all()
インスタンス.クラス名_set

・repr(serializer)
str()と似ている。

・from django.http import HttpResponse, JsonResponse
辞書型のオブジェクトをJsonreaponseに渡し、returnするとjsonで送信できる

・from django.views.decorators.csrf import csrf_exempt
csrf_tokenを渡していないPOSTメソッドはエラーになるが、それを無効化する。関数の上に@csrf_exemptを記述

・from rest_framework.parsers import JSONParser
parserは、構文を解析するプログラム。

・many=True
引数にmany=Trueを指定すると複数のデータをまとめて扱えるようになる。

・safe=False
文字列に対してエスケープが必要でないと示す

・Pastebin
保存したデータを公開できるウェブアプリケーションのサービス。

参考

・公式サイト(https://www.django-rest-framework.org)
https://qiita.com/ryoo17/items/326f8adda8423abf1ce4
https://note.crohaco.net/2018/django-rest-framework-serializer/
http://sandmark.hateblo.jp/entry/2017/09/30/160945
https://qiita.com/NagaokaKenichi/items/6298eb8960570c7ad2e9
https://qiita.com/kimihiro_n/items/86e0a9e619720e57ecd8

43
43
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
43
43