Медиаданные
Что такое и где используются?
Контент любых файлов, как правило, плохо хранить прямо в базе данных, так как это вызывает дополнительную нагрузку на хранение, репликацию, очистку, vacuum (для postgres), выборку данных (если у вас БД не с колоночным хранением).
Поэтому в мете есть специальное API для работы с файлами. По сути вся работа сводится к тому, чтобы на интерфейсе через форму или на сервере через metasdk загрузить данные, а потом на интерфейсе их показать или получить на бекенде.
Мета может хранить данные просто в расшаренной папке, но рекомендуется настраивать S3 совместимое хранилище через общий конфигурационный файл платформы.
Конфигурация
В конфиге самой меты могут быть зарегистрированы несколько s3 стораджей. При авторизации юзера из хранимой процедуры БД meta.get_user мета получает конфиг для настройки хранения медиаданных юзера
Например, такую конфигурацию раздела mediaData
из общего конфига meta.yaml
:
...
mediaData:
s3:
bucketName: example-default
serviceEndpoint: storage.yandexcloud.net
signingRegion: ru-central1
accessKey: XXXXXXXX
secretKey: XXXXXXXXXXX
additionalStorages:
- id: test
s3:
bucketName: example-meta-test
serviceEndpoint: storage.yandexcloud.net
signingRegion: ru-central1
accessKey: ZZZZZZZZ
secretKey: ZZZZZZZZZZZ
...
нужно читать так:
- По умолчанию файлы будут складываться в Яндекс. Облако в бакет
example-default
. Внутренний сторадж id =default
- Также зарегистрирован сторадж с внутренним id =
test
, который также будет размещен в Яндекс.Облаке в бакетеexample-meta-test
Загрузка данных
Для загрузки данных через интерфейс используйте компонент me-input type=attach (opens in a new tab)
Если вам нужно загрузить данные программно, то используйте метод upload в MediaService (opens in a new tab) в python metasdk.
При загрузке файлов желательно, но необязательно устанавливать значения для entityId
и objectId
.
Это нужно, чтобы семантически привязать медиафайл к какому-либо объекту меты.
Например, установив entityId=190 и objectId=42, вы привяжете загружаемый медиафайл к сущности Клиент с primary key = 42.
Для оптимизации хранения очень полезно устанавливать ttl хранения для временных данных и результатов, которые можно автоматически стирать через время. Если его не устанавливать, то хранение будет вечным, что может негативно сказаться на стоимости вашего решения.
С недавнего времени медиафайлы могут храниться в папках. Это виртуальные папки,
которые никак не влияют на организацию физического хранения файлов. Для загрузки в папку укажите folderId
в параметрах API
запроса или атрибуте элемента интерфейса. Параметр находится на одном уровне с параметрами entityId
и objectId
.
Хранение
Таблица для хранения: meta.media
Данные записываются ядром меты, не рекомендуется делать вставку данных в таблицу в ручную, так как это может негативно повлиять на систему.
При этом можно делать UPDATE в поле info, чтобы далее с ним работать в режиме чтения. Это удобно, если вам нужно после загрузки файла обработать его через Шину сообщений и сложить результат в таблицу, чтобы не размазывать данные между разными таблицами и получать эти данные даже через metasdk в MediaService (opens in a new tab).
В таблице meta.media
для s3 хранения используются следующая нотация записи пути:
s3://{S3_STORAGE_ID}/{S3_PATH}
S3_STORAGE_ID - id стораджа из конфигурации меты. По умолчанию default
Например, в поле disk_path в таблице meta.media
мы увидим такие записи:
-
Хранилище по умолчанию:
s3://default/9514/9999/9514e1c6-8e29-47d2-ae0f-741adfaf8d41.png
. Эквивалентной записью для этого файла будет строка без указания префиксаs3://default/
, а конкретно9514/10191/9514e1c6-8e29-47d2-ae0f-741adfaf8d41.png
. Это работает только дляdefault
хранилище и будет упразднено в будущем -
Отдельное хранилище
test
:s3://test/30fb/9999/30fbbb73-dc75-42d7-be67-9fda5e29f18e.png
Управление доступом к файлам
Мета обращается к функции БД meta.get_media_with_access
, но чтобы поправить условия, вам надо исправляет другую функцию - meta.get_media_private_access
meta_db.update(
"""
UPDATE meta.media
SET info = jsonb_strip_nulls(info || :check_res::jsonb)
WHERE id=:media_id::uuid
""",
{"media_id": media_id, "check_res": json.dumps(check_res)},
)
Markdown
В полях типа MARKDOWN или при использовании MarkdownService вы можете достаточно просто сгенерировать полную ссылку на скачивание или просмотр медиафайла.
[~media_view:41d1d840-5055-4852-84d1-7edd8497e41a]
- ссылка на просмотр медиафайла[~media_download:41d1d840-5055-4852-84d1-7edd8497e41a]
- ссылка на загрузку медиафайла
В этих случаях сгенерируется абсолютная ссылка с каноническим именем медиафайла. Это очень удобно при работе с рассылками через сервис Feeds.
Работа через metasdk
Ознакомьтесь с основным описанием (opens in a new tab)
Локальная разработка
Когда вы разрабатываете фичу на локальной мете "http://localhost:9999 (opens in a new tab)", прикладываемые там файлы так же как и в production среде записываются в БД в таблицу meta.media, но складываются к вам на локальный диск.
Если в скриптах вам надо с ними поработать, то надо перенастроить SDK на работу с локальной метой:
META = MetaApp(meta_url="http://localhost:9999")
Важно и стоит помнить, что если вы прикладывали файл в локальной мете, то и sdk должна работать с локальной метой. А если файл прикладывался в продакшене, то и sdk должна работать с продакшн метой.
В принципе этот подход применим и при других изменениях, которые есть только локально у вас на компьютере.
Пример
Простой пример кода, который загружает файл в хранилище и скачивает его.
import os
import shutil
from tempfile import NamedTemporaryFile
from metasdk import MetaApp
META = MetaApp()
log = META.log
# Загрузка файла может происходить через API как показано ниже или через интерфейс
# как в примере http://localhost:9999/page?p=4201&a=35
# Сути дела это не поменяет, в итоге файл попадает в хранилище, а также получает запись в таблице meta.media
upload_file = open('myfile.yml', 'rb')
upload_result = META.MediaService.upload(upload_file, {
"isPrivate": True, # Файл будет доступен только для пользователя, работающего с api
"ttlInSec": 9999, # Обязательно для временных файлов. Кол-во секунд через которые мета автоматически удалит файл
"entityId": 2770,
"objectId": "114aecf5-04f1-44fa-8ad1-842b7f31a2df",
"info": {"test": True} # Метаданные файла
})
print("upload_result = %s" % str(upload_result))
media_id = upload_result['id']
info_response = META.MediaService.info(media_id)
print("info_response = %s" % str(info_response))
# Для больших файлов важно скачивать их as_stream, чтобы экономить память на сервере и на клиенте
dwn_response = META.MediaService.download(media_id, as_stream=True)
print("response = %s" % str(dwn_response))
# мета старается сжимать ответы через gzip, поэтому
# для текстовых файлов часто очень важно устанавливать decode_content=True
# например для mime application/json плохо скачиваются
# response.raw.decode_content = True
dwn_response.raw.decode_content = True
source_file = NamedTemporaryFile(delete=False)
print("source_file.name = %s" % str(source_file.name))
with open(source_file.name, "wb") as out_file:
shutil.copyfileobj(dwn_response.raw, out_file)
Работа от лица пользователя
Иногда надо работать с файлами от имени пользователя, например если пользователь заказал фоновую обработку.
В этом случае надо устанавливать, а потом убирать параметр META.auth_user_id
try:
META.auth_user_id = {{NEED_USER_ID}}
upload_file = open('/Users/arturgspb/PycharmProjects/metaappscriptworkers/1.yml', 'rb')
upload_result = META.MediaService.upload(upload_file, {
"isPrivate": True, # Файл будет доступен только для пользователя, работающего с api
"ttlInSec": 9999, # Обязательно для временных файлов. Кол-во секунд через которые мета автоматически удалит файл
"entityId": 2770,
"objectId": "114aecf5-04f1-44fa-8ad1-842b7f31a2df",
"info": {"test": True} # Метаданные файла
})
print("upload_result = %s" % str(upload_result))
finally:
META.auth_user_id = None
Пример
Пример показывает с чего можно начать, если у вас есть задача загрузить в интерфейсе файл через форму и отправить его на обработку в фоновый скрипт.
Страница в интерфейсе
Рисует форму, куда в данном примере прикладывается только файл, но, как и любая lego-форма она может содержать любые другие доступные контролы.
<elem span="12" states="default">
<tpl>
<form name="editGroupForm" ng-submit="changeState('run_parsing', {obj:env.sp.obj})">
<me-lego elems="editGroup.legoForm.elems" output="env.sp.obj"></me-lego>
</form>
</tpl>
<script type="meta/js" id="editGroup" states="default">
function main(env, log, vm, pvm) {
vm.legoForm = {
elems: [
{
id: "input_file",
name: "me-input",
label: "Файл сюда",
span: 4,
attrs: {
pattern: 'application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv',
type: "attach",
required: true,
info: {
additionalInfo: {ttlInSec:1209600}
}
}
},
{
id: "smb",
name: "me-submit",
attrs: {
value: 'Запустить задачу'
}
},
]
};
}
</script>
</elem>
<script type="meta/sql" db-alias="adplatform" internal states="run_parsing">
SELECT *
FROM api.meta_starter_submit_task('your_parser_script_name', jsonb_build_object(
'media_id', :env.sp.obj.input_file
, 'user_id', :env.userId
))
</script>
<script type="meta/js" states="run_parsing">
function main(pvm) {
pvm.changeState('waiting_parsing');
}
</script>
<script type="meta/sql" db-alias="adplatform" id="waiting_cnt" states="waiting_parsing" elem="hidden">
SELECT COUNT(*) as cnt
FROM job.task
WHERE service_id='your_parser_script_name'
AND status IN ('NEW', 'PROCESSING')
</script>
<elem states="waiting_parsing" id="view_waiting_parsing">
<template>
<div class="alert alert-info" style="max-width: 780px;">
<p><i class="fa fa-clock-o"></i> Ожидается.</p>
<p>Подождите немного и все будет. Страница будет обновляться сама и даст вам скачать файл, когда придет время</p>
</div>
</template>
</elem>
<script type="meta/js" states="waiting_parsing" id="timer_waiting_parsing" internal>
function main(pvm) {
if (pvm.data.waiting_cnt.rows[0].cnt > 0) {
pvm.__refreshAfter = 2000;
} else {
pvm.__refreshAfter = null;
pvm.changeState('default');
}
}
</script>
<script type="meta/sql" db-alias="adplatform" order="100" id="tasks">
SELECT
t.creation_time,
t.status,
t.retries,
(CASE WHEN u.id=${env.userId} OR [#if env.hasRoleDev || env.hasRoleSupport]true[#else]false[/#if] THEN
'<a target="_blank" href="' || (COALESCE(t.result_data->>'downloadUrlPart')) || '.csv"><i class="fa fa-download"></i> Скачать</a>'
ELSE '-' END) as html_field_ссылка,
COALESCE(t.end_time, NOW())-COALESCE(t.start_time, t.target_time) as duration,
u.full_name,
'<a href='||(info->'intranet'->'url')::text||' target="_blank"><img class="img-circle" src='||(info->'intranet'->'avatar')::text||'/></a>' as "html_field_user"
[#if env.hasRoleDev || env.hasRoleSupport]
,LEFT(REPLACE((last_error->>'text')::text, E'\\n', ' '), 50) as last_error,
json_build_object(
'last_error', json_build_object(
'expand', json_build_object(
'mode', 'modal',
'contentAsIs', last_error->>'text',
'size', 'lg'
)
)
) as cell_props_field
[/#if]
FROM job.task t
LEFT JOIN users u ON u.id=(t.data->>'user_id')::bigint
WHERE t.service_id='your_parser_script_name'
ORDER BY ${sort}, t.creation_time DESC
${pager}
</script>
Пример фонового скрипта для страницы
import os
import shutil
from tempfile import NamedTemporaryFile
from metasdk import MetaApp
META = MetaApp()
log = META.log
def download_file(media_id):
dwn_response = META.MediaService.download(media_id, as_stream=True)
dwn_response.raw.decode_content = True
source_file = NamedTemporaryFile(delete=False)
print("source_file.name = %s" % str(source_file.name))
with open(source_file.name, "wb") as out_file:
shutil.copyfileobj(dwn_response.raw, out_file)
return source_file.name
def process_file(filepath):
log.info("Start process file")
# Запуск таски для отладки
META.worker.debug_tasks = [
{
"taskId": "3bab8c75-4603-4b33-8aa6-5c22f76e4a5c",
"data": {
"user_id": 10468,
"media_id": "3bab8c75-4603-4b33-8aa6-5c22f76e4a5c",
}
}
]
@META.worker.single_task
def main(task):
task_id = task["taskId"]
user_id = task["data"]["user_id"]
media_id = task["data"]["media_id"]
log.set_entity("user_id", user_id)
log.set_entity("media_id", media_id)
filepath = None
try:
filepath = download_file(media_id)
process_file(filepath)
finally:
if filepath and os.path.exists(filepath):
os.remove(filepath)