帶你進入異步Django+Vue的世界 - Didi打車實戰(5)

上一篇 帶你進入異步Django+Vue的世界 - Didi打車實戰(4)
Demo: https://didi-taxi.herokuapp.com/

上一篇,前、后端已經完整支持了Websockets。
接下來,我們來實現創建訂單、群發群收、修改訂單功能。

Refactoring: Trip返回信息

后臺返回Trip信息里,driver/rider是一個primary key,指向User。我們希望能直接看到ForeignKey: driver/rider的詳細信息。

[{created: "2019-05-20T10:08:59.950536Z"
driver: null
drop_off_address: "牛首山"
id: "4a25dde1-dd0d-422a-9e5e-706958b65046"
pick_up_address: "總統府"
rider: {id: 5, username: "rider3", first_name: "", last_name: "", group: "rider"}
status: "REQUESTED"
updated: "2019-05-20T10:08:59.950563Z"}, ...]

Serializer添加ReadOnlyTripSerializer,關聯UserSerializer即可。

# /backend/api/serializers.py
class ReadOnlyTripSerializer(serializers.ModelSerializer):
    driver = UserSerializer(read_only=True)
    rider = UserSerializer(read_only=True)

    class Meta:
        model = Trip
        fields = '__all__'

然后修改DRF view, 用戶HTTP訪問/trip/時的TripView,

#
class TripView(viewsets.ReadOnlyModelViewSet):
    lookup_field = 'id'
    lookup_url_kwarg = 'trip_id'
    permission_classes = (permissions.IsAuthenticated,)
    queryset = Trip.objects.all()
    serializer_class = ReadOnlyTripSerializer  # changed

Channels 創建訂單

當用戶創建一個訂單時,我們用Consumer來創建訂單:

  • 判斷消息type是否為create.trip
  • 調用DRFtrip = serializer.create()創建
  • 注意Django的數據庫操作,都是同步的,而Channels是異步的,所以需要加個裝飾器:@database_sync_to_async
  • 創建Trip記錄后,再添加用戶信息,調用ReadOnlyTripSerializer()
  • 發送Websockets: self.send_json()
  • 新訂單創建時,通知所有的司機:channel_layer.group_send( group='drivers', message={ 'type': 'echo.message', 'data': trip_data } )
    • 其中'type': 'echo.message',Channels會自動調用echo_message(event)函數,保證在drivers組里的司機們都能收到
# api/consumers.py

from channels.db import database_sync_to_async # new
from channels.generic.websocket import AsyncJsonWebsocketConsumer

from api.serializers import ReadOnlyTripSerializer, TripSerializer # new


class TaxiConsumer(AsyncJsonWebsocketConsumer):
    # modified
    async def connect(self):
        user = self.scope['user']
        if user.is_anonymous:
            await self.close()
        else:
            channel_groups = []
            # Add a driver to the 'drivers' group.
            user_group = await self._get_user_group(self.scope['user'])
            if user_group == 'driver':
                channel_groups.append(self.channel_layer.group_add(
                    group='drivers',
                    channel=self.channel_name
                ))
            # Get trips and add rider to each one's group.
            self.trips = set([
                str(trip_id) for trip_id in await self._get_trips(self.scope['user'])
            ])
            for trip in self.trips:
                channel_groups.append(self.channel_layer.group_add(trip, self.channel_name))
            await asyncio.gather(*channel_groups)

            await self.accept()

    # new
    async def receive_json(self, content, **kwargs):
        message_type = content.get('type')
        if message_type == 'create.trip':
            await self.create_trip(content)

    # new
    async def echo_message(self, event):
        await self.send_json(event)
    # new
    async def create_trip(self, event):
        trip = await self._create_trip(event.get('data'))
        trip_id = f'{trip.id}'
        trip_data = ReadOnlyTripSerializer(trip).data
        # Send rider requests to all drivers.
        await self.channel_layer.group_send(
            group='drivers', message={
                'type': 'echo.message',
                'data': trip_data
            }
        )
        # Add trip to set.
        if trip_id not in self.trips:
            self.trips.add(trip_id)
            # Add this channel to the new trip's group.
            await self.channel_layer.group_add(
                group=trip_id, channel=self.channel_name
            )

        await self.send_json({
            'type': 'create.trip',
            'data': trip_data
        })

    # new
    @database_sync_to_async
    def _create_trip(self, content):
        serializer = TripSerializer(data=content)
        serializer.is_valid(raise_exception=True)
        trip = serializer.create(serializer.validated_data)
        return trip

前端 - 創建訂單

點擊導航條上的叫車按鈕,顯示對話框:


image.png

<template>

# /src/App.vue
  <v-dialog v-model="dialog">
      <v-card>
        <v-card-title class="headline">你想去哪里?</v-card-title>

        <v-card-text>
          <v-layout row>
          <v-flex xs12 sm8>
                    <v-text-field
                      name="from"
                      label="出發地點"
                      v-model="from"
                      type="text"
                      required></v-text-field>
                  </v-flex>
                </v-layout>
                <v-layout row wrap>
                  <v-flex xs12 sm8>
                    <v-text-field
                      name="dest"
                      label="目的地"
                      v-model="dest"
                      type="text"
                      required></v-text-field>
                  </v-flex>
                </v-layout>
        </v-card-text>

        <v-card-actions>
          <v-btn
            color="red"
            flat="flat"
            @click="dialog = false"
          >
            Cancel
          </v-btn>
          <v-spacer></v-spacer>
          <v-btn
            color="green"
            flat outline
            :disabled="!(from && dest)"
            @click="dialog = false; callTaxi()"
          >
            叫車
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>

點擊對話框里的“叫車”時,調用Vuex的createTrip action來發送WebSockets消息:
<script>

  data () {
    return {
      dialog: false,
      from: '',
      dest: ''
    }
  },
  methods: {
    ...mapActions(['clearAlert']),
    menu_click (title) {
      if (title === 'Exit') {
        this.$store.dispatch('messages/signUserOut')
      } else if (title === 'Call') {
        this.dialog = true
      }
    },
    callTaxi () {
      let data = { pick_up_address: this.from, drop_off_address: this.dest, rider: this.user.id }
      this.$store.dispatch('ws/createTrip', data)
    }
  }

同axios,所有與后臺交互的WS操作,全部集中到wsService.js中,方便管理和更新。

# /src/services/wsService.js
// send Websockets msg to server

export default {
  async createTrip (ws, payload) {
    let data = JSON.stringify({
      type: 'create.trip',
      data: payload
    })
    await ws.send(data)
  }
}

然后,Vuex store里,根據需求,添加不同的actions:

# /src/store/modules/ws.js
const actions = {
  async createTrip ({ commit }, message) {
    await wsService.createTrip(state.websocket.ws, message)
  },
  async updateTrip ({ commit }, message) {
    await wsService.updateTrip(state.websocket.ws, message)
  }
}

測試:按F12,瀏覽器Console窗口,點叫車按鈕,輸入數據,就能看到創建成功的WS消息了:

WS received: {
"type":"create.trip",
"data":{
  "id":"69caf2d4-a9cb-4b3e-80d3-2412a2debe99","driver":null,
  "rider":{"id":2,"username":"rider1","first_name":"","last_name":""},
  "created":"2019-05-19T11:40:41.278098Z",
  "updated":"2019-05-19T11:40:41.278126Z",
  "pick_up_address":"南京",
  "drop_off_address":"大理",
  "status":"REQUESTED"}
}

收到后臺WS消息后,setAlert消息,并且更新“當前訂單”。這是前端業務邏輯,集中放在ws.js

# /src/store.modules/ws.js
const actions = {
  // handle msg from server
  wsOnMessage ({ dispatch, commit }, e) {
    const rdata = JSON.parse(e.data)
    console.log('WS received: ' + JSON.stringify(rdata))
    switch (rdata.type) {
      case 'create.trip':
        commit('messages/addTrip', rdata.data, { root: true })
        break
      case 'update.trip':
        break
    }
  },

添加addTrip action,并且我們讓trips按更新時間逆序排序:

# /scr/store/modules/messages.js
const getters = {
  trips: state => {
    return state.trips.sort((a, b) => new Date(b.updated) - new Date(a.updated))
  },
}

const mutations = {
  addTrip (state, messages) {
    state.trips.splice(0, 0, message)
  },
image.png

Channels 更新消息的群發群收

用戶創建訂單后,如果有司機接單,則用戶應能即時得到通知。
用戶退出時,司機也能收到通知。
實現:利用Channels group

Consumer

  • 每個用戶,維護一個trips列表
  • 在新訂單創建后,channel_layer.group_add來新建一個group - 群,在群內的所有成員(乘客和司機),會同時收到更新提醒
  • 用戶WS連接關閉(可能是退出程序,也可能是無信號),則Channels里解散用戶所處的群,并把trips列表清空
# backend/api/consumers.py
import asyncio # new

from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer

from api.serializers import ReadOnlyTripSerializer, TripSerializer


class TaxiConsumer(AsyncJsonWebsocketConsumer):

    # new
    def __init__(self, scope):
        super().__init__(scope)

        # Keep track of the user's trips.
        self.trips = set()

    async def connect(self): ...

    async def receive_json(self, content, **kwargs): ...

    # new
    async def echo_message(self, event):
        await self.send_json(event)

    # changed
    async def create_trip(self, event):
        trip = await self._create_trip(event.get('data'))
        trip_id = f'{trip.id}'
        trip_data = ReadOnlyTripSerializer(trip).data

        # Add trip to set.
        self.trips.add(trip_id)

        # Add this channel to the new trip's group.
        await self.channel_layer.group_add(
            group=trip_id,
            channel=self.channel_name
        )

        await self.send_json({
            'type': 'create.trip',
            'data': trip_data
        })

    # new
    async def disconnect(self, code):
        # Remove this channel from every trip's group.
        channel_groups = [
            self.channel_layer.group_discard(
                group=trip,
                channel=self.channel_name
            )
            for trip in self.trips
        ]
        asyncio.gather(*channel_groups)

        # Remove all references to trips.
        self.trips.clear()

        await super().disconnect(code)

    @database_sync_to_async
    def _create_trip(self, content): ...

用戶恢復WS連接時,應該能從數據庫里,讀取已有trip,然后重新添加用戶到群里

Consumer

  • _get_trips讀取數據庫記錄,排除已完成的訂單
  • channel_layer.group_add添加用戶到所有未完成訂單的群里
# api/consumers.py

import asyncio

from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer

from api.models import Trip # new
from api.serializers import ReadOnlyTripSerializer, TripSerializer


class TaxiConsumer(AsyncJsonWebSocketConsumer):

    def __init__(self, scope): ...

     # changed
    async def connect(self):
        user = self.scope['user']
        if user.is_anonymous:
            await self.close()
        else:
            # Get trips and add rider to each one's group.
            channel_groups = []
            self.trips = set([
                str(trip_id) for trip_id in await self._get_trips(self.scope['user'])
            ])
            for trip in self.trips:
                channel_groups.append(self.channel_layer.group_add(trip, self.channel_name))
            asyncio.gather(*channel_groups)
            await self.accept()

    async def receive_json(self, content, **kwargs): ...

    async def echo_message(self, event): ...

    async def create_trip(self, event): ...

    async def disconnect(self, code): ...

    @database_sync_to_async
    def _create_trip(self, content): ...

    # new
    @database_sync_to_async
    def _get_trips(self, user):
        if not user.is_authenticated:
            raise Exception('User is not authenticated.')
        user_groups = user.groups.values_list('name', flat=True)
        if 'driver' in user_groups:
            return user.trips_as_driver.exclude(
                status=Trip.COMPLETED
            ).only('id').values_list('id', flat=True)
        else:
            return user.trips_as_rider.exclude(
                status=Trip.COMPLETED
            ).only('id').values_list('id', flat=True)

創建訂單時,檢查是否已存在記錄。如果已存在,則跳過加群的步驟。

# api/consumers.py

async def create_trip(self, event):
    trip = await self._create_trip(event.get('data'))
    trip_id = f'{trip.id}'
    trip_data = ReadOnlyTripSerializer(trip).data

    # Handle add only if trip is not being tracked.
    if trip_id not in self.trips:
        self.trips.add(trip_id)
        await self.channel_layer.group_add(
            group=trip_id,
            channel=self.channel_name
        )

    await self.send_json({
        'type': 'create.trip',
        'data': trip_data
    })

更新訂單

Consumer

  • 如果司機/乘客更新了訂單,則觸發update_trip動作
  • 通過Serializer,更新訂單狀態
  • 如果是司機接單,則把司機加入到群里channel_layer.group_add()
  • 通知乘客,已有司機接單。(group=trip_id, message={ 'type': 'echo.message', 'data': trip_data })
    • 注意message={'type': 'echo.message',Channels會自動尋找對應的方法函數:echo_message(event)
# api/consumers.py

import asyncio

from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer

from api.models import Trip
from api.serializers import ReadOnlyTripSerializer, TripSerializer


class TaxiConsumer(AsyncJsonWebsocketConsumer):

    def __init__(self, scope): ...

    async def connect(self): ...

    async def receive_json(self, content, **kwargs):
        message_type = content.get('type')
        if message_type == 'create.trip':
            await self.create_trip(content)
        elif message_type == 'update.trip':  # new
            await self.update_trip(content)

    async def echo_message(self, event): ...

    async def create_trip(self, event): ...

    # new
    async def update_trip(self, event):
        trip = await self._update_trip(event.get('data'))
        trip_id = f'{trip.id}'
        trip_data = ReadOnlyTripSerializer(trip).data
        # Send updates to riders that subscribe to this trip.
        await self.channel_layer.group_send(group=trip_id, message={
            'type': 'echo.message',
            'data': trip_data
        })
        if trip_id not in self.trips:
            self.trips.add(trip_id)
            await self.channel_layer.group_add(
                group=trip_id,
                channel=self.channel_name
            )

        await self.send_json({
            'type': 'update.trip',
            'data': trip_data
        })

    async def disconnect(self, code): ...

    @database_sync_to_async
    def _create_trip(self, content): ...

    @database_sync_to_async
    def _get_trips(self, user): ...

    # new
    @database_sync_to_async
    def _update_trip(self, content):
        instance = Trip.objects.get(id=content.get('id'))
        # https://www.django-rest-framework.org/api-guide/serializers/#partial-updates
        serializer = TripSerializer(data=content, partial=True)
        serializer.is_valid(raise_exception=True)
        trip = serializer.update(instance, serializer.validated_data)
        return trip

引入User group概念

為了區分用戶是乘客還是司機,需要把用戶分組。
數據模型添加group計算字段,類似于Vue computed():

class User(AbstractUser):
    # photo = models.ImageField(upload_to='photos', null=True, blank=True)

    @property
    def group(self):
        groups = self.groups.all()
        return groups[0].name if groups else None

DRF Serializer在注冊時,增加group字段的處理:

# /backend/api/serializers.py
class UserSerializer(serializers.ModelSerializer):
    password1 = serializers.CharField(write_only=True)
    password2 = serializers.CharField(write_only=True)
    group = serializers.CharField()
    # photo = MediaImageField(allow_empty_file=True)

    def validate(self, data):
        if data['password1'] != data['password2']:
            raise serializers.ValidationError('兩次密碼不一致')
        return data

    def create(self, validated_data):
        group_data = validated_data.pop('group')
        group, _ = Group.objects.get_or_create(name=group_data)
        data = {
            key: value for key, value in validated_data.items()
            if key not in ('password1', 'password2')
        }
        data['password'] = validated_data['password1']
        user = self.Meta.model.objects.create_user(**data)
        user.groups.add(group)
        user.save()
        return user

    class Meta:
        model = get_user_model()
        fields = (
            'id', 'username', 'password1', 'password2', 'first_name', 'last_name', 'group', #'photo',
        )
        read_only_fields = ('id',)

admin后臺管理頁面:

# /backend/api/admin.py
@admin.register(User)
class UserAdmin(DefaultUserAdmin):
    list_display = (
        'username', 'id', 'group', 'first_name', 'last_name', 'email', 'is_staff',
    )
    readonly_fields = (
        'id',
    )

注意:數據庫不需要重新migrate,應該不是新字段,而且計算字段。
注意:已有用戶,需要在admin里添加“group”字段。或者刪除重新注冊。

前端Sign-Up頁面

我們在注冊用戶時,讓用戶選擇不同角色:

image.png

更新一下Vue view:
<template>

# /src/views/Signup.vue
                <v-radio-group v-model="group" row>
                  <v-radio label="乘客" value="rider"></v-radio>
                  <v-radio label="司機" value="driver"></v-radio>
                </v-radio-group>
                <v-layout>
                  <v-flex xs12>
                    <v-card-actions>
                    <v-spacer />
                    <v-btn round type="submit" :loading="loading" class="orange">Register</v-btn>
                  </v-card-actions>
                  </v-flex>
                </v-layout>

<script>

data () {
    return {
      username: '',
      password: '',
      confirmPassword: '',
      group: 'rider'
    }
  },
methods: {
    onSignup () {
      this.$store.dispatch('messages/signUserUp', { username: this.username, password2: this.confirmPassword, password1: this.password, group: this.group })
    },

總結

后臺對訂單的更新、群發群收,已經全部ready了。

下一篇,會介紹前端如何處理訂單更新
帶你進入異步Django+Vue的世界 - Didi打車實戰(6)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容